|
6 | 6 | import inspect |
7 | 7 | import shutil |
8 | 8 | import sys |
| 9 | +import tempfile |
9 | 10 | import warnings |
| 11 | +from dataclasses import asdict |
10 | 12 | from pathlib import Path |
| 13 | +from subprocess import CompletedProcess, SubprocessError |
11 | 14 |
|
| 15 | +import orjson |
12 | 16 | import pytest |
13 | 17 | import yaml |
14 | 18 | from boltons.typeutils import classproperty |
15 | 19 |
|
| 20 | +from airbyte_cdk.models import ( |
| 21 | + AirbyteCatalog, |
| 22 | + ConfiguredAirbyteCatalog, |
| 23 | + ConfiguredAirbyteStream, |
| 24 | + DestinationSyncMode, |
| 25 | +) |
| 26 | +from airbyte_cdk.models.airbyte_protocol_serializers import ( |
| 27 | + AirbyteCatalogSerializer, |
| 28 | + AirbyteStreamSerializer, |
| 29 | +) |
16 | 30 | from airbyte_cdk.models.connector_metadata import MetadataFile |
17 | 31 | from airbyte_cdk.test.models import ConnectorTestScenario |
| 32 | +from airbyte_cdk.test.utils.reading import catalog |
18 | 33 | from airbyte_cdk.utils.connector_paths import ( |
19 | 34 | ACCEPTANCE_TEST_CONFIG, |
20 | 35 | find_connector_root, |
@@ -127,17 +142,23 @@ def test_docker_image_build_and_spec( |
127 | 142 | no_verify=False, |
128 | 143 | ) |
129 | 144 |
|
130 | | - _ = run_docker_command( |
131 | | - [ |
132 | | - "docker", |
133 | | - "run", |
134 | | - "--rm", |
135 | | - connector_image, |
136 | | - "spec", |
137 | | - ], |
138 | | - check=True, # Raise an error if the command fails |
139 | | - capture_output=False, |
140 | | - ) |
| 145 | + try: |
| 146 | + result: CompletedProcess[str] = run_docker_command( |
| 147 | + [ |
| 148 | + "docker", |
| 149 | + "run", |
| 150 | + "--rm", |
| 151 | + connector_image, |
| 152 | + "spec", |
| 153 | + ], |
| 154 | + check=True, # Raise an error if the command fails |
| 155 | + capture_stderr=True, |
| 156 | + capture_stdout=True, |
| 157 | + ) |
| 158 | + except SubprocessError as ex: |
| 159 | + raise AssertionError( |
| 160 | + f"Failed to run `spec` command in docker image {connector_image!r}. Error: {ex!s}" |
| 161 | + ) from None |
141 | 162 |
|
142 | 163 | @pytest.mark.skipif( |
143 | 164 | shutil.which("docker") is None, |
@@ -192,5 +213,166 @@ def test_docker_image_build_and_check( |
192 | 213 | container_config_path, |
193 | 214 | ], |
194 | 215 | check=True, # Raise an error if the command fails |
195 | | - capture_output=False, |
| 216 | + capture_stderr=True, |
| 217 | + capture_stdout=True, |
| 218 | + ) |
| 219 | + |
| 220 | + @pytest.mark.skipif( |
| 221 | + shutil.which("docker") is None, |
| 222 | + reason="docker CLI not found in PATH, skipping docker image tests", |
| 223 | + ) |
| 224 | + @pytest.mark.image_tests |
| 225 | + def test_docker_image_build_and_read( |
| 226 | + self, |
| 227 | + scenario: ConnectorTestScenario, |
| 228 | + connector_image_override: str | None, |
| 229 | + read_from_streams: Literal["all", "none", "default"] | list[str], |
| 230 | + read_scenarios: Literal["all", "none", "default"] | list[str], |
| 231 | + ) -> None: |
| 232 | + """Read from the connector's Docker image. |
| 233 | +
|
| 234 | + This test builds the connector image and runs the `read` command inside the container. |
| 235 | +
|
| 236 | + Note: |
| 237 | + - It is expected for docker image caches to be reused between test runs. |
| 238 | + - In the rare case that image caches need to be cleared, please clear |
| 239 | + the local docker image cache using `docker image prune -a` command. |
| 240 | + - If the --connector-image arg is provided, it will be used instead of building the image. |
| 241 | + """ |
| 242 | + if scenario.expected_outcome.expect_exception(): |
| 243 | + pytest.skip("Skipping (expected to fail).") |
| 244 | + |
| 245 | + if read_from_streams == "none": |
| 246 | + pytest.skip("Skipping read test (`--read-from-streams=false`).") |
| 247 | + |
| 248 | + if read_scenarios == "none": |
| 249 | + pytest.skip("Skipping (`--read-scenarios=none`).") |
| 250 | + |
| 251 | + default_scenario_ids = ["config", "valid_config", "default"] |
| 252 | + if read_scenarios == "all": |
| 253 | + pass |
| 254 | + elif read_scenarios == "default": |
| 255 | + if scenario.id not in default_scenario_ids: |
| 256 | + pytest.skip( |
| 257 | + f"Skipping read test for scenario '{scenario.id}' " |
| 258 | + f"(not in default scenarios list '{default_scenario_ids}')." |
| 259 | + ) |
| 260 | + elif scenario.id not in read_scenarios: |
| 261 | + # pytest.skip( |
| 262 | + raise ValueError( |
| 263 | + f"Skipping read test for scenario '{scenario.id}' " |
| 264 | + f"(not in --read-scenarios={read_scenarios})." |
| 265 | + ) |
| 266 | + |
| 267 | + tag = "dev-latest" |
| 268 | + connector_root = self.get_connector_root_dir() |
| 269 | + connector_name = connector_root.name |
| 270 | + metadata = MetadataFile.from_file(connector_root / "metadata.yaml") |
| 271 | + connector_image: str | None = connector_image_override |
| 272 | + if not connector_image: |
| 273 | + tag = "dev-latest" |
| 274 | + connector_image = build_connector_image( |
| 275 | + connector_name=connector_name, |
| 276 | + connector_directory=connector_root, |
| 277 | + metadata=metadata, |
| 278 | + tag=tag, |
| 279 | + no_verify=False, |
| 280 | + ) |
| 281 | + |
| 282 | + container_config_path = "/secrets/config.json" |
| 283 | + container_catalog_path = "/secrets/catalog.json" |
| 284 | + |
| 285 | + discovered_catalog_path = Path( |
| 286 | + tempfile.mktemp(prefix=f"{connector_name}-discovered-catalog-", suffix=".json") |
| 287 | + ) |
| 288 | + configured_catalog_path = Path( |
| 289 | + tempfile.mktemp(prefix=f"{connector_name}-configured-catalog-", suffix=".json") |
| 290 | + ) |
| 291 | + with scenario.with_temp_config_file( |
| 292 | + connector_root=connector_root, |
| 293 | + ) as temp_config_file: |
| 294 | + discover_result = run_docker_command( |
| 295 | + [ |
| 296 | + "docker", |
| 297 | + "run", |
| 298 | + "--rm", |
| 299 | + "-v", |
| 300 | + f"{temp_config_file}:{container_config_path}", |
| 301 | + connector_image, |
| 302 | + "discover", |
| 303 | + "--config", |
| 304 | + container_config_path, |
| 305 | + ], |
| 306 | + check=True, # Raise an error if the command fails |
| 307 | + capture_stderr=True, |
| 308 | + capture_stdout=True, |
| 309 | + ) |
| 310 | + try: |
| 311 | + discovered_catalog: AirbyteCatalog = AirbyteCatalogSerializer.load( |
| 312 | + orjson.loads(discover_result.stdout)["catalog"], |
| 313 | + ) |
| 314 | + except Exception as ex: |
| 315 | + raise AssertionError( |
| 316 | + f"Failed to load discovered catalog from {discover_result.stdout}. " |
| 317 | + f"Error: {ex!s}" |
| 318 | + ) from None |
| 319 | + if not discovered_catalog.streams: |
| 320 | + raise ValueError( |
| 321 | + f"Discovered catalog for connector '{connector_name}' is empty. " |
| 322 | + "Please check the connector's discover implementation." |
| 323 | + ) |
| 324 | + |
| 325 | + streams_list = [stream.name for stream in discovered_catalog.streams] |
| 326 | + if read_from_streams == "default" and metadata.data.suggestedStreams: |
| 327 | + # set `streams_list` to be the intersection of discovered and suggested streams. |
| 328 | + streams_list = list(set(streams_list) & set(metadata.data.suggestedStreams)) |
| 329 | + |
| 330 | + if isinstance(read_from_streams, list): |
| 331 | + # If `read_from_streams` is a list, we filter the discovered streams. |
| 332 | + streams_list = list(set(streams_list) & set(read_from_streams)) |
| 333 | + |
| 334 | + configured_catalog: ConfiguredAirbyteCatalog = ConfiguredAirbyteCatalog( |
| 335 | + streams=[ |
| 336 | + ConfiguredAirbyteStream( |
| 337 | + stream=stream, |
| 338 | + sync_mode=stream.supported_sync_modes[0], |
| 339 | + destination_sync_mode=DestinationSyncMode.append, |
| 340 | + ) |
| 341 | + for stream in discovered_catalog.streams |
| 342 | + if stream.name in streams_list |
| 343 | + ] |
| 344 | + ) |
| 345 | + configured_catalog_path.write_text( |
| 346 | + orjson.dumps(asdict(configured_catalog)).decode("utf-8") |
| 347 | + ) |
| 348 | + read_result: CompletedProcess[str] = run_docker_command( |
| 349 | + [ |
| 350 | + "docker", |
| 351 | + "run", |
| 352 | + "--rm", |
| 353 | + "-v", |
| 354 | + f"{temp_config_file}:{container_config_path}", |
| 355 | + "-v", |
| 356 | + f"{configured_catalog_path}:{container_catalog_path}", |
| 357 | + connector_image, |
| 358 | + "read", |
| 359 | + "--config", |
| 360 | + container_config_path, |
| 361 | + "--catalog", |
| 362 | + container_catalog_path, |
| 363 | + ], |
| 364 | + check=False, |
| 365 | + capture_stderr=True, |
| 366 | + capture_stdout=True, |
196 | 367 | ) |
| 368 | + if read_result.returncode != 0: |
| 369 | + raise AssertionError( |
| 370 | + f"Failed to run `read` command in docker image {connector_image!r}. " |
| 371 | + "\n-----------------" |
| 372 | + f"EXIT CODE: {read_result.returncode}\n" |
| 373 | + "STDERR:\n" |
| 374 | + f"{read_result.stderr}\n" |
| 375 | + f"STDOUT:\n" |
| 376 | + f"{read_result.stdout}\n" |
| 377 | + "\n-----------------" |
| 378 | + ) from None |
0 commit comments