airbyte-cdk 6.55.0__py3-none-any.whl → 6.55.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- airbyte_cdk/cli/airbyte_cdk/_connector.py +32 -8
- airbyte_cdk/cli/airbyte_cdk/_image.py +76 -0
- airbyte_cdk/cli/airbyte_cdk/_secrets.py +13 -12
- airbyte_cdk/models/airbyte_protocol_serializers.py +4 -0
- airbyte_cdk/models/connector_metadata.py +14 -0
- airbyte_cdk/test/entrypoint_wrapper.py +163 -26
- airbyte_cdk/test/models/scenario.py +49 -10
- airbyte_cdk/test/standard_tests/__init__.py +2 -4
- airbyte_cdk/test/standard_tests/connector_base.py +12 -80
- airbyte_cdk/test/standard_tests/docker_base.py +388 -0
- airbyte_cdk/test/standard_tests/pytest_hooks.py +115 -2
- airbyte_cdk/test/standard_tests/source_base.py +13 -7
- airbyte_cdk/test/standard_tests/util.py +4 -3
- airbyte_cdk/utils/connector_paths.py +3 -3
- airbyte_cdk/utils/docker.py +83 -34
- {airbyte_cdk-6.55.0.dist-info → airbyte_cdk-6.55.1.dist-info}/METADATA +2 -1
- {airbyte_cdk-6.55.0.dist-info → airbyte_cdk-6.55.1.dist-info}/RECORD +21 -20
- {airbyte_cdk-6.55.0.dist-info → airbyte_cdk-6.55.1.dist-info}/LICENSE.txt +0 -0
- {airbyte_cdk-6.55.0.dist-info → airbyte_cdk-6.55.1.dist-info}/LICENSE_SHORT +0 -0
- {airbyte_cdk-6.55.0.dist-info → airbyte_cdk-6.55.1.dist-info}/WHEEL +0 -0
- {airbyte_cdk-6.55.0.dist-info → airbyte_cdk-6.55.1.dist-info}/entry_points.txt +0 -0
@@ -3,16 +3,11 @@
|
|
3
3
|
|
4
4
|
from __future__ import annotations
|
5
5
|
|
6
|
-
import abc
|
7
6
|
import importlib
|
8
|
-
import inspect
|
9
7
|
import os
|
10
|
-
import sys
|
11
|
-
from collections.abc import Callable
|
12
8
|
from pathlib import Path
|
13
|
-
from typing import cast
|
9
|
+
from typing import TYPE_CHECKING, cast
|
14
10
|
|
15
|
-
import yaml
|
16
11
|
from boltons.typeutils import classproperty
|
17
12
|
|
18
13
|
from airbyte_cdk.models import (
|
@@ -24,14 +19,20 @@ from airbyte_cdk.test.models import (
|
|
24
19
|
ConnectorTestScenario,
|
25
20
|
)
|
26
21
|
from airbyte_cdk.test.standard_tests._job_runner import IConnector, run_test_job
|
22
|
+
from airbyte_cdk.test.standard_tests.docker_base import DockerConnectorTestSuite
|
27
23
|
from airbyte_cdk.utils.connector_paths import (
|
28
24
|
ACCEPTANCE_TEST_CONFIG,
|
29
25
|
find_connector_root,
|
30
26
|
)
|
31
27
|
|
28
|
+
if TYPE_CHECKING:
|
29
|
+
from collections.abc import Callable
|
32
30
|
|
33
|
-
|
34
|
-
|
31
|
+
from airbyte_cdk.test import entrypoint_wrapper
|
32
|
+
|
33
|
+
|
34
|
+
class ConnectorTestSuiteBase(DockerConnectorTestSuite):
|
35
|
+
"""Base class for Python connector test suites."""
|
35
36
|
|
36
37
|
connector: type[IConnector] | Callable[[], IConnector] | None # type: ignore [reportRedeclaration]
|
37
38
|
"""The connector class or a factory function that returns an scenario of IConnector."""
|
@@ -79,13 +80,6 @@ class ConnectorTestSuiteBase(abc.ABC):
|
|
79
80
|
) from e
|
80
81
|
return cast(type[IConnector], getattr(module, matching_class_name))
|
81
82
|
|
82
|
-
@classmethod
|
83
|
-
def get_test_class_dir(cls) -> Path:
|
84
|
-
"""Get the file path that contains the class."""
|
85
|
-
module = sys.modules[cls.__module__]
|
86
|
-
# Get the directory containing the test file
|
87
|
-
return Path(inspect.getfile(module)).parent
|
88
|
-
|
89
83
|
@classmethod
|
90
84
|
def create_connector(
|
91
85
|
cls,
|
@@ -118,69 +112,7 @@ class ConnectorTestSuiteBase(abc.ABC):
|
|
118
112
|
test_scenario=scenario,
|
119
113
|
connector_root=self.get_connector_root_dir(),
|
120
114
|
)
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
assert len(conn_status_messages) == 1, (
|
125
|
-
f"Expected exactly one CONNECTION_STATUS message. Got: {result._messages}"
|
115
|
+
assert len(result.connection_status_messages) == 1, (
|
116
|
+
f"Expected exactly one CONNECTION_STATUS message. "
|
117
|
+
"Got: {result.connection_status_messages!s}"
|
126
118
|
)
|
127
|
-
|
128
|
-
@classmethod
|
129
|
-
def get_connector_root_dir(cls) -> Path:
|
130
|
-
"""Get the root directory of the connector."""
|
131
|
-
return find_connector_root([cls.get_test_class_dir(), Path.cwd()])
|
132
|
-
|
133
|
-
@classproperty
|
134
|
-
def acceptance_test_config_path(cls) -> Path:
|
135
|
-
"""Get the path to the acceptance test config file."""
|
136
|
-
result = cls.get_connector_root_dir() / ACCEPTANCE_TEST_CONFIG
|
137
|
-
if result.exists():
|
138
|
-
return result
|
139
|
-
|
140
|
-
raise FileNotFoundError(f"Acceptance test config file not found at: {str(result)}")
|
141
|
-
|
142
|
-
@classmethod
|
143
|
-
def get_scenarios(
|
144
|
-
cls,
|
145
|
-
) -> list[ConnectorTestScenario]:
|
146
|
-
"""Get acceptance tests for a given category.
|
147
|
-
|
148
|
-
This has to be a separate function because pytest does not allow
|
149
|
-
parametrization of fixtures with arguments from the test class itself.
|
150
|
-
"""
|
151
|
-
categories = ["connection", "spec"]
|
152
|
-
all_tests_config = yaml.safe_load(cls.acceptance_test_config_path.read_text())
|
153
|
-
if "acceptance_tests" not in all_tests_config:
|
154
|
-
raise ValueError(
|
155
|
-
f"Acceptance tests config not found in {cls.acceptance_test_config_path}."
|
156
|
-
f" Found only: {str(all_tests_config)}."
|
157
|
-
)
|
158
|
-
|
159
|
-
test_scenarios: list[ConnectorTestScenario] = []
|
160
|
-
for category in categories:
|
161
|
-
if (
|
162
|
-
category not in all_tests_config["acceptance_tests"]
|
163
|
-
or "tests" not in all_tests_config["acceptance_tests"][category]
|
164
|
-
):
|
165
|
-
continue
|
166
|
-
|
167
|
-
for test in all_tests_config["acceptance_tests"][category]["tests"]:
|
168
|
-
if "config_path" not in test:
|
169
|
-
# Skip tests without a config_path
|
170
|
-
continue
|
171
|
-
|
172
|
-
if "iam_role" in test["config_path"]:
|
173
|
-
# We skip iam_role tests for now, as they are not supported in the test suite.
|
174
|
-
continue
|
175
|
-
|
176
|
-
scenario = ConnectorTestScenario.model_validate(test)
|
177
|
-
|
178
|
-
if scenario.config_path and scenario.config_path in [
|
179
|
-
s.config_path for s in test_scenarios
|
180
|
-
]:
|
181
|
-
# Skip duplicate scenarios based on config_path
|
182
|
-
continue
|
183
|
-
|
184
|
-
test_scenarios.append(scenario)
|
185
|
-
|
186
|
-
return test_scenarios
|
@@ -0,0 +1,388 @@
|
|
1
|
+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
|
2
|
+
"""Base class for connector test suites."""
|
3
|
+
|
4
|
+
from __future__ import annotations
|
5
|
+
|
6
|
+
import inspect
|
7
|
+
import shutil
|
8
|
+
import sys
|
9
|
+
import tempfile
|
10
|
+
import warnings
|
11
|
+
from dataclasses import asdict
|
12
|
+
from pathlib import Path
|
13
|
+
from subprocess import CompletedProcess, SubprocessError
|
14
|
+
from typing import Literal
|
15
|
+
|
16
|
+
import orjson
|
17
|
+
import pytest
|
18
|
+
import yaml
|
19
|
+
from boltons.typeutils import classproperty
|
20
|
+
|
21
|
+
from airbyte_cdk.models import (
|
22
|
+
AirbyteCatalog,
|
23
|
+
ConfiguredAirbyteCatalog,
|
24
|
+
ConfiguredAirbyteStream,
|
25
|
+
DestinationSyncMode,
|
26
|
+
SyncMode,
|
27
|
+
)
|
28
|
+
from airbyte_cdk.models.airbyte_protocol_serializers import (
|
29
|
+
AirbyteCatalogSerializer,
|
30
|
+
AirbyteStreamSerializer,
|
31
|
+
)
|
32
|
+
from airbyte_cdk.models.connector_metadata import MetadataFile
|
33
|
+
from airbyte_cdk.test.entrypoint_wrapper import EntrypointOutput
|
34
|
+
from airbyte_cdk.test.models import ConnectorTestScenario
|
35
|
+
from airbyte_cdk.test.utils.reading import catalog
|
36
|
+
from airbyte_cdk.utils.connector_paths import (
|
37
|
+
ACCEPTANCE_TEST_CONFIG,
|
38
|
+
find_connector_root,
|
39
|
+
)
|
40
|
+
from airbyte_cdk.utils.docker import build_connector_image, run_docker_command
|
41
|
+
|
42
|
+
|
43
|
+
class DockerConnectorTestSuite:
|
44
|
+
"""Base class for connector test suites."""
|
45
|
+
|
46
|
+
@classmethod
|
47
|
+
def get_test_class_dir(cls) -> Path:
|
48
|
+
"""Get the file path that contains the class."""
|
49
|
+
module = sys.modules[cls.__module__]
|
50
|
+
# Get the directory containing the test file
|
51
|
+
return Path(inspect.getfile(module)).parent
|
52
|
+
|
53
|
+
@classmethod
|
54
|
+
def get_connector_root_dir(cls) -> Path:
|
55
|
+
"""Get the root directory of the connector."""
|
56
|
+
return find_connector_root([cls.get_test_class_dir(), Path.cwd()])
|
57
|
+
|
58
|
+
@classproperty
|
59
|
+
def acceptance_test_config_path(cls) -> Path:
|
60
|
+
"""Get the path to the acceptance test config file."""
|
61
|
+
result = cls.get_connector_root_dir() / ACCEPTANCE_TEST_CONFIG
|
62
|
+
if result.exists():
|
63
|
+
return result
|
64
|
+
|
65
|
+
raise FileNotFoundError(f"Acceptance test config file not found at: {str(result)}")
|
66
|
+
|
67
|
+
@classmethod
|
68
|
+
def get_scenarios(
|
69
|
+
cls,
|
70
|
+
) -> list[ConnectorTestScenario]:
|
71
|
+
"""Get acceptance tests for a given category.
|
72
|
+
|
73
|
+
This has to be a separate function because pytest does not allow
|
74
|
+
parametrization of fixtures with arguments from the test class itself.
|
75
|
+
"""
|
76
|
+
categories = ["connection", "spec"]
|
77
|
+
try:
|
78
|
+
acceptance_test_config_path = cls.acceptance_test_config_path
|
79
|
+
except FileNotFoundError as e:
|
80
|
+
# Destinations sometimes do not have an acceptance tests file.
|
81
|
+
warnings.warn(
|
82
|
+
f"Acceptance test config file not found: {e!s}. No scenarios will be loaded.",
|
83
|
+
category=UserWarning,
|
84
|
+
stacklevel=1,
|
85
|
+
)
|
86
|
+
return []
|
87
|
+
|
88
|
+
all_tests_config = yaml.safe_load(cls.acceptance_test_config_path.read_text())
|
89
|
+
if "acceptance_tests" not in all_tests_config:
|
90
|
+
raise ValueError(
|
91
|
+
f"Acceptance tests config not found in {cls.acceptance_test_config_path}."
|
92
|
+
f" Found only: {str(all_tests_config)}."
|
93
|
+
)
|
94
|
+
|
95
|
+
test_scenarios: list[ConnectorTestScenario] = []
|
96
|
+
for category in categories:
|
97
|
+
if (
|
98
|
+
category not in all_tests_config["acceptance_tests"]
|
99
|
+
or "tests" not in all_tests_config["acceptance_tests"][category]
|
100
|
+
):
|
101
|
+
continue
|
102
|
+
|
103
|
+
for test in all_tests_config["acceptance_tests"][category]["tests"]:
|
104
|
+
if "config_path" not in test:
|
105
|
+
# Skip tests without a config_path
|
106
|
+
continue
|
107
|
+
|
108
|
+
if "iam_role" in test["config_path"]:
|
109
|
+
# We skip iam_role tests for now, as they are not supported in the test suite.
|
110
|
+
continue
|
111
|
+
|
112
|
+
scenario = ConnectorTestScenario.model_validate(test)
|
113
|
+
|
114
|
+
if scenario.config_path and scenario.config_path in [
|
115
|
+
s.config_path for s in test_scenarios
|
116
|
+
]:
|
117
|
+
# Skip duplicate scenarios based on config_path
|
118
|
+
continue
|
119
|
+
|
120
|
+
test_scenarios.append(scenario)
|
121
|
+
|
122
|
+
return test_scenarios
|
123
|
+
|
124
|
+
@pytest.mark.skipif(
|
125
|
+
shutil.which("docker") is None,
|
126
|
+
reason="docker CLI not found in PATH, skipping docker image tests",
|
127
|
+
)
|
128
|
+
@pytest.mark.image_tests
|
129
|
+
def test_docker_image_build_and_spec(
|
130
|
+
self,
|
131
|
+
connector_image_override: str | None,
|
132
|
+
) -> None:
|
133
|
+
"""Run `docker_image` acceptance tests."""
|
134
|
+
connector_root = self.get_connector_root_dir().absolute()
|
135
|
+
metadata = MetadataFile.from_file(connector_root / "metadata.yaml")
|
136
|
+
|
137
|
+
connector_image: str | None = connector_image_override
|
138
|
+
if not connector_image:
|
139
|
+
tag = "dev-latest"
|
140
|
+
connector_image = build_connector_image(
|
141
|
+
connector_name=connector_root.absolute().name,
|
142
|
+
connector_directory=connector_root,
|
143
|
+
metadata=metadata,
|
144
|
+
tag=tag,
|
145
|
+
no_verify=False,
|
146
|
+
)
|
147
|
+
|
148
|
+
try:
|
149
|
+
result: CompletedProcess[str] = run_docker_command(
|
150
|
+
[
|
151
|
+
"docker",
|
152
|
+
"run",
|
153
|
+
"--rm",
|
154
|
+
connector_image,
|
155
|
+
"spec",
|
156
|
+
],
|
157
|
+
check=True, # Raise an error if the command fails
|
158
|
+
capture_stderr=True,
|
159
|
+
capture_stdout=True,
|
160
|
+
)
|
161
|
+
except SubprocessError as ex:
|
162
|
+
raise AssertionError(
|
163
|
+
f"Failed to run `spec` command in docker image {connector_image!r}. Error: {ex!s}"
|
164
|
+
) from None
|
165
|
+
|
166
|
+
@pytest.mark.skipif(
|
167
|
+
shutil.which("docker") is None,
|
168
|
+
reason="docker CLI not found in PATH, skipping docker image tests",
|
169
|
+
)
|
170
|
+
@pytest.mark.image_tests
|
171
|
+
def test_docker_image_build_and_check(
|
172
|
+
self,
|
173
|
+
scenario: ConnectorTestScenario,
|
174
|
+
connector_image_override: str | None,
|
175
|
+
) -> None:
|
176
|
+
"""Run `docker_image` acceptance tests.
|
177
|
+
|
178
|
+
This test builds the connector image and runs the `check` command inside the container.
|
179
|
+
|
180
|
+
Note:
|
181
|
+
- It is expected for docker image caches to be reused between test runs.
|
182
|
+
- In the rare case that image caches need to be cleared, please clear
|
183
|
+
the local docker image cache using `docker image prune -a` command.
|
184
|
+
"""
|
185
|
+
if scenario.expected_outcome.expect_exception():
|
186
|
+
pytest.skip("Skipping test_docker_image_build_and_check (expected to fail).")
|
187
|
+
|
188
|
+
tag = "dev-latest"
|
189
|
+
connector_root = self.get_connector_root_dir()
|
190
|
+
metadata = MetadataFile.from_file(connector_root / "metadata.yaml")
|
191
|
+
connector_image: str | None = connector_image_override
|
192
|
+
if not connector_image:
|
193
|
+
tag = "dev-latest"
|
194
|
+
connector_image = build_connector_image(
|
195
|
+
connector_name=connector_root.absolute().name,
|
196
|
+
connector_directory=connector_root,
|
197
|
+
metadata=metadata,
|
198
|
+
tag=tag,
|
199
|
+
no_verify=False,
|
200
|
+
)
|
201
|
+
|
202
|
+
container_config_path = "/secrets/config.json"
|
203
|
+
with scenario.with_temp_config_file(
|
204
|
+
connector_root=connector_root,
|
205
|
+
) as temp_config_file:
|
206
|
+
_ = run_docker_command(
|
207
|
+
[
|
208
|
+
"docker",
|
209
|
+
"run",
|
210
|
+
"--rm",
|
211
|
+
"-v",
|
212
|
+
f"{temp_config_file}:{container_config_path}",
|
213
|
+
connector_image,
|
214
|
+
"check",
|
215
|
+
"--config",
|
216
|
+
container_config_path,
|
217
|
+
],
|
218
|
+
check=True, # Raise an error if the command fails
|
219
|
+
capture_stderr=True,
|
220
|
+
capture_stdout=True,
|
221
|
+
)
|
222
|
+
|
223
|
+
@pytest.mark.skipif(
|
224
|
+
shutil.which("docker") is None,
|
225
|
+
reason="docker CLI not found in PATH, skipping docker image tests",
|
226
|
+
)
|
227
|
+
@pytest.mark.image_tests
|
228
|
+
def test_docker_image_build_and_read(
|
229
|
+
self,
|
230
|
+
scenario: ConnectorTestScenario,
|
231
|
+
connector_image_override: str | None,
|
232
|
+
read_from_streams: Literal["all", "none", "default"] | list[str],
|
233
|
+
read_scenarios: Literal["all", "none", "default"] | list[str],
|
234
|
+
) -> None:
|
235
|
+
"""Read from the connector's Docker image.
|
236
|
+
|
237
|
+
This test builds the connector image and runs the `read` command inside the container.
|
238
|
+
|
239
|
+
Note:
|
240
|
+
- It is expected for docker image caches to be reused between test runs.
|
241
|
+
- In the rare case that image caches need to be cleared, please clear
|
242
|
+
the local docker image cache using `docker image prune -a` command.
|
243
|
+
- If the --connector-image arg is provided, it will be used instead of building the image.
|
244
|
+
"""
|
245
|
+
if scenario.expected_outcome.expect_exception():
|
246
|
+
pytest.skip("Skipping (expected to fail).")
|
247
|
+
|
248
|
+
if read_from_streams == "none":
|
249
|
+
pytest.skip("Skipping read test (`--read-from-streams=false`).")
|
250
|
+
|
251
|
+
if read_scenarios == "none":
|
252
|
+
pytest.skip("Skipping (`--read-scenarios=none`).")
|
253
|
+
|
254
|
+
default_scenario_ids = ["config", "valid_config", "default"]
|
255
|
+
if read_scenarios == "all":
|
256
|
+
pass
|
257
|
+
elif read_scenarios == "default":
|
258
|
+
if scenario.id not in default_scenario_ids:
|
259
|
+
pytest.skip(
|
260
|
+
f"Skipping read test for scenario '{scenario.id}' "
|
261
|
+
f"(not in default scenarios list '{default_scenario_ids}')."
|
262
|
+
)
|
263
|
+
elif scenario.id not in read_scenarios:
|
264
|
+
# pytest.skip(
|
265
|
+
raise ValueError(
|
266
|
+
f"Skipping read test for scenario '{scenario.id}' "
|
267
|
+
f"(not in --read-scenarios={read_scenarios})."
|
268
|
+
)
|
269
|
+
|
270
|
+
tag = "dev-latest"
|
271
|
+
connector_root = self.get_connector_root_dir()
|
272
|
+
connector_name = connector_root.absolute().name
|
273
|
+
metadata = MetadataFile.from_file(connector_root / "metadata.yaml")
|
274
|
+
connector_image: str | None = connector_image_override
|
275
|
+
if not connector_image:
|
276
|
+
tag = "dev-latest"
|
277
|
+
connector_image = build_connector_image(
|
278
|
+
connector_name=connector_name,
|
279
|
+
connector_directory=connector_root,
|
280
|
+
metadata=metadata,
|
281
|
+
tag=tag,
|
282
|
+
no_verify=False,
|
283
|
+
)
|
284
|
+
|
285
|
+
container_config_path = "/secrets/config.json"
|
286
|
+
container_catalog_path = "/secrets/catalog.json"
|
287
|
+
|
288
|
+
with (
|
289
|
+
scenario.with_temp_config_file(
|
290
|
+
connector_root=connector_root,
|
291
|
+
) as temp_config_file,
|
292
|
+
tempfile.TemporaryDirectory(
|
293
|
+
prefix=f"{connector_name}-test",
|
294
|
+
ignore_cleanup_errors=True,
|
295
|
+
) as temp_dir_str,
|
296
|
+
):
|
297
|
+
temp_dir = Path(temp_dir_str)
|
298
|
+
discover_result = run_docker_command(
|
299
|
+
[
|
300
|
+
"docker",
|
301
|
+
"run",
|
302
|
+
"--rm",
|
303
|
+
"-v",
|
304
|
+
f"{temp_config_file}:{container_config_path}",
|
305
|
+
connector_image,
|
306
|
+
"discover",
|
307
|
+
"--config",
|
308
|
+
container_config_path,
|
309
|
+
],
|
310
|
+
check=True, # Raise an error if the command fails
|
311
|
+
capture_stderr=True,
|
312
|
+
capture_stdout=True,
|
313
|
+
)
|
314
|
+
parsed_output = EntrypointOutput(messages=discover_result.stdout.splitlines())
|
315
|
+
try:
|
316
|
+
catalog_message = parsed_output.catalog # Get catalog message
|
317
|
+
assert catalog_message.catalog is not None, "Catalog message missing catalog."
|
318
|
+
discovered_catalog: AirbyteCatalog = parsed_output.catalog.catalog
|
319
|
+
except Exception as ex:
|
320
|
+
raise AssertionError(
|
321
|
+
f"Failed to load discovered catalog from {discover_result.stdout}. "
|
322
|
+
f"Error: {ex!s}"
|
323
|
+
) from None
|
324
|
+
if not discovered_catalog.streams:
|
325
|
+
raise ValueError(
|
326
|
+
f"Discovered catalog for connector '{connector_name}' is empty. "
|
327
|
+
"Please check the connector's discover implementation."
|
328
|
+
)
|
329
|
+
|
330
|
+
streams_list = [stream.name for stream in discovered_catalog.streams]
|
331
|
+
if read_from_streams == "default" and metadata.data.suggestedStreams:
|
332
|
+
# set `streams_list` to be the intersection of discovered and suggested streams.
|
333
|
+
streams_list = list(set(streams_list) & set(metadata.data.suggestedStreams.streams))
|
334
|
+
|
335
|
+
if isinstance(read_from_streams, list):
|
336
|
+
# If `read_from_streams` is a list, we filter the discovered streams.
|
337
|
+
streams_list = list(set(streams_list) & set(read_from_streams))
|
338
|
+
|
339
|
+
configured_catalog: ConfiguredAirbyteCatalog = ConfiguredAirbyteCatalog(
|
340
|
+
streams=[
|
341
|
+
ConfiguredAirbyteStream(
|
342
|
+
stream=stream,
|
343
|
+
sync_mode=(
|
344
|
+
stream.supported_sync_modes[0]
|
345
|
+
if stream.supported_sync_modes
|
346
|
+
else SyncMode.full_refresh
|
347
|
+
),
|
348
|
+
destination_sync_mode=DestinationSyncMode.append,
|
349
|
+
)
|
350
|
+
for stream in discovered_catalog.streams
|
351
|
+
if stream.name in streams_list
|
352
|
+
]
|
353
|
+
)
|
354
|
+
configured_catalog_path = temp_dir / "catalog.json"
|
355
|
+
configured_catalog_path.write_text(
|
356
|
+
orjson.dumps(asdict(configured_catalog)).decode("utf-8")
|
357
|
+
)
|
358
|
+
read_result: CompletedProcess[str] = run_docker_command(
|
359
|
+
[
|
360
|
+
"docker",
|
361
|
+
"run",
|
362
|
+
"--rm",
|
363
|
+
"-v",
|
364
|
+
f"{temp_config_file}:{container_config_path}",
|
365
|
+
"-v",
|
366
|
+
f"{configured_catalog_path}:{container_catalog_path}",
|
367
|
+
connector_image,
|
368
|
+
"read",
|
369
|
+
"--config",
|
370
|
+
container_config_path,
|
371
|
+
"--catalog",
|
372
|
+
container_catalog_path,
|
373
|
+
],
|
374
|
+
check=False,
|
375
|
+
capture_stderr=True,
|
376
|
+
capture_stdout=True,
|
377
|
+
)
|
378
|
+
if read_result.returncode != 0:
|
379
|
+
raise AssertionError(
|
380
|
+
f"Failed to run `read` command in docker image {connector_image!r}. "
|
381
|
+
"\n-----------------"
|
382
|
+
f"EXIT CODE: {read_result.returncode}\n"
|
383
|
+
"STDERR:\n"
|
384
|
+
f"{read_result.stderr}\n"
|
385
|
+
f"STDOUT:\n"
|
386
|
+
f"{read_result.stdout}\n"
|
387
|
+
"\n-----------------"
|
388
|
+
) from None
|
@@ -13,12 +13,125 @@ pytest_plugins = [
|
|
13
13
|
```
|
14
14
|
"""
|
15
15
|
|
16
|
+
from typing import Literal, cast
|
17
|
+
|
16
18
|
import pytest
|
17
19
|
|
18
20
|
|
19
|
-
|
21
|
+
@pytest.fixture
|
22
|
+
def connector_image_override(request: pytest.FixtureRequest) -> str | None:
|
23
|
+
"""Return the value of --connector-image, or None if not set."""
|
24
|
+
return cast(str | None, request.config.getoption("--connector-image"))
|
25
|
+
|
26
|
+
|
27
|
+
@pytest.fixture
|
28
|
+
def read_from_streams(
|
29
|
+
request: pytest.FixtureRequest,
|
30
|
+
) -> Literal["all", "none", "default"] | list[str]:
|
31
|
+
"""Specify if the test should read from streams.
|
32
|
+
|
33
|
+
The input can be one of the following:
|
34
|
+
- [Omitted] - Default to False, meaning no streams will be read.
|
35
|
+
- `--read-from-streams`: Read from all suggested streams.
|
36
|
+
- `--read-from-streams=true`: Read from all suggested streams.
|
37
|
+
- `--read-from-streams=suggested`: Read from all suggested streams.
|
38
|
+
- `--read-from-streams=default`: Read from all suggested streams.
|
39
|
+
- `--read-from-streams=all`: Read from all streams.
|
40
|
+
- `--read-from-streams=stream1,stream2`: Read from the specified streams only.
|
41
|
+
- `--read-from-streams=false`: Do not read from any streams.
|
42
|
+
- `--read-from-streams=none`: Do not read from any streams.
|
43
|
+
"""
|
44
|
+
input_val: str = request.config.getoption(
|
45
|
+
"--read-from-streams",
|
46
|
+
default="default", # type: ignore
|
47
|
+
) # type: ignore
|
48
|
+
|
49
|
+
if isinstance(input_val, str):
|
50
|
+
if input_val.lower() == "false":
|
51
|
+
return "none"
|
52
|
+
if input_val.lower() in ["true", "suggested", "default"]:
|
53
|
+
# Default to 'default' (suggested) streams if the input is 'true', 'suggested', or
|
54
|
+
# 'default'.
|
55
|
+
# This is the default behavior if the option is not set.
|
56
|
+
return "default"
|
57
|
+
if input_val.lower() == "all":
|
58
|
+
# This will sometimes fail if the account doesn't have permissions
|
59
|
+
# to premium or restricted stream data.
|
60
|
+
return "all"
|
61
|
+
|
62
|
+
# If the input is a comma-separated list, split it into a list.
|
63
|
+
# This will return a one-element list if the input is a single stream name.
|
64
|
+
return input_val.split(",")
|
65
|
+
|
66
|
+
# Else, probably a bool; return it as is.
|
67
|
+
return input_val or "none"
|
68
|
+
|
69
|
+
|
70
|
+
@pytest.fixture
|
71
|
+
def read_scenarios(
|
72
|
+
request: pytest.FixtureRequest,
|
73
|
+
) -> list[str] | Literal["all", "default"]:
|
74
|
+
"""Return the value of `--read-scenarios`.
|
75
|
+
|
76
|
+
This argument is ignored if `--read-from-streams` is False or not set.
|
77
|
+
|
78
|
+
The input can be one of the following:
|
79
|
+
- [Omitted] - Default to 'config.json', meaning the default scenario will be read.
|
80
|
+
- `--read-scenarios=all`: Read all scenarios.
|
81
|
+
- `--read-scenarios=none`: Read no scenarios. (Overrides `--read-from-streams`, if set.)
|
82
|
+
- `--read-scenarios=scenario1,scenario2`: Read the specified scenarios only.
|
83
|
+
|
20
84
|
"""
|
21
|
-
|
85
|
+
input_val = cast(
|
86
|
+
str,
|
87
|
+
request.config.getoption(
|
88
|
+
"--read-scenarios",
|
89
|
+
default="default", # type: ignore
|
90
|
+
),
|
91
|
+
)
|
92
|
+
|
93
|
+
if input_val.lower() == "default":
|
94
|
+
# Default config scenario is always 'config.json'.
|
95
|
+
return "default"
|
96
|
+
|
97
|
+
if input_val.lower() == "none":
|
98
|
+
# Default config scenario is always 'config.json'.
|
99
|
+
return []
|
100
|
+
|
101
|
+
return (
|
102
|
+
[
|
103
|
+
scenario_name.strip().lower().removesuffix(".json")
|
104
|
+
for scenario_name in input_val.split(",")
|
105
|
+
]
|
106
|
+
if input_val
|
107
|
+
else []
|
108
|
+
)
|
109
|
+
|
110
|
+
|
111
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
112
|
+
"""Add --connector-image to pytest's CLI."""
|
113
|
+
parser.addoption(
|
114
|
+
"--connector-image",
|
115
|
+
action="store",
|
116
|
+
default=None,
|
117
|
+
help="Use this pre-built connector Docker image instead of building one.",
|
118
|
+
)
|
119
|
+
parser.addoption(
|
120
|
+
"--read-from-streams",
|
121
|
+
action="store",
|
122
|
+
default=None,
|
123
|
+
help=read_from_streams.__doc__,
|
124
|
+
)
|
125
|
+
parser.addoption(
|
126
|
+
"--read-scenarios",
|
127
|
+
action="store",
|
128
|
+
default="default",
|
129
|
+
help=read_scenarios.__doc__,
|
130
|
+
)
|
131
|
+
|
132
|
+
|
133
|
+
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
|
134
|
+
"""A helper for pytest_generate_tests hook.
|
22
135
|
|
23
136
|
If a test method (in a class subclassed from our base class)
|
24
137
|
declares an argument 'scenario', this function retrieves the
|
@@ -4,6 +4,8 @@
|
|
4
4
|
from dataclasses import asdict
|
5
5
|
from typing import TYPE_CHECKING
|
6
6
|
|
7
|
+
import pytest
|
8
|
+
|
7
9
|
from airbyte_cdk.models import (
|
8
10
|
AirbyteMessage,
|
9
11
|
AirbyteStream,
|
@@ -48,13 +50,10 @@ class SourceTestSuiteBase(ConnectorTestSuiteBase):
|
|
48
50
|
test_scenario=scenario,
|
49
51
|
connector_root=self.get_connector_root_dir(),
|
50
52
|
)
|
51
|
-
|
52
|
-
msg for msg in result._messages if msg.type == Type.CONNECTION_STATUS
|
53
|
-
] # noqa: SLF001 # Non-public API
|
54
|
-
num_status_messages = len(conn_status_messages)
|
53
|
+
num_status_messages = len(result.connection_status_messages)
|
55
54
|
assert num_status_messages == 1, (
|
56
55
|
f"Expected exactly one CONNECTION_STATUS message. Got {num_status_messages}: \n"
|
57
|
-
+ "\n".join([str(m) for m in result.
|
56
|
+
+ "\n".join([str(m) for m in result.get_message_iterator()])
|
58
57
|
)
|
59
58
|
|
60
59
|
def test_discover(
|
@@ -62,6 +61,13 @@ class SourceTestSuiteBase(ConnectorTestSuiteBase):
|
|
62
61
|
scenario: ConnectorTestScenario,
|
63
62
|
) -> None:
|
64
63
|
"""Standard test for `discover`."""
|
64
|
+
if scenario.expected_outcome.expect_exception():
|
65
|
+
# If the scenario expects an exception, we can't ensure it specifically would fail
|
66
|
+
# in discover, because some discover implementations do not need to make a connection.
|
67
|
+
# We skip this test in that case.
|
68
|
+
pytest.skip("Skipping discover test for scenario that expects an exception.")
|
69
|
+
return
|
70
|
+
|
65
71
|
run_test_job(
|
66
72
|
self.create_connector(scenario),
|
67
73
|
"discover",
|
@@ -133,8 +139,8 @@ class SourceTestSuiteBase(ConnectorTestSuiteBase):
|
|
133
139
|
catalog=configured_catalog,
|
134
140
|
)
|
135
141
|
|
136
|
-
if not result.records:
|
137
|
-
raise AssertionError("Expected records but got none.")
|
142
|
+
if scenario.expected_outcome.expect_success() and not result.records:
|
143
|
+
raise AssertionError("Expected records but got none.")
|
138
144
|
|
139
145
|
def test_fail_read_with_bad_catalog(
|
140
146
|
self,
|