airbyte-cdk 6.54.11__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.
Files changed (26) hide show
  1. airbyte_cdk/cli/airbyte_cdk/_connector.py +32 -8
  2. airbyte_cdk/cli/airbyte_cdk/_image.py +76 -0
  3. airbyte_cdk/cli/airbyte_cdk/_secrets.py +13 -12
  4. airbyte_cdk/models/airbyte_protocol_serializers.py +4 -0
  5. airbyte_cdk/models/connector_metadata.py +14 -0
  6. airbyte_cdk/sources/declarative/concurrent_declarative_source.py +1 -1
  7. airbyte_cdk/sources/declarative/declarative_component_schema.yaml +31 -0
  8. airbyte_cdk/sources/declarative/manifest_declarative_source.py +28 -9
  9. airbyte_cdk/sources/declarative/models/declarative_component_schema.py +23 -2
  10. airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py +2 -2
  11. airbyte_cdk/test/entrypoint_wrapper.py +163 -26
  12. airbyte_cdk/test/models/scenario.py +49 -10
  13. airbyte_cdk/test/standard_tests/__init__.py +2 -4
  14. airbyte_cdk/test/standard_tests/connector_base.py +12 -80
  15. airbyte_cdk/test/standard_tests/docker_base.py +388 -0
  16. airbyte_cdk/test/standard_tests/pytest_hooks.py +115 -2
  17. airbyte_cdk/test/standard_tests/source_base.py +13 -7
  18. airbyte_cdk/test/standard_tests/util.py +4 -3
  19. airbyte_cdk/utils/connector_paths.py +3 -3
  20. airbyte_cdk/utils/docker.py +83 -34
  21. {airbyte_cdk-6.54.11.dist-info → airbyte_cdk-6.55.1.dist-info}/METADATA +2 -1
  22. {airbyte_cdk-6.54.11.dist-info → airbyte_cdk-6.55.1.dist-info}/RECORD +26 -25
  23. {airbyte_cdk-6.54.11.dist-info → airbyte_cdk-6.55.1.dist-info}/LICENSE.txt +0 -0
  24. {airbyte_cdk-6.54.11.dist-info → airbyte_cdk-6.55.1.dist-info}/LICENSE_SHORT +0 -0
  25. {airbyte_cdk-6.54.11.dist-info → airbyte_cdk-6.55.1.dist-info}/WHEEL +0 -0
  26. {airbyte_cdk-6.54.11.dist-info → airbyte_cdk-6.55.1.dist-info}/entry_points.txt +0 -0
@@ -101,7 +101,7 @@ def connector_cli_group() -> None:
101
101
  pass
102
102
 
103
103
 
104
- @connector_cli_group.command()
104
+ @connector_cli_group.command("test")
105
105
  @click.argument(
106
106
  "connector",
107
107
  required=False,
@@ -114,10 +114,18 @@ def connector_cli_group() -> None:
114
114
  default=False,
115
115
  help="Only collect tests, do not run them.",
116
116
  )
117
- def test(
117
+ @click.option(
118
+ "--pytest-arg",
119
+ "pytest_args", # ← map --pytest-arg into pytest_args
120
+ type=str,
121
+ multiple=True,
122
+ help="Additional argument(s) to pass to pytest. Can be specified multiple times.",
123
+ )
124
+ def connector_test(
118
125
  connector: str | Path | None = None,
119
126
  *,
120
127
  collect_only: bool = False,
128
+ pytest_args: list[str] | None = None,
121
129
  ) -> None:
122
130
  """Run connector tests.
123
131
 
@@ -130,19 +138,36 @@ def test(
130
138
  directory. If the current working directory is not a connector directory (e.g. starting
131
139
  with 'source-') and no connector name or path is provided, the process will fail.
132
140
  """
141
+ click.echo("Connector test command executed.")
142
+ connector_name, connector_directory = resolve_connector_name_and_directory(connector)
143
+
144
+ pytest_args = pytest_args or []
145
+ if collect_only:
146
+ pytest_args.append("--collect-only")
147
+
148
+ run_connector_tests(
149
+ connector_name=connector_name,
150
+ connector_directory=connector_directory,
151
+ extra_pytest_args=pytest_args,
152
+ )
153
+
154
+
155
+ def run_connector_tests(
156
+ connector_name: str,
157
+ connector_directory: Path,
158
+ extra_pytest_args: list[str],
159
+ ) -> None:
133
160
  if pytest is None:
134
161
  raise ImportError(
135
162
  "pytest is not installed. Please install pytest to run the connector tests."
136
163
  )
137
- click.echo("Connector test command executed.")
138
- connector_name, connector_directory = resolve_connector_name_and_directory(connector)
139
164
 
140
165
  connector_test_suite = create_connector_test_suite(
141
166
  connector_name=connector_name if not connector_directory else None,
142
167
  connector_directory=connector_directory,
143
168
  )
144
169
 
145
- pytest_args: list[str] = []
170
+ pytest_args: list[str] = ["-p", "airbyte_cdk.test.standard_tests.pytest_hooks"]
146
171
  if connector_directory:
147
172
  pytest_args.append(f"--rootdir={connector_directory}")
148
173
  os.chdir(str(connector_directory))
@@ -158,8 +183,8 @@ def test(
158
183
  test_file_path.parent.mkdir(parents=True, exist_ok=True)
159
184
  test_file_path.write_text(file_text)
160
185
 
161
- if collect_only:
162
- pytest_args.append("--collect-only")
186
+ if extra_pytest_args:
187
+ pytest_args.extend(extra_pytest_args)
163
188
 
164
189
  pytest_args.append(str(test_file_path))
165
190
 
@@ -170,7 +195,6 @@ def test(
170
195
 
171
196
  click.echo(f"Running tests from connector directory: {connector_directory}...")
172
197
  click.echo(f"Test file: {test_file_path}")
173
- click.echo(f"Collect only: {collect_only}")
174
198
  click.echo(f"Pytest args: {pytest_args}")
175
199
  click.echo("Invoking Pytest...")
176
200
  exit_code = pytest.main(
@@ -10,6 +10,7 @@ from pathlib import Path
10
10
 
11
11
  import rich_click as click
12
12
 
13
+ from airbyte_cdk.cli.airbyte_cdk._connector import run_connector_tests
13
14
  from airbyte_cdk.models.connector_metadata import MetadataFile
14
15
  from airbyte_cdk.utils.connector_paths import resolve_connector_name_and_directory
15
16
  from airbyte_cdk.utils.docker import (
@@ -88,6 +89,81 @@ def build(
88
89
  sys.exit(1)
89
90
 
90
91
 
92
+ @image_cli_group.command("test")
93
+ @click.argument(
94
+ "connector",
95
+ required=False,
96
+ type=str,
97
+ metavar="[CONNECTOR]",
98
+ )
99
+ @click.option(
100
+ "--image",
101
+ help="Image to test, instead of building a new one.",
102
+ )
103
+ def image_test( # "image test" command
104
+ connector: str | None = None,
105
+ *,
106
+ image: str | None = None,
107
+ ) -> None:
108
+ """Test a connector Docker image.
109
+
110
+ [CONNECTOR] can be a connector name (e.g. 'source-pokeapi'), a path to a connector directory, or omitted to use the current working directory.
111
+ If a string containing '/' is provided, it is treated as a path. Otherwise, it is treated as a connector name.
112
+
113
+ If an image is provided, it will be used for testing instead of building a new one.
114
+
115
+ Note: You should run `airbyte-cdk secrets fetch` before running this command to ensure
116
+ that the secrets are available for the connector tests.
117
+ """
118
+ if not verify_docker_installation():
119
+ click.echo(
120
+ "Docker is not installed or not running. Please install Docker and try again.", err=True
121
+ )
122
+ sys.exit(1)
123
+
124
+ connector_name, connector_directory = resolve_connector_name_and_directory(connector)
125
+
126
+ # Select only tests with the 'image_tests' mark
127
+ pytest_args = ["-m", "image_tests"]
128
+ if not image:
129
+ metadata_file_path: Path = connector_directory / "metadata.yaml"
130
+ try:
131
+ metadata = MetadataFile.from_file(metadata_file_path)
132
+ except (FileNotFoundError, ValueError) as e:
133
+ click.echo(
134
+ f"Error loading metadata file '{metadata_file_path}': {e!s}",
135
+ err=True,
136
+ )
137
+ sys.exit(1)
138
+
139
+ tag = "dev-latest"
140
+ image = f"{metadata.data.dockerRepository}:{tag}"
141
+ click.echo(f"Building Image for Connector: {image}")
142
+ try:
143
+ image = build_connector_image(
144
+ connector_directory=connector_directory,
145
+ connector_name=connector_name,
146
+ metadata=metadata,
147
+ tag=tag,
148
+ no_verify=True,
149
+ )
150
+ except ConnectorImageBuildError as e:
151
+ click.echo(
152
+ f"Error building connector image: {e!s}",
153
+ err=True,
154
+ )
155
+ sys.exit(1)
156
+
157
+ pytest_args.extend(["--connector-image", image])
158
+
159
+ click.echo(f"Testing Connector Image: {image}")
160
+ run_connector_tests(
161
+ connector_name=connector_name,
162
+ connector_directory=connector_directory,
163
+ extra_pytest_args=pytest_args,
164
+ )
165
+
166
+
91
167
  __all__ = [
92
168
  "image_cli_group",
93
169
  ]
@@ -99,12 +99,12 @@ def secrets_cli_group() -> None:
99
99
  help="Print GitHub CI mask for secrets.",
100
100
  type=bool,
101
101
  is_flag=True,
102
- default=False,
102
+ default=None,
103
103
  )
104
104
  def fetch(
105
105
  connector: str | Path | None = None,
106
106
  gcp_project_id: str = GCP_PROJECT_ID,
107
- print_ci_secrets_masks: bool = False,
107
+ print_ci_secrets_masks: bool | None = None,
108
108
  ) -> None:
109
109
  """Fetch secrets for a connector from Google Secret Manager.
110
110
 
@@ -181,22 +181,23 @@ def fetch(
181
181
  if secret_count == 0:
182
182
  raise exceptions[0]
183
183
 
184
- if not print_ci_secrets_masks:
185
- return
186
-
187
- if not os.environ.get("CI", None):
184
+ if print_ci_secrets_masks and "CI" not in os.environ:
188
185
  click.echo(
189
186
  "The `--print-ci-secrets-masks` option is only available in CI environments. "
190
187
  "The `CI` env var is either not set or not set to a truthy value. "
191
188
  "Skipping printing secret masks.",
192
189
  err=True,
193
190
  )
194
- return
195
-
196
- # Else print the CI mask
197
- _print_ci_secrets_masks(
198
- secrets_dir=secrets_dir,
199
- )
191
+ print_ci_secrets_masks = False
192
+ elif print_ci_secrets_masks is None:
193
+ # If not explicitly set, we check if we are in a CI environment
194
+ # and set to True if so.
195
+ print_ci_secrets_masks = os.environ.get("CI", "") != ""
196
+
197
+ if print_ci_secrets_masks:
198
+ _print_ci_secrets_masks(
199
+ secrets_dir=secrets_dir,
200
+ )
200
201
 
201
202
 
202
203
  @secrets_cli_group.command("list")
@@ -4,9 +4,11 @@ from typing import Any, Dict
4
4
  from serpyco_rs import CustomType, Serializer
5
5
 
6
6
  from .airbyte_protocol import ( # type: ignore[attr-defined] # all classes are imported to airbyte_protocol via *
7
+ AirbyteCatalog,
7
8
  AirbyteMessage,
8
9
  AirbyteStateBlob,
9
10
  AirbyteStateMessage,
11
+ AirbyteStream,
10
12
  AirbyteStreamState,
11
13
  ConfiguredAirbyteCatalog,
12
14
  ConfiguredAirbyteStream,
@@ -30,6 +32,8 @@ def custom_type_resolver(t: type) -> CustomType[AirbyteStateBlob, Dict[str, Any]
30
32
  return AirbyteStateBlobType() if t is AirbyteStateBlob else None
31
33
 
32
34
 
35
+ AirbyteCatalogSerializer = Serializer(AirbyteCatalog, omit_none=True)
36
+ AirbyteStreamSerializer = Serializer(AirbyteStream, omit_none=True)
33
37
  AirbyteStreamStateSerializer = Serializer(
34
38
  AirbyteStreamState, omit_none=True, custom_type_resolver=custom_type_resolver
35
39
  )
@@ -34,6 +34,15 @@ class ConnectorBuildOptions(BaseModel):
34
34
  )
35
35
 
36
36
 
37
+ class SuggestedStreams(BaseModel):
38
+ """Suggested streams from metadata.yaml."""
39
+
40
+ streams: list[str] = Field(
41
+ default=[],
42
+ description="List of suggested streams for the connector",
43
+ )
44
+
45
+
37
46
  class ConnectorMetadata(BaseModel):
38
47
  """Connector metadata from metadata.yaml."""
39
48
 
@@ -47,6 +56,11 @@ class ConnectorMetadata(BaseModel):
47
56
  description="List of tags for the connector",
48
57
  )
49
58
 
59
+ suggestedStreams: SuggestedStreams | None = Field(
60
+ default=None,
61
+ description="Suggested streams for the connector",
62
+ )
63
+
50
64
  @property
51
65
  def language(self) -> ConnectorLanguage:
52
66
  """Get the connector language."""
@@ -202,7 +202,7 @@ class ConcurrentDeclarativeSource(ManifestDeclarativeSource, Generic[TState]):
202
202
 
203
203
  # Combine streams and dynamic_streams. Note: both cannot be empty at the same time,
204
204
  # and this is validated during the initialization of the source.
205
- streams = self._stream_configs(self._source_config) + self._dynamic_stream_configs(
205
+ streams = self._stream_configs(self._source_config, config) + self._dynamic_stream_configs(
206
206
  self._source_config, config
207
207
  )
208
208
 
@@ -25,6 +25,7 @@ properties:
25
25
  type: array
26
26
  items:
27
27
  anyOf:
28
+ - "$ref": "#/definitions/ConditionalStreams"
28
29
  - "$ref": "#/definitions/DeclarativeStream"
29
30
  - "$ref": "#/definitions/StateDelegatingStream"
30
31
  dynamic_streams:
@@ -424,6 +425,36 @@ definitions:
424
425
  $parameters:
425
426
  type: object
426
427
  additionalProperties: true
428
+ ConditionalStreams:
429
+ title: Conditional Streams
430
+ description: Streams that are only available while performing a connector operation when the condition is met.
431
+ type: object
432
+ required:
433
+ - type
434
+ - streams
435
+ - condition
436
+ properties:
437
+ type:
438
+ type: string
439
+ enum: [ConditionalStreams]
440
+ condition:
441
+ title: Condition
442
+ description: Condition that will be evaluated to determine if a set of streams should be available.
443
+ type: string
444
+ interpolation_context:
445
+ - config
446
+ - parameters
447
+ examples:
448
+ - "{{ config['is_sandbox'] }}"
449
+ streams:
450
+ title: Streams
451
+ description: Streams that will be used during an operation based on the condition.
452
+ type: array
453
+ items:
454
+ "$ref": "#/definitions/DeclarativeStream"
455
+ $parameters:
456
+ type: object
457
+ additionalProperties: true
427
458
  ConstantBackoffStrategy:
428
459
  title: Constant Backoff
429
460
  description: Backoff strategy with a constant backoff interval.
@@ -8,7 +8,7 @@ import pkgutil
8
8
  from copy import deepcopy
9
9
  from importlib import metadata
10
10
  from types import ModuleType
11
- from typing import Any, Dict, Iterator, List, Mapping, MutableMapping, Optional, Set
11
+ from typing import Any, Dict, Iterator, List, Mapping, Optional, Set
12
12
 
13
13
  import orjson
14
14
  import yaml
@@ -35,6 +35,10 @@ from airbyte_cdk.models.airbyte_protocol_serializers import AirbyteMessageSerial
35
35
  from airbyte_cdk.sources.declarative.checks import COMPONENTS_CHECKER_TYPE_MAPPING
36
36
  from airbyte_cdk.sources.declarative.checks.connection_checker import ConnectionChecker
37
37
  from airbyte_cdk.sources.declarative.declarative_source import DeclarativeSource
38
+ from airbyte_cdk.sources.declarative.interpolation import InterpolatedBoolean
39
+ from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
40
+ ConditionalStreams as ConditionalStreamsModel,
41
+ )
38
42
  from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
39
43
  DeclarativeStream as DeclarativeStreamModel,
40
44
  )
@@ -300,7 +304,9 @@ class ManifestDeclarativeSource(DeclarativeSource):
300
304
  }
301
305
  )
302
306
 
303
- stream_configs = self._stream_configs(self._source_config) + self.dynamic_streams
307
+ stream_configs = (
308
+ self._stream_configs(self._source_config, config=config) + self.dynamic_streams
309
+ )
304
310
 
305
311
  api_budget_model = self._source_config.get("api_budget")
306
312
  if api_budget_model:
@@ -319,7 +325,6 @@ class ManifestDeclarativeSource(DeclarativeSource):
319
325
  )
320
326
  for stream_config in self._initialize_cache_for_parent_streams(deepcopy(stream_configs))
321
327
  ]
322
-
323
328
  return source_streams
324
329
 
325
330
  @staticmethod
@@ -373,7 +378,6 @@ class ManifestDeclarativeSource(DeclarativeSource):
373
378
  )
374
379
  else:
375
380
  stream_config["retriever"]["requester"]["use_cache"] = True
376
-
377
381
  return stream_configs
378
382
 
379
383
  def spec(self, logger: logging.Logger) -> ConnectorSpecification:
@@ -477,12 +481,27 @@ class ManifestDeclarativeSource(DeclarativeSource):
477
481
  # No exception
478
482
  return parsed_version
479
483
 
480
- def _stream_configs(self, manifest: Mapping[str, Any]) -> List[Dict[str, Any]]:
484
+ def _stream_configs(
485
+ self, manifest: Mapping[str, Any], config: Mapping[str, Any]
486
+ ) -> List[Dict[str, Any]]:
481
487
  # This has a warning flag for static, but after we finish part 4 we'll replace manifest with self._source_config
482
- stream_configs: List[Dict[str, Any]] = manifest.get("streams", [])
483
- for s in stream_configs:
484
- if "type" not in s:
485
- s["type"] = "DeclarativeStream"
488
+ stream_configs = []
489
+ for current_stream_config in manifest.get("streams", []):
490
+ if (
491
+ "type" in current_stream_config
492
+ and current_stream_config["type"] == "ConditionalStreams"
493
+ ):
494
+ interpolated_boolean = InterpolatedBoolean(
495
+ condition=current_stream_config.get("condition"),
496
+ parameters={},
497
+ )
498
+
499
+ if interpolated_boolean.eval(config=config):
500
+ stream_configs.extend(current_stream_config.get("streams", []))
501
+ else:
502
+ if "type" not in current_stream_config:
503
+ current_stream_config["type"] = "DeclarativeStream"
504
+ stream_configs.append(current_stream_config)
486
505
  return stream_configs
487
506
 
488
507
  def _dynamic_stream_configs(
@@ -1,3 +1,5 @@
1
+ # Copyright (c) 2025 Airbyte, Inc., all rights reserved.
2
+
1
3
  # generated by datamodel-codegen:
2
4
  # filename: declarative_component_schema.yaml
3
5
 
@@ -2168,7 +2170,7 @@ class DeclarativeSource1(BaseModel):
2168
2170
 
2169
2171
  type: Literal["DeclarativeSource"]
2170
2172
  check: Union[CheckStream, CheckDynamicStream]
2171
- streams: List[Union[DeclarativeStream, StateDelegatingStream]]
2173
+ streams: List[Union[ConditionalStreams, DeclarativeStream, StateDelegatingStream]]
2172
2174
  dynamic_streams: Optional[List[DynamicDeclarativeStream]] = None
2173
2175
  version: str = Field(
2174
2176
  ...,
@@ -2201,7 +2203,9 @@ class DeclarativeSource2(BaseModel):
2201
2203
 
2202
2204
  type: Literal["DeclarativeSource"]
2203
2205
  check: Union[CheckStream, CheckDynamicStream]
2204
- streams: Optional[List[Union[DeclarativeStream, StateDelegatingStream]]] = None
2206
+ streams: Optional[List[Union[ConditionalStreams, DeclarativeStream, StateDelegatingStream]]] = (
2207
+ None
2208
+ )
2205
2209
  dynamic_streams: List[DynamicDeclarativeStream]
2206
2210
  version: str = Field(
2207
2211
  ...,
@@ -2280,6 +2284,22 @@ class SelectiveAuthenticator(BaseModel):
2280
2284
  parameters: Optional[Dict[str, Any]] = Field(None, alias="$parameters")
2281
2285
 
2282
2286
 
2287
+ class ConditionalStreams(BaseModel):
2288
+ type: Literal["ConditionalStreams"]
2289
+ condition: str = Field(
2290
+ ...,
2291
+ description="Condition that will be evaluated to determine if a set of streams should be available.",
2292
+ examples=["{{ config['is_sandbox'] }}"],
2293
+ title="Condition",
2294
+ )
2295
+ streams: List[DeclarativeStream] = Field(
2296
+ ...,
2297
+ description="Streams that will be used during an operation based on the condition.",
2298
+ title="Streams",
2299
+ )
2300
+ parameters: Optional[Dict[str, Any]] = Field(None, alias="$parameters")
2301
+
2302
+
2283
2303
  class FileUploader(BaseModel):
2284
2304
  type: Literal["FileUploader"]
2285
2305
  requester: Union[HttpRequester, CustomRequester] = Field(
@@ -2936,6 +2956,7 @@ CompositeErrorHandler.update_forward_refs()
2936
2956
  DeclarativeSource1.update_forward_refs()
2937
2957
  DeclarativeSource2.update_forward_refs()
2938
2958
  SelectiveAuthenticator.update_forward_refs()
2959
+ ConditionalStreams.update_forward_refs()
2939
2960
  FileUploader.update_forward_refs()
2940
2961
  DeclarativeStream.update_forward_refs()
2941
2962
  SessionTokenAuthenticator.update_forward_refs()
@@ -3150,12 +3150,12 @@ class ModelToComponentFactory:
3150
3150
  This is needed because the URL is not set until the requester is created.
3151
3151
  """
3152
3152
 
3153
- _url = (
3153
+ _url: str = (
3154
3154
  model.requester.url
3155
3155
  if hasattr(model.requester, "url") and model.requester.url is not None
3156
3156
  else requester.get_url()
3157
3157
  )
3158
- _url_base = (
3158
+ _url_base: str = (
3159
3159
  model.requester.url_base
3160
3160
  if hasattr(model.requester, "url_base") and model.requester.url_base is not None
3161
3161
  else requester.get_url_base()