airbyte-cdk 6.45.10__py3-none-any.whl → 6.46.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.
Files changed (31) hide show
  1. airbyte_cdk/cli/__init__.py +9 -1
  2. airbyte_cdk/cli/airbyte_cdk/__init__.py +86 -0
  3. airbyte_cdk/cli/airbyte_cdk/_connector.py +179 -0
  4. airbyte_cdk/cli/airbyte_cdk/_image.py +95 -0
  5. airbyte_cdk/cli/airbyte_cdk/_manifest.py +24 -0
  6. airbyte_cdk/cli/airbyte_cdk/_secrets.py +150 -0
  7. airbyte_cdk/cli/airbyte_cdk/_util.py +43 -0
  8. airbyte_cdk/cli/airbyte_cdk/_version.py +13 -0
  9. airbyte_cdk/connector_builder/connector_builder_handler.py +10 -0
  10. airbyte_cdk/models/connector_metadata.py +97 -0
  11. airbyte_cdk/sources/declarative/declarative_component_schema.yaml +108 -79
  12. airbyte_cdk/sources/declarative/manifest_declarative_source.py +122 -45
  13. airbyte_cdk/sources/declarative/models/declarative_component_schema.py +87 -82
  14. airbyte_cdk/sources/declarative/parsers/custom_exceptions.py +9 -0
  15. airbyte_cdk/sources/declarative/parsers/manifest_component_transformer.py +2 -2
  16. airbyte_cdk/sources/declarative/parsers/manifest_normalizer.py +462 -0
  17. airbyte_cdk/sources/declarative/parsers/manifest_reference_resolver.py +2 -2
  18. airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py +24 -24
  19. airbyte_cdk/sources/declarative/stream_slicers/declarative_partition_generator.py +17 -1
  20. airbyte_cdk/test/standard_tests/connector_base.py +51 -25
  21. airbyte_cdk/test/standard_tests/declarative_sources.py +3 -1
  22. airbyte_cdk/test/standard_tests/test_resources.py +69 -0
  23. airbyte_cdk/test/standard_tests/util.py +79 -0
  24. airbyte_cdk/utils/docker.py +337 -0
  25. airbyte_cdk/utils/docker_image_templates.py +101 -0
  26. {airbyte_cdk-6.45.10.dist-info → airbyte_cdk-6.46.1.dist-info}/METADATA +6 -1
  27. {airbyte_cdk-6.45.10.dist-info → airbyte_cdk-6.46.1.dist-info}/RECORD +31 -18
  28. {airbyte_cdk-6.45.10.dist-info → airbyte_cdk-6.46.1.dist-info}/entry_points.txt +1 -0
  29. {airbyte_cdk-6.45.10.dist-info → airbyte_cdk-6.46.1.dist-info}/LICENSE.txt +0 -0
  30. {airbyte_cdk-6.45.10.dist-info → airbyte_cdk-6.46.1.dist-info}/LICENSE_SHORT +0 -0
  31. {airbyte_cdk-6.45.10.dist-info → airbyte_cdk-6.46.1.dist-info}/WHEEL +0 -0
@@ -4,7 +4,9 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import abc
7
+ import importlib
7
8
  import inspect
9
+ import os
8
10
  import sys
9
11
  from collections.abc import Callable
10
12
  from pathlib import Path
@@ -22,17 +24,61 @@ from airbyte_cdk.test.standard_tests._job_runner import IConnector, run_test_job
22
24
  from airbyte_cdk.test.standard_tests.models import (
23
25
  ConnectorTestScenario,
24
26
  )
25
-
26
- ACCEPTANCE_TEST_CONFIG = "acceptance-test-config.yml"
27
- MANIFEST_YAML = "manifest.yaml"
27
+ from airbyte_cdk.test.standard_tests.test_resources import (
28
+ ACCEPTANCE_TEST_CONFIG,
29
+ find_connector_root,
30
+ )
28
31
 
29
32
 
30
33
  class ConnectorTestSuiteBase(abc.ABC):
31
34
  """Base class for connector test suites."""
32
35
 
33
- connector: type[IConnector] | Callable[[], IConnector] | None = None
36
+ connector: type[IConnector] | Callable[[], IConnector] | None # type: ignore [reportRedeclaration]
34
37
  """The connector class or a factory function that returns an scenario of IConnector."""
35
38
 
39
+ @classproperty # type: ignore [no-redef]
40
+ def connector(cls) -> type[IConnector] | Callable[[], IConnector] | None:
41
+ """Get the connector class for the test suite.
42
+
43
+ This assumes a python connector and should be overridden by subclasses to provide the
44
+ specific connector class to be tested.
45
+ """
46
+ connector_root = cls.get_connector_root_dir()
47
+ connector_name = connector_root.absolute().name
48
+
49
+ expected_module_name = connector_name.replace("-", "_").lower()
50
+ expected_class_name = connector_name.replace("-", "_").title().replace("_", "")
51
+
52
+ # dynamically import and get the connector class: <expected_module_name>.<expected_class_name>
53
+
54
+ cwd_snapshot = Path().absolute()
55
+ os.chdir(connector_root)
56
+
57
+ # Dynamically import the module
58
+ try:
59
+ module = importlib.import_module(expected_module_name)
60
+ except ModuleNotFoundError as e:
61
+ raise ImportError(f"Could not import module '{expected_module_name}'.") from e
62
+ finally:
63
+ # Change back to the original working directory
64
+ os.chdir(cwd_snapshot)
65
+
66
+ # Dynamically get the class from the module
67
+ try:
68
+ return cast(type[IConnector], getattr(module, expected_class_name))
69
+ except AttributeError as e:
70
+ # We did not find it based on our expectations, so let's check if we can find it
71
+ # with a case-insensitive match.
72
+ matching_class_name = next(
73
+ (name for name in dir(module) if name.lower() == expected_class_name.lower()),
74
+ None,
75
+ )
76
+ if not matching_class_name:
77
+ raise ImportError(
78
+ f"Module '{expected_module_name}' does not have a class named '{expected_class_name}'."
79
+ ) from e
80
+ return cast(type[IConnector], getattr(module, matching_class_name))
81
+
36
82
  @classmethod
37
83
  def get_test_class_dir(cls) -> Path:
38
84
  """Get the file path that contains the class."""
@@ -81,27 +127,7 @@ class ConnectorTestSuiteBase(abc.ABC):
81
127
  @classmethod
82
128
  def get_connector_root_dir(cls) -> Path:
83
129
  """Get the root directory of the connector."""
84
- for parent in cls.get_test_class_dir().parents:
85
- if (parent / MANIFEST_YAML).exists():
86
- return parent
87
- if (parent / ACCEPTANCE_TEST_CONFIG).exists():
88
- return parent
89
- if parent.name == "airbyte_cdk":
90
- break
91
- # If we reach here, we didn't find the manifest file in any parent directory
92
- # Check if the manifest file exists in the current directory
93
- for parent in Path.cwd().parents:
94
- if (parent / MANIFEST_YAML).exists():
95
- return parent
96
- if (parent / ACCEPTANCE_TEST_CONFIG).exists():
97
- return parent
98
- if parent.name == "airbyte_cdk":
99
- break
100
-
101
- raise FileNotFoundError(
102
- "Could not find connector root directory relative to "
103
- f"'{str(cls.get_test_class_dir())}' or '{str(Path.cwd())}'."
104
- )
130
+ return find_connector_root([cls.get_test_class_dir(), Path.cwd()])
105
131
 
106
132
  @classproperty
107
133
  def acceptance_test_config_path(cls) -> Path:
@@ -10,9 +10,9 @@ from airbyte_cdk.sources.declarative.concurrent_declarative_source import (
10
10
  ConcurrentDeclarativeSource,
11
11
  )
12
12
  from airbyte_cdk.test.standard_tests._job_runner import IConnector
13
- from airbyte_cdk.test.standard_tests.connector_base import MANIFEST_YAML
14
13
  from airbyte_cdk.test.standard_tests.models import ConnectorTestScenario
15
14
  from airbyte_cdk.test.standard_tests.source_base import SourceTestSuiteBase
15
+ from airbyte_cdk.test.standard_tests.test_resources import MANIFEST_YAML
16
16
 
17
17
 
18
18
  def md5_checksum(file_path: Path) -> str:
@@ -35,6 +35,8 @@ class DeclarativeSourceTestSuite(SourceTestSuiteBase):
35
35
  `components.py` file (if it exists) in the connector's directory.
36
36
  """
37
37
 
38
+ connector: type[IConnector] | None = None
39
+
38
40
  @classproperty
39
41
  def manifest_yaml_path(cls) -> Path:
40
42
  """Get the path to the manifest.yaml file."""
@@ -0,0 +1,69 @@
1
+ # Copyright (c) 2025 Airbyte, Inc., all rights reserved.
2
+ """Resources for Airbyte CDK tests."""
3
+
4
+ from contextlib import suppress
5
+ from pathlib import Path
6
+
7
+ ACCEPTANCE_TEST_CONFIG = "acceptance-test-config.yml"
8
+ MANIFEST_YAML = "manifest.yaml"
9
+ METADATA_YAML = "metadata.yaml"
10
+
11
+
12
+ def find_connector_root(from_paths: list[Path]) -> Path:
13
+ """Find the root directory of the connector."""
14
+ for path in from_paths:
15
+ # If we reach here, we didn't find the manifest file in any parent directory
16
+ # Check if the manifest file exists in the current directory
17
+ for parent in [path, *path.parents]:
18
+ if (parent / METADATA_YAML).exists():
19
+ return parent
20
+ if (parent / MANIFEST_YAML).exists():
21
+ return parent
22
+ if (parent / ACCEPTANCE_TEST_CONFIG).exists():
23
+ return parent
24
+ if parent.name == "airbyte_cdk":
25
+ break
26
+
27
+ raise FileNotFoundError(
28
+ "Could not find connector root directory relative to the provided directories: "
29
+ f"'{str(from_paths)}'."
30
+ )
31
+
32
+
33
+ def find_connector_root_from_name(connector_name: str) -> Path:
34
+ """Find the root directory of the connector from its name."""
35
+ with suppress(FileNotFoundError):
36
+ return find_connector_root([Path(connector_name)])
37
+
38
+ # If the connector name is not found, check if we are in the airbyte monorepo
39
+ # and try to find the connector root from the current directory.
40
+
41
+ cwd: Path = Path().absolute()
42
+
43
+ if "airbyte" not in cwd.parts:
44
+ raise FileNotFoundError(
45
+ "Could not find connector root directory relative and we are not in the airbyte repo. "
46
+ f"Current directory: {cwd} "
47
+ )
48
+
49
+ # Find the connector root from the current directory
50
+
51
+ airbyte_repo_root: Path
52
+ for parent in [cwd, *cwd.parents]:
53
+ if parent.name == "airbyte":
54
+ airbyte_repo_root = parent
55
+ break
56
+ else:
57
+ raise FileNotFoundError(
58
+ "Could not find connector root directory relative and we are not in the airbyte repo."
59
+ )
60
+
61
+ expected_connector_dir: Path = (
62
+ airbyte_repo_root / "airbyte-integrations" / "connectors" / connector_name
63
+ )
64
+ if not expected_connector_dir.exists():
65
+ raise FileNotFoundError(
66
+ f"Could not find connector directory '{expected_connector_dir}' relative to the airbyte repo."
67
+ )
68
+
69
+ return expected_connector_dir
@@ -0,0 +1,79 @@
1
+ # Copyright (c) 2025 Airbyte, Inc., all rights reserved.
2
+ """Utility and factory functions for testing Airbyte connectors."""
3
+
4
+ from pathlib import Path
5
+ from typing import Any, Literal
6
+
7
+ import yaml
8
+
9
+ from airbyte_cdk.test.standard_tests.connector_base import ConnectorTestSuiteBase
10
+ from airbyte_cdk.test.standard_tests.declarative_sources import (
11
+ DeclarativeSourceTestSuite,
12
+ )
13
+ from airbyte_cdk.test.standard_tests.destination_base import DestinationTestSuiteBase
14
+ from airbyte_cdk.test.standard_tests.source_base import SourceTestSuiteBase
15
+ from airbyte_cdk.test.standard_tests.test_resources import (
16
+ METADATA_YAML,
17
+ find_connector_root_from_name,
18
+ )
19
+
20
+ TEST_CLASS_MAPPING: dict[
21
+ Literal["python", "manifest-only", "declarative"], type[ConnectorTestSuiteBase]
22
+ ] = {
23
+ "python": SourceTestSuiteBase,
24
+ "manifest-only": DeclarativeSourceTestSuite,
25
+ # "declarative": DeclarativeSourceTestSuite,
26
+ }
27
+
28
+
29
+ def create_connector_test_suite(
30
+ *,
31
+ connector_name: str | None = None,
32
+ connector_directory: Path | None = None,
33
+ ) -> type[ConnectorTestSuiteBase]:
34
+ """Get the test class for the specified connector name or path."""
35
+ if connector_name and connector_directory:
36
+ raise ValueError("Specify either `connector_name` or `connector_directory`, not both.")
37
+ if not connector_name and not connector_directory:
38
+ raise ValueError("Specify either `connector_name` or `connector_directory`.")
39
+
40
+ if connector_name:
41
+ connector_directory = find_connector_root_from_name(
42
+ connector_name,
43
+ )
44
+ else:
45
+ # By here, we know that connector_directory is not None
46
+ # but connector_name is None. Set the connector_name.
47
+ assert connector_directory is not None, "connector_directory should not be None here."
48
+ connector_name = connector_directory.name
49
+
50
+ metadata_yaml_path = connector_directory / METADATA_YAML
51
+ if not metadata_yaml_path.exists():
52
+ raise FileNotFoundError(
53
+ f"Could not find metadata YAML file '{metadata_yaml_path}' relative to the connector directory."
54
+ )
55
+ metadata_dict: dict[str, Any] = yaml.safe_load(metadata_yaml_path.read_text())
56
+ metadata_tags = metadata_dict["data"].get("tags", [])
57
+ for language_option in TEST_CLASS_MAPPING:
58
+ if f"language:{language_option}" in metadata_tags:
59
+ language = language_option
60
+ test_suite_class = TEST_CLASS_MAPPING[language]
61
+ break
62
+ else:
63
+ raise ValueError(
64
+ f"Unsupported connector type. "
65
+ f"Supported language values are: {', '.join(TEST_CLASS_MAPPING.keys())}. "
66
+ f"Found tags: {', '.join(metadata_tags)}"
67
+ )
68
+
69
+ subclass_overrides: dict[str, Any] = {
70
+ "get_connector_root_dir": lambda: connector_directory,
71
+ }
72
+
73
+ TestSuiteAuto = type(
74
+ "TestSuiteAuto",
75
+ (test_suite_class,),
76
+ subclass_overrides,
77
+ )
78
+
79
+ return TestSuiteAuto
@@ -0,0 +1,337 @@
1
+ """Docker build utilities for Airbyte CDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ from dataclasses import dataclass
11
+ from enum import Enum
12
+ from pathlib import Path
13
+
14
+ import click
15
+
16
+ from airbyte_cdk.models.connector_metadata import ConnectorLanguage, MetadataFile
17
+ from airbyte_cdk.utils.docker_image_templates import (
18
+ DOCKERIGNORE_TEMPLATE,
19
+ MANIFEST_ONLY_DOCKERFILE_TEMPLATE,
20
+ PYTHON_CONNECTOR_DOCKERFILE_TEMPLATE,
21
+ )
22
+
23
+
24
+ @dataclass(kw_only=True)
25
+ class ConnectorImageBuildError(Exception):
26
+ """Custom exception for Docker build errors."""
27
+
28
+ error_text: str
29
+ build_args: list[str]
30
+
31
+ def __str__(self) -> str:
32
+ return "\n".join(
33
+ [
34
+ f"ConnectorImageBuildError: Could not build image.",
35
+ f"Build args: {self.build_args}",
36
+ f"Error text: {self.error_text}",
37
+ ]
38
+ )
39
+
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ class ArchEnum(str, Enum):
45
+ """Enum for supported architectures."""
46
+
47
+ ARM64 = "arm64"
48
+ AMD64 = "amd64"
49
+
50
+
51
+ def _build_image(
52
+ context_dir: Path,
53
+ dockerfile: Path,
54
+ metadata: MetadataFile,
55
+ tag: str,
56
+ arch: ArchEnum,
57
+ build_args: dict[str, str | None] | None = None,
58
+ ) -> str:
59
+ """Build a Docker image for the specified architecture.
60
+
61
+ Returns the tag of the built image.
62
+
63
+ Raises: ConnectorImageBuildError if the build fails.
64
+ """
65
+ docker_args: list[str] = [
66
+ "docker",
67
+ "build",
68
+ "--platform",
69
+ f"linux/{arch.value}",
70
+ "--file",
71
+ str(dockerfile),
72
+ "--label",
73
+ f"io.airbyte.version={metadata.data.dockerImageTag}",
74
+ "--label",
75
+ f"io.airbyte.name={metadata.data.dockerRepository}",
76
+ ]
77
+ if build_args:
78
+ for key, value in build_args.items():
79
+ if value is not None:
80
+ docker_args.append(f"--build-arg={key}={value}")
81
+ else:
82
+ docker_args.append(f"--build-arg={key}")
83
+ docker_args.extend(
84
+ [
85
+ "-t",
86
+ tag,
87
+ str(context_dir),
88
+ ]
89
+ )
90
+
91
+ print(f"Building image: {tag} ({arch})")
92
+ try:
93
+ run_docker_command(
94
+ docker_args,
95
+ check=True,
96
+ )
97
+ except subprocess.CalledProcessError as e:
98
+ raise ConnectorImageBuildError(
99
+ error_text=e.stderr,
100
+ build_args=docker_args,
101
+ ) from e
102
+
103
+ return tag
104
+
105
+
106
+ def _tag_image(
107
+ tag: str,
108
+ new_tags: list[str] | str,
109
+ ) -> None:
110
+ """Build a Docker image for the specified architecture.
111
+
112
+ Returns the tag of the built image.
113
+
114
+ Raises:
115
+ ConnectorImageBuildError: If the docker tag command fails.
116
+ """
117
+ if not isinstance(new_tags, list):
118
+ new_tags = [new_tags]
119
+
120
+ for new_tag in new_tags:
121
+ print(f"Tagging image '{tag}' as: {new_tag}")
122
+ docker_args = [
123
+ "docker",
124
+ "tag",
125
+ tag,
126
+ new_tag,
127
+ ]
128
+ try:
129
+ run_docker_command(
130
+ docker_args,
131
+ check=True,
132
+ )
133
+ except subprocess.CalledProcessError as e:
134
+ raise ConnectorImageBuildError(
135
+ error_text=e.stderr,
136
+ build_args=docker_args,
137
+ ) from e
138
+
139
+
140
+ def build_connector_image(
141
+ connector_name: str,
142
+ connector_directory: Path,
143
+ metadata: MetadataFile,
144
+ tag: str,
145
+ primary_arch: ArchEnum = ArchEnum.ARM64, # Assume MacBook M series by default
146
+ no_verify: bool = False,
147
+ ) -> None:
148
+ """Build a connector Docker image.
149
+
150
+ This command builds a Docker image for a connector, using either
151
+ the connector's Dockerfile or a base image specified in the metadata.
152
+ The image is built for both AMD64 and ARM64 architectures.
153
+
154
+ Args:
155
+ connector_name: The name of the connector.
156
+ connector_directory: The directory containing the connector code.
157
+ metadata: The metadata of the connector.
158
+ tag: The tag to apply to the built image.
159
+ primary_arch: The primary architecture for the build (default: arm64). This
160
+ architecture will be used for the same-named tag. Both AMD64 and ARM64
161
+ images will be built, with the suffixes '-amd64' and '-arm64'.
162
+ no_verify: If True, skip verification of the built image.
163
+
164
+ Raises:
165
+ ValueError: If the connector build options are not defined in metadata.yaml.
166
+ ConnectorImageBuildError: If the image build or tag operation fails.
167
+ """
168
+ connector_kebab_name = connector_name
169
+ connector_snake_name = connector_kebab_name.replace("-", "_")
170
+
171
+ dockerfile_path = connector_directory / "build" / "docker" / "Dockerfile"
172
+ dockerignore_path = connector_directory / "build" / "docker" / "Dockerfile.dockerignore"
173
+
174
+ extra_build_script: str = ""
175
+ build_customization_path = connector_directory / "build_customization.py"
176
+ if build_customization_path.exists():
177
+ extra_build_script = str(build_customization_path)
178
+
179
+ dockerfile_path.parent.mkdir(parents=True, exist_ok=True)
180
+ if not metadata.data.connectorBuildOptions:
181
+ raise ValueError(
182
+ "Connector build options are not defined in metadata.yaml. "
183
+ "Please check the connector's metadata file."
184
+ )
185
+
186
+ base_image = metadata.data.connectorBuildOptions.baseImage
187
+
188
+ dockerfile_path.write_text(get_dockerfile_template(metadata))
189
+ dockerignore_path.write_text(DOCKERIGNORE_TEMPLATE)
190
+
191
+ build_args: dict[str, str | None] = {
192
+ "BASE_IMAGE": base_image,
193
+ "CONNECTOR_SNAKE_NAME": connector_snake_name,
194
+ "CONNECTOR_KEBAB_NAME": connector_kebab_name,
195
+ "EXTRA_BUILD_SCRIPT": extra_build_script,
196
+ }
197
+
198
+ base_tag = f"{metadata.data.dockerRepository}:{tag}"
199
+ arch_images: list[str] = []
200
+ for arch in [ArchEnum.AMD64, ArchEnum.ARM64]:
201
+ docker_tag = f"{base_tag}-{arch.value}"
202
+ docker_tag_parts = docker_tag.split("/")
203
+ if len(docker_tag_parts) > 2:
204
+ docker_tag = "/".join(docker_tag_parts[-1:])
205
+ arch_images.append(
206
+ _build_image(
207
+ context_dir=connector_directory,
208
+ dockerfile=dockerfile_path,
209
+ metadata=metadata,
210
+ tag=docker_tag,
211
+ arch=arch,
212
+ build_args=build_args,
213
+ )
214
+ )
215
+
216
+ _tag_image(
217
+ tag=f"{base_tag}-{primary_arch.value}",
218
+ new_tags=[base_tag],
219
+ )
220
+ if not no_verify:
221
+ if verify_connector_image(base_tag):
222
+ click.echo(f"Build completed successfully: {base_tag}")
223
+ sys.exit(0)
224
+ else:
225
+ click.echo(f"Built image failed verification: {base_tag}", err=True)
226
+ sys.exit(1)
227
+ else:
228
+ click.echo(f"Build completed successfully (without verification): {base_tag}")
229
+ sys.exit(0)
230
+
231
+
232
+ def get_dockerfile_template(
233
+ metadata: MetadataFile,
234
+ ) -> str:
235
+ """Get the Dockerfile template for the connector.
236
+
237
+ Args:
238
+ metadata: The metadata of the connector.
239
+ connector_name: The name of the connector.
240
+
241
+ Returns:
242
+ The Dockerfile template as a string.
243
+ """
244
+ if metadata.data.language == ConnectorLanguage.PYTHON:
245
+ return PYTHON_CONNECTOR_DOCKERFILE_TEMPLATE
246
+
247
+ if metadata.data.language == ConnectorLanguage.MANIFEST_ONLY:
248
+ return MANIFEST_ONLY_DOCKERFILE_TEMPLATE
249
+
250
+ if metadata.data.language == ConnectorLanguage.JAVA:
251
+ raise ValueError(
252
+ f"Java and Kotlin connectors are not yet supported. "
253
+ "Please use airbyte-ci or gradle to build your image."
254
+ )
255
+
256
+ raise ValueError(
257
+ f"Unsupported connector language: {metadata.data.language}. "
258
+ "Please check the connector's metadata file."
259
+ )
260
+
261
+
262
+ def run_docker_command(
263
+ cmd: list[str],
264
+ *,
265
+ check: bool = True,
266
+ capture_output: bool = False,
267
+ ) -> subprocess.CompletedProcess[str]:
268
+ """Run a Docker command as a subprocess.
269
+
270
+ Args:
271
+ cmd: The command to run as a list of strings.
272
+ check: If True, raises an exception if the command fails. If False, the caller is
273
+ responsible for checking the return code.
274
+ capture_output: If True, captures stdout and stderr and returns to the caller.
275
+ If False, the output is printed to the console.
276
+
277
+ Raises:
278
+ subprocess.CalledProcessError: If the command fails and check is True.
279
+ """
280
+ print(f"Running command: {' '.join(cmd)}")
281
+
282
+ process = subprocess.run(
283
+ cmd,
284
+ text=True,
285
+ check=check,
286
+ # If capture_output=True, stderr and stdout are captured and returned to caller:
287
+ capture_output=capture_output,
288
+ env={**os.environ, "DOCKER_BUILDKIT": "1"},
289
+ )
290
+ return process
291
+
292
+
293
+ def verify_docker_installation() -> bool:
294
+ """Verify Docker is installed and running."""
295
+ try:
296
+ run_docker_command(["docker", "--version"])
297
+ return True
298
+ except (subprocess.CalledProcessError, FileNotFoundError):
299
+ return False
300
+
301
+
302
+ def verify_connector_image(
303
+ image_name: str,
304
+ ) -> bool:
305
+ """Verify the built image by running the spec command.
306
+
307
+ Args:
308
+ image_name: The full image name with tag.
309
+
310
+ Returns:
311
+ True if the spec command succeeds, False otherwise.
312
+ """
313
+ logger.info(f"Verifying image {image_name} with 'spec' command...")
314
+
315
+ cmd = ["docker", "run", "--rm", image_name, "spec"]
316
+
317
+ try:
318
+ result = run_docker_command(
319
+ cmd,
320
+ check=True,
321
+ capture_output=True,
322
+ )
323
+ # check that the output is valid JSON
324
+ if result.stdout:
325
+ try:
326
+ json.loads(result.stdout)
327
+ except json.JSONDecodeError:
328
+ logger.error("Invalid JSON output from spec command.")
329
+ return False
330
+ else:
331
+ logger.error("No output from spec command.")
332
+ return False
333
+ except subprocess.CalledProcessError as e:
334
+ logger.error(f"Image verification failed: {e.stderr}")
335
+ return False
336
+
337
+ return True