airbyte_cdk.sources.declarative.concurrent_declarative_source

  1# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
  2
  3import json
  4import logging
  5import pkgutil
  6from contextlib import contextmanager
  7from copy import deepcopy
  8from dataclasses import dataclass, field
  9from queue import Queue
 10from types import ModuleType
 11from typing import (
 12    Any,
 13    ClassVar,
 14    Dict,
 15    Iterator,
 16    List,
 17    Mapping,
 18    Optional,
 19    Set,
 20)
 21
 22import orjson
 23import yaml
 24from airbyte_protocol_dataclasses.models import AirbyteStreamStatus, Level, StreamDescriptor
 25from jsonschema.exceptions import ValidationError
 26from jsonschema.validators import validate
 27
 28from airbyte_cdk.config_observation import create_connector_config_control_message
 29from airbyte_cdk.connector_builder.models import (
 30    LogMessage as ConnectorBuilderLogMessage,
 31)
 32from airbyte_cdk.manifest_migrations.migration_handler import (
 33    ManifestMigrationHandler,
 34)
 35from airbyte_cdk.models import (
 36    AirbyteCatalog,
 37    AirbyteConnectionStatus,
 38    AirbyteMessage,
 39    AirbyteStateMessage,
 40    ConfiguredAirbyteCatalog,
 41    ConnectorSpecification,
 42    FailureType,
 43    Status,
 44)
 45from airbyte_cdk.models.airbyte_protocol_serializers import AirbyteMessageSerializer
 46from airbyte_cdk.sources import Source
 47from airbyte_cdk.sources.concurrent_source.concurrent_source import ConcurrentSource
 48from airbyte_cdk.sources.connector_state_manager import ConnectorStateManager
 49from airbyte_cdk.sources.declarative.checks import COMPONENTS_CHECKER_TYPE_MAPPING
 50from airbyte_cdk.sources.declarative.checks.connection_checker import ConnectionChecker
 51from airbyte_cdk.sources.declarative.concurrency_level import ConcurrencyLevel
 52from airbyte_cdk.sources.declarative.interpolation import InterpolatedBoolean
 53from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
 54    ConcurrencyLevel as ConcurrencyLevelModel,
 55)
 56from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
 57    DeclarativeStream as DeclarativeStreamModel,
 58)
 59from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
 60    Spec as SpecModel,
 61)
 62from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
 63    StateDelegatingStream as StateDelegatingStreamModel,
 64)
 65from airbyte_cdk.sources.declarative.parsers.custom_code_compiler import (
 66    get_registered_components_module,
 67)
 68from airbyte_cdk.sources.declarative.parsers.manifest_component_transformer import (
 69    ManifestComponentTransformer,
 70)
 71from airbyte_cdk.sources.declarative.parsers.manifest_normalizer import (
 72    ManifestNormalizer,
 73)
 74from airbyte_cdk.sources.declarative.parsers.manifest_reference_resolver import (
 75    ManifestReferenceResolver,
 76)
 77from airbyte_cdk.sources.declarative.parsers.model_to_component_factory import (
 78    ModelToComponentFactory,
 79)
 80from airbyte_cdk.sources.declarative.partition_routers.cartesian_product_stream_slicer import (
 81    CartesianProductStreamSlicer,
 82)
 83from airbyte_cdk.sources.declarative.partition_routers.grouping_partition_router import (
 84    GroupingPartitionRouter,
 85)
 86from airbyte_cdk.sources.declarative.partition_routers.substream_partition_router import (
 87    SubstreamPartitionRouter,
 88)
 89from airbyte_cdk.sources.declarative.partition_routers.union_partition_router import (
 90    UnionPartitionRouter,
 91)
 92from airbyte_cdk.sources.declarative.resolvers import COMPONENTS_RESOLVER_TYPE_MAPPING
 93from airbyte_cdk.sources.declarative.spec.spec import Spec
 94from airbyte_cdk.sources.declarative.types import Config, ConnectionDefinition
 95from airbyte_cdk.sources.message.concurrent_repository import ConcurrentMessageRepository
 96from airbyte_cdk.sources.message.repository import InMemoryMessageRepository
 97from airbyte_cdk.sources.streams.concurrent.abstract_stream import AbstractStream
 98from airbyte_cdk.sources.streams.concurrent.default_stream import DefaultStream
 99from airbyte_cdk.sources.streams.concurrent.partitions.types import QueueItem
100from airbyte_cdk.sources.utils.slice_logger import (
101    AlwaysLogSliceLogger,
102    DebugSliceLogger,
103    SliceLogger,
104)
105from airbyte_cdk.utils.airbyte_secrets_utils import add_to_secrets, get_secrets
106from airbyte_cdk.utils.stream_status_utils import as_airbyte_message
107from airbyte_cdk.utils.traced_exception import AirbyteTracedException
108
109
110@dataclass
111class TestLimits:
112    __test__: ClassVar[bool] = False  # Tell Pytest this is not a Pytest class, despite its name
113
114    DEFAULT_MAX_PAGES_PER_SLICE: ClassVar[int] = 5
115    DEFAULT_MAX_SLICES: ClassVar[int] = 5
116    DEFAULT_MAX_RECORDS: ClassVar[int] = 100
117    DEFAULT_MAX_STREAMS: ClassVar[int] = 100
118
119    max_records: int = field(default=DEFAULT_MAX_RECORDS)
120    max_pages_per_slice: int = field(default=DEFAULT_MAX_PAGES_PER_SLICE)
121    max_slices: int = field(default=DEFAULT_MAX_SLICES)
122    max_streams: int = field(default=DEFAULT_MAX_STREAMS)
123
124
125def _get_declarative_component_schema() -> Dict[str, Any]:
126    try:
127        raw_component_schema = pkgutil.get_data(
128            "airbyte_cdk", "sources/declarative/declarative_component_schema.yaml"
129        )
130        if raw_component_schema is not None:
131            declarative_component_schema = yaml.load(raw_component_schema, Loader=yaml.SafeLoader)
132            return declarative_component_schema  # type: ignore
133        else:
134            raise RuntimeError(
135                "Failed to read manifest component json schema required for deduplication"
136            )
137    except FileNotFoundError as e:
138        raise FileNotFoundError(
139            f"Failed to read manifest component json schema required for deduplication: {e}"
140        )
141
142
143class ConcurrentDeclarativeSource(Source):
144    # By default, we defer to a value of 2. A value lower than could cause a PartitionEnqueuer to be stuck in a state of deadlock
145    # because it has hit the limit of futures but not partition reader is consuming them.
146    _LOWEST_SAFE_CONCURRENCY_LEVEL = 2
147
148    # Component types that hold the connector config and can write it back. `refresh_token_updater` is
149    # declared on `OAuthAuthenticator` alone; `CustomAuthenticator` is the type the transformer injects
150    # for a `class_name` component.
151    _CONFIG_PERSISTING_AUTHENTICATOR_TYPES = frozenset(
152        {"OAuthAuthenticator", "CustomAuthenticator"}
153    )
154
155    def __init__(
156        self,
157        catalog: Optional[ConfiguredAirbyteCatalog] = None,
158        config: Optional[Mapping[str, Any]] = None,
159        state: Optional[List[AirbyteStateMessage]] = None,
160        *,
161        source_config: ConnectionDefinition,
162        debug: bool = False,
163        emit_connector_builder_messages: bool = False,
164        migrate_manifest: bool = False,
165        normalize_manifest: bool = False,
166        limits: Optional[TestLimits] = None,
167        config_path: Optional[str] = None,
168        custom_components_trusted: bool = True,
169        **kwargs: Any,
170    ) -> None:
171        self.logger = logging.getLogger(f"airbyte.{self.name}")
172
173        self._limits = limits
174
175        # todo: We could remove state from initialization. Now that streams are grouped during the read(), a source
176        #  no longer needs to store the original incoming state. But maybe there's an edge case?
177        self._connector_state_manager = ConnectorStateManager(state=state)  # type: ignore  # state is always in the form of List[AirbyteStateMessage]. The ConnectorStateManager should use generics, but this can be done later
178
179        # We set a maxsize to for the main thread to process record items when the queue size grows. This assumes that there are less
180        # threads generating partitions that than are max number of workers. If it weren't the case, we could have threads only generating
181        # partitions which would fill the queue. This number is arbitrarily set to 10_000 but will probably need to be changed given more
182        # information and might even need to be configurable depending on the source
183        queue: Queue[QueueItem] = Queue(maxsize=10_000)
184        message_repository = InMemoryMessageRepository(
185            Level.DEBUG if emit_connector_builder_messages else Level.INFO
186        )
187
188        # To reduce the complexity of the concurrent framework, we are not enabling RFR with synthetic
189        # cursors. We do this by no longer automatically instantiating RFR cursors when converting
190        # the declarative models into runtime components. Concurrent sources will continue to checkpoint
191        # incremental streams running in full refresh.
192        component_factory = ModelToComponentFactory(
193            custom_components_trusted=custom_components_trusted,
194            emit_connector_builder_messages=emit_connector_builder_messages,
195            message_repository=ConcurrentMessageRepository(queue, message_repository),
196            configured_catalog=catalog,
197            connector_state_manager=self._connector_state_manager,
198            max_concurrent_async_job_count=source_config.get("max_concurrent_async_job_count"),
199            limit_pages_fetched_per_slice=limits.max_pages_per_slice if limits else None,
200            limit_slices_fetched=limits.max_slices if limits else None,
201            disable_retries=True if limits else False,
202            disable_cache=True if limits else False,
203        )
204
205        self._should_normalize = normalize_manifest
206        self._should_migrate = migrate_manifest
207        self._declarative_component_schema = _get_declarative_component_schema()
208        # If custom components are needed, locate and/or register them.
209        self.components_module: ModuleType | None = get_registered_components_module(config=config)
210        # set additional attributes
211        self._debug = debug
212        self._emit_connector_builder_messages = emit_connector_builder_messages
213        self._constructor = (
214            component_factory
215            if component_factory
216            else ModelToComponentFactory(
217                custom_components_trusted=custom_components_trusted,
218                emit_connector_builder_messages=emit_connector_builder_messages,
219                max_concurrent_async_job_count=source_config.get("max_concurrent_async_job_count"),
220            )
221        )
222
223        self._message_repository = self._constructor.get_message_repository()
224        self._slice_logger: SliceLogger = (
225            AlwaysLogSliceLogger() if emit_connector_builder_messages else DebugSliceLogger()
226        )
227
228        # resolve all components in the manifest
229        self._source_config = self._pre_process_manifest(dict(source_config))
230        # validate resolved manifest against the declarative component schema
231        self._validate_source()
232        # apply additional post-processing to the manifest
233        self._post_process_manifest()
234
235        spec: Optional[Mapping[str, Any]] = self._source_config.get("spec")
236        self._spec_component: Optional[Spec] = (
237            self._constructor.create_component(SpecModel, spec, dict()) if spec else None
238        )
239        self._config = self._migrate_and_transform_config(config_path, config) or {}
240        # `check` may temporarily overlay values onto `self._config` (see
241        # `_config_overridden_for_check`). The manifest's `config_validations` express intent about the
242        # config as supplied, so they must run against it rather than against a check-time overlay.
243        self._config_for_validation = self._config
244
245        concurrency_level_from_manifest = self._source_config.get("concurrency_level")
246        if concurrency_level_from_manifest:
247            concurrency_level_component = self._constructor.create_component(
248                model_type=ConcurrencyLevelModel,
249                component_definition=concurrency_level_from_manifest,
250                config=config or {},
251            )
252            if not isinstance(concurrency_level_component, ConcurrencyLevel):
253                raise ValueError(
254                    f"Expected to generate a ConcurrencyLevel component, but received {concurrency_level_component.__class__}"
255                )
256
257            concurrency_level = concurrency_level_component.get_concurrency_level()
258            initial_number_of_partitions_to_generate = max(
259                concurrency_level // 2, 1
260            )  # Partition_generation iterates using range based on this value. If this is floored to zero we end up in a dead lock during start up
261        else:
262            concurrency_level = self._LOWEST_SAFE_CONCURRENCY_LEVEL
263            initial_number_of_partitions_to_generate = self._LOWEST_SAFE_CONCURRENCY_LEVEL // 2
264
265        self._concurrent_source = ConcurrentSource.create(
266            num_workers=concurrency_level,
267            initial_number_of_partitions_to_generate=initial_number_of_partitions_to_generate,
268            logger=self.logger,
269            slice_logger=self._slice_logger,
270            queue=queue,
271            message_repository=self._message_repository,
272        )
273
274    def _pre_process_manifest(self, manifest: Dict[str, Any]) -> Dict[str, Any]:
275        """
276        Preprocesses the provided manifest dictionary by resolving any manifest references.
277
278        This method modifies the input manifest in place, resolving references using the
279        ManifestReferenceResolver to ensure all references within the manifest are properly handled.
280
281        Args:
282            manifest (Dict[str, Any]): The manifest dictionary to preprocess and resolve references in.
283
284        Returns:
285            None
286        """
287        # For ease of use we don't require the type to be specified at the top level manifest, but it should be included during processing
288        manifest = self._fix_source_type(manifest)
289        # Resolve references in the manifest
290        resolved_manifest = ManifestReferenceResolver().preprocess_manifest(manifest)
291        # Propagate types and parameters throughout the manifest
292        propagated_manifest = ManifestComponentTransformer().propagate_types_and_parameters(
293            "", resolved_manifest, {}
294        )
295
296        return propagated_manifest
297
298    def _fix_source_type(self, manifest: Dict[str, Any]) -> Dict[str, Any]:
299        """
300        Fix the source type in the manifest. This is necessary because the source type is not always set in the manifest.
301        """
302        if "type" not in manifest:
303            manifest["type"] = "DeclarativeSource"
304
305        return manifest
306
307    def _post_process_manifest(self) -> None:
308        """
309        Post-processes the manifest after validation.
310        This method is responsible for any additional modifications or transformations needed
311        after the manifest has been validated and before it is used in the source.
312        """
313        # apply manifest migration, if required
314        self._migrate_manifest()
315        # apply manifest normalization, if required
316        self._normalize_manifest()
317
318    def _migrate_manifest(self) -> None:
319        """
320        This method is used to migrate the manifest. It should be called after the manifest has been validated.
321        The migration is done in place, so the original manifest is modified.
322
323        The original manifest is returned if any error occurs during migration.
324        """
325        if self._should_migrate:
326            manifest_migrator = ManifestMigrationHandler(self._source_config)
327            self._source_config = manifest_migrator.apply_migrations()
328            # validate migrated manifest against the declarative component schema
329            self._validate_source()
330
331    def _normalize_manifest(self) -> None:
332        """
333        This method is used to normalize the manifest. It should be called after the manifest has been validated.
334
335        Connector Builder UI rendering requires the manifest to be in a specific format.
336         - references have been resolved
337         - the commonly used definitions are extracted to the `definitions.linked.*`
338        """
339        if self._should_normalize:
340            normalizer = ManifestNormalizer(self._source_config, self._declarative_component_schema)
341            self._source_config = normalizer.normalize()
342
343    def _validate_source(self) -> None:
344        """
345        Validates the connector manifest against the declarative component schema
346        """
347
348        try:
349            validate(self._source_config, self._declarative_component_schema)
350        except ValidationError as e:
351            raise ValidationError(
352                "Validation against json schema defined in declarative_component_schema.yaml schema failed"
353            ) from e
354
355    def _migrate_and_transform_config(
356        self,
357        config_path: Optional[str],
358        config: Optional[Config],
359    ) -> Optional[Config]:
360        if not config:
361            return None
362        if not self._spec_component:
363            return config
364        mutable_config = dict(config)
365        self._spec_component.migrate_config(mutable_config)
366        if mutable_config != config:
367            if config_path:
368                with open(config_path, "w") as f:
369                    json.dump(mutable_config, f)
370            control_message = create_connector_config_control_message(mutable_config)
371            print(orjson.dumps(AirbyteMessageSerializer.dump(control_message)).decode())
372        self._spec_component.transform_config(mutable_config)
373        return mutable_config
374
375    def configure(self, config: Mapping[str, Any], temp_dir: str) -> Mapping[str, Any]:
376        config = self._config or config
377        return super().configure(config, temp_dir)
378
379    @property
380    def resolved_manifest(self) -> Mapping[str, Any]:
381        """
382        Returns the resolved manifest configuration for the source.
383
384        This property provides access to the internal source configuration as a mapping,
385        which contains all settings and parameters required to define the source's behavior.
386
387        Returns:
388            Mapping[str, Any]: The resolved source configuration manifest.
389        """
390        return self._source_config
391
392    def deprecation_warnings(self) -> List[ConnectorBuilderLogMessage]:
393        return self._constructor.get_model_deprecations()
394
395    def read(
396        self,
397        logger: logging.Logger,
398        config: Mapping[str, Any],
399        catalog: ConfiguredAirbyteCatalog,
400        state: Optional[List[AirbyteStateMessage]] = None,
401    ) -> Iterator[AirbyteMessage]:
402        selected_concurrent_streams = self._select_streams(
403            streams=self.streams(config=self._config),  # type: ignore  # We are migrating away from the DeclarativeStream implementation and streams() only returns the concurrent-compatible AbstractStream. To preserve compatibility, we retain the existing method interface
404            configured_catalog=catalog,
405        )
406
407        # It would appear that passing in an empty set of streams causes an infinite loop in ConcurrentReadProcessor.
408        # This is also evident in concurrent_source_adapter.py so I'll leave this out of scope to fix for now
409        if len(selected_concurrent_streams) > 0:
410            yield from self._concurrent_source.read(selected_concurrent_streams)
411
412    def discover(self, logger: logging.Logger, config: Mapping[str, Any]) -> AirbyteCatalog:
413        return AirbyteCatalog(
414            streams=[stream.as_airbyte_stream() for stream in self.streams(config=self._config)]
415        )
416
417    # todo: add PR comment about whether we can change the signature to List[AbstractStream]
418    def streams(self, config: Mapping[str, Any]) -> List[AbstractStream]:  # type: ignore  # we are migrating away from the AbstractSource and are expecting that this will only be called by ConcurrentDeclarativeSource or the Connector Builder
419        """
420        The `streams` method is used as part of the AbstractSource in the following cases:
421        * ConcurrentDeclarativeSource.check -> ManifestDeclarativeSource.check -> AbstractSource.check -> DeclarativeSource.check_connection -> CheckStream.check_connection -> streams
422        * ConcurrentDeclarativeSource.read -> AbstractSource.read -> streams (note that we filter for a specific catalog which excludes concurrent streams so not all streams actually read from all the streams returned by `streams`)
423        Note that `super.streams(config)` is also called when splitting the streams between concurrent or not in `_group_streams`.
424
425        In both case, we will assume that calling the DeclarativeStream is perfectly fine as the result for these is the same regardless of if it is a DeclarativeStream or a DefaultStream (concurrent). This should simply be removed once we have moved away from the mentioned code paths above.
426        """
427
428        if self._spec_component:
429            self._spec_component.validate_config(self._config_for_validation)
430
431        api_budget_model = self._source_config.get("api_budget")
432        if api_budget_model:
433            self._constructor.set_api_budget(api_budget_model, self._config)
434
435        stream_configs = self._stream_configs(self._source_config) + self.dynamic_streams
436
437        prepared_configs = self._initialize_cache_for_parent_streams(deepcopy(stream_configs))
438
439        source_streams = [
440            self._constructor.create_component(
441                (
442                    StateDelegatingStreamModel
443                    if stream_config.get("type") == StateDelegatingStreamModel.__name__
444                    else DeclarativeStreamModel
445                ),
446                stream_config,
447                self._config,
448                emit_connector_builder_messages=self._emit_connector_builder_messages,
449            )
450            for stream_config in prepared_configs
451        ]
452
453        self._apply_stream_groups(source_streams)
454
455        return source_streams
456
457    def _apply_stream_groups(self, streams: List[AbstractStream]) -> None:
458        """Set block_simultaneous_read on streams based on the manifest's stream_groups config.
459
460        Iterates over the resolved manifest's stream_groups and matches group membership
461        against actual created stream instances by name. Validates that no stream shares a
462        group with any of its parent streams, which would cause a deadlock.
463        """
464        stream_groups = self._source_config.get("stream_groups", {})
465        if not stream_groups:
466            return
467
468        # Build stream_name -> group_name mapping from the resolved manifest
469        stream_name_to_group: Dict[str, str] = {}
470        for group_name, group_config in stream_groups.items():
471            for stream_ref in group_config.get("streams", []):
472                if isinstance(stream_ref, dict):
473                    stream_name = stream_ref.get("name", "")
474                    if stream_name:
475                        stream_name_to_group[stream_name] = group_name
476
477        # Validate no stream shares a group with any of its ancestor streams
478        stream_name_to_instance: Dict[str, AbstractStream] = {s.name: s for s in streams}
479
480        def _collect_all_ancestor_names(stream_name: str) -> Set[str]:
481            """Recursively collect all ancestor stream names."""
482            ancestors: Set[str] = set()
483            inst = stream_name_to_instance.get(stream_name)
484            if not isinstance(inst, DefaultStream):
485                return ancestors
486            partition_router = inst.get_partition_router()
487            routers = [partition_router] if partition_router is not None else []
488            while routers:
489                router = routers.pop()
490                if isinstance(router, GroupingPartitionRouter):
491                    routers.append(router.underlying_partition_router)
492                elif isinstance(router, UnionPartitionRouter):
493                    routers.extend(router.partition_routers)
494                elif isinstance(router, CartesianProductStreamSlicer):
495                    routers.extend(router.stream_slicers)
496                elif isinstance(router, SubstreamPartitionRouter):
497                    for parent_config in router.parent_stream_configs:
498                        parent_name = parent_config.stream.name
499                        ancestors.add(parent_name)
500                        ancestors.update(_collect_all_ancestor_names(parent_name))
501            return ancestors
502
503        for stream in streams:
504            if not isinstance(stream, DefaultStream) or stream.name not in stream_name_to_group:
505                continue
506            group_name = stream_name_to_group[stream.name]
507            for ancestor_name in _collect_all_ancestor_names(stream.name):
508                if stream_name_to_group.get(ancestor_name) == group_name:
509                    raise ValueError(
510                        f"Stream '{stream.name}' and its parent stream '{ancestor_name}' "
511                        f"are both in group '{group_name}'. "
512                        f"A child stream must not share a group with its parent to avoid deadlock."
513                    )
514
515        # Apply group to matching stream instances
516        for stream in streams:
517            if isinstance(stream, DefaultStream) and stream.name in stream_name_to_group:
518                stream.block_simultaneous_read = stream_name_to_group[stream.name]
519
520    @staticmethod
521    def _initialize_cache_for_parent_streams(
522        stream_configs: List[Dict[str, Any]],
523    ) -> List[Dict[str, Any]]:
524        """Enable caching for parent streams unless explicitly disabled.
525
526        Caching is enabled by default for parent streams to optimize performance when the same
527        parent data is needed by multiple child streams. However, explicit `use_cache: false`
528        settings are respected for streams that cannot use caching (e.g., scroll-based pagination
529        APIs where caching causes duplicate records).
530        """
531        parent_streams = set()
532
533        def _set_cache_if_not_disabled(requester: Dict[str, Any]) -> None:
534            """Set use_cache to True only if not explicitly disabled."""
535            if requester.get("use_cache") is not False:
536                requester["use_cache"] = True
537
538        def update_with_cache_parent_configs(
539            parent_configs: list[dict[str, Any]],
540        ) -> None:
541            for parent_config in parent_configs:
542                parent_streams.add(parent_config["stream"]["name"])
543                if parent_config["stream"]["type"] == "StateDelegatingStream":
544                    _set_cache_if_not_disabled(
545                        parent_config["stream"]["full_refresh_stream"]["retriever"]["requester"]
546                    )
547                    _set_cache_if_not_disabled(
548                        parent_config["stream"]["incremental_stream"]["retriever"]["requester"]
549                    )
550                else:
551                    _set_cache_if_not_disabled(parent_config["stream"]["retriever"]["requester"])
552
553        for stream_config in stream_configs:
554            if stream_config.get("incremental_sync", {}).get("parent_stream"):
555                parent_streams.add(stream_config["incremental_sync"]["parent_stream"]["name"])
556                _set_cache_if_not_disabled(
557                    stream_config["incremental_sync"]["parent_stream"]["retriever"]["requester"]
558                )
559
560            elif stream_config.get("retriever", {}).get("partition_router", {}):
561                partition_router = stream_config["retriever"]["partition_router"]
562
563                routers = (
564                    list(partition_router)
565                    if isinstance(partition_router, list)
566                    else [partition_router]
567                )
568                while routers:
569                    router = routers.pop()
570                    if not isinstance(router, dict):
571                        continue
572                    if router.get("parent_stream_configs"):
573                        update_with_cache_parent_configs(router["parent_stream_configs"])
574                    # Descend into composed partition routers (e.g. UnionPartitionRouter's
575                    # partition_routers or GroupingPartitionRouter's underlying_partition_router)
576                    # so nested parent streams also get caching enabled.
577                    routers.extend(router.get("partition_routers") or [])
578                    if router.get("underlying_partition_router"):
579                        routers.append(router["underlying_partition_router"])
580
581        for stream_config in stream_configs:
582            if stream_config["name"] in parent_streams:
583                if stream_config["type"] == "StateDelegatingStream":
584                    _set_cache_if_not_disabled(
585                        stream_config["full_refresh_stream"]["retriever"]["requester"]
586                    )
587                    _set_cache_if_not_disabled(
588                        stream_config["incremental_stream"]["retriever"]["requester"]
589                    )
590                else:
591                    _set_cache_if_not_disabled(stream_config["retriever"]["requester"])
592        return stream_configs
593
594    def spec(self, logger: logging.Logger) -> ConnectorSpecification:
595        """
596        Returns the connector specification (spec) as defined in the Airbyte Protocol. The spec is an object describing the possible
597        configurations (e.g: username and password) which can be configured when running this connector. For low-code connectors, this
598        will first attempt to load the spec from the manifest's spec block, otherwise it will load it from "spec.yaml" or "spec.json"
599        in the project root.
600        """
601        return (
602            self._spec_component.generate_spec() if self._spec_component else super().spec(logger)
603        )
604
605    def check(self, logger: logging.Logger, config: Mapping[str, Any]) -> AirbyteConnectionStatus:
606        check = self._source_config.get("check")
607        if not check:
608            raise ValueError(f"Missing 'check' component definition within the manifest.")
609
610        if "type" not in check:
611            check["type"] = "CheckStream"
612        connection_checker = self._constructor.create_component(
613            COMPONENTS_CHECKER_TYPE_MAPPING[check["type"]],
614            check,
615            dict(),
616            emit_connector_builder_messages=self._emit_connector_builder_messages,
617        )
618        if not isinstance(connection_checker, ConnectionChecker):
619            raise ValueError(
620                f"Expected to generate a ConnectionChecker component, but received {connection_checker.__class__}"
621            )
622
623        with self._config_overridden_for_check(check.get("config_overrides")):
624            check_succeeded, error = connection_checker.check_connection(self, logger, self._config)
625        if not check_succeeded:
626            return AirbyteConnectionStatus(status=Status.FAILED, message=repr(error))
627        return AirbyteConnectionStatus(status=Status.SUCCEEDED)
628
629    @contextmanager
630    def _config_overridden_for_check(
631        self, config_overrides: Optional[Mapping[str, Any]]
632    ) -> Iterator[None]:
633        """Overlay the check component's `config_overrides` onto the config for the duration of a check.
634
635        Rebinding `self._config` reaches the whole component tree, because `streams()` interpolates from
636        it and ignores its own `config` argument. Not thread safe; `check` is one command per process.
637
638        Semantics:
639          - values are applied verbatim, never interpolated;
640          - the merge is one level deep, so an object-valued override replaces rather than merges;
641          - it happens after `_migrate_and_transform_config`, so derived fields are not recomputed;
642          - `config_validations` run against `self._config_for_validation`, not the overlay.
643
644        The copy is shallow, so a component writing into a nested path writes through to the config the
645        source was constructed with and the restore does not undo it. Only top-level writes are
646        discarded, which is why `_raise_if_config_is_persisted` refuses config-persisting manifests.
647
648        Known limitation: `$parameters` declared on a check component still propagate into object-valued
649        overrides. See https://github.com/airbytehq/airbyte-internal-issues/issues/16995.
650        """
651        if not config_overrides:
652            yield
653            return
654
655        self._raise_on_non_string_override_keys(config_overrides)
656        self._raise_on_reserved_override_keys(config_overrides)
657        self._raise_if_config_is_persisted(config_overrides)
658        self._warn_on_unknown_override_keys(config_overrides)
659        self._register_override_secrets(config_overrides)
660        # Keys only. An override may name a secret field, so values must not reach the logs.
661        self.logger.info(
662            f"Overriding config keys for the check operation: {', '.join(sorted(config_overrides))}"
663        )
664
665        unmodified_config = self._config
666        self._config = {**self._config, **config_overrides}
667        try:
668            yield
669        finally:
670            self._config = unmodified_config
671
672    @staticmethod
673    def _raise_on_reserved_override_keys(config_overrides: Mapping[str, Any]) -> None:
674        """Refuse keys in the platform's reserved `__airbyte` namespace.
675
676        `CheckStream` reads `__airbyte_check_stream_names` out of the config this overlay writes to, so
677        an override there would change which streams a check tests. The whole prefix is refused.
678        """
679        reserved = sorted(key for key in config_overrides if key.startswith("__airbyte"))
680        if reserved:
681            raise AirbyteTracedException(
682                message=(
683                    f"This connector's manifest is invalid: its check component overrides the "
684                    f"reserved config key(s) {reserved}. Keys prefixed with `__airbyte` belong to "
685                    "the platform, not to the connector's spec. This is a bug in the connector "
686                    "rather than in this connection's settings."
687                ),
688                internal_message=(
689                    f"config_overrides rejected: reserved __airbyte key(s) {reserved}. "
690                    "`CheckStream` reads `__airbyte_check_stream_names` out of the config this "
691                    "overlay writes to, so an override there would change which streams a check "
692                    "tests. Use `stream_names` instead."
693                ),
694                failure_type=FailureType.system_error,
695            )
696
697    @staticmethod
698    def _raise_on_non_string_override_keys(config_overrides: Mapping[str, Any]) -> None:
699        """Reject non-string keys, which YAML allows and every consumer below assumes away."""
700        non_strings = [key for key in config_overrides if not isinstance(key, str)]
701        if non_strings:
702            raise AirbyteTracedException(
703                message=(
704                    "This connector's manifest is invalid: its check component has "
705                    f"`config_overrides` key(s) {sorted(map(repr, non_strings))} that are not "
706                    "strings, so they cannot name a field in the connector's spec. This is a bug "
707                    "in the connector rather than in this connection's settings."
708                ),
709                internal_message=(
710                    "config_overrides rejected: non-string key(s) "
711                    f"{sorted(map(repr, non_strings))}. Quote them in the manifest so they name a "
712                    "field in the connector's spec."
713                ),
714                failure_type=FailureType.system_error,
715            )
716
717    def _raise_if_config_is_persisted(self, config_overrides: Mapping[str, Any]) -> None:
718        """Reject `config_overrides` on a manifest whose authenticator writes the config back.
719
720        A `refresh_token_updater` builds a `DeclarativeSingleUseRefreshTokenOauth2Authenticator`, whose
721        `_emit_control_message` emits the entire config it was handed as a `CONNECTOR_CONFIG` message.
722        During a check that is the overlay, and the platform persists it, so a check-only override would
723        become the connection's saved config. The restore cannot recall a message already on stdout.
724        """
725        if not self._manifest_writes_back_config(self._source_config):
726            return
727        raise AirbyteTracedException(
728            message=(
729                "This connector's manifest is invalid: `config_overrides` cannot be used by a "
730                "manifest that declares a `refresh_token_updater`. A token refresh during `check` "
731                "emits the whole config it was handed as a CONNECTOR_CONFIG control message, which "
732                f"the platform persists, so the check-only override(s) {sorted(config_overrides)} "
733                "would be saved as this connection's config and applied to every later sync. This "
734                "is a bug in the connector rather than in this connection's settings."
735            ),
736            internal_message=(
737                "config_overrides rejected: manifest persists config via refresh_token_updater. "
738                "Remove `config_overrides` from the check component, or drop the "
739                "`refresh_token_updater`."
740            ),
741            failure_type=FailureType.system_error,
742        )
743
744    @staticmethod
745    def _manifest_writes_back_config(definition: Any) -> bool:
746        """Whether any component in the manifest emits the connector config back to the platform.
747
748        Walks the whole manifest, since an authenticator can sit under any requester, inside a
749        `SelectiveAuthenticator`, or in a `ConditionalStreams` branch. Callers pass `self._source_config`,
750        which is post-`_pre_process_manifest`, so a `$ref`-ed authenticator is already inlined and
751        carries its own `type`.
752
753        `is not None` rather than truthiness because `refresh_token_updater: {}` takes every default and
754        still builds a single-use authenticator - the factory tests a model instance, which is truthy.
755        The type condition is what separates a component from a data blob that happens to use the name.
756
757        Does not detect a `CustomAuthenticator` that persists config without declaring the field.
758        """
759        if isinstance(definition, Mapping):
760            if (
761                definition.get("refresh_token_updater") is not None
762                and definition.get("type")
763                in ConcurrentDeclarativeSource._CONFIG_PERSISTING_AUTHENTICATOR_TYPES
764            ):
765                return True
766            return any(
767                ConcurrentDeclarativeSource._manifest_writes_back_config(value)
768                for value in definition.values()
769            )
770        if isinstance(definition, list):
771            return any(
772                ConcurrentDeclarativeSource._manifest_writes_back_config(item)
773                for item in definition
774            )
775        return False
776
777    def _warn_on_unknown_override_keys(self, config_overrides: Mapping[str, Any]) -> None:
778        """Warn about override keys the spec does not declare.
779
780        Nothing validates the overlay - the entrypoint validates what the user supplied, and
781        `config_validations` run against `self._config_for_validation` - so a typo is a silent no-op.
782        """
783        if not self._spec_component:
784            return
785        declared = self._declared_config_properties(self._spec_component.connection_specification)
786        if declared is None:
787            return
788        unknown = sorted(key for key in config_overrides if key not in declared)
789        if unknown:
790            self.logger.warning(
791                f"Check-only config override(s) {unknown} are not declared in the connector spec, so "
792                "they will have no effect on any component that reads the config by field name."
793            )
794
795    @staticmethod
796    def _declared_config_properties(
797        connection_specification: Mapping[str, Any],
798    ) -> Optional[Set[str]]:
799        """Every field name a spec declares, or `None` when it does not enumerate them.
800
801        `oneOf` and `allOf` composition put declarations one level down, so reading only the top-level
802        `properties` would report a declared field as undeclared.
803        """
804        if not isinstance(connection_specification, Mapping):
805            return None
806
807        names: Set[str] = set()
808        found_any = False
809        properties = connection_specification.get("properties")
810        if isinstance(properties, Mapping):
811            found_any = True
812            names.update(str(key) for key in properties)
813        for keyword in ("allOf", "anyOf", "oneOf"):
814            for branch in connection_specification.get(keyword) or []:
815                nested = ConcurrentDeclarativeSource._declared_config_properties(branch)
816                if nested is not None:
817                    found_any = True
818                    names.update(nested)
819        return names if found_any else None
820
821    def _register_override_secrets(self, config_overrides: Mapping[str, Any]) -> None:
822        """Register override values landing on an `airbyte_secret` field, so they get redacted.
823
824        The entrypoint builds the secret list from the config the user supplied, so a value substituted
825        here is unknown to `filter_secrets`. Uses the same discovery as the entrypoint, so the overlay is
826        redacted exactly where the user's own value at that path would be.
827        """
828        if not self._spec_component:
829            return
830        for secret in get_secrets(
831            self._spec_component.connection_specification, dict(config_overrides)
832        ):
833            if secret is not None:
834                add_to_secrets(str(secret))
835
836    @property
837    def dynamic_streams(self) -> List[Dict[str, Any]]:
838        return self._dynamic_stream_configs(
839            manifest=self._source_config,
840            with_dynamic_stream_name=True,
841        )
842
843    def _stream_configs(self, manifest: Mapping[str, Any]) -> List[Dict[str, Any]]:
844        # This has a warning flag for static, but after we finish part 4 we'll replace manifest with self._source_config
845        stream_configs = []
846        for current_stream_config in manifest.get("streams", []):
847            if (
848                "type" in current_stream_config
849                and current_stream_config["type"] == "ConditionalStreams"
850            ):
851                interpolated_boolean = InterpolatedBoolean(
852                    condition=current_stream_config.get("condition"),
853                    parameters={},
854                )
855
856                if interpolated_boolean.eval(config=self._config):
857                    stream_configs.extend(current_stream_config.get("streams", []))
858            else:
859                if "type" not in current_stream_config:
860                    current_stream_config["type"] = "DeclarativeStream"
861                stream_configs.append(current_stream_config)
862        return stream_configs
863
864    def _dynamic_stream_configs(
865        self,
866        manifest: Mapping[str, Any],
867        with_dynamic_stream_name: Optional[bool] = None,
868    ) -> List[Dict[str, Any]]:
869        dynamic_stream_definitions: List[Dict[str, Any]] = manifest.get("dynamic_streams", [])
870        dynamic_stream_configs: List[Dict[str, Any]] = []
871        seen_dynamic_streams: Set[str] = set()
872
873        for dynamic_definition_index, dynamic_definition in enumerate(dynamic_stream_definitions):
874            components_resolver_config = dynamic_definition["components_resolver"]
875
876            if not components_resolver_config:
877                raise ValueError(
878                    f"Missing 'components_resolver' in dynamic definition: {dynamic_definition}"
879                )
880
881            resolver_type = components_resolver_config.get("type")
882            if not resolver_type:
883                raise ValueError(
884                    f"Missing 'type' in components resolver configuration: {components_resolver_config}"
885                )
886
887            if resolver_type not in COMPONENTS_RESOLVER_TYPE_MAPPING:
888                raise ValueError(
889                    f"Invalid components resolver type '{resolver_type}'. "
890                    f"Expected one of {list(COMPONENTS_RESOLVER_TYPE_MAPPING.keys())}."
891                )
892
893            if "retriever" in components_resolver_config:
894                components_resolver_config["retriever"]["requester"]["use_cache"] = True
895
896            # Create a resolver for dynamic components based on type
897            if resolver_type == "HttpComponentsResolver":
898                components_resolver = self._constructor.create_component(
899                    model_type=COMPONENTS_RESOLVER_TYPE_MAPPING[resolver_type],
900                    component_definition=components_resolver_config,
901                    config=self._config,
902                    stream_name=dynamic_definition.get("name"),
903                )
904            else:
905                components_resolver = self._constructor.create_component(
906                    model_type=COMPONENTS_RESOLVER_TYPE_MAPPING[resolver_type],
907                    component_definition=components_resolver_config,
908                    config=self._config,
909                )
910
911            stream_template_config = dynamic_definition["stream_template"]
912
913            for dynamic_stream in components_resolver.resolve_components(
914                stream_template_config=stream_template_config
915            ):
916                # Get the use_parent_parameters configuration from the dynamic definition
917                # Default to True for backward compatibility, since connectors were already using it by default when this param was added
918                use_parent_parameters = dynamic_definition.get("use_parent_parameters", True)
919
920                dynamic_stream = {
921                    **ManifestComponentTransformer().propagate_types_and_parameters(
922                        "", dynamic_stream, {}, use_parent_parameters=use_parent_parameters
923                    )
924                }
925
926                if "type" not in dynamic_stream:
927                    dynamic_stream["type"] = "DeclarativeStream"
928
929                # Ensure that each stream is created with a unique name
930                name = dynamic_stream.get("name")
931
932                if with_dynamic_stream_name:
933                    dynamic_stream["dynamic_stream_name"] = dynamic_definition.get(
934                        "name", f"dynamic_stream_{dynamic_definition_index}"
935                    )
936
937                if not isinstance(name, str):
938                    raise ValueError(
939                        f"Expected stream name {name} to be a string, got {type(name)}."
940                    )
941
942                if name in seen_dynamic_streams:
943                    error_message = f"Dynamic streams list contains a duplicate name: {name}. Please contact Airbyte Support."
944                    failure_type = FailureType.system_error
945
946                    if resolver_type == "ConfigComponentsResolver":
947                        error_message = f"Dynamic streams list contains a duplicate name: {name}. Please check your configuration."
948                        failure_type = FailureType.config_error
949
950                    raise AirbyteTracedException(
951                        message=error_message,
952                        internal_message=error_message,
953                        failure_type=failure_type,
954                    )
955
956                seen_dynamic_streams.add(name)
957                dynamic_stream_configs.append(dynamic_stream)
958
959        return dynamic_stream_configs
960
961    def _select_streams(
962        self, streams: List[AbstractStream], configured_catalog: ConfiguredAirbyteCatalog
963    ) -> List[AbstractStream]:
964        stream_name_to_instance: Mapping[str, AbstractStream] = {s.name: s for s in streams}
965        abstract_streams: List[AbstractStream] = []
966        for configured_stream in configured_catalog.streams:
967            stream_instance = stream_name_to_instance.get(configured_stream.stream.name)
968            if stream_instance:
969                abstract_streams.append(stream_instance)
970            else:
971                # Previous behavior in the legacy synchronous CDK was to also raise an error TRACE message if
972                # the source was configured with raise_exception_on_missing_stream=True. This was used on very
973                # few sources like facebook-marketing and google-ads. We decided not to port this feature over,
974                # but we can do so if we feel it necessary. With the current behavior,we should still result
975                # in a partial failure since missing streams will be marked as INCOMPLETE.
976                self._message_repository.emit_message(
977                    as_airbyte_message(configured_stream.stream, AirbyteStreamStatus.INCOMPLETE)
978                )
979        return abstract_streams
@dataclass
class TestLimits:
111@dataclass
112class TestLimits:
113    __test__: ClassVar[bool] = False  # Tell Pytest this is not a Pytest class, despite its name
114
115    DEFAULT_MAX_PAGES_PER_SLICE: ClassVar[int] = 5
116    DEFAULT_MAX_SLICES: ClassVar[int] = 5
117    DEFAULT_MAX_RECORDS: ClassVar[int] = 100
118    DEFAULT_MAX_STREAMS: ClassVar[int] = 100
119
120    max_records: int = field(default=DEFAULT_MAX_RECORDS)
121    max_pages_per_slice: int = field(default=DEFAULT_MAX_PAGES_PER_SLICE)
122    max_slices: int = field(default=DEFAULT_MAX_SLICES)
123    max_streams: int = field(default=DEFAULT_MAX_STREAMS)
TestLimits( max_records: int = 100, max_pages_per_slice: int = 5, max_slices: int = 5, max_streams: int = 100)
DEFAULT_MAX_PAGES_PER_SLICE: ClassVar[int] = 5
DEFAULT_MAX_SLICES: ClassVar[int] = 5
DEFAULT_MAX_RECORDS: ClassVar[int] = 100
DEFAULT_MAX_STREAMS: ClassVar[int] = 100
max_records: int = 100
max_pages_per_slice: int = 5
max_slices: int = 5
max_streams: int = 100
144class ConcurrentDeclarativeSource(Source):
145    # By default, we defer to a value of 2. A value lower than could cause a PartitionEnqueuer to be stuck in a state of deadlock
146    # because it has hit the limit of futures but not partition reader is consuming them.
147    _LOWEST_SAFE_CONCURRENCY_LEVEL = 2
148
149    # Component types that hold the connector config and can write it back. `refresh_token_updater` is
150    # declared on `OAuthAuthenticator` alone; `CustomAuthenticator` is the type the transformer injects
151    # for a `class_name` component.
152    _CONFIG_PERSISTING_AUTHENTICATOR_TYPES = frozenset(
153        {"OAuthAuthenticator", "CustomAuthenticator"}
154    )
155
156    def __init__(
157        self,
158        catalog: Optional[ConfiguredAirbyteCatalog] = None,
159        config: Optional[Mapping[str, Any]] = None,
160        state: Optional[List[AirbyteStateMessage]] = None,
161        *,
162        source_config: ConnectionDefinition,
163        debug: bool = False,
164        emit_connector_builder_messages: bool = False,
165        migrate_manifest: bool = False,
166        normalize_manifest: bool = False,
167        limits: Optional[TestLimits] = None,
168        config_path: Optional[str] = None,
169        custom_components_trusted: bool = True,
170        **kwargs: Any,
171    ) -> None:
172        self.logger = logging.getLogger(f"airbyte.{self.name}")
173
174        self._limits = limits
175
176        # todo: We could remove state from initialization. Now that streams are grouped during the read(), a source
177        #  no longer needs to store the original incoming state. But maybe there's an edge case?
178        self._connector_state_manager = ConnectorStateManager(state=state)  # type: ignore  # state is always in the form of List[AirbyteStateMessage]. The ConnectorStateManager should use generics, but this can be done later
179
180        # We set a maxsize to for the main thread to process record items when the queue size grows. This assumes that there are less
181        # threads generating partitions that than are max number of workers. If it weren't the case, we could have threads only generating
182        # partitions which would fill the queue. This number is arbitrarily set to 10_000 but will probably need to be changed given more
183        # information and might even need to be configurable depending on the source
184        queue: Queue[QueueItem] = Queue(maxsize=10_000)
185        message_repository = InMemoryMessageRepository(
186            Level.DEBUG if emit_connector_builder_messages else Level.INFO
187        )
188
189        # To reduce the complexity of the concurrent framework, we are not enabling RFR with synthetic
190        # cursors. We do this by no longer automatically instantiating RFR cursors when converting
191        # the declarative models into runtime components. Concurrent sources will continue to checkpoint
192        # incremental streams running in full refresh.
193        component_factory = ModelToComponentFactory(
194            custom_components_trusted=custom_components_trusted,
195            emit_connector_builder_messages=emit_connector_builder_messages,
196            message_repository=ConcurrentMessageRepository(queue, message_repository),
197            configured_catalog=catalog,
198            connector_state_manager=self._connector_state_manager,
199            max_concurrent_async_job_count=source_config.get("max_concurrent_async_job_count"),
200            limit_pages_fetched_per_slice=limits.max_pages_per_slice if limits else None,
201            limit_slices_fetched=limits.max_slices if limits else None,
202            disable_retries=True if limits else False,
203            disable_cache=True if limits else False,
204        )
205
206        self._should_normalize = normalize_manifest
207        self._should_migrate = migrate_manifest
208        self._declarative_component_schema = _get_declarative_component_schema()
209        # If custom components are needed, locate and/or register them.
210        self.components_module: ModuleType | None = get_registered_components_module(config=config)
211        # set additional attributes
212        self._debug = debug
213        self._emit_connector_builder_messages = emit_connector_builder_messages
214        self._constructor = (
215            component_factory
216            if component_factory
217            else ModelToComponentFactory(
218                custom_components_trusted=custom_components_trusted,
219                emit_connector_builder_messages=emit_connector_builder_messages,
220                max_concurrent_async_job_count=source_config.get("max_concurrent_async_job_count"),
221            )
222        )
223
224        self._message_repository = self._constructor.get_message_repository()
225        self._slice_logger: SliceLogger = (
226            AlwaysLogSliceLogger() if emit_connector_builder_messages else DebugSliceLogger()
227        )
228
229        # resolve all components in the manifest
230        self._source_config = self._pre_process_manifest(dict(source_config))
231        # validate resolved manifest against the declarative component schema
232        self._validate_source()
233        # apply additional post-processing to the manifest
234        self._post_process_manifest()
235
236        spec: Optional[Mapping[str, Any]] = self._source_config.get("spec")
237        self._spec_component: Optional[Spec] = (
238            self._constructor.create_component(SpecModel, spec, dict()) if spec else None
239        )
240        self._config = self._migrate_and_transform_config(config_path, config) or {}
241        # `check` may temporarily overlay values onto `self._config` (see
242        # `_config_overridden_for_check`). The manifest's `config_validations` express intent about the
243        # config as supplied, so they must run against it rather than against a check-time overlay.
244        self._config_for_validation = self._config
245
246        concurrency_level_from_manifest = self._source_config.get("concurrency_level")
247        if concurrency_level_from_manifest:
248            concurrency_level_component = self._constructor.create_component(
249                model_type=ConcurrencyLevelModel,
250                component_definition=concurrency_level_from_manifest,
251                config=config or {},
252            )
253            if not isinstance(concurrency_level_component, ConcurrencyLevel):
254                raise ValueError(
255                    f"Expected to generate a ConcurrencyLevel component, but received {concurrency_level_component.__class__}"
256                )
257
258            concurrency_level = concurrency_level_component.get_concurrency_level()
259            initial_number_of_partitions_to_generate = max(
260                concurrency_level // 2, 1
261            )  # Partition_generation iterates using range based on this value. If this is floored to zero we end up in a dead lock during start up
262        else:
263            concurrency_level = self._LOWEST_SAFE_CONCURRENCY_LEVEL
264            initial_number_of_partitions_to_generate = self._LOWEST_SAFE_CONCURRENCY_LEVEL // 2
265
266        self._concurrent_source = ConcurrentSource.create(
267            num_workers=concurrency_level,
268            initial_number_of_partitions_to_generate=initial_number_of_partitions_to_generate,
269            logger=self.logger,
270            slice_logger=self._slice_logger,
271            queue=queue,
272            message_repository=self._message_repository,
273        )
274
275    def _pre_process_manifest(self, manifest: Dict[str, Any]) -> Dict[str, Any]:
276        """
277        Preprocesses the provided manifest dictionary by resolving any manifest references.
278
279        This method modifies the input manifest in place, resolving references using the
280        ManifestReferenceResolver to ensure all references within the manifest are properly handled.
281
282        Args:
283            manifest (Dict[str, Any]): The manifest dictionary to preprocess and resolve references in.
284
285        Returns:
286            None
287        """
288        # For ease of use we don't require the type to be specified at the top level manifest, but it should be included during processing
289        manifest = self._fix_source_type(manifest)
290        # Resolve references in the manifest
291        resolved_manifest = ManifestReferenceResolver().preprocess_manifest(manifest)
292        # Propagate types and parameters throughout the manifest
293        propagated_manifest = ManifestComponentTransformer().propagate_types_and_parameters(
294            "", resolved_manifest, {}
295        )
296
297        return propagated_manifest
298
299    def _fix_source_type(self, manifest: Dict[str, Any]) -> Dict[str, Any]:
300        """
301        Fix the source type in the manifest. This is necessary because the source type is not always set in the manifest.
302        """
303        if "type" not in manifest:
304            manifest["type"] = "DeclarativeSource"
305
306        return manifest
307
308    def _post_process_manifest(self) -> None:
309        """
310        Post-processes the manifest after validation.
311        This method is responsible for any additional modifications or transformations needed
312        after the manifest has been validated and before it is used in the source.
313        """
314        # apply manifest migration, if required
315        self._migrate_manifest()
316        # apply manifest normalization, if required
317        self._normalize_manifest()
318
319    def _migrate_manifest(self) -> None:
320        """
321        This method is used to migrate the manifest. It should be called after the manifest has been validated.
322        The migration is done in place, so the original manifest is modified.
323
324        The original manifest is returned if any error occurs during migration.
325        """
326        if self._should_migrate:
327            manifest_migrator = ManifestMigrationHandler(self._source_config)
328            self._source_config = manifest_migrator.apply_migrations()
329            # validate migrated manifest against the declarative component schema
330            self._validate_source()
331
332    def _normalize_manifest(self) -> None:
333        """
334        This method is used to normalize the manifest. It should be called after the manifest has been validated.
335
336        Connector Builder UI rendering requires the manifest to be in a specific format.
337         - references have been resolved
338         - the commonly used definitions are extracted to the `definitions.linked.*`
339        """
340        if self._should_normalize:
341            normalizer = ManifestNormalizer(self._source_config, self._declarative_component_schema)
342            self._source_config = normalizer.normalize()
343
344    def _validate_source(self) -> None:
345        """
346        Validates the connector manifest against the declarative component schema
347        """
348
349        try:
350            validate(self._source_config, self._declarative_component_schema)
351        except ValidationError as e:
352            raise ValidationError(
353                "Validation against json schema defined in declarative_component_schema.yaml schema failed"
354            ) from e
355
356    def _migrate_and_transform_config(
357        self,
358        config_path: Optional[str],
359        config: Optional[Config],
360    ) -> Optional[Config]:
361        if not config:
362            return None
363        if not self._spec_component:
364            return config
365        mutable_config = dict(config)
366        self._spec_component.migrate_config(mutable_config)
367        if mutable_config != config:
368            if config_path:
369                with open(config_path, "w") as f:
370                    json.dump(mutable_config, f)
371            control_message = create_connector_config_control_message(mutable_config)
372            print(orjson.dumps(AirbyteMessageSerializer.dump(control_message)).decode())
373        self._spec_component.transform_config(mutable_config)
374        return mutable_config
375
376    def configure(self, config: Mapping[str, Any], temp_dir: str) -> Mapping[str, Any]:
377        config = self._config or config
378        return super().configure(config, temp_dir)
379
380    @property
381    def resolved_manifest(self) -> Mapping[str, Any]:
382        """
383        Returns the resolved manifest configuration for the source.
384
385        This property provides access to the internal source configuration as a mapping,
386        which contains all settings and parameters required to define the source's behavior.
387
388        Returns:
389            Mapping[str, Any]: The resolved source configuration manifest.
390        """
391        return self._source_config
392
393    def deprecation_warnings(self) -> List[ConnectorBuilderLogMessage]:
394        return self._constructor.get_model_deprecations()
395
396    def read(
397        self,
398        logger: logging.Logger,
399        config: Mapping[str, Any],
400        catalog: ConfiguredAirbyteCatalog,
401        state: Optional[List[AirbyteStateMessage]] = None,
402    ) -> Iterator[AirbyteMessage]:
403        selected_concurrent_streams = self._select_streams(
404            streams=self.streams(config=self._config),  # type: ignore  # We are migrating away from the DeclarativeStream implementation and streams() only returns the concurrent-compatible AbstractStream. To preserve compatibility, we retain the existing method interface
405            configured_catalog=catalog,
406        )
407
408        # It would appear that passing in an empty set of streams causes an infinite loop in ConcurrentReadProcessor.
409        # This is also evident in concurrent_source_adapter.py so I'll leave this out of scope to fix for now
410        if len(selected_concurrent_streams) > 0:
411            yield from self._concurrent_source.read(selected_concurrent_streams)
412
413    def discover(self, logger: logging.Logger, config: Mapping[str, Any]) -> AirbyteCatalog:
414        return AirbyteCatalog(
415            streams=[stream.as_airbyte_stream() for stream in self.streams(config=self._config)]
416        )
417
418    # todo: add PR comment about whether we can change the signature to List[AbstractStream]
419    def streams(self, config: Mapping[str, Any]) -> List[AbstractStream]:  # type: ignore  # we are migrating away from the AbstractSource and are expecting that this will only be called by ConcurrentDeclarativeSource or the Connector Builder
420        """
421        The `streams` method is used as part of the AbstractSource in the following cases:
422        * ConcurrentDeclarativeSource.check -> ManifestDeclarativeSource.check -> AbstractSource.check -> DeclarativeSource.check_connection -> CheckStream.check_connection -> streams
423        * ConcurrentDeclarativeSource.read -> AbstractSource.read -> streams (note that we filter for a specific catalog which excludes concurrent streams so not all streams actually read from all the streams returned by `streams`)
424        Note that `super.streams(config)` is also called when splitting the streams between concurrent or not in `_group_streams`.
425
426        In both case, we will assume that calling the DeclarativeStream is perfectly fine as the result for these is the same regardless of if it is a DeclarativeStream or a DefaultStream (concurrent). This should simply be removed once we have moved away from the mentioned code paths above.
427        """
428
429        if self._spec_component:
430            self._spec_component.validate_config(self._config_for_validation)
431
432        api_budget_model = self._source_config.get("api_budget")
433        if api_budget_model:
434            self._constructor.set_api_budget(api_budget_model, self._config)
435
436        stream_configs = self._stream_configs(self._source_config) + self.dynamic_streams
437
438        prepared_configs = self._initialize_cache_for_parent_streams(deepcopy(stream_configs))
439
440        source_streams = [
441            self._constructor.create_component(
442                (
443                    StateDelegatingStreamModel
444                    if stream_config.get("type") == StateDelegatingStreamModel.__name__
445                    else DeclarativeStreamModel
446                ),
447                stream_config,
448                self._config,
449                emit_connector_builder_messages=self._emit_connector_builder_messages,
450            )
451            for stream_config in prepared_configs
452        ]
453
454        self._apply_stream_groups(source_streams)
455
456        return source_streams
457
458    def _apply_stream_groups(self, streams: List[AbstractStream]) -> None:
459        """Set block_simultaneous_read on streams based on the manifest's stream_groups config.
460
461        Iterates over the resolved manifest's stream_groups and matches group membership
462        against actual created stream instances by name. Validates that no stream shares a
463        group with any of its parent streams, which would cause a deadlock.
464        """
465        stream_groups = self._source_config.get("stream_groups", {})
466        if not stream_groups:
467            return
468
469        # Build stream_name -> group_name mapping from the resolved manifest
470        stream_name_to_group: Dict[str, str] = {}
471        for group_name, group_config in stream_groups.items():
472            for stream_ref in group_config.get("streams", []):
473                if isinstance(stream_ref, dict):
474                    stream_name = stream_ref.get("name", "")
475                    if stream_name:
476                        stream_name_to_group[stream_name] = group_name
477
478        # Validate no stream shares a group with any of its ancestor streams
479        stream_name_to_instance: Dict[str, AbstractStream] = {s.name: s for s in streams}
480
481        def _collect_all_ancestor_names(stream_name: str) -> Set[str]:
482            """Recursively collect all ancestor stream names."""
483            ancestors: Set[str] = set()
484            inst = stream_name_to_instance.get(stream_name)
485            if not isinstance(inst, DefaultStream):
486                return ancestors
487            partition_router = inst.get_partition_router()
488            routers = [partition_router] if partition_router is not None else []
489            while routers:
490                router = routers.pop()
491                if isinstance(router, GroupingPartitionRouter):
492                    routers.append(router.underlying_partition_router)
493                elif isinstance(router, UnionPartitionRouter):
494                    routers.extend(router.partition_routers)
495                elif isinstance(router, CartesianProductStreamSlicer):
496                    routers.extend(router.stream_slicers)
497                elif isinstance(router, SubstreamPartitionRouter):
498                    for parent_config in router.parent_stream_configs:
499                        parent_name = parent_config.stream.name
500                        ancestors.add(parent_name)
501                        ancestors.update(_collect_all_ancestor_names(parent_name))
502            return ancestors
503
504        for stream in streams:
505            if not isinstance(stream, DefaultStream) or stream.name not in stream_name_to_group:
506                continue
507            group_name = stream_name_to_group[stream.name]
508            for ancestor_name in _collect_all_ancestor_names(stream.name):
509                if stream_name_to_group.get(ancestor_name) == group_name:
510                    raise ValueError(
511                        f"Stream '{stream.name}' and its parent stream '{ancestor_name}' "
512                        f"are both in group '{group_name}'. "
513                        f"A child stream must not share a group with its parent to avoid deadlock."
514                    )
515
516        # Apply group to matching stream instances
517        for stream in streams:
518            if isinstance(stream, DefaultStream) and stream.name in stream_name_to_group:
519                stream.block_simultaneous_read = stream_name_to_group[stream.name]
520
521    @staticmethod
522    def _initialize_cache_for_parent_streams(
523        stream_configs: List[Dict[str, Any]],
524    ) -> List[Dict[str, Any]]:
525        """Enable caching for parent streams unless explicitly disabled.
526
527        Caching is enabled by default for parent streams to optimize performance when the same
528        parent data is needed by multiple child streams. However, explicit `use_cache: false`
529        settings are respected for streams that cannot use caching (e.g., scroll-based pagination
530        APIs where caching causes duplicate records).
531        """
532        parent_streams = set()
533
534        def _set_cache_if_not_disabled(requester: Dict[str, Any]) -> None:
535            """Set use_cache to True only if not explicitly disabled."""
536            if requester.get("use_cache") is not False:
537                requester["use_cache"] = True
538
539        def update_with_cache_parent_configs(
540            parent_configs: list[dict[str, Any]],
541        ) -> None:
542            for parent_config in parent_configs:
543                parent_streams.add(parent_config["stream"]["name"])
544                if parent_config["stream"]["type"] == "StateDelegatingStream":
545                    _set_cache_if_not_disabled(
546                        parent_config["stream"]["full_refresh_stream"]["retriever"]["requester"]
547                    )
548                    _set_cache_if_not_disabled(
549                        parent_config["stream"]["incremental_stream"]["retriever"]["requester"]
550                    )
551                else:
552                    _set_cache_if_not_disabled(parent_config["stream"]["retriever"]["requester"])
553
554        for stream_config in stream_configs:
555            if stream_config.get("incremental_sync", {}).get("parent_stream"):
556                parent_streams.add(stream_config["incremental_sync"]["parent_stream"]["name"])
557                _set_cache_if_not_disabled(
558                    stream_config["incremental_sync"]["parent_stream"]["retriever"]["requester"]
559                )
560
561            elif stream_config.get("retriever", {}).get("partition_router", {}):
562                partition_router = stream_config["retriever"]["partition_router"]
563
564                routers = (
565                    list(partition_router)
566                    if isinstance(partition_router, list)
567                    else [partition_router]
568                )
569                while routers:
570                    router = routers.pop()
571                    if not isinstance(router, dict):
572                        continue
573                    if router.get("parent_stream_configs"):
574                        update_with_cache_parent_configs(router["parent_stream_configs"])
575                    # Descend into composed partition routers (e.g. UnionPartitionRouter's
576                    # partition_routers or GroupingPartitionRouter's underlying_partition_router)
577                    # so nested parent streams also get caching enabled.
578                    routers.extend(router.get("partition_routers") or [])
579                    if router.get("underlying_partition_router"):
580                        routers.append(router["underlying_partition_router"])
581
582        for stream_config in stream_configs:
583            if stream_config["name"] in parent_streams:
584                if stream_config["type"] == "StateDelegatingStream":
585                    _set_cache_if_not_disabled(
586                        stream_config["full_refresh_stream"]["retriever"]["requester"]
587                    )
588                    _set_cache_if_not_disabled(
589                        stream_config["incremental_stream"]["retriever"]["requester"]
590                    )
591                else:
592                    _set_cache_if_not_disabled(stream_config["retriever"]["requester"])
593        return stream_configs
594
595    def spec(self, logger: logging.Logger) -> ConnectorSpecification:
596        """
597        Returns the connector specification (spec) as defined in the Airbyte Protocol. The spec is an object describing the possible
598        configurations (e.g: username and password) which can be configured when running this connector. For low-code connectors, this
599        will first attempt to load the spec from the manifest's spec block, otherwise it will load it from "spec.yaml" or "spec.json"
600        in the project root.
601        """
602        return (
603            self._spec_component.generate_spec() if self._spec_component else super().spec(logger)
604        )
605
606    def check(self, logger: logging.Logger, config: Mapping[str, Any]) -> AirbyteConnectionStatus:
607        check = self._source_config.get("check")
608        if not check:
609            raise ValueError(f"Missing 'check' component definition within the manifest.")
610
611        if "type" not in check:
612            check["type"] = "CheckStream"
613        connection_checker = self._constructor.create_component(
614            COMPONENTS_CHECKER_TYPE_MAPPING[check["type"]],
615            check,
616            dict(),
617            emit_connector_builder_messages=self._emit_connector_builder_messages,
618        )
619        if not isinstance(connection_checker, ConnectionChecker):
620            raise ValueError(
621                f"Expected to generate a ConnectionChecker component, but received {connection_checker.__class__}"
622            )
623
624        with self._config_overridden_for_check(check.get("config_overrides")):
625            check_succeeded, error = connection_checker.check_connection(self, logger, self._config)
626        if not check_succeeded:
627            return AirbyteConnectionStatus(status=Status.FAILED, message=repr(error))
628        return AirbyteConnectionStatus(status=Status.SUCCEEDED)
629
630    @contextmanager
631    def _config_overridden_for_check(
632        self, config_overrides: Optional[Mapping[str, Any]]
633    ) -> Iterator[None]:
634        """Overlay the check component's `config_overrides` onto the config for the duration of a check.
635
636        Rebinding `self._config` reaches the whole component tree, because `streams()` interpolates from
637        it and ignores its own `config` argument. Not thread safe; `check` is one command per process.
638
639        Semantics:
640          - values are applied verbatim, never interpolated;
641          - the merge is one level deep, so an object-valued override replaces rather than merges;
642          - it happens after `_migrate_and_transform_config`, so derived fields are not recomputed;
643          - `config_validations` run against `self._config_for_validation`, not the overlay.
644
645        The copy is shallow, so a component writing into a nested path writes through to the config the
646        source was constructed with and the restore does not undo it. Only top-level writes are
647        discarded, which is why `_raise_if_config_is_persisted` refuses config-persisting manifests.
648
649        Known limitation: `$parameters` declared on a check component still propagate into object-valued
650        overrides. See https://github.com/airbytehq/airbyte-internal-issues/issues/16995.
651        """
652        if not config_overrides:
653            yield
654            return
655
656        self._raise_on_non_string_override_keys(config_overrides)
657        self._raise_on_reserved_override_keys(config_overrides)
658        self._raise_if_config_is_persisted(config_overrides)
659        self._warn_on_unknown_override_keys(config_overrides)
660        self._register_override_secrets(config_overrides)
661        # Keys only. An override may name a secret field, so values must not reach the logs.
662        self.logger.info(
663            f"Overriding config keys for the check operation: {', '.join(sorted(config_overrides))}"
664        )
665
666        unmodified_config = self._config
667        self._config = {**self._config, **config_overrides}
668        try:
669            yield
670        finally:
671            self._config = unmodified_config
672
673    @staticmethod
674    def _raise_on_reserved_override_keys(config_overrides: Mapping[str, Any]) -> None:
675        """Refuse keys in the platform's reserved `__airbyte` namespace.
676
677        `CheckStream` reads `__airbyte_check_stream_names` out of the config this overlay writes to, so
678        an override there would change which streams a check tests. The whole prefix is refused.
679        """
680        reserved = sorted(key for key in config_overrides if key.startswith("__airbyte"))
681        if reserved:
682            raise AirbyteTracedException(
683                message=(
684                    f"This connector's manifest is invalid: its check component overrides the "
685                    f"reserved config key(s) {reserved}. Keys prefixed with `__airbyte` belong to "
686                    "the platform, not to the connector's spec. This is a bug in the connector "
687                    "rather than in this connection's settings."
688                ),
689                internal_message=(
690                    f"config_overrides rejected: reserved __airbyte key(s) {reserved}. "
691                    "`CheckStream` reads `__airbyte_check_stream_names` out of the config this "
692                    "overlay writes to, so an override there would change which streams a check "
693                    "tests. Use `stream_names` instead."
694                ),
695                failure_type=FailureType.system_error,
696            )
697
698    @staticmethod
699    def _raise_on_non_string_override_keys(config_overrides: Mapping[str, Any]) -> None:
700        """Reject non-string keys, which YAML allows and every consumer below assumes away."""
701        non_strings = [key for key in config_overrides if not isinstance(key, str)]
702        if non_strings:
703            raise AirbyteTracedException(
704                message=(
705                    "This connector's manifest is invalid: its check component has "
706                    f"`config_overrides` key(s) {sorted(map(repr, non_strings))} that are not "
707                    "strings, so they cannot name a field in the connector's spec. This is a bug "
708                    "in the connector rather than in this connection's settings."
709                ),
710                internal_message=(
711                    "config_overrides rejected: non-string key(s) "
712                    f"{sorted(map(repr, non_strings))}. Quote them in the manifest so they name a "
713                    "field in the connector's spec."
714                ),
715                failure_type=FailureType.system_error,
716            )
717
718    def _raise_if_config_is_persisted(self, config_overrides: Mapping[str, Any]) -> None:
719        """Reject `config_overrides` on a manifest whose authenticator writes the config back.
720
721        A `refresh_token_updater` builds a `DeclarativeSingleUseRefreshTokenOauth2Authenticator`, whose
722        `_emit_control_message` emits the entire config it was handed as a `CONNECTOR_CONFIG` message.
723        During a check that is the overlay, and the platform persists it, so a check-only override would
724        become the connection's saved config. The restore cannot recall a message already on stdout.
725        """
726        if not self._manifest_writes_back_config(self._source_config):
727            return
728        raise AirbyteTracedException(
729            message=(
730                "This connector's manifest is invalid: `config_overrides` cannot be used by a "
731                "manifest that declares a `refresh_token_updater`. A token refresh during `check` "
732                "emits the whole config it was handed as a CONNECTOR_CONFIG control message, which "
733                f"the platform persists, so the check-only override(s) {sorted(config_overrides)} "
734                "would be saved as this connection's config and applied to every later sync. This "
735                "is a bug in the connector rather than in this connection's settings."
736            ),
737            internal_message=(
738                "config_overrides rejected: manifest persists config via refresh_token_updater. "
739                "Remove `config_overrides` from the check component, or drop the "
740                "`refresh_token_updater`."
741            ),
742            failure_type=FailureType.system_error,
743        )
744
745    @staticmethod
746    def _manifest_writes_back_config(definition: Any) -> bool:
747        """Whether any component in the manifest emits the connector config back to the platform.
748
749        Walks the whole manifest, since an authenticator can sit under any requester, inside a
750        `SelectiveAuthenticator`, or in a `ConditionalStreams` branch. Callers pass `self._source_config`,
751        which is post-`_pre_process_manifest`, so a `$ref`-ed authenticator is already inlined and
752        carries its own `type`.
753
754        `is not None` rather than truthiness because `refresh_token_updater: {}` takes every default and
755        still builds a single-use authenticator - the factory tests a model instance, which is truthy.
756        The type condition is what separates a component from a data blob that happens to use the name.
757
758        Does not detect a `CustomAuthenticator` that persists config without declaring the field.
759        """
760        if isinstance(definition, Mapping):
761            if (
762                definition.get("refresh_token_updater") is not None
763                and definition.get("type")
764                in ConcurrentDeclarativeSource._CONFIG_PERSISTING_AUTHENTICATOR_TYPES
765            ):
766                return True
767            return any(
768                ConcurrentDeclarativeSource._manifest_writes_back_config(value)
769                for value in definition.values()
770            )
771        if isinstance(definition, list):
772            return any(
773                ConcurrentDeclarativeSource._manifest_writes_back_config(item)
774                for item in definition
775            )
776        return False
777
778    def _warn_on_unknown_override_keys(self, config_overrides: Mapping[str, Any]) -> None:
779        """Warn about override keys the spec does not declare.
780
781        Nothing validates the overlay - the entrypoint validates what the user supplied, and
782        `config_validations` run against `self._config_for_validation` - so a typo is a silent no-op.
783        """
784        if not self._spec_component:
785            return
786        declared = self._declared_config_properties(self._spec_component.connection_specification)
787        if declared is None:
788            return
789        unknown = sorted(key for key in config_overrides if key not in declared)
790        if unknown:
791            self.logger.warning(
792                f"Check-only config override(s) {unknown} are not declared in the connector spec, so "
793                "they will have no effect on any component that reads the config by field name."
794            )
795
796    @staticmethod
797    def _declared_config_properties(
798        connection_specification: Mapping[str, Any],
799    ) -> Optional[Set[str]]:
800        """Every field name a spec declares, or `None` when it does not enumerate them.
801
802        `oneOf` and `allOf` composition put declarations one level down, so reading only the top-level
803        `properties` would report a declared field as undeclared.
804        """
805        if not isinstance(connection_specification, Mapping):
806            return None
807
808        names: Set[str] = set()
809        found_any = False
810        properties = connection_specification.get("properties")
811        if isinstance(properties, Mapping):
812            found_any = True
813            names.update(str(key) for key in properties)
814        for keyword in ("allOf", "anyOf", "oneOf"):
815            for branch in connection_specification.get(keyword) or []:
816                nested = ConcurrentDeclarativeSource._declared_config_properties(branch)
817                if nested is not None:
818                    found_any = True
819                    names.update(nested)
820        return names if found_any else None
821
822    def _register_override_secrets(self, config_overrides: Mapping[str, Any]) -> None:
823        """Register override values landing on an `airbyte_secret` field, so they get redacted.
824
825        The entrypoint builds the secret list from the config the user supplied, so a value substituted
826        here is unknown to `filter_secrets`. Uses the same discovery as the entrypoint, so the overlay is
827        redacted exactly where the user's own value at that path would be.
828        """
829        if not self._spec_component:
830            return
831        for secret in get_secrets(
832            self._spec_component.connection_specification, dict(config_overrides)
833        ):
834            if secret is not None:
835                add_to_secrets(str(secret))
836
837    @property
838    def dynamic_streams(self) -> List[Dict[str, Any]]:
839        return self._dynamic_stream_configs(
840            manifest=self._source_config,
841            with_dynamic_stream_name=True,
842        )
843
844    def _stream_configs(self, manifest: Mapping[str, Any]) -> List[Dict[str, Any]]:
845        # This has a warning flag for static, but after we finish part 4 we'll replace manifest with self._source_config
846        stream_configs = []
847        for current_stream_config in manifest.get("streams", []):
848            if (
849                "type" in current_stream_config
850                and current_stream_config["type"] == "ConditionalStreams"
851            ):
852                interpolated_boolean = InterpolatedBoolean(
853                    condition=current_stream_config.get("condition"),
854                    parameters={},
855                )
856
857                if interpolated_boolean.eval(config=self._config):
858                    stream_configs.extend(current_stream_config.get("streams", []))
859            else:
860                if "type" not in current_stream_config:
861                    current_stream_config["type"] = "DeclarativeStream"
862                stream_configs.append(current_stream_config)
863        return stream_configs
864
865    def _dynamic_stream_configs(
866        self,
867        manifest: Mapping[str, Any],
868        with_dynamic_stream_name: Optional[bool] = None,
869    ) -> List[Dict[str, Any]]:
870        dynamic_stream_definitions: List[Dict[str, Any]] = manifest.get("dynamic_streams", [])
871        dynamic_stream_configs: List[Dict[str, Any]] = []
872        seen_dynamic_streams: Set[str] = set()
873
874        for dynamic_definition_index, dynamic_definition in enumerate(dynamic_stream_definitions):
875            components_resolver_config = dynamic_definition["components_resolver"]
876
877            if not components_resolver_config:
878                raise ValueError(
879                    f"Missing 'components_resolver' in dynamic definition: {dynamic_definition}"
880                )
881
882            resolver_type = components_resolver_config.get("type")
883            if not resolver_type:
884                raise ValueError(
885                    f"Missing 'type' in components resolver configuration: {components_resolver_config}"
886                )
887
888            if resolver_type not in COMPONENTS_RESOLVER_TYPE_MAPPING:
889                raise ValueError(
890                    f"Invalid components resolver type '{resolver_type}'. "
891                    f"Expected one of {list(COMPONENTS_RESOLVER_TYPE_MAPPING.keys())}."
892                )
893
894            if "retriever" in components_resolver_config:
895                components_resolver_config["retriever"]["requester"]["use_cache"] = True
896
897            # Create a resolver for dynamic components based on type
898            if resolver_type == "HttpComponentsResolver":
899                components_resolver = self._constructor.create_component(
900                    model_type=COMPONENTS_RESOLVER_TYPE_MAPPING[resolver_type],
901                    component_definition=components_resolver_config,
902                    config=self._config,
903                    stream_name=dynamic_definition.get("name"),
904                )
905            else:
906                components_resolver = self._constructor.create_component(
907                    model_type=COMPONENTS_RESOLVER_TYPE_MAPPING[resolver_type],
908                    component_definition=components_resolver_config,
909                    config=self._config,
910                )
911
912            stream_template_config = dynamic_definition["stream_template"]
913
914            for dynamic_stream in components_resolver.resolve_components(
915                stream_template_config=stream_template_config
916            ):
917                # Get the use_parent_parameters configuration from the dynamic definition
918                # Default to True for backward compatibility, since connectors were already using it by default when this param was added
919                use_parent_parameters = dynamic_definition.get("use_parent_parameters", True)
920
921                dynamic_stream = {
922                    **ManifestComponentTransformer().propagate_types_and_parameters(
923                        "", dynamic_stream, {}, use_parent_parameters=use_parent_parameters
924                    )
925                }
926
927                if "type" not in dynamic_stream:
928                    dynamic_stream["type"] = "DeclarativeStream"
929
930                # Ensure that each stream is created with a unique name
931                name = dynamic_stream.get("name")
932
933                if with_dynamic_stream_name:
934                    dynamic_stream["dynamic_stream_name"] = dynamic_definition.get(
935                        "name", f"dynamic_stream_{dynamic_definition_index}"
936                    )
937
938                if not isinstance(name, str):
939                    raise ValueError(
940                        f"Expected stream name {name} to be a string, got {type(name)}."
941                    )
942
943                if name in seen_dynamic_streams:
944                    error_message = f"Dynamic streams list contains a duplicate name: {name}. Please contact Airbyte Support."
945                    failure_type = FailureType.system_error
946
947                    if resolver_type == "ConfigComponentsResolver":
948                        error_message = f"Dynamic streams list contains a duplicate name: {name}. Please check your configuration."
949                        failure_type = FailureType.config_error
950
951                    raise AirbyteTracedException(
952                        message=error_message,
953                        internal_message=error_message,
954                        failure_type=failure_type,
955                    )
956
957                seen_dynamic_streams.add(name)
958                dynamic_stream_configs.append(dynamic_stream)
959
960        return dynamic_stream_configs
961
962    def _select_streams(
963        self, streams: List[AbstractStream], configured_catalog: ConfiguredAirbyteCatalog
964    ) -> List[AbstractStream]:
965        stream_name_to_instance: Mapping[str, AbstractStream] = {s.name: s for s in streams}
966        abstract_streams: List[AbstractStream] = []
967        for configured_stream in configured_catalog.streams:
968            stream_instance = stream_name_to_instance.get(configured_stream.stream.name)
969            if stream_instance:
970                abstract_streams.append(stream_instance)
971            else:
972                # Previous behavior in the legacy synchronous CDK was to also raise an error TRACE message if
973                # the source was configured with raise_exception_on_missing_stream=True. This was used on very
974                # few sources like facebook-marketing and google-ads. We decided not to port this feature over,
975                # but we can do so if we feel it necessary. With the current behavior,we should still result
976                # in a partial failure since missing streams will be marked as INCOMPLETE.
977                self._message_repository.emit_message(
978                    as_airbyte_message(configured_stream.stream, AirbyteStreamStatus.INCOMPLETE)
979                )
980        return abstract_streams

Helper class that provides a standard way to create an ABC using inheritance.

ConcurrentDeclarativeSource( catalog: Optional[airbyte_protocol_dataclasses.models.airbyte_protocol.ConfiguredAirbyteCatalog] = None, config: Optional[Mapping[str, Any]] = None, state: Optional[List[airbyte_cdk.models.airbyte_protocol.AirbyteStateMessage]] = None, *, source_config: Mapping[str, Any], debug: bool = False, emit_connector_builder_messages: bool = False, migrate_manifest: bool = False, normalize_manifest: bool = False, limits: Optional[TestLimits] = None, config_path: Optional[str] = None, custom_components_trusted: bool = True, **kwargs: Any)
156    def __init__(
157        self,
158        catalog: Optional[ConfiguredAirbyteCatalog] = None,
159        config: Optional[Mapping[str, Any]] = None,
160        state: Optional[List[AirbyteStateMessage]] = None,
161        *,
162        source_config: ConnectionDefinition,
163        debug: bool = False,
164        emit_connector_builder_messages: bool = False,
165        migrate_manifest: bool = False,
166        normalize_manifest: bool = False,
167        limits: Optional[TestLimits] = None,
168        config_path: Optional[str] = None,
169        custom_components_trusted: bool = True,
170        **kwargs: Any,
171    ) -> None:
172        self.logger = logging.getLogger(f"airbyte.{self.name}")
173
174        self._limits = limits
175
176        # todo: We could remove state from initialization. Now that streams are grouped during the read(), a source
177        #  no longer needs to store the original incoming state. But maybe there's an edge case?
178        self._connector_state_manager = ConnectorStateManager(state=state)  # type: ignore  # state is always in the form of List[AirbyteStateMessage]. The ConnectorStateManager should use generics, but this can be done later
179
180        # We set a maxsize to for the main thread to process record items when the queue size grows. This assumes that there are less
181        # threads generating partitions that than are max number of workers. If it weren't the case, we could have threads only generating
182        # partitions which would fill the queue. This number is arbitrarily set to 10_000 but will probably need to be changed given more
183        # information and might even need to be configurable depending on the source
184        queue: Queue[QueueItem] = Queue(maxsize=10_000)
185        message_repository = InMemoryMessageRepository(
186            Level.DEBUG if emit_connector_builder_messages else Level.INFO
187        )
188
189        # To reduce the complexity of the concurrent framework, we are not enabling RFR with synthetic
190        # cursors. We do this by no longer automatically instantiating RFR cursors when converting
191        # the declarative models into runtime components. Concurrent sources will continue to checkpoint
192        # incremental streams running in full refresh.
193        component_factory = ModelToComponentFactory(
194            custom_components_trusted=custom_components_trusted,
195            emit_connector_builder_messages=emit_connector_builder_messages,
196            message_repository=ConcurrentMessageRepository(queue, message_repository),
197            configured_catalog=catalog,
198            connector_state_manager=self._connector_state_manager,
199            max_concurrent_async_job_count=source_config.get("max_concurrent_async_job_count"),
200            limit_pages_fetched_per_slice=limits.max_pages_per_slice if limits else None,
201            limit_slices_fetched=limits.max_slices if limits else None,
202            disable_retries=True if limits else False,
203            disable_cache=True if limits else False,
204        )
205
206        self._should_normalize = normalize_manifest
207        self._should_migrate = migrate_manifest
208        self._declarative_component_schema = _get_declarative_component_schema()
209        # If custom components are needed, locate and/or register them.
210        self.components_module: ModuleType | None = get_registered_components_module(config=config)
211        # set additional attributes
212        self._debug = debug
213        self._emit_connector_builder_messages = emit_connector_builder_messages
214        self._constructor = (
215            component_factory
216            if component_factory
217            else ModelToComponentFactory(
218                custom_components_trusted=custom_components_trusted,
219                emit_connector_builder_messages=emit_connector_builder_messages,
220                max_concurrent_async_job_count=source_config.get("max_concurrent_async_job_count"),
221            )
222        )
223
224        self._message_repository = self._constructor.get_message_repository()
225        self._slice_logger: SliceLogger = (
226            AlwaysLogSliceLogger() if emit_connector_builder_messages else DebugSliceLogger()
227        )
228
229        # resolve all components in the manifest
230        self._source_config = self._pre_process_manifest(dict(source_config))
231        # validate resolved manifest against the declarative component schema
232        self._validate_source()
233        # apply additional post-processing to the manifest
234        self._post_process_manifest()
235
236        spec: Optional[Mapping[str, Any]] = self._source_config.get("spec")
237        self._spec_component: Optional[Spec] = (
238            self._constructor.create_component(SpecModel, spec, dict()) if spec else None
239        )
240        self._config = self._migrate_and_transform_config(config_path, config) or {}
241        # `check` may temporarily overlay values onto `self._config` (see
242        # `_config_overridden_for_check`). The manifest's `config_validations` express intent about the
243        # config as supplied, so they must run against it rather than against a check-time overlay.
244        self._config_for_validation = self._config
245
246        concurrency_level_from_manifest = self._source_config.get("concurrency_level")
247        if concurrency_level_from_manifest:
248            concurrency_level_component = self._constructor.create_component(
249                model_type=ConcurrencyLevelModel,
250                component_definition=concurrency_level_from_manifest,
251                config=config or {},
252            )
253            if not isinstance(concurrency_level_component, ConcurrencyLevel):
254                raise ValueError(
255                    f"Expected to generate a ConcurrencyLevel component, but received {concurrency_level_component.__class__}"
256                )
257
258            concurrency_level = concurrency_level_component.get_concurrency_level()
259            initial_number_of_partitions_to_generate = max(
260                concurrency_level // 2, 1
261            )  # Partition_generation iterates using range based on this value. If this is floored to zero we end up in a dead lock during start up
262        else:
263            concurrency_level = self._LOWEST_SAFE_CONCURRENCY_LEVEL
264            initial_number_of_partitions_to_generate = self._LOWEST_SAFE_CONCURRENCY_LEVEL // 2
265
266        self._concurrent_source = ConcurrentSource.create(
267            num_workers=concurrency_level,
268            initial_number_of_partitions_to_generate=initial_number_of_partitions_to_generate,
269            logger=self.logger,
270            slice_logger=self._slice_logger,
271            queue=queue,
272            message_repository=self._message_repository,
273        )
logger
components_module: module | None
resolved_manifest: Mapping[str, Any]
380    @property
381    def resolved_manifest(self) -> Mapping[str, Any]:
382        """
383        Returns the resolved manifest configuration for the source.
384
385        This property provides access to the internal source configuration as a mapping,
386        which contains all settings and parameters required to define the source's behavior.
387
388        Returns:
389            Mapping[str, Any]: The resolved source configuration manifest.
390        """
391        return self._source_config

Returns the resolved manifest configuration for the source.

This property provides access to the internal source configuration as a mapping, which contains all settings and parameters required to define the source's behavior.

Returns:

Mapping[str, Any]: The resolved source configuration manifest.

def deprecation_warnings(self) -> List[airbyte_cdk.connector_builder.models.LogMessage]:
393    def deprecation_warnings(self) -> List[ConnectorBuilderLogMessage]:
394        return self._constructor.get_model_deprecations()
def read( self, logger: logging.Logger, config: Mapping[str, Any], catalog: airbyte_protocol_dataclasses.models.airbyte_protocol.ConfiguredAirbyteCatalog, state: Optional[List[airbyte_cdk.models.airbyte_protocol.AirbyteStateMessage]] = None) -> Iterator[airbyte_cdk.AirbyteMessage]:
396    def read(
397        self,
398        logger: logging.Logger,
399        config: Mapping[str, Any],
400        catalog: ConfiguredAirbyteCatalog,
401        state: Optional[List[AirbyteStateMessage]] = None,
402    ) -> Iterator[AirbyteMessage]:
403        selected_concurrent_streams = self._select_streams(
404            streams=self.streams(config=self._config),  # type: ignore  # We are migrating away from the DeclarativeStream implementation and streams() only returns the concurrent-compatible AbstractStream. To preserve compatibility, we retain the existing method interface
405            configured_catalog=catalog,
406        )
407
408        # It would appear that passing in an empty set of streams causes an infinite loop in ConcurrentReadProcessor.
409        # This is also evident in concurrent_source_adapter.py so I'll leave this out of scope to fix for now
410        if len(selected_concurrent_streams) > 0:
411            yield from self._concurrent_source.read(selected_concurrent_streams)

Returns a generator of the AirbyteMessages generated by reading the source with the given configuration, catalog, and state.

def discover( self, logger: logging.Logger, config: Mapping[str, Any]) -> airbyte_protocol_dataclasses.models.airbyte_protocol.AirbyteCatalog:
413    def discover(self, logger: logging.Logger, config: Mapping[str, Any]) -> AirbyteCatalog:
414        return AirbyteCatalog(
415            streams=[stream.as_airbyte_stream() for stream in self.streams(config=self._config)]
416        )

Returns an AirbyteCatalog representing the available streams and fields in this integration. For example, given valid credentials to a Postgres database, returns an Airbyte catalog where each postgres table is a stream, and each table column is a field.

def streams( self, config: Mapping[str, Any]) -> List[airbyte_cdk.sources.streams.concurrent.abstract_stream.AbstractStream]:
419    def streams(self, config: Mapping[str, Any]) -> List[AbstractStream]:  # type: ignore  # we are migrating away from the AbstractSource and are expecting that this will only be called by ConcurrentDeclarativeSource or the Connector Builder
420        """
421        The `streams` method is used as part of the AbstractSource in the following cases:
422        * ConcurrentDeclarativeSource.check -> ManifestDeclarativeSource.check -> AbstractSource.check -> DeclarativeSource.check_connection -> CheckStream.check_connection -> streams
423        * ConcurrentDeclarativeSource.read -> AbstractSource.read -> streams (note that we filter for a specific catalog which excludes concurrent streams so not all streams actually read from all the streams returned by `streams`)
424        Note that `super.streams(config)` is also called when splitting the streams between concurrent or not in `_group_streams`.
425
426        In both case, we will assume that calling the DeclarativeStream is perfectly fine as the result for these is the same regardless of if it is a DeclarativeStream or a DefaultStream (concurrent). This should simply be removed once we have moved away from the mentioned code paths above.
427        """
428
429        if self._spec_component:
430            self._spec_component.validate_config(self._config_for_validation)
431
432        api_budget_model = self._source_config.get("api_budget")
433        if api_budget_model:
434            self._constructor.set_api_budget(api_budget_model, self._config)
435
436        stream_configs = self._stream_configs(self._source_config) + self.dynamic_streams
437
438        prepared_configs = self._initialize_cache_for_parent_streams(deepcopy(stream_configs))
439
440        source_streams = [
441            self._constructor.create_component(
442                (
443                    StateDelegatingStreamModel
444                    if stream_config.get("type") == StateDelegatingStreamModel.__name__
445                    else DeclarativeStreamModel
446                ),
447                stream_config,
448                self._config,
449                emit_connector_builder_messages=self._emit_connector_builder_messages,
450            )
451            for stream_config in prepared_configs
452        ]
453
454        self._apply_stream_groups(source_streams)
455
456        return source_streams

The streams method is used as part of the AbstractSource in the following cases:

  • ConcurrentDeclarativeSource.check -> ManifestDeclarativeSource.check -> AbstractSource.check -> DeclarativeSource.check_connection -> CheckStream.check_connection -> streams
  • ConcurrentDeclarativeSource.read -> AbstractSource.read -> streams (note that we filter for a specific catalog which excludes concurrent streams so not all streams actually read from all the streams returned by streams) Note that super.streams(config) is also called when splitting the streams between concurrent or not in _group_streams.

In both case, we will assume that calling the DeclarativeStream is perfectly fine as the result for these is the same regardless of if it is a DeclarativeStream or a DefaultStream (concurrent). This should simply be removed once we have moved away from the mentioned code paths above.

def spec( self, logger: logging.Logger) -> airbyte_cdk.ConnectorSpecification:
595    def spec(self, logger: logging.Logger) -> ConnectorSpecification:
596        """
597        Returns the connector specification (spec) as defined in the Airbyte Protocol. The spec is an object describing the possible
598        configurations (e.g: username and password) which can be configured when running this connector. For low-code connectors, this
599        will first attempt to load the spec from the manifest's spec block, otherwise it will load it from "spec.yaml" or "spec.json"
600        in the project root.
601        """
602        return (
603            self._spec_component.generate_spec() if self._spec_component else super().spec(logger)
604        )

Returns the connector specification (spec) as defined in the Airbyte Protocol. The spec is an object describing the possible configurations (e.g: username and password) which can be configured when running this connector. For low-code connectors, this will first attempt to load the spec from the manifest's spec block, otherwise it will load it from "spec.yaml" or "spec.json" in the project root.

def check( self, logger: logging.Logger, config: Mapping[str, Any]) -> airbyte_protocol_dataclasses.models.airbyte_protocol.AirbyteConnectionStatus:
606    def check(self, logger: logging.Logger, config: Mapping[str, Any]) -> AirbyteConnectionStatus:
607        check = self._source_config.get("check")
608        if not check:
609            raise ValueError(f"Missing 'check' component definition within the manifest.")
610
611        if "type" not in check:
612            check["type"] = "CheckStream"
613        connection_checker = self._constructor.create_component(
614            COMPONENTS_CHECKER_TYPE_MAPPING[check["type"]],
615            check,
616            dict(),
617            emit_connector_builder_messages=self._emit_connector_builder_messages,
618        )
619        if not isinstance(connection_checker, ConnectionChecker):
620            raise ValueError(
621                f"Expected to generate a ConnectionChecker component, but received {connection_checker.__class__}"
622            )
623
624        with self._config_overridden_for_check(check.get("config_overrides")):
625            check_succeeded, error = connection_checker.check_connection(self, logger, self._config)
626        if not check_succeeded:
627            return AirbyteConnectionStatus(status=Status.FAILED, message=repr(error))
628        return AirbyteConnectionStatus(status=Status.SUCCEEDED)

Tests if the input configuration can be used to successfully connect to the integration e.g: if a provided Stripe API token can be used to connect to the Stripe API.

dynamic_streams: List[Dict[str, Any]]
837    @property
838    def dynamic_streams(self) -> List[Dict[str, Any]]:
839        return self._dynamic_stream_configs(
840            manifest=self._source_config,
841            with_dynamic_stream_name=True,
842        )