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

Validate a manifest against the shipped component schema.

Unit tests check the schema's metaschema, so valid manifests skip check_schema. On failure, check the schema first to surface SchemaError, while preserving jsonschema.validate's best-match error as the cause with a traceback.

 166class ConcurrentDeclarativeSource(Source):
 167    # 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
 168    # because it has hit the limit of futures but not partition reader is consuming them.
 169    _LOWEST_SAFE_CONCURRENCY_LEVEL = 2
 170
 171    # Component types that hold the connector config and can write it back. `refresh_token_updater` is
 172    # declared on `OAuthAuthenticator` alone; `CustomAuthenticator` is the type the transformer injects
 173    # for a `class_name` component.
 174    _CONFIG_PERSISTING_AUTHENTICATOR_TYPES = frozenset(
 175        {"OAuthAuthenticator", "CustomAuthenticator"}
 176    )
 177
 178    def __init__(
 179        self,
 180        catalog: Optional[ConfiguredAirbyteCatalog] = None,
 181        config: Optional[Mapping[str, Any]] = None,
 182        state: Optional[List[AirbyteStateMessage]] = None,
 183        *,
 184        source_config: ConnectionDefinition,
 185        debug: bool = False,
 186        emit_connector_builder_messages: bool = False,
 187        migrate_manifest: bool = False,
 188        normalize_manifest: bool = False,
 189        limits: Optional[TestLimits] = None,
 190        config_path: Optional[str] = None,
 191        custom_components_trusted: bool = True,
 192        **kwargs: Any,
 193    ) -> None:
 194        self.logger = logging.getLogger(f"airbyte.{self.name}")
 195
 196        self._limits = limits
 197
 198        # todo: We could remove state from initialization. Now that streams are grouped during the read(), a source
 199        #  no longer needs to store the original incoming state. But maybe there's an edge case?
 200        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
 201
 202        # We set a maxsize to for the main thread to process record items when the queue size grows. This assumes that there are less
 203        # threads generating partitions that than are max number of workers. If it weren't the case, we could have threads only generating
 204        # partitions which would fill the queue. This number is arbitrarily set to 10_000 but will probably need to be changed given more
 205        # information and might even need to be configurable depending on the source
 206        queue: Queue[QueueItem] = Queue(maxsize=10_000)
 207        message_repository = InMemoryMessageRepository(
 208            Level.DEBUG if emit_connector_builder_messages else Level.INFO
 209        )
 210
 211        # To reduce the complexity of the concurrent framework, we are not enabling RFR with synthetic
 212        # cursors. We do this by no longer automatically instantiating RFR cursors when converting
 213        # the declarative models into runtime components. Concurrent sources will continue to checkpoint
 214        # incremental streams running in full refresh.
 215        component_factory = ModelToComponentFactory(
 216            custom_components_trusted=custom_components_trusted,
 217            emit_connector_builder_messages=emit_connector_builder_messages,
 218            message_repository=ConcurrentMessageRepository(queue, message_repository),
 219            configured_catalog=catalog,
 220            connector_state_manager=self._connector_state_manager,
 221            max_concurrent_async_job_count=source_config.get("max_concurrent_async_job_count"),
 222            limit_pages_fetched_per_slice=limits.max_pages_per_slice if limits else None,
 223            limit_slices_fetched=limits.max_slices if limits else None,
 224            disable_retries=True if limits else False,
 225            disable_cache=True if limits else False,
 226        )
 227
 228        self._should_normalize = normalize_manifest
 229        self._should_migrate = migrate_manifest
 230        self._declarative_component_schema = get_declarative_component_schema()
 231        # If custom components are needed, locate and/or register them.
 232        self.components_module: ModuleType | None = get_registered_components_module(config=config)
 233        # set additional attributes
 234        self._debug = debug
 235        self._emit_connector_builder_messages = emit_connector_builder_messages
 236        self._constructor = (
 237            component_factory
 238            if component_factory
 239            else ModelToComponentFactory(
 240                custom_components_trusted=custom_components_trusted,
 241                emit_connector_builder_messages=emit_connector_builder_messages,
 242                max_concurrent_async_job_count=source_config.get("max_concurrent_async_job_count"),
 243            )
 244        )
 245
 246        self._message_repository = self._constructor.get_message_repository()
 247        self._slice_logger: SliceLogger = (
 248            AlwaysLogSliceLogger() if emit_connector_builder_messages else DebugSliceLogger()
 249        )
 250
 251        # resolve all components in the manifest
 252        self._source_config = self._pre_process_manifest(dict(source_config))
 253        # validate resolved manifest against the declarative component schema
 254        self._validate_source()
 255        # apply additional post-processing to the manifest
 256        self._post_process_manifest()
 257
 258        spec: Optional[Mapping[str, Any]] = self._source_config.get("spec")
 259        self._spec_component: Optional[Spec] = (
 260            self._constructor.create_component(SpecModel, spec, dict()) if spec else None
 261        )
 262        self._config = self._migrate_and_transform_config(config_path, config) or {}
 263        # `check` may temporarily overlay values onto `self._config` (see
 264        # `_config_overridden_for_check`). The manifest's `config_validations` express intent about the
 265        # config as supplied, so they must run against it rather than against a check-time overlay.
 266        self._config_for_validation = self._config
 267
 268        concurrency_level_from_manifest = self._source_config.get("concurrency_level")
 269        if concurrency_level_from_manifest:
 270            concurrency_level_component = self._constructor.create_component(
 271                model_type=ConcurrencyLevelModel,
 272                component_definition=concurrency_level_from_manifest,
 273                config=self._config,
 274            )
 275            if not isinstance(concurrency_level_component, ConcurrencyLevel):
 276                raise ValueError(
 277                    f"Expected to generate a ConcurrencyLevel component, but received {concurrency_level_component.__class__}"
 278                )
 279
 280            concurrency_level = concurrency_level_component.get_concurrency_level()
 281            initial_number_of_partitions_to_generate = max(
 282                concurrency_level // 2, 1
 283            )  # 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
 284        else:
 285            concurrency_level = self._LOWEST_SAFE_CONCURRENCY_LEVEL
 286            initial_number_of_partitions_to_generate = self._LOWEST_SAFE_CONCURRENCY_LEVEL // 2
 287
 288        self._concurrent_source = ConcurrentSource.create(
 289            num_workers=concurrency_level,
 290            initial_number_of_partitions_to_generate=initial_number_of_partitions_to_generate,
 291            logger=self.logger,
 292            slice_logger=self._slice_logger,
 293            queue=queue,
 294            message_repository=self._message_repository,
 295        )
 296
 297    def _pre_process_manifest(self, manifest: Dict[str, Any]) -> Dict[str, Any]:
 298        """
 299        Preprocesses the provided manifest dictionary by resolving any manifest references.
 300
 301        This method modifies the input manifest in place, resolving references using the
 302        ManifestReferenceResolver to ensure all references within the manifest are properly handled.
 303
 304        Args:
 305            manifest (Dict[str, Any]): The manifest dictionary to preprocess and resolve references in.
 306
 307        Returns:
 308            None
 309        """
 310        # 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
 311        manifest = self._fix_source_type(manifest)
 312        # Resolve references in the manifest
 313        resolved_manifest = ManifestReferenceResolver().preprocess_manifest(manifest)
 314        # Propagate types and parameters throughout the manifest
 315        propagated_manifest = ManifestComponentTransformer().propagate_types_and_parameters(
 316            "", resolved_manifest, {}
 317        )
 318
 319        return propagated_manifest
 320
 321    def _fix_source_type(self, manifest: Dict[str, Any]) -> Dict[str, Any]:
 322        """
 323        Fix the source type in the manifest. This is necessary because the source type is not always set in the manifest.
 324        """
 325        if "type" not in manifest:
 326            manifest["type"] = "DeclarativeSource"
 327
 328        return manifest
 329
 330    def _post_process_manifest(self) -> None:
 331        """
 332        Post-processes the manifest after validation.
 333        This method is responsible for any additional modifications or transformations needed
 334        after the manifest has been validated and before it is used in the source.
 335        """
 336        # apply manifest migration, if required
 337        self._migrate_manifest()
 338        # apply manifest normalization, if required
 339        self._normalize_manifest()
 340
 341    def _migrate_manifest(self) -> None:
 342        """
 343        This method is used to migrate the manifest. It should be called after the manifest has been validated.
 344        The migration is done in place, so the original manifest is modified.
 345
 346        The original manifest is returned if any error occurs during migration.
 347        """
 348        if self._should_migrate:
 349            manifest_migrator = ManifestMigrationHandler(self._source_config)
 350            self._source_config = manifest_migrator.apply_migrations()
 351            # validate migrated manifest against the declarative component schema
 352            self._validate_source()
 353
 354    def _normalize_manifest(self) -> None:
 355        """
 356        This method is used to normalize the manifest. It should be called after the manifest has been validated.
 357
 358        Connector Builder UI rendering requires the manifest to be in a specific format.
 359         - references have been resolved
 360         - the commonly used definitions are extracted to the `definitions.linked.*`
 361        """
 362        if self._should_normalize:
 363            normalizer = ManifestNormalizer(self._source_config, self._declarative_component_schema)
 364            self._source_config = normalizer.normalize()
 365
 366    def _validate_source(self) -> None:
 367        """
 368        Validates the connector manifest against the declarative component schema
 369        """
 370
 371        validate_manifest(self._source_config, self._declarative_component_schema)
 372
 373    def _migrate_and_transform_config(
 374        self,
 375        config_path: Optional[str],
 376        config: Optional[Config],
 377    ) -> Optional[Config]:
 378        if not config:
 379            return None
 380        if not self._spec_component:
 381            return config
 382        mutable_config = dict(config)
 383        self._spec_component.migrate_config(mutable_config)
 384        if mutable_config != config:
 385            if config_path:
 386                with open(config_path, "w") as f:
 387                    json.dump(mutable_config, f)
 388            control_message = create_connector_config_control_message(mutable_config)
 389            print(orjson.dumps(AirbyteMessageSerializer.dump(control_message)).decode())
 390        self._spec_component.transform_config(mutable_config)
 391        return mutable_config
 392
 393    def configure(self, config: Mapping[str, Any], temp_dir: str) -> Mapping[str, Any]:
 394        config = self._config or config
 395        return super().configure(config, temp_dir)
 396
 397    @property
 398    def resolved_manifest(self) -> Mapping[str, Any]:
 399        """
 400        Returns the resolved manifest configuration for the source.
 401
 402        This property provides access to the internal source configuration as a mapping,
 403        which contains all settings and parameters required to define the source's behavior.
 404
 405        Returns:
 406            Mapping[str, Any]: The resolved source configuration manifest.
 407        """
 408        return self._source_config
 409
 410    def deprecation_warnings(self) -> List[ConnectorBuilderLogMessage]:
 411        return self._constructor.get_model_deprecations()
 412
 413    def read(
 414        self,
 415        logger: logging.Logger,
 416        config: Mapping[str, Any],
 417        catalog: ConfiguredAirbyteCatalog,
 418        state: Optional[List[AirbyteStateMessage]] = None,
 419    ) -> Iterator[AirbyteMessage]:
 420        selected_concurrent_streams = self._select_streams(
 421            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
 422            configured_catalog=catalog,
 423        )
 424
 425        # It would appear that passing in an empty set of streams causes an infinite loop in ConcurrentReadProcessor.
 426        # This is also evident in concurrent_source_adapter.py so I'll leave this out of scope to fix for now
 427        if len(selected_concurrent_streams) > 0:
 428            yield from self._concurrent_source.read(selected_concurrent_streams)
 429
 430    def discover(self, logger: logging.Logger, config: Mapping[str, Any]) -> AirbyteCatalog:
 431        return AirbyteCatalog(
 432            streams=[stream.as_airbyte_stream() for stream in self.streams(config=self._config)]
 433        )
 434
 435    # todo: add PR comment about whether we can change the signature to List[AbstractStream]
 436    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
 437        """
 438        The `streams` method is used as part of the AbstractSource in the following cases:
 439        * ConcurrentDeclarativeSource.check -> ManifestDeclarativeSource.check -> AbstractSource.check -> DeclarativeSource.check_connection -> CheckStream.check_connection -> streams
 440        * 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`)
 441        Note that `super.streams(config)` is also called when splitting the streams between concurrent or not in `_group_streams`.
 442
 443        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.
 444        """
 445
 446        self._prepare_for_stream_build()
 447        stream_configs = self._stream_configs(self._source_config) + self.dynamic_streams
 448        return self._create_streams(stream_configs, stream_configs)
 449
 450    def _prepare_for_stream_build(self) -> None:
 451        if self._spec_component:
 452            self._spec_component.validate_config(self._config_for_validation)
 453
 454        api_budget_model = self._source_config.get("api_budget")
 455        if api_budget_model:
 456            self._constructor.set_api_budget(api_budget_model, self._config)
 457
 458    def _create_streams(
 459        self,
 460        selected_configs: List[Dict[str, Any]],
 461        all_configs: List[Dict[str, Any]],
 462    ) -> List[AbstractStream]:
 463        prepared_configs = self._initialize_cache_for_parent_streams(
 464            deepcopy(selected_configs), all_stream_configs=all_configs
 465        )
 466
 467        source_streams = [
 468            self._constructor.create_component(
 469                (
 470                    StateDelegatingStreamModel
 471                    if stream_config.get("type") == StateDelegatingStreamModel.__name__
 472                    else DeclarativeStreamModel
 473                ),
 474                stream_config,
 475                self._config,
 476                emit_connector_builder_messages=self._emit_connector_builder_messages,
 477            )
 478            for stream_config in prepared_configs
 479        ]
 480
 481        top_level_stream_names: Set[str] = set()
 482        for stream_config in all_configs:
 483            stream_name = stream_config.get("name")
 484            if isinstance(stream_name, str):
 485                top_level_stream_names.add(stream_name)
 486        self._apply_stream_groups(source_streams, top_level_stream_names)
 487
 488        return source_streams
 489
 490    def _uses_default_streams(self) -> bool:
 491        """A subclass that overrides `streams()` must keep going through it during check."""
 492        return type(self).streams is ConcurrentDeclarativeSource.streams
 493
 494    def _streams_by_name(self, stream_names: List[str]) -> Optional[List[AbstractStream]]:
 495        """Build the named static streams without resolving or building dynamic streams.
 496
 497        Return None when the names are empty, any name isn't static, or `streams()` is overridden.
 498        """
 499        if not self._uses_default_streams():
 500            return None
 501        try:
 502            static_configs = self._stream_configs(self._source_config)
 503        except Exception:
 504            return None
 505        static_stream_names = {stream_config.get("name") for stream_config in static_configs}
 506        if not stream_names or any(name not in static_stream_names for name in stream_names):
 507            return None
 508
 509        self._prepare_for_stream_build()
 510        selected_names = set(stream_names)
 511        selected_configs = [
 512            stream_config
 513            for stream_config in static_configs
 514            if stream_config.get("name") in selected_names
 515        ]
 516        return self._create_streams(selected_configs, static_configs)
 517
 518    def _first_streams(self, count: int) -> Optional[List[AbstractStream]]:
 519        """Build the first `count` streams, static then dynamic, in `streams()` order.
 520
 521        `count` must be at least 1. Return None when `streams()` is overridden.
 522        """
 523        if not self._uses_default_streams():
 524            return None
 525        if count < 1:
 526            raise ValueError("count must be >= 1")
 527        self._prepare_for_stream_build()
 528        stream_configs = self._stream_configs(self._source_config) + self.dynamic_streams
 529        return self._create_streams(stream_configs[:count], stream_configs)
 530
 531    def _apply_stream_groups(
 532        self,
 533        streams: List[AbstractStream],
 534        top_level_stream_names: Optional[Set[str]] = None,
 535    ) -> None:
 536        """Set block_simultaneous_read on streams based on the manifest's stream_groups config.
 537
 538        Iterates over the resolved manifest's stream_groups and matches group membership
 539        against actual created stream instances by name. Validates that no stream shares a
 540        group with any of its parent streams, which would cause a deadlock.
 541        """
 542        stream_groups = self._source_config.get("stream_groups", {})
 543        if not stream_groups:
 544            return
 545
 546        # Build stream_name -> group_name mapping from the resolved manifest
 547        stream_name_to_group: Dict[str, str] = {}
 548        for group_name, group_config in stream_groups.items():
 549            for stream_ref in group_config.get("streams", []):
 550                if isinstance(stream_ref, dict):
 551                    stream_name = stream_ref.get("name", "")
 552                    if stream_name:
 553                        stream_name_to_group[stream_name] = group_name
 554
 555        # Validate no stream shares a group with any of its ancestor streams
 556        stream_name_to_instance: Dict[str, AbstractStream] = {s.name: s for s in streams}
 557
 558        def _collect_all_ancestor_names(
 559            stream_name: str, inline_instance: Optional[AbstractStream] = None
 560        ) -> Set[str]:
 561            """Recursively collect all ancestor stream names."""
 562            ancestors: Set[str] = set()
 563            inst = stream_name_to_instance.get(stream_name)
 564            if inst is None and stream_name in (top_level_stream_names or set()):
 565                # A top-level stream that this call did not build: walk the copy inlined in the child
 566                inst = inline_instance
 567            if not isinstance(inst, DefaultStream):
 568                return ancestors
 569            partition_router = inst.get_partition_router()
 570            routers = [partition_router] if partition_router is not None else []
 571            while routers:
 572                router = routers.pop()
 573                if isinstance(router, GroupingPartitionRouter):
 574                    routers.append(router.underlying_partition_router)
 575                elif isinstance(router, UnionPartitionRouter):
 576                    routers.extend(router.partition_routers)
 577                elif isinstance(router, CartesianProductStreamSlicer):
 578                    routers.extend(router.stream_slicers)
 579                elif isinstance(router, SubstreamPartitionRouter):
 580                    for parent_config in router.parent_stream_configs:
 581                        parent_name = parent_config.stream.name
 582                        ancestors.add(parent_name)
 583                        ancestors.update(
 584                            _collect_all_ancestor_names(parent_name, parent_config.stream)
 585                        )
 586            return ancestors
 587
 588        for stream in streams:
 589            if not isinstance(stream, DefaultStream) or stream.name not in stream_name_to_group:
 590                continue
 591            group_name = stream_name_to_group[stream.name]
 592            for ancestor_name in _collect_all_ancestor_names(stream.name):
 593                if stream_name_to_group.get(ancestor_name) == group_name:
 594                    raise ValueError(
 595                        f"Stream '{stream.name}' and its parent stream '{ancestor_name}' "
 596                        f"are both in group '{group_name}'. "
 597                        f"A child stream must not share a group with its parent to avoid deadlock."
 598                    )
 599
 600        # Apply group to matching stream instances
 601        for stream in streams:
 602            if isinstance(stream, DefaultStream) and stream.name in stream_name_to_group:
 603                stream.block_simultaneous_read = stream_name_to_group[stream.name]
 604
 605    @staticmethod
 606    def _parent_stream_names(stream_configs: List[Dict[str, Any]]) -> Set[str]:
 607        parent_stream_names: Set[str] = set()
 608        for stream_config in stream_configs:
 609            parent_stream = stream_config.get("incremental_sync", {}).get("parent_stream")
 610            if parent_stream:
 611                parent_stream_names.add(parent_stream["name"])
 612            elif stream_config.get("retriever", {}).get("partition_router", {}):
 613                partition_router = stream_config["retriever"]["partition_router"]
 614                routers = (
 615                    list(partition_router)
 616                    if isinstance(partition_router, list)
 617                    else [partition_router]
 618                )
 619                while routers:
 620                    router = routers.pop()
 621                    if not isinstance(router, dict):
 622                        continue
 623                    parent_stream_names.update(
 624                        parent_config["stream"]["name"]
 625                        for parent_config in router.get("parent_stream_configs") or []
 626                    )
 627                    routers.extend(router.get("partition_routers") or [])
 628                    if router.get("underlying_partition_router"):
 629                        routers.append(router["underlying_partition_router"])
 630        return parent_stream_names
 631
 632    @staticmethod
 633    def _initialize_cache_for_parent_streams(
 634        stream_configs: List[Dict[str, Any]],
 635        all_stream_configs: Optional[List[Dict[str, Any]]] = None,
 636    ) -> List[Dict[str, Any]]:
 637        """Enable caching for parent streams unless explicitly disabled.
 638
 639        Caching is enabled by default for parent streams to optimize performance when the same
 640        parent data is needed by multiple child streams. However, explicit `use_cache: false`
 641        settings are respected for streams that cannot use caching (e.g., scroll-based pagination
 642        APIs where caching causes duplicate records).
 643        """
 644        parent_streams = ConcurrentDeclarativeSource._parent_stream_names(stream_configs)
 645        parent_streams.update(
 646            ConcurrentDeclarativeSource._parent_stream_names(all_stream_configs or [])
 647        )
 648
 649        def _set_cache_if_not_disabled(requester: Dict[str, Any]) -> None:
 650            """Set use_cache to True only if not explicitly disabled."""
 651            if requester.get("use_cache") is not False:
 652                requester["use_cache"] = True
 653
 654        def update_with_cache_parent_configs(
 655            parent_configs: list[dict[str, Any]],
 656        ) -> None:
 657            for parent_config in parent_configs:
 658                if parent_config["stream"]["type"] == "StateDelegatingStream":
 659                    _set_cache_if_not_disabled(
 660                        parent_config["stream"]["full_refresh_stream"]["retriever"]["requester"]
 661                    )
 662                    _set_cache_if_not_disabled(
 663                        parent_config["stream"]["incremental_stream"]["retriever"]["requester"]
 664                    )
 665                else:
 666                    _set_cache_if_not_disabled(parent_config["stream"]["retriever"]["requester"])
 667
 668        for stream_config in stream_configs:
 669            if stream_config.get("incremental_sync", {}).get("parent_stream"):
 670                _set_cache_if_not_disabled(
 671                    stream_config["incremental_sync"]["parent_stream"]["retriever"]["requester"]
 672                )
 673
 674            elif stream_config.get("retriever", {}).get("partition_router", {}):
 675                partition_router = stream_config["retriever"]["partition_router"]
 676
 677                routers = (
 678                    list(partition_router)
 679                    if isinstance(partition_router, list)
 680                    else [partition_router]
 681                )
 682                while routers:
 683                    router = routers.pop()
 684                    if not isinstance(router, dict):
 685                        continue
 686                    if router.get("parent_stream_configs"):
 687                        update_with_cache_parent_configs(router["parent_stream_configs"])
 688                    # Descend into composed partition routers (e.g. UnionPartitionRouter's
 689                    # partition_routers or GroupingPartitionRouter's underlying_partition_router)
 690                    # so nested parent streams also get caching enabled.
 691                    routers.extend(router.get("partition_routers") or [])
 692                    if router.get("underlying_partition_router"):
 693                        routers.append(router["underlying_partition_router"])
 694
 695        for stream_config in stream_configs:
 696            if stream_config["name"] in parent_streams:
 697                if stream_config["type"] == "StateDelegatingStream":
 698                    _set_cache_if_not_disabled(
 699                        stream_config["full_refresh_stream"]["retriever"]["requester"]
 700                    )
 701                    _set_cache_if_not_disabled(
 702                        stream_config["incremental_stream"]["retriever"]["requester"]
 703                    )
 704                else:
 705                    _set_cache_if_not_disabled(stream_config["retriever"]["requester"])
 706        return stream_configs
 707
 708    def spec(self, logger: logging.Logger) -> ConnectorSpecification:
 709        """
 710        Returns the connector specification (spec) as defined in the Airbyte Protocol. The spec is an object describing the possible
 711        configurations (e.g: username and password) which can be configured when running this connector. For low-code connectors, this
 712        will first attempt to load the spec from the manifest's spec block, otherwise it will load it from "spec.yaml" or "spec.json"
 713        in the project root.
 714        """
 715        return (
 716            self._spec_component.generate_spec() if self._spec_component else super().spec(logger)
 717        )
 718
 719    def check(self, logger: logging.Logger, config: Mapping[str, Any]) -> AirbyteConnectionStatus:
 720        check = self._source_config.get("check")
 721        if not check:
 722            raise ValueError(f"Missing 'check' component definition within the manifest.")
 723
 724        if "type" not in check:
 725            check["type"] = "CheckStream"
 726        connection_checker = self._constructor.create_component(
 727            COMPONENTS_CHECKER_TYPE_MAPPING[check["type"]],
 728            check,
 729            dict(),
 730            emit_connector_builder_messages=self._emit_connector_builder_messages,
 731        )
 732        if not isinstance(connection_checker, ConnectionChecker):
 733            raise ValueError(
 734                f"Expected to generate a ConnectionChecker component, but received {connection_checker.__class__}"
 735            )
 736
 737        with self._config_overridden_for_check(check.get("config_overrides")):
 738            check_succeeded, error = connection_checker.check_connection(self, logger, self._config)
 739        if not check_succeeded:
 740            return AirbyteConnectionStatus(status=Status.FAILED, message=repr(error))
 741        return AirbyteConnectionStatus(status=Status.SUCCEEDED)
 742
 743    def check_section(
 744        self, logger: logging.Logger, section: str, config: Mapping[str, Any]
 745    ) -> AirbyteConnectionStatus:
 746        """
 747        Section check for the stepped setup form, see `airbyte_cdk.sources.utils.check_section`.
 748
 749        A declarative source validates a configuration by reading from the streams its `check` component names, so a
 750        section check runs that same check on the configuration collected so far. The manifest is responsible for
 751        ordering its `sections` so that the sections which declare `"check": true` already hold every field those
 752        streams need; the entrypoint has validated that the required fields of the section and of the sections before
 753        it are present.
 754        """
 755        logger.info(f"Checking configuration section '{section}' with the declared check streams.")
 756        return self.check(logger, config)
 757
 758    @contextmanager
 759    def _config_overridden_for_check(
 760        self, config_overrides: Optional[Mapping[str, Any]]
 761    ) -> Iterator[None]:
 762        """Overlay the check component's `config_overrides` onto the config for the duration of a check.
 763
 764        Rebinding `self._config` reaches the whole component tree, because `streams()` interpolates from
 765        it and ignores its own `config` argument. Not thread safe; `check` is one command per process.
 766
 767        Semantics:
 768          - values are applied verbatim, never interpolated;
 769          - the merge is one level deep, so an object-valued override replaces rather than merges;
 770          - it happens after `_migrate_and_transform_config`, so derived fields are not recomputed;
 771          - `config_validations` run against `self._config_for_validation`, not the overlay.
 772
 773        The copy is shallow, so a component writing into a nested path writes through to the config the
 774        source was constructed with and the restore does not undo it. Only top-level writes are
 775        discarded, which is why `_raise_if_config_is_persisted` refuses config-persisting manifests.
 776
 777        Known limitation: `$parameters` declared on a check component still propagate into object-valued
 778        overrides. See https://github.com/airbytehq/airbyte-internal-issues/issues/16995.
 779        """
 780        if not config_overrides:
 781            yield
 782            return
 783
 784        self._raise_on_non_string_override_keys(config_overrides)
 785        self._raise_on_reserved_override_keys(config_overrides)
 786        self._raise_if_config_is_persisted(config_overrides)
 787        self._warn_on_unknown_override_keys(config_overrides)
 788        self._register_override_secrets(config_overrides)
 789        # Keys only. An override may name a secret field, so values must not reach the logs.
 790        self.logger.info(
 791            f"Overriding config keys for the check operation: {', '.join(sorted(config_overrides))}"
 792        )
 793
 794        unmodified_config = self._config
 795        self._config = {**self._config, **config_overrides}
 796        try:
 797            yield
 798        finally:
 799            self._config = unmodified_config
 800
 801    @staticmethod
 802    def _raise_on_reserved_override_keys(config_overrides: Mapping[str, Any]) -> None:
 803        """Refuse keys in the platform's reserved `__airbyte` namespace.
 804
 805        `CheckStream` reads `__airbyte_check_stream_names` out of the config this overlay writes to, so
 806        an override there would change which streams a check tests. The whole prefix is refused.
 807        """
 808        reserved = sorted(key for key in config_overrides if key.startswith("__airbyte"))
 809        if reserved:
 810            raise AirbyteTracedException(
 811                message=(
 812                    f"This connector's manifest is invalid: its check component overrides the "
 813                    f"reserved config key(s) {reserved}. Keys prefixed with `__airbyte` belong to "
 814                    "the platform, not to the connector's spec. This is a bug in the connector "
 815                    "rather than in this connection's settings."
 816                ),
 817                internal_message=(
 818                    f"config_overrides rejected: reserved __airbyte key(s) {reserved}. "
 819                    "`CheckStream` reads `__airbyte_check_stream_names` out of the config this "
 820                    "overlay writes to, so an override there would change which streams a check "
 821                    "tests. Use `stream_names` instead."
 822                ),
 823                failure_type=FailureType.system_error,
 824            )
 825
 826    @staticmethod
 827    def _raise_on_non_string_override_keys(config_overrides: Mapping[str, Any]) -> None:
 828        """Reject non-string keys, which YAML allows and every consumer below assumes away."""
 829        non_strings = [key for key in config_overrides if not isinstance(key, str)]
 830        if non_strings:
 831            raise AirbyteTracedException(
 832                message=(
 833                    "This connector's manifest is invalid: its check component has "
 834                    f"`config_overrides` key(s) {sorted(map(repr, non_strings))} that are not "
 835                    "strings, so they cannot name a field in the connector's spec. This is a bug "
 836                    "in the connector rather than in this connection's settings."
 837                ),
 838                internal_message=(
 839                    "config_overrides rejected: non-string key(s) "
 840                    f"{sorted(map(repr, non_strings))}. Quote them in the manifest so they name a "
 841                    "field in the connector's spec."
 842                ),
 843                failure_type=FailureType.system_error,
 844            )
 845
 846    def _raise_if_config_is_persisted(self, config_overrides: Mapping[str, Any]) -> None:
 847        """Reject `config_overrides` on a manifest whose authenticator writes the config back.
 848
 849        A `refresh_token_updater` builds a `DeclarativeSingleUseRefreshTokenOauth2Authenticator`, whose
 850        `_emit_control_message` emits the entire config it was handed as a `CONNECTOR_CONFIG` message.
 851        During a check that is the overlay, and the platform persists it, so a check-only override would
 852        become the connection's saved config. The restore cannot recall a message already on stdout.
 853        """
 854        if not self._manifest_writes_back_config(self._source_config):
 855            return
 856        raise AirbyteTracedException(
 857            message=(
 858                "This connector's manifest is invalid: `config_overrides` cannot be used by a "
 859                "manifest that declares a `refresh_token_updater`. A token refresh during `check` "
 860                "emits the whole config it was handed as a CONNECTOR_CONFIG control message, which "
 861                f"the platform persists, so the check-only override(s) {sorted(config_overrides)} "
 862                "would be saved as this connection's config and applied to every later sync. This "
 863                "is a bug in the connector rather than in this connection's settings."
 864            ),
 865            internal_message=(
 866                "config_overrides rejected: manifest persists config via refresh_token_updater. "
 867                "Remove `config_overrides` from the check component, or drop the "
 868                "`refresh_token_updater`."
 869            ),
 870            failure_type=FailureType.system_error,
 871        )
 872
 873    @staticmethod
 874    def _manifest_writes_back_config(definition: Any) -> bool:
 875        """Whether any component in the manifest emits the connector config back to the platform.
 876
 877        Walks the whole manifest, since an authenticator can sit under any requester, inside a
 878        `SelectiveAuthenticator`, or in a `ConditionalStreams` branch. Callers pass `self._source_config`,
 879        which is post-`_pre_process_manifest`, so a `$ref`-ed authenticator is already inlined and
 880        carries its own `type`.
 881
 882        `is not None` rather than truthiness because `refresh_token_updater: {}` takes every default and
 883        still builds a single-use authenticator - the factory tests a model instance, which is truthy.
 884        The type condition is what separates a component from a data blob that happens to use the name.
 885
 886        Does not detect a `CustomAuthenticator` that persists config without declaring the field.
 887        """
 888        if isinstance(definition, Mapping):
 889            if (
 890                definition.get("refresh_token_updater") is not None
 891                and definition.get("type")
 892                in ConcurrentDeclarativeSource._CONFIG_PERSISTING_AUTHENTICATOR_TYPES
 893            ):
 894                return True
 895            return any(
 896                ConcurrentDeclarativeSource._manifest_writes_back_config(value)
 897                for value in definition.values()
 898            )
 899        if isinstance(definition, list):
 900            return any(
 901                ConcurrentDeclarativeSource._manifest_writes_back_config(item)
 902                for item in definition
 903            )
 904        return False
 905
 906    def _warn_on_unknown_override_keys(self, config_overrides: Mapping[str, Any]) -> None:
 907        """Warn about override keys the spec does not declare.
 908
 909        Nothing validates the overlay - the entrypoint validates what the user supplied, and
 910        `config_validations` run against `self._config_for_validation` - so a typo is a silent no-op.
 911        """
 912        if not self._spec_component:
 913            return
 914        declared = self._declared_config_properties(self._spec_component.connection_specification)
 915        if declared is None:
 916            return
 917        unknown = sorted(key for key in config_overrides if key not in declared)
 918        if unknown:
 919            self.logger.warning(
 920                f"Check-only config override(s) {unknown} are not declared in the connector spec, so "
 921                "they will have no effect on any component that reads the config by field name."
 922            )
 923
 924    @staticmethod
 925    def _declared_config_properties(
 926        connection_specification: Mapping[str, Any],
 927    ) -> Optional[Set[str]]:
 928        """Every field name a spec declares, or `None` when it does not enumerate them.
 929
 930        `oneOf` and `allOf` composition put declarations one level down, so reading only the top-level
 931        `properties` would report a declared field as undeclared.
 932        """
 933        if not isinstance(connection_specification, Mapping):
 934            return None
 935
 936        names: Set[str] = set()
 937        found_any = False
 938        properties = connection_specification.get("properties")
 939        if isinstance(properties, Mapping):
 940            found_any = True
 941            names.update(str(key) for key in properties)
 942        for keyword in ("allOf", "anyOf", "oneOf"):
 943            for branch in connection_specification.get(keyword) or []:
 944                nested = ConcurrentDeclarativeSource._declared_config_properties(branch)
 945                if nested is not None:
 946                    found_any = True
 947                    names.update(nested)
 948        return names if found_any else None
 949
 950    def _register_override_secrets(self, config_overrides: Mapping[str, Any]) -> None:
 951        """Register override values landing on an `airbyte_secret` field, so they get redacted.
 952
 953        The entrypoint builds the secret list from the config the user supplied, so a value substituted
 954        here is unknown to `filter_secrets`. Uses the same discovery as the entrypoint, so the overlay is
 955        redacted exactly where the user's own value at that path would be.
 956        """
 957        if not self._spec_component:
 958            return
 959        for secret in get_secrets(
 960            self._spec_component.connection_specification, dict(config_overrides)
 961        ):
 962            if secret is not None:
 963                add_to_secrets(str(secret))
 964
 965    @property
 966    def dynamic_streams(self) -> List[Dict[str, Any]]:
 967        return self._dynamic_stream_configs(
 968            manifest=self._source_config,
 969            with_dynamic_stream_name=True,
 970        )
 971
 972    def _stream_configs(self, manifest: Mapping[str, Any]) -> List[Dict[str, Any]]:
 973        # This has a warning flag for static, but after we finish part 4 we'll replace manifest with self._source_config
 974        stream_configs = []
 975        for current_stream_config in manifest.get("streams", []):
 976            if (
 977                "type" in current_stream_config
 978                and current_stream_config["type"] == "ConditionalStreams"
 979            ):
 980                interpolated_boolean = InterpolatedBoolean(
 981                    condition=current_stream_config.get("condition"),
 982                    parameters={},
 983                )
 984
 985                if interpolated_boolean.eval(config=self._config):
 986                    stream_configs.extend(current_stream_config.get("streams", []))
 987            else:
 988                if "type" not in current_stream_config:
 989                    current_stream_config["type"] = "DeclarativeStream"
 990                stream_configs.append(current_stream_config)
 991        return stream_configs
 992
 993    def _dynamic_stream_configs(
 994        self,
 995        manifest: Mapping[str, Any],
 996        with_dynamic_stream_name: Optional[bool] = None,
 997    ) -> List[Dict[str, Any]]:
 998        dynamic_stream_definitions: List[Dict[str, Any]] = manifest.get("dynamic_streams", [])
 999        dynamic_stream_configs: List[Dict[str, Any]] = []
1000        seen_dynamic_streams: Set[str] = set()
1001
1002        for dynamic_definition_index, dynamic_definition in enumerate(dynamic_stream_definitions):
1003            components_resolver_config = dynamic_definition["components_resolver"]
1004
1005            if not components_resolver_config:
1006                raise ValueError(
1007                    f"Missing 'components_resolver' in dynamic definition: {dynamic_definition}"
1008                )
1009
1010            resolver_type = components_resolver_config.get("type")
1011            if not resolver_type:
1012                raise ValueError(
1013                    f"Missing 'type' in components resolver configuration: {components_resolver_config}"
1014                )
1015
1016            if resolver_type not in COMPONENTS_RESOLVER_TYPE_MAPPING:
1017                raise ValueError(
1018                    f"Invalid components resolver type '{resolver_type}'. "
1019                    f"Expected one of {list(COMPONENTS_RESOLVER_TYPE_MAPPING.keys())}."
1020                )
1021
1022            # AsyncRetriever has no `requester`, and a cached polling request replays a stale status
1023            if "requester" in components_resolver_config.get("retriever", {}):
1024                components_resolver_config["retriever"]["requester"]["use_cache"] = True
1025
1026            # Create a resolver for dynamic components based on type
1027            if resolver_type == "HttpComponentsResolver":
1028                components_resolver = self._constructor.create_component(
1029                    model_type=COMPONENTS_RESOLVER_TYPE_MAPPING[resolver_type],
1030                    component_definition=components_resolver_config,
1031                    config=self._config,
1032                    stream_name=dynamic_definition.get("name"),
1033                )
1034            else:
1035                components_resolver = self._constructor.create_component(
1036                    model_type=COMPONENTS_RESOLVER_TYPE_MAPPING[resolver_type],
1037                    component_definition=components_resolver_config,
1038                    config=self._config,
1039                )
1040
1041            stream_template_config = dynamic_definition["stream_template"]
1042
1043            for dynamic_stream in components_resolver.resolve_components(
1044                stream_template_config=stream_template_config
1045            ):
1046                # Get the use_parent_parameters configuration from the dynamic definition
1047                # Default to True for backward compatibility, since connectors were already using it by default when this param was added
1048                use_parent_parameters = dynamic_definition.get("use_parent_parameters", True)
1049
1050                dynamic_stream = {
1051                    **ManifestComponentTransformer().propagate_types_and_parameters(
1052                        "", dynamic_stream, {}, use_parent_parameters=use_parent_parameters
1053                    )
1054                }
1055
1056                if "type" not in dynamic_stream:
1057                    dynamic_stream["type"] = "DeclarativeStream"
1058
1059                # Ensure that each stream is created with a unique name
1060                name = dynamic_stream.get("name")
1061
1062                if with_dynamic_stream_name:
1063                    dynamic_stream["dynamic_stream_name"] = dynamic_definition.get(
1064                        "name", f"dynamic_stream_{dynamic_definition_index}"
1065                    )
1066
1067                if not isinstance(name, str):
1068                    raise ValueError(
1069                        f"Expected stream name {name} to be a string, got {type(name)}."
1070                    )
1071
1072                if name in seen_dynamic_streams:
1073                    error_message = f"Dynamic streams list contains a duplicate name: {name}. Please contact Airbyte Support."
1074                    failure_type = FailureType.system_error
1075
1076                    if resolver_type == "ConfigComponentsResolver":
1077                        error_message = f"Dynamic streams list contains a duplicate name: {name}. Please check your configuration."
1078                        failure_type = FailureType.config_error
1079
1080                    raise AirbyteTracedException(
1081                        message=error_message,
1082                        internal_message=error_message,
1083                        failure_type=failure_type,
1084                    )
1085
1086                seen_dynamic_streams.add(name)
1087                dynamic_stream_configs.append(dynamic_stream)
1088
1089        return dynamic_stream_configs
1090
1091    def _select_streams(
1092        self, streams: List[AbstractStream], configured_catalog: ConfiguredAirbyteCatalog
1093    ) -> List[AbstractStream]:
1094        stream_name_to_instance: Mapping[str, AbstractStream] = {s.name: s for s in streams}
1095        abstract_streams: List[AbstractStream] = []
1096        for configured_stream in configured_catalog.streams:
1097            stream_instance = stream_name_to_instance.get(configured_stream.stream.name)
1098            if stream_instance:
1099                abstract_streams.append(stream_instance)
1100            else:
1101                # Previous behavior in the legacy synchronous CDK was to also raise an error TRACE message if
1102                # the source was configured with raise_exception_on_missing_stream=True. This was used on very
1103                # few sources like facebook-marketing and google-ads. We decided not to port this feature over,
1104                # but we can do so if we feel it necessary. With the current behavior,we should still result
1105                # in a partial failure since missing streams will be marked as INCOMPLETE.
1106                self._message_repository.emit_message(
1107                    as_airbyte_message(configured_stream.stream, AirbyteStreamStatus.INCOMPLETE)
1108                )
1109        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)
178    def __init__(
179        self,
180        catalog: Optional[ConfiguredAirbyteCatalog] = None,
181        config: Optional[Mapping[str, Any]] = None,
182        state: Optional[List[AirbyteStateMessage]] = None,
183        *,
184        source_config: ConnectionDefinition,
185        debug: bool = False,
186        emit_connector_builder_messages: bool = False,
187        migrate_manifest: bool = False,
188        normalize_manifest: bool = False,
189        limits: Optional[TestLimits] = None,
190        config_path: Optional[str] = None,
191        custom_components_trusted: bool = True,
192        **kwargs: Any,
193    ) -> None:
194        self.logger = logging.getLogger(f"airbyte.{self.name}")
195
196        self._limits = limits
197
198        # todo: We could remove state from initialization. Now that streams are grouped during the read(), a source
199        #  no longer needs to store the original incoming state. But maybe there's an edge case?
200        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
201
202        # We set a maxsize to for the main thread to process record items when the queue size grows. This assumes that there are less
203        # threads generating partitions that than are max number of workers. If it weren't the case, we could have threads only generating
204        # partitions which would fill the queue. This number is arbitrarily set to 10_000 but will probably need to be changed given more
205        # information and might even need to be configurable depending on the source
206        queue: Queue[QueueItem] = Queue(maxsize=10_000)
207        message_repository = InMemoryMessageRepository(
208            Level.DEBUG if emit_connector_builder_messages else Level.INFO
209        )
210
211        # To reduce the complexity of the concurrent framework, we are not enabling RFR with synthetic
212        # cursors. We do this by no longer automatically instantiating RFR cursors when converting
213        # the declarative models into runtime components. Concurrent sources will continue to checkpoint
214        # incremental streams running in full refresh.
215        component_factory = ModelToComponentFactory(
216            custom_components_trusted=custom_components_trusted,
217            emit_connector_builder_messages=emit_connector_builder_messages,
218            message_repository=ConcurrentMessageRepository(queue, message_repository),
219            configured_catalog=catalog,
220            connector_state_manager=self._connector_state_manager,
221            max_concurrent_async_job_count=source_config.get("max_concurrent_async_job_count"),
222            limit_pages_fetched_per_slice=limits.max_pages_per_slice if limits else None,
223            limit_slices_fetched=limits.max_slices if limits else None,
224            disable_retries=True if limits else False,
225            disable_cache=True if limits else False,
226        )
227
228        self._should_normalize = normalize_manifest
229        self._should_migrate = migrate_manifest
230        self._declarative_component_schema = get_declarative_component_schema()
231        # If custom components are needed, locate and/or register them.
232        self.components_module: ModuleType | None = get_registered_components_module(config=config)
233        # set additional attributes
234        self._debug = debug
235        self._emit_connector_builder_messages = emit_connector_builder_messages
236        self._constructor = (
237            component_factory
238            if component_factory
239            else ModelToComponentFactory(
240                custom_components_trusted=custom_components_trusted,
241                emit_connector_builder_messages=emit_connector_builder_messages,
242                max_concurrent_async_job_count=source_config.get("max_concurrent_async_job_count"),
243            )
244        )
245
246        self._message_repository = self._constructor.get_message_repository()
247        self._slice_logger: SliceLogger = (
248            AlwaysLogSliceLogger() if emit_connector_builder_messages else DebugSliceLogger()
249        )
250
251        # resolve all components in the manifest
252        self._source_config = self._pre_process_manifest(dict(source_config))
253        # validate resolved manifest against the declarative component schema
254        self._validate_source()
255        # apply additional post-processing to the manifest
256        self._post_process_manifest()
257
258        spec: Optional[Mapping[str, Any]] = self._source_config.get("spec")
259        self._spec_component: Optional[Spec] = (
260            self._constructor.create_component(SpecModel, spec, dict()) if spec else None
261        )
262        self._config = self._migrate_and_transform_config(config_path, config) or {}
263        # `check` may temporarily overlay values onto `self._config` (see
264        # `_config_overridden_for_check`). The manifest's `config_validations` express intent about the
265        # config as supplied, so they must run against it rather than against a check-time overlay.
266        self._config_for_validation = self._config
267
268        concurrency_level_from_manifest = self._source_config.get("concurrency_level")
269        if concurrency_level_from_manifest:
270            concurrency_level_component = self._constructor.create_component(
271                model_type=ConcurrencyLevelModel,
272                component_definition=concurrency_level_from_manifest,
273                config=self._config,
274            )
275            if not isinstance(concurrency_level_component, ConcurrencyLevel):
276                raise ValueError(
277                    f"Expected to generate a ConcurrencyLevel component, but received {concurrency_level_component.__class__}"
278                )
279
280            concurrency_level = concurrency_level_component.get_concurrency_level()
281            initial_number_of_partitions_to_generate = max(
282                concurrency_level // 2, 1
283            )  # 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
284        else:
285            concurrency_level = self._LOWEST_SAFE_CONCURRENCY_LEVEL
286            initial_number_of_partitions_to_generate = self._LOWEST_SAFE_CONCURRENCY_LEVEL // 2
287
288        self._concurrent_source = ConcurrentSource.create(
289            num_workers=concurrency_level,
290            initial_number_of_partitions_to_generate=initial_number_of_partitions_to_generate,
291            logger=self.logger,
292            slice_logger=self._slice_logger,
293            queue=queue,
294            message_repository=self._message_repository,
295        )
logger
components_module: module | None
resolved_manifest: Mapping[str, Any]
397    @property
398    def resolved_manifest(self) -> Mapping[str, Any]:
399        """
400        Returns the resolved manifest configuration for the source.
401
402        This property provides access to the internal source configuration as a mapping,
403        which contains all settings and parameters required to define the source's behavior.
404
405        Returns:
406            Mapping[str, Any]: The resolved source configuration manifest.
407        """
408        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]:
410    def deprecation_warnings(self) -> List[ConnectorBuilderLogMessage]:
411        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]:
413    def read(
414        self,
415        logger: logging.Logger,
416        config: Mapping[str, Any],
417        catalog: ConfiguredAirbyteCatalog,
418        state: Optional[List[AirbyteStateMessage]] = None,
419    ) -> Iterator[AirbyteMessage]:
420        selected_concurrent_streams = self._select_streams(
421            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
422            configured_catalog=catalog,
423        )
424
425        # It would appear that passing in an empty set of streams causes an infinite loop in ConcurrentReadProcessor.
426        # This is also evident in concurrent_source_adapter.py so I'll leave this out of scope to fix for now
427        if len(selected_concurrent_streams) > 0:
428            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:
430    def discover(self, logger: logging.Logger, config: Mapping[str, Any]) -> AirbyteCatalog:
431        return AirbyteCatalog(
432            streams=[stream.as_airbyte_stream() for stream in self.streams(config=self._config)]
433        )

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]:
436    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
437        """
438        The `streams` method is used as part of the AbstractSource in the following cases:
439        * ConcurrentDeclarativeSource.check -> ManifestDeclarativeSource.check -> AbstractSource.check -> DeclarativeSource.check_connection -> CheckStream.check_connection -> streams
440        * 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`)
441        Note that `super.streams(config)` is also called when splitting the streams between concurrent or not in `_group_streams`.
442
443        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.
444        """
445
446        self._prepare_for_stream_build()
447        stream_configs = self._stream_configs(self._source_config) + self.dynamic_streams
448        return self._create_streams(stream_configs, stream_configs)

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:
708    def spec(self, logger: logging.Logger) -> ConnectorSpecification:
709        """
710        Returns the connector specification (spec) as defined in the Airbyte Protocol. The spec is an object describing the possible
711        configurations (e.g: username and password) which can be configured when running this connector. For low-code connectors, this
712        will first attempt to load the spec from the manifest's spec block, otherwise it will load it from "spec.yaml" or "spec.json"
713        in the project root.
714        """
715        return (
716            self._spec_component.generate_spec() if self._spec_component else super().spec(logger)
717        )

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:
719    def check(self, logger: logging.Logger, config: Mapping[str, Any]) -> AirbyteConnectionStatus:
720        check = self._source_config.get("check")
721        if not check:
722            raise ValueError(f"Missing 'check' component definition within the manifest.")
723
724        if "type" not in check:
725            check["type"] = "CheckStream"
726        connection_checker = self._constructor.create_component(
727            COMPONENTS_CHECKER_TYPE_MAPPING[check["type"]],
728            check,
729            dict(),
730            emit_connector_builder_messages=self._emit_connector_builder_messages,
731        )
732        if not isinstance(connection_checker, ConnectionChecker):
733            raise ValueError(
734                f"Expected to generate a ConnectionChecker component, but received {connection_checker.__class__}"
735            )
736
737        with self._config_overridden_for_check(check.get("config_overrides")):
738            check_succeeded, error = connection_checker.check_connection(self, logger, self._config)
739        if not check_succeeded:
740            return AirbyteConnectionStatus(status=Status.FAILED, message=repr(error))
741        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.

def check_section( self, logger: logging.Logger, section: str, config: Mapping[str, Any]) -> airbyte_protocol_dataclasses.models.airbyte_protocol.AirbyteConnectionStatus:
743    def check_section(
744        self, logger: logging.Logger, section: str, config: Mapping[str, Any]
745    ) -> AirbyteConnectionStatus:
746        """
747        Section check for the stepped setup form, see `airbyte_cdk.sources.utils.check_section`.
748
749        A declarative source validates a configuration by reading from the streams its `check` component names, so a
750        section check runs that same check on the configuration collected so far. The manifest is responsible for
751        ordering its `sections` so that the sections which declare `"check": true` already hold every field those
752        streams need; the entrypoint has validated that the required fields of the section and of the sections before
753        it are present.
754        """
755        logger.info(f"Checking configuration section '{section}' with the declared check streams.")
756        return self.check(logger, config)

Section check for the stepped setup form, see airbyte_cdk.sources.utils.check_section.

A declarative source validates a configuration by reading from the streams its check component names, so a section check runs that same check on the configuration collected so far. The manifest is responsible for ordering its sections so that the sections which declare "check": true already hold every field those streams need; the entrypoint has validated that the required fields of the section and of the sections before it are present.

dynamic_streams: List[Dict[str, Any]]
965    @property
966    def dynamic_streams(self) -> List[Dict[str, Any]]:
967        return self._dynamic_stream_configs(
968            manifest=self._source_config,
969            with_dynamic_stream_name=True,
970        )