airbyte_cdk.sources.declarative.parsers.model_to_component_factory
1# 2# Copyright (c) 2025 Airbyte, Inc., all rights reserved. 3# 4 5from __future__ import annotations 6 7import datetime 8import importlib 9import inspect 10import json 11import logging 12import re 13from functools import partial 14from typing import ( 15 TYPE_CHECKING, 16 Any, 17 Callable, 18 Dict, 19 List, 20 Mapping, 21 MutableMapping, 22 Optional, 23 Tuple, 24 Type, 25 Union, 26 cast, 27 get_args, 28 get_origin, 29 get_type_hints, 30) 31 32if TYPE_CHECKING: 33 from airbyte_cdk.legacy.sources.declarative.incremental.datetime_based_cursor import ( 34 DatetimeBasedCursor, 35 ) 36 37from airbyte_protocol_dataclasses.models import ConfiguredAirbyteStream 38from isodate import parse_duration 39from pydantic.v1 import BaseModel 40from requests import Response 41 42from airbyte_cdk.connector_builder.models import ( 43 LogMessage as ConnectorBuilderLogMessage, 44) 45from airbyte_cdk.models import ( 46 AirbyteStateBlob, 47 AirbyteStateMessage, 48 AirbyteStateType, 49 AirbyteStreamState, 50 ConfiguredAirbyteCatalog, 51 FailureType, 52 Level, 53 StreamDescriptor, 54) 55from airbyte_cdk.sources.connector_state_manager import ConnectorStateManager 56from airbyte_cdk.sources.declarative.async_job.job_orchestrator import AsyncJobOrchestrator 57from airbyte_cdk.sources.declarative.async_job.job_tracker import JobTracker 58from airbyte_cdk.sources.declarative.async_job.repository import AsyncJobRepository 59from airbyte_cdk.sources.declarative.async_job.status import AsyncJobStatus 60from airbyte_cdk.sources.declarative.auth import DeclarativeOauth2Authenticator, JwtAuthenticator 61from airbyte_cdk.sources.declarative.auth.declarative_authenticator import ( 62 DeclarativeAuthenticator, 63 NoAuth, 64) 65from airbyte_cdk.sources.declarative.auth.jwt import JwtAlgorithm 66from airbyte_cdk.sources.declarative.auth.oauth import ( 67 DeclarativeSingleUseRefreshTokenOauth2Authenticator, 68) 69from airbyte_cdk.sources.declarative.auth.rate_limited_multiple_token import ( 70 RateLimitedMultipleTokenAuthenticator, 71 TokenQuota, 72) 73from airbyte_cdk.sources.declarative.auth.selective_authenticator import SelectiveAuthenticator 74from airbyte_cdk.sources.declarative.auth.token import ( 75 ApiKeyAuthenticator, 76 BasicHttpAuthenticator, 77 BearerAuthenticator, 78 LegacySessionTokenAuthenticator, 79) 80from airbyte_cdk.sources.declarative.auth.token_provider import ( 81 InterpolatedSessionTokenProvider, 82 InterpolatedStringTokenProvider, 83 SessionTokenProvider, 84 TokenProvider, 85) 86from airbyte_cdk.sources.declarative.checks import ( 87 CheckDynamicStream, 88 CheckStream, 89 DynamicStreamCheckConfig, 90) 91from airbyte_cdk.sources.declarative.concurrency_level import ConcurrencyLevel 92from airbyte_cdk.sources.declarative.datetime.min_max_datetime import MinMaxDatetime 93from airbyte_cdk.sources.declarative.decoders import ( 94 Decoder, 95 IterableDecoder, 96 JsonDecoder, 97 PaginationDecoderDecorator, 98 XmlDecoder, 99 ZipfileDecoder, 100) 101from airbyte_cdk.sources.declarative.decoders.composite_raw_decoder import ( 102 CompositeRawDecoder, 103 CsvParser, 104 GzipParser, 105 JsonItemsParser, 106 JsonLineParser, 107 JsonParser, 108 Parser, 109) 110from airbyte_cdk.sources.declarative.expanders.record_expander import ( 111 OnNoRecords, 112 RecordExpander, 113) 114from airbyte_cdk.sources.declarative.extractors import ( 115 DpathExtractor, 116 RecordFilter, 117 RecordSelector, 118 ResponseToFileExtractor, 119) 120from airbyte_cdk.sources.declarative.extractors.record_extractor import RecordExtractor 121from airbyte_cdk.sources.declarative.extractors.record_filter import ( 122 ClientSideIncrementalRecordFilterDecorator, 123) 124from airbyte_cdk.sources.declarative.incremental import ( 125 ConcurrentCursorFactory, 126 ConcurrentPerPartitionCursor, 127) 128from airbyte_cdk.sources.declarative.interpolation import InterpolatedString 129from airbyte_cdk.sources.declarative.interpolation.interpolated_mapping import InterpolatedMapping 130from airbyte_cdk.sources.declarative.migrations.legacy_to_per_partition_state_migration import ( 131 LegacyToPerPartitionStateMigration, 132) 133from airbyte_cdk.sources.declarative.models import ( 134 CustomStateMigration, 135 PaginationResetLimits, 136) 137from airbyte_cdk.sources.declarative.models.base_model_with_deprecations import ( 138 DEPRECATION_LOGS_TAG, 139 BaseModelWithDeprecations, 140) 141from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 142 Action1 as PaginationResetActionModel, 143) 144from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 145 AddedFieldDefinition as AddedFieldDefinitionModel, 146) 147from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 148 AddFields as AddFieldsModel, 149) 150from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 151 ApiKeyAuthenticator as ApiKeyAuthenticatorModel, 152) 153from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 154 AsyncJobStatusMap as AsyncJobStatusMapModel, 155) 156from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 157 AsyncRetriever as AsyncRetrieverModel, 158) 159from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 160 BasicHttpAuthenticator as BasicHttpAuthenticatorModel, 161) 162from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 163 BearerAuthenticator as BearerAuthenticatorModel, 164) 165from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 166 CheckDynamicStream as CheckDynamicStreamModel, 167) 168from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 169 CheckStream as CheckStreamModel, 170) 171from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 172 ComplexFieldType as ComplexFieldTypeModel, 173) 174from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 175 ComponentMappingDefinition as ComponentMappingDefinitionModel, 176) 177from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 178 CompositeErrorHandler as CompositeErrorHandlerModel, 179) 180from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 181 ConcurrencyLevel as ConcurrencyLevelModel, 182) 183from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 184 ConfigAddFields as ConfigAddFieldsModel, 185) 186from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 187 ConfigComponentsResolver as ConfigComponentsResolverModel, 188) 189from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 190 ConfigMigration as ConfigMigrationModel, 191) 192from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 193 ConfigRemapField as ConfigRemapFieldModel, 194) 195from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 196 ConfigRemoveFields as ConfigRemoveFieldsModel, 197) 198from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 199 ConstantBackoffStrategy as ConstantBackoffStrategyModel, 200) 201from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 202 CsvDecoder as CsvDecoderModel, 203) 204from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 205 CursorPagination as CursorPaginationModel, 206) 207from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 208 CustomAuthenticator as CustomAuthenticatorModel, 209) 210from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 211 CustomBackoffStrategy as CustomBackoffStrategyModel, 212) 213from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 214 CustomConfigTransformation as CustomConfigTransformationModel, 215) 216from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 217 CustomDecoder as CustomDecoderModel, 218) 219from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 220 CustomErrorHandler as CustomErrorHandlerModel, 221) 222from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 223 CustomPaginationStrategy as CustomPaginationStrategyModel, 224) 225from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 226 CustomPartitionRouter as CustomPartitionRouterModel, 227) 228from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 229 CustomRecordExtractor as CustomRecordExtractorModel, 230) 231from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 232 CustomRecordFilter as CustomRecordFilterModel, 233) 234from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 235 CustomRequester as CustomRequesterModel, 236) 237from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 238 CustomRetriever as CustomRetrieverModel, 239) 240from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 241 CustomSchemaLoader as CustomSchemaLoader, 242) 243from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 244 CustomSchemaNormalization as CustomSchemaNormalizationModel, 245) 246from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 247 CustomTransformation as CustomTransformationModel, 248) 249from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 250 CustomValidationStrategy as CustomValidationStrategyModel, 251) 252from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 253 DatetimeBasedCursor as DatetimeBasedCursorModel, 254) 255from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 256 DeclarativeStream as DeclarativeStreamModel, 257) 258from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 259 DefaultErrorHandler as DefaultErrorHandlerModel, 260) 261from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 262 DefaultPaginator as DefaultPaginatorModel, 263) 264from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 265 DpathExtractor as DpathExtractorModel, 266) 267from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 268 DpathFlattenFields as DpathFlattenFieldsModel, 269) 270from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 271 DpathValidator as DpathValidatorModel, 272) 273from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 274 DynamicSchemaLoader as DynamicSchemaLoaderModel, 275) 276from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 277 DynamicStreamCheckConfig as DynamicStreamCheckConfigModel, 278) 279from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 280 ExponentialBackoffStrategy as ExponentialBackoffStrategyModel, 281) 282from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 283 FileUploader as FileUploaderModel, 284) 285from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 286 FixedWindowCallRatePolicy as FixedWindowCallRatePolicyModel, 287) 288from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 289 FlattenFields as FlattenFieldsModel, 290) 291from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 292 GroupByKeyMergeStrategy as GroupByKeyMergeStrategyModel, 293) 294from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 295 GroupingPartitionRouter as GroupingPartitionRouterModel, 296) 297from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 298 GzipDecoder as GzipDecoderModel, 299) 300from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 301 HTTPAPIBudget as HTTPAPIBudgetModel, 302) 303from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 304 HttpComponentsResolver as HttpComponentsResolverModel, 305) 306from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 307 HttpRequester as HttpRequesterModel, 308) 309from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 310 HttpRequestRegexMatcher as HttpRequestRegexMatcherModel, 311) 312from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 313 HttpResponseFilter as HttpResponseFilterModel, 314) 315from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 316 IncrementingCountCursor as IncrementingCountCursorModel, 317) 318from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 319 InlineSchemaLoader as InlineSchemaLoaderModel, 320) 321from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 322 IterableDecoder as IterableDecoderModel, 323) 324from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 325 JsonDecoder as JsonDecoderModel, 326) 327from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 328 JsonFileSchemaLoader as JsonFileSchemaLoaderModel, 329) 330from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 331 JsonItemsDecoder as JsonItemsDecoderModel, 332) 333from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 334 JsonlDecoder as JsonlDecoderModel, 335) 336from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 337 JsonSchemaPropertySelector as JsonSchemaPropertySelectorModel, 338) 339from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 340 JwtAuthenticator as JwtAuthenticatorModel, 341) 342from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 343 JwtHeaders as JwtHeadersModel, 344) 345from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 346 JwtPayload as JwtPayloadModel, 347) 348from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 349 KeysReplace as KeysReplaceModel, 350) 351from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 352 KeysToLower as KeysToLowerModel, 353) 354from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 355 KeysToSnakeCase as KeysToSnakeCaseModel, 356) 357from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 358 LegacySessionTokenAuthenticator as LegacySessionTokenAuthenticatorModel, 359) 360from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 361 LegacyToPerPartitionStateMigration as LegacyToPerPartitionStateMigrationModel, 362) 363from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 364 ListPartitionRouter as ListPartitionRouterModel, 365) 366from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 367 MinMaxDatetime as MinMaxDatetimeModel, 368) 369from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 370 MovingWindowCallRatePolicy as MovingWindowCallRatePolicyModel, 371) 372from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 373 NoAuth as NoAuthModel, 374) 375from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 376 NoPagination as NoPaginationModel, 377) 378from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 379 OAuthAuthenticator as OAuthAuthenticatorModel, 380) 381from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 382 OffsetIncrement as OffsetIncrementModel, 383) 384from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 385 PageIncrement as PageIncrementModel, 386) 387from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 388 PaginationReset as PaginationResetModel, 389) 390from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 391 ParametrizedComponentsResolver as ParametrizedComponentsResolverModel, 392) 393from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 394 ParentStreamConfig as ParentStreamConfigModel, 395) 396from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 397 PredicateValidator as PredicateValidatorModel, 398) 399from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 400 PropertiesFromEndpoint as PropertiesFromEndpointModel, 401) 402from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 403 PropertyChunking as PropertyChunkingModel, 404) 405from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 406 PropertyLimitType as PropertyLimitTypeModel, 407) 408from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 409 QueryProperties as QueryPropertiesModel, 410) 411from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 412 Rate as RateModel, 413) 414from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 415 RateLimitedMultipleTokenAuthenticator as RateLimitedMultipleTokenAuthenticatorModel, 416) 417from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 418 RecordExpander as RecordExpanderModel, 419) 420from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 421 RecordFilter as RecordFilterModel, 422) 423from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 424 RecordSelector as RecordSelectorModel, 425) 426from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 427 RefreshTokenUpdater as RefreshTokenUpdaterModel, 428) 429from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 430 RemoveFields as RemoveFieldsModel, 431) 432from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 433 RequestOption as RequestOptionModel, 434) 435from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 436 RequestPath as RequestPathModel, 437) 438from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 439 ResponseToFileExtractor as ResponseToFileExtractorModel, 440) 441from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 442 SchemaNormalization as SchemaNormalizationModel, 443) 444from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 445 SchemaTypeIdentifier as SchemaTypeIdentifierModel, 446) 447from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 448 SelectiveAuthenticator as SelectiveAuthenticatorModel, 449) 450from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 451 SessionTokenAuthenticator as SessionTokenAuthenticatorModel, 452) 453from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 454 SimpleRetriever as SimpleRetrieverModel, 455) 456from airbyte_cdk.sources.declarative.models.declarative_component_schema import Spec as SpecModel 457from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 458 StateDelegatingStream as StateDelegatingStreamModel, 459) 460from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 461 StreamConfig as StreamConfigModel, 462) 463from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 464 SubstreamPartitionRouter as SubstreamPartitionRouterModel, 465) 466from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 467 TypesMap as TypesMapModel, 468) 469from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 470 UnionPartitionRouter as UnionPartitionRouterModel, 471) 472from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 473 UnlimitedCallRatePolicy as UnlimitedCallRatePolicyModel, 474) 475from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 476 ValidateAdheresToSchema as ValidateAdheresToSchemaModel, 477) 478from airbyte_cdk.sources.declarative.models.declarative_component_schema import ValueType 479from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 480 WaitTimeFromHeader as WaitTimeFromHeaderModel, 481) 482from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 483 WaitUntilTimeFromHeader as WaitUntilTimeFromHeaderModel, 484) 485from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 486 XmlDecoder as XmlDecoderModel, 487) 488from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 489 ZipfileDecoder as ZipfileDecoderModel, 490) 491from airbyte_cdk.sources.declarative.parsers.custom_code_compiler import ( 492 INJECTED_MANIFEST, 493 AirbyteCustomCodeNotPermittedError, 494 custom_code_execution_permitted, 495) 496from airbyte_cdk.sources.declarative.partition_routers import ( 497 CartesianProductStreamSlicer, 498 GroupingPartitionRouter, 499 ListPartitionRouter, 500 PartitionRouter, 501 SinglePartitionRouter, 502 SubstreamPartitionRouter, 503 UnionPartitionRouter, 504) 505from airbyte_cdk.sources.declarative.partition_routers.async_job_partition_router import ( 506 AsyncJobPartitionRouter, 507) 508from airbyte_cdk.sources.declarative.partition_routers.substream_partition_router import ( 509 ParentStreamConfig, 510) 511from airbyte_cdk.sources.declarative.requesters import HttpRequester, RequestOption 512from airbyte_cdk.sources.declarative.requesters.error_handlers import ( 513 CompositeErrorHandler, 514 DefaultErrorHandler, 515 HttpResponseFilter, 516) 517from airbyte_cdk.sources.declarative.requesters.error_handlers.backoff_strategies import ( 518 ConstantBackoffStrategy, 519 ExponentialBackoffStrategy, 520 WaitTimeFromHeaderBackoffStrategy, 521 WaitUntilTimeFromHeaderBackoffStrategy, 522) 523from airbyte_cdk.sources.declarative.requesters.http_job_repository import AsyncHttpJobRepository 524from airbyte_cdk.sources.declarative.requesters.paginators import ( 525 DefaultPaginator, 526 NoPagination, 527 PaginatorTestReadDecorator, 528) 529from airbyte_cdk.sources.declarative.requesters.paginators.strategies import ( 530 CursorPaginationStrategy, 531 CursorStopCondition, 532 OffsetIncrement, 533 PageIncrement, 534 StopConditionPaginationStrategyDecorator, 535) 536from airbyte_cdk.sources.declarative.requesters.query_properties import ( 537 PropertiesFromEndpoint, 538 PropertyChunking, 539 QueryProperties, 540) 541from airbyte_cdk.sources.declarative.requesters.query_properties.property_chunking import ( 542 PropertyLimitType, 543) 544from airbyte_cdk.sources.declarative.requesters.query_properties.property_selector import ( 545 JsonSchemaPropertySelector, 546) 547from airbyte_cdk.sources.declarative.requesters.query_properties.strategies import ( 548 GroupByKey, 549) 550from airbyte_cdk.sources.declarative.requesters.request_option import RequestOptionType 551from airbyte_cdk.sources.declarative.requesters.request_options import ( 552 DatetimeBasedRequestOptionsProvider, 553 DefaultRequestOptionsProvider, 554 InterpolatedRequestOptionsProvider, 555 RequestOptionsProvider, 556) 557from airbyte_cdk.sources.declarative.requesters.request_options.per_partition_request_option_provider import ( 558 PerPartitionRequestOptionsProvider, 559) 560from airbyte_cdk.sources.declarative.requesters.request_path import RequestPath 561from airbyte_cdk.sources.declarative.requesters.requester import HttpMethod, Requester 562from airbyte_cdk.sources.declarative.resolvers import ( 563 ComponentMappingDefinition, 564 ConfigComponentsResolver, 565 HttpComponentsResolver, 566 ParametrizedComponentsResolver, 567 StreamConfig, 568 StreamParametersDefinition, 569) 570from airbyte_cdk.sources.declarative.retrievers import ( 571 AsyncRetriever, 572 LazySimpleRetriever, 573 SimpleRetriever, 574) 575from airbyte_cdk.sources.declarative.retrievers.file_uploader import ( 576 ConnectorBuilderFileUploader, 577 DefaultFileUploader, 578 FileUploader, 579 LocalFileSystemFileWriter, 580 NoopFileWriter, 581) 582from airbyte_cdk.sources.declarative.retrievers.pagination_tracker import PaginationTracker 583from airbyte_cdk.sources.declarative.schema import ( 584 ComplexFieldType, 585 DefaultSchemaLoader, 586 DynamicSchemaLoader, 587 InlineSchemaLoader, 588 JsonFileSchemaLoader, 589 SchemaLoader, 590 SchemaTypeIdentifier, 591 TypesMap, 592) 593from airbyte_cdk.sources.declarative.schema.caching_schema_loader_decorator import ( 594 CachingSchemaLoaderDecorator, 595) 596from airbyte_cdk.sources.declarative.schema.composite_schema_loader import CompositeSchemaLoader 597from airbyte_cdk.sources.declarative.spec import ConfigMigration, Spec 598from airbyte_cdk.sources.declarative.stream_slicers import ( 599 StreamSlicer, 600 StreamSlicerTestReadDecorator, 601) 602from airbyte_cdk.sources.declarative.stream_slicers.declarative_partition_generator import ( 603 DeclarativePartitionFactory, 604 StreamSlicerPartitionGenerator, 605) 606from airbyte_cdk.sources.declarative.transformations import ( 607 AddFields, 608 RecordTransformation, 609 RemoveFields, 610) 611from airbyte_cdk.sources.declarative.transformations.add_fields import AddedFieldDefinition 612from airbyte_cdk.sources.declarative.transformations.config_transformations import ( 613 ConfigAddFields, 614 ConfigRemapField, 615 ConfigRemoveFields, 616) 617from airbyte_cdk.sources.declarative.transformations.config_transformations.config_transformation import ( 618 ConfigTransformation, 619) 620from airbyte_cdk.sources.declarative.transformations.dpath_flatten_fields import ( 621 DpathFlattenFields, 622 KeyTransformation, 623) 624from airbyte_cdk.sources.declarative.transformations.flatten_fields import ( 625 FlattenFields, 626) 627from airbyte_cdk.sources.declarative.transformations.keys_replace_transformation import ( 628 KeysReplaceTransformation, 629) 630from airbyte_cdk.sources.declarative.transformations.keys_to_lower_transformation import ( 631 KeysToLowerTransformation, 632) 633from airbyte_cdk.sources.declarative.transformations.keys_to_snake_transformation import ( 634 KeysToSnakeCaseTransformation, 635) 636from airbyte_cdk.sources.declarative.validators import ( 637 DpathValidator, 638 PredicateValidator, 639 ValidateAdheresToSchema, 640) 641from airbyte_cdk.sources.http_logger import format_http_message 642from airbyte_cdk.sources.message import ( 643 InMemoryMessageRepository, 644 LogAppenderMessageRepositoryDecorator, 645 MessageRepository, 646 NoopMessageRepository, 647) 648from airbyte_cdk.sources.message.repository import StateFilteringMessageRepository 649from airbyte_cdk.sources.streams import NO_CURSOR_STATE_KEY 650from airbyte_cdk.sources.streams.call_rate import ( 651 APIBudget, 652 FixedWindowCallRatePolicy, 653 HttpAPIBudget, 654 HttpRequestRegexMatcher, 655 MovingWindowCallRatePolicy, 656 Rate, 657 UnlimitedCallRatePolicy, 658) 659from airbyte_cdk.sources.streams.concurrent.abstract_stream import AbstractStream 660from airbyte_cdk.sources.streams.concurrent.clamping import ( 661 ClampingEndProvider, 662 ClampingStrategy, 663 DayClampingStrategy, 664 MonthClampingStrategy, 665 NoClamping, 666 WeekClampingStrategy, 667 Weekday, 668) 669from airbyte_cdk.sources.streams.concurrent.cursor import ( 670 ConcurrentCursor, 671 Cursor, 672 CursorField, 673 FinalStateCursor, 674) 675from airbyte_cdk.sources.streams.concurrent.default_stream import DefaultStream 676from airbyte_cdk.sources.streams.concurrent.helpers import get_primary_key_from_stream 677from airbyte_cdk.sources.streams.concurrent.partitions.stream_slicer import ( 678 StreamSlicer as ConcurrentStreamSlicer, 679) 680from airbyte_cdk.sources.streams.concurrent.state_converters.datetime_stream_state_converter import ( 681 CustomFormatConcurrentStreamStateConverter, 682 DateTimeStreamStateConverter, 683) 684from airbyte_cdk.sources.streams.concurrent.state_converters.incrementing_count_stream_state_converter import ( 685 IncrementingCountStreamStateConverter, 686) 687from airbyte_cdk.sources.streams.http.error_handlers.response_models import ResponseAction 688from airbyte_cdk.sources.types import Config 689from airbyte_cdk.sources.utils.transform import TransformConfig, TypeTransformer 690 691ComponentDefinition = Mapping[str, Any] 692 693SCHEMA_TRANSFORMER_TYPE_MAPPING = { 694 SchemaNormalizationModel.None_: TransformConfig.NoTransform, 695 SchemaNormalizationModel.Default: TransformConfig.DefaultSchemaNormalization, 696} 697_NO_STREAM_SLICING = SinglePartitionRouter(parameters={}) 698 699# Ideally this should use the value defined in ConcurrentDeclarativeSource, but 700# this would be a circular import 701MAX_SLICES = 5 702 703LOGGER = logging.getLogger(f"airbyte.model_to_component_factory") 704 705 706class ModelToComponentFactory: 707 EPOCH_DATETIME_FORMAT = "%s" 708 709 def __init__( 710 self, 711 limit_pages_fetched_per_slice: Optional[int] = None, 712 limit_slices_fetched: Optional[int] = None, 713 emit_connector_builder_messages: bool = False, 714 disable_retries: bool = False, 715 disable_cache: bool = False, 716 message_repository: Optional[MessageRepository] = None, 717 connector_state_manager: Optional[ConnectorStateManager] = None, 718 max_concurrent_async_job_count: Optional[int] = None, 719 configured_catalog: Optional[ConfiguredAirbyteCatalog] = None, 720 api_budget: Optional[APIBudget] = None, 721 rate_limited_authenticators: Optional[ 722 Dict[str, RateLimitedMultipleTokenAuthenticator] 723 ] = None, 724 custom_components_trusted: bool = True, 725 ): 726 self._init_mappings() 727 self._custom_components_trusted = custom_components_trusted 728 self._limit_pages_fetched_per_slice = limit_pages_fetched_per_slice 729 self._limit_slices_fetched = limit_slices_fetched 730 self._emit_connector_builder_messages = emit_connector_builder_messages 731 self._disable_retries = disable_retries 732 self._disable_cache = disable_cache 733 self._message_repository = message_repository or InMemoryMessageRepository( 734 self._evaluate_log_level(emit_connector_builder_messages) 735 ) 736 self._stream_name_to_configured_stream = self._create_stream_name_to_configured_stream( 737 configured_catalog 738 ) 739 self._connector_state_manager = connector_state_manager or ConnectorStateManager() 740 self._api_budget: Optional[Union[APIBudget]] = api_budget 741 # Shared instances so all streams see the same token quota counters (like api_budget) 742 self._rate_limited_authenticators: Dict[str, RateLimitedMultipleTokenAuthenticator] = ( 743 rate_limited_authenticators if rate_limited_authenticators is not None else {} 744 ) 745 self._job_tracker: JobTracker = JobTracker(max_concurrent_async_job_count or 1) 746 # placeholder for deprecation warnings 747 self._collected_deprecation_logs: List[ConnectorBuilderLogMessage] = [] 748 749 def _init_mappings(self) -> None: 750 self.PYDANTIC_MODEL_TO_CONSTRUCTOR: Mapping[Type[BaseModel], Callable[..., Any]] = { 751 AddedFieldDefinitionModel: self.create_added_field_definition, 752 AddFieldsModel: self.create_add_fields, 753 ApiKeyAuthenticatorModel: self.create_api_key_authenticator, 754 BasicHttpAuthenticatorModel: self.create_basic_http_authenticator, 755 BearerAuthenticatorModel: self.create_bearer_authenticator, 756 CheckStreamModel: self.create_check_stream, 757 DynamicStreamCheckConfigModel: self.create_dynamic_stream_check_config, 758 CheckDynamicStreamModel: self.create_check_dynamic_stream, 759 CompositeErrorHandlerModel: self.create_composite_error_handler, 760 ConcurrencyLevelModel: self.create_concurrency_level, 761 ConfigMigrationModel: self.create_config_migration, 762 ConfigAddFieldsModel: self.create_config_add_fields, 763 ConfigRemapFieldModel: self.create_config_remap_field, 764 ConfigRemoveFieldsModel: self.create_config_remove_fields, 765 ConstantBackoffStrategyModel: self.create_constant_backoff_strategy, 766 CsvDecoderModel: self.create_csv_decoder, 767 CursorPaginationModel: self.create_cursor_pagination, 768 CustomAuthenticatorModel: self.create_custom_component, 769 CustomBackoffStrategyModel: self.create_custom_component, 770 CustomDecoderModel: self.create_custom_component, 771 CustomErrorHandlerModel: self.create_custom_component, 772 CustomRecordExtractorModel: self.create_custom_component, 773 CustomRecordFilterModel: self.create_custom_component, 774 CustomRequesterModel: self.create_custom_component, 775 CustomRetrieverModel: self.create_custom_component, 776 CustomSchemaLoader: self.create_custom_component, 777 CustomSchemaNormalizationModel: self.create_custom_component, 778 CustomStateMigration: self.create_custom_component, 779 CustomPaginationStrategyModel: self.create_custom_component, 780 CustomPartitionRouterModel: self.create_custom_component, 781 CustomTransformationModel: self.create_custom_component, 782 CustomValidationStrategyModel: self.create_custom_component, 783 CustomConfigTransformationModel: self.create_custom_component, 784 DeclarativeStreamModel: self.create_default_stream, 785 DefaultErrorHandlerModel: self.create_default_error_handler, 786 DefaultPaginatorModel: self.create_default_paginator, 787 DpathExtractorModel: self.create_dpath_extractor, 788 DpathValidatorModel: self.create_dpath_validator, 789 ResponseToFileExtractorModel: self.create_response_to_file_extractor, 790 ExponentialBackoffStrategyModel: self.create_exponential_backoff_strategy, 791 SessionTokenAuthenticatorModel: self.create_session_token_authenticator, 792 GroupByKeyMergeStrategyModel: self.create_group_by_key, 793 HttpRequesterModel: self.create_http_requester, 794 HttpResponseFilterModel: self.create_http_response_filter, 795 InlineSchemaLoaderModel: self.create_inline_schema_loader, 796 JsonDecoderModel: self.create_json_decoder, 797 JsonItemsDecoderModel: self.create_json_items_decoder, 798 JsonlDecoderModel: self.create_jsonl_decoder, 799 JsonSchemaPropertySelectorModel: self.create_json_schema_property_selector, 800 GzipDecoderModel: self.create_gzip_decoder, 801 KeysToLowerModel: self.create_keys_to_lower_transformation, 802 KeysToSnakeCaseModel: self.create_keys_to_snake_transformation, 803 KeysReplaceModel: self.create_keys_replace_transformation, 804 FlattenFieldsModel: self.create_flatten_fields, 805 DpathFlattenFieldsModel: self.create_dpath_flatten_fields, 806 IterableDecoderModel: self.create_iterable_decoder, 807 XmlDecoderModel: self.create_xml_decoder, 808 JsonFileSchemaLoaderModel: self.create_json_file_schema_loader, 809 DynamicSchemaLoaderModel: self.create_dynamic_schema_loader, 810 SchemaTypeIdentifierModel: self.create_schema_type_identifier, 811 TypesMapModel: self.create_types_map, 812 ComplexFieldTypeModel: self.create_complex_field_type, 813 JwtAuthenticatorModel: self.create_jwt_authenticator, 814 LegacyToPerPartitionStateMigrationModel: self.create_legacy_to_per_partition_state_migration, 815 ListPartitionRouterModel: self.create_list_partition_router, 816 MinMaxDatetimeModel: self.create_min_max_datetime, 817 NoAuthModel: self.create_no_auth, 818 NoPaginationModel: self.create_no_pagination, 819 OAuthAuthenticatorModel: self.create_oauth_authenticator, 820 OffsetIncrementModel: self.create_offset_increment, 821 PageIncrementModel: self.create_page_increment, 822 ParentStreamConfigModel: self.create_parent_stream_config_with_substream_wrapper, 823 PredicateValidatorModel: self.create_predicate_validator, 824 PropertiesFromEndpointModel: self.create_properties_from_endpoint, 825 PropertyChunkingModel: self.create_property_chunking, 826 QueryPropertiesModel: self.create_query_properties, 827 RecordExpanderModel: self.create_record_expander, 828 RecordFilterModel: self.create_record_filter, 829 RecordSelectorModel: self.create_record_selector, 830 RemoveFieldsModel: self.create_remove_fields, 831 RequestPathModel: self.create_request_path, 832 RequestOptionModel: self.create_request_option, 833 LegacySessionTokenAuthenticatorModel: self.create_legacy_session_token_authenticator, 834 SelectiveAuthenticatorModel: self.create_selective_authenticator, 835 SimpleRetrieverModel: self.create_simple_retriever, 836 StateDelegatingStreamModel: self.create_state_delegating_stream, 837 SpecModel: self.create_spec, 838 SubstreamPartitionRouterModel: self.create_substream_partition_router, 839 ValidateAdheresToSchemaModel: self.create_validate_adheres_to_schema, 840 WaitTimeFromHeaderModel: self.create_wait_time_from_header, 841 WaitUntilTimeFromHeaderModel: self.create_wait_until_time_from_header, 842 AsyncRetrieverModel: self.create_async_retriever, 843 HttpComponentsResolverModel: self.create_http_components_resolver, 844 ConfigComponentsResolverModel: self.create_config_components_resolver, 845 ParametrizedComponentsResolverModel: self.create_parametrized_components_resolver, 846 StreamConfigModel: self.create_stream_config, 847 ComponentMappingDefinitionModel: self.create_components_mapping_definition, 848 ZipfileDecoderModel: self.create_zipfile_decoder, 849 HTTPAPIBudgetModel: self.create_http_api_budget, 850 FileUploaderModel: self.create_file_uploader, 851 FixedWindowCallRatePolicyModel: self.create_fixed_window_call_rate_policy, 852 MovingWindowCallRatePolicyModel: self.create_moving_window_call_rate_policy, 853 UnlimitedCallRatePolicyModel: self.create_unlimited_call_rate_policy, 854 RateModel: self.create_rate, 855 HttpRequestRegexMatcherModel: self.create_http_request_matcher, 856 RateLimitedMultipleTokenAuthenticatorModel: self.create_rate_limited_multiple_token_authenticator, 857 GroupingPartitionRouterModel: self.create_grouping_partition_router, 858 UnionPartitionRouterModel: self.create_union_partition_router, 859 } 860 861 # Needed for the case where we need to perform a second parse on the fields of a custom component 862 self.TYPE_NAME_TO_MODEL = {cls.__name__: cls for cls in self.PYDANTIC_MODEL_TO_CONSTRUCTOR} 863 864 @staticmethod 865 def _create_stream_name_to_configured_stream( 866 configured_catalog: Optional[ConfiguredAirbyteCatalog], 867 ) -> Mapping[str, ConfiguredAirbyteStream]: 868 return ( 869 {stream.stream.name: stream for stream in configured_catalog.streams} 870 if configured_catalog 871 else {} 872 ) 873 874 def create_component( 875 self, 876 model_type: Type[BaseModel], 877 component_definition: ComponentDefinition, 878 config: Config, 879 **kwargs: Any, 880 ) -> Any: 881 """ 882 Takes a given Pydantic model type and Mapping representing a component definition and creates a declarative component and 883 subcomponents which will be used at runtime. This is done by first parsing the mapping into a Pydantic model and then creating 884 creating declarative components from that model. 885 886 :param model_type: The type of declarative component that is being initialized 887 :param component_definition: The mapping that represents a declarative component 888 :param config: The connector config that is provided by the customer 889 :return: The declarative component to be used at runtime 890 """ 891 892 component_type = component_definition.get("type") 893 if component_definition.get("type") != model_type.__name__: 894 raise ValueError( 895 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 896 ) 897 898 declarative_component_model = model_type.parse_obj(component_definition) 899 900 if not isinstance(declarative_component_model, model_type): 901 raise ValueError( 902 f"Expected {model_type.__name__} component, but received {declarative_component_model.__class__.__name__}" 903 ) 904 905 return self._create_component_from_model( 906 model=declarative_component_model, config=config, **kwargs 907 ) 908 909 def _create_component_from_model(self, model: BaseModel, config: Config, **kwargs: Any) -> Any: 910 if model.__class__ not in self.PYDANTIC_MODEL_TO_CONSTRUCTOR: 911 raise ValueError( 912 f"{model.__class__} with attributes {model} is not a valid component type" 913 ) 914 component_constructor = self.PYDANTIC_MODEL_TO_CONSTRUCTOR.get(model.__class__) 915 if not component_constructor: 916 raise ValueError(f"Could not find constructor for {model.__class__}") 917 918 # collect deprecation warnings for supported models. 919 if isinstance(model, BaseModelWithDeprecations): 920 self._collect_model_deprecations(model) 921 922 return component_constructor(model=model, config=config, **kwargs) 923 924 def get_model_deprecations(self) -> List[ConnectorBuilderLogMessage]: 925 """ 926 Returns the deprecation warnings that were collected during the creation of components. 927 """ 928 return self._collected_deprecation_logs 929 930 def _collect_model_deprecations(self, model: BaseModelWithDeprecations) -> None: 931 """ 932 Collects deprecation logs from the given model and appends any new logs to the internal collection. 933 934 This method checks if the provided model has deprecation logs (identified by the presence of the DEPRECATION_LOGS_TAG attribute and a non-None `_deprecation_logs` property). It iterates through each deprecation log in the model and appends it to the `_collected_deprecation_logs` list if it has not already been collected, ensuring that duplicate logs are avoided. 935 936 Args: 937 model (BaseModelWithDeprecations): The model instance from which to collect deprecation logs. 938 """ 939 if hasattr(model, DEPRECATION_LOGS_TAG) and model._deprecation_logs is not None: 940 for log in model._deprecation_logs: 941 # avoid duplicates for deprecation logs observed. 942 if log not in self._collected_deprecation_logs: 943 self._collected_deprecation_logs.append(log) 944 945 def create_config_migration( 946 self, model: ConfigMigrationModel, config: Config 947 ) -> ConfigMigration: 948 transformations: List[ConfigTransformation] = [ 949 self._create_component_from_model(transformation, config) 950 for transformation in model.transformations 951 ] 952 953 return ConfigMigration( 954 description=model.description, 955 transformations=transformations, 956 ) 957 958 def create_config_add_fields( 959 self, model: ConfigAddFieldsModel, config: Config, **kwargs: Any 960 ) -> ConfigAddFields: 961 fields = [self._create_component_from_model(field, config) for field in model.fields] 962 return ConfigAddFields( 963 fields=fields, 964 condition=model.condition or "", 965 ) 966 967 @staticmethod 968 def create_config_remove_fields( 969 model: ConfigRemoveFieldsModel, config: Config, **kwargs: Any 970 ) -> ConfigRemoveFields: 971 return ConfigRemoveFields( 972 field_pointers=model.field_pointers, 973 condition=model.condition or "", 974 ) 975 976 @staticmethod 977 def create_config_remap_field( 978 model: ConfigRemapFieldModel, config: Config, **kwargs: Any 979 ) -> ConfigRemapField: 980 mapping = cast(Mapping[str, Any], model.map) 981 return ConfigRemapField( 982 map=mapping, 983 field_path=model.field_path, 984 config=config, 985 ) 986 987 def create_dpath_validator(self, model: DpathValidatorModel, config: Config) -> DpathValidator: 988 strategy = self._create_component_from_model(model.validation_strategy, config) 989 990 return DpathValidator( 991 field_path=model.field_path, 992 strategy=strategy, 993 ) 994 995 def create_predicate_validator( 996 self, model: PredicateValidatorModel, config: Config 997 ) -> PredicateValidator: 998 strategy = self._create_component_from_model(model.validation_strategy, config) 999 1000 return PredicateValidator( 1001 value=model.value, 1002 strategy=strategy, 1003 ) 1004 1005 @staticmethod 1006 def create_validate_adheres_to_schema( 1007 model: ValidateAdheresToSchemaModel, config: Config, **kwargs: Any 1008 ) -> ValidateAdheresToSchema: 1009 base_schema = cast(Mapping[str, Any], model.base_schema) 1010 return ValidateAdheresToSchema( 1011 schema=base_schema, 1012 ) 1013 1014 @staticmethod 1015 def create_added_field_definition( 1016 model: AddedFieldDefinitionModel, config: Config, **kwargs: Any 1017 ) -> AddedFieldDefinition: 1018 interpolated_value = InterpolatedString.create( 1019 model.value, parameters=model.parameters or {} 1020 ) 1021 return AddedFieldDefinition( 1022 path=model.path, 1023 value=interpolated_value, 1024 value_type=ModelToComponentFactory._json_schema_type_name_to_type(model.value_type), 1025 parameters=model.parameters or {}, 1026 ) 1027 1028 def create_add_fields(self, model: AddFieldsModel, config: Config, **kwargs: Any) -> AddFields: 1029 added_field_definitions = [ 1030 self._create_component_from_model( 1031 model=added_field_definition_model, 1032 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 1033 added_field_definition_model.value_type 1034 ), 1035 config=config, 1036 ) 1037 for added_field_definition_model in model.fields 1038 ] 1039 return AddFields( 1040 fields=added_field_definitions, 1041 condition=model.condition or "", 1042 parameters=model.parameters or {}, 1043 ) 1044 1045 def create_keys_to_lower_transformation( 1046 self, model: KeysToLowerModel, config: Config, **kwargs: Any 1047 ) -> KeysToLowerTransformation: 1048 return KeysToLowerTransformation() 1049 1050 def create_keys_to_snake_transformation( 1051 self, model: KeysToSnakeCaseModel, config: Config, **kwargs: Any 1052 ) -> KeysToSnakeCaseTransformation: 1053 return KeysToSnakeCaseTransformation() 1054 1055 def create_keys_replace_transformation( 1056 self, model: KeysReplaceModel, config: Config, **kwargs: Any 1057 ) -> KeysReplaceTransformation: 1058 return KeysReplaceTransformation( 1059 old=model.old, new=model.new, parameters=model.parameters or {} 1060 ) 1061 1062 def create_flatten_fields( 1063 self, model: FlattenFieldsModel, config: Config, **kwargs: Any 1064 ) -> FlattenFields: 1065 return FlattenFields( 1066 flatten_lists=model.flatten_lists if model.flatten_lists is not None else True 1067 ) 1068 1069 def create_dpath_flatten_fields( 1070 self, model: DpathFlattenFieldsModel, config: Config, **kwargs: Any 1071 ) -> DpathFlattenFields: 1072 model_field_path: List[Union[InterpolatedString, str]] = [x for x in model.field_path] 1073 key_transformation = ( 1074 KeyTransformation( 1075 config=config, 1076 prefix=model.key_transformation.prefix, 1077 suffix=model.key_transformation.suffix, 1078 parameters=model.parameters or {}, 1079 ) 1080 if model.key_transformation is not None 1081 else None 1082 ) 1083 return DpathFlattenFields( 1084 config=config, 1085 field_path=model_field_path, 1086 delete_origin_value=model.delete_origin_value 1087 if model.delete_origin_value is not None 1088 else False, 1089 replace_record=model.replace_record if model.replace_record is not None else False, 1090 key_transformation=key_transformation, 1091 parameters=model.parameters or {}, 1092 ) 1093 1094 @staticmethod 1095 def _json_schema_type_name_to_type(value_type: Optional[ValueType]) -> Optional[Type[Any]]: 1096 if not value_type: 1097 return None 1098 names_to_types = { 1099 ValueType.string: str, 1100 ValueType.number: float, 1101 ValueType.integer: int, 1102 ValueType.boolean: bool, 1103 } 1104 return names_to_types[value_type] 1105 1106 def create_api_key_authenticator( 1107 self, 1108 model: ApiKeyAuthenticatorModel, 1109 config: Config, 1110 token_provider: Optional[TokenProvider] = None, 1111 **kwargs: Any, 1112 ) -> ApiKeyAuthenticator: 1113 if model.inject_into is None and model.header is None: 1114 raise ValueError( 1115 "Expected either inject_into or header to be set for ApiKeyAuthenticator" 1116 ) 1117 1118 if model.inject_into is not None and model.header is not None: 1119 raise ValueError( 1120 "inject_into and header cannot be set both for ApiKeyAuthenticator - remove the deprecated header option" 1121 ) 1122 1123 if token_provider is not None and model.api_token != "": 1124 raise ValueError( 1125 "If token_provider is set, api_token is ignored and has to be set to empty string." 1126 ) 1127 1128 request_option = ( 1129 self._create_component_from_model( 1130 model.inject_into, config, parameters=model.parameters or {} 1131 ) 1132 if model.inject_into 1133 else RequestOption( 1134 inject_into=RequestOptionType.header, 1135 field_name=model.header or "", 1136 parameters=model.parameters or {}, 1137 ) 1138 ) 1139 1140 return ApiKeyAuthenticator( 1141 token_provider=( 1142 token_provider 1143 if token_provider is not None 1144 else InterpolatedStringTokenProvider( 1145 api_token=model.api_token or "", 1146 config=config, 1147 parameters=model.parameters or {}, 1148 ) 1149 ), 1150 request_option=request_option, 1151 config=config, 1152 parameters=model.parameters or {}, 1153 ) 1154 1155 def create_legacy_to_per_partition_state_migration( 1156 self, 1157 model: LegacyToPerPartitionStateMigrationModel, 1158 config: Mapping[str, Any], 1159 declarative_stream: DeclarativeStreamModel, 1160 ) -> LegacyToPerPartitionStateMigration: 1161 retriever = declarative_stream.retriever 1162 if not isinstance(retriever, (SimpleRetrieverModel, AsyncRetrieverModel)): 1163 raise ValueError( 1164 f"LegacyToPerPartitionStateMigrations can only be applied on a DeclarativeStream with a SimpleRetriever or AsyncRetriever. Got {type(retriever)}" 1165 ) 1166 partition_router = retriever.partition_router 1167 if not isinstance( 1168 partition_router, 1169 ( 1170 SubstreamPartitionRouterModel, 1171 CustomPartitionRouterModel, 1172 UnionPartitionRouterModel, 1173 ), 1174 ): 1175 raise ValueError( 1176 f"LegacyToPerPartitionStateMigrations can only be applied on a SimpleRetriever with a SubstreamPartitionRouter, UnionPartitionRouter or CustomPartitionRouter. Got {type(partition_router)}" 1177 ) 1178 if not isinstance(partition_router, UnionPartitionRouterModel) and not hasattr( 1179 partition_router, "parent_stream_configs" 1180 ): 1181 raise ValueError( 1182 "LegacyToPerPartitionStateMigrations can only be applied with a parent stream configuration." 1183 ) 1184 1185 if not hasattr(declarative_stream, "incremental_sync"): 1186 raise ValueError( 1187 "LegacyToPerPartitionStateMigrations can only be applied with an incremental_sync configuration." 1188 ) 1189 1190 return LegacyToPerPartitionStateMigration( 1191 partition_router, # type: ignore # was already checked above 1192 declarative_stream.incremental_sync, # type: ignore # was already checked. Migration can be applied only to incremental streams. 1193 config, 1194 declarative_stream.parameters, # type: ignore # different type is expected here Mapping[str, Any], got Dict[str, Any] 1195 ) 1196 1197 def create_session_token_authenticator( 1198 self, model: SessionTokenAuthenticatorModel, config: Config, name: str, **kwargs: Any 1199 ) -> Union[ApiKeyAuthenticator, BearerAuthenticator]: 1200 decoder = ( 1201 self._create_component_from_model(model=model.decoder, config=config) 1202 if model.decoder 1203 else JsonDecoder(parameters={}) 1204 ) 1205 login_requester = self._create_component_from_model( 1206 model=model.login_requester, 1207 config=config, 1208 name=f"{name}_login_requester", 1209 decoder=decoder, 1210 ) 1211 token_provider = SessionTokenProvider( 1212 login_requester=login_requester, 1213 session_token_path=model.session_token_path, 1214 expiration_duration=parse_duration(model.expiration_duration) 1215 if model.expiration_duration 1216 else None, 1217 parameters=model.parameters or {}, 1218 message_repository=self._message_repository, 1219 decoder=decoder, 1220 ) 1221 if model.request_authentication.type == "Bearer": 1222 return ModelToComponentFactory.create_bearer_authenticator( 1223 BearerAuthenticatorModel(type="BearerAuthenticator", api_token=""), # type: ignore # $parameters has a default value 1224 config, 1225 token_provider=token_provider, 1226 ) 1227 else: 1228 # Get the api_token template if specified, default to just the session token 1229 api_token_template = ( 1230 getattr(model.request_authentication, "api_token", None) or "{{ session_token }}" 1231 ) 1232 final_token_provider: TokenProvider = InterpolatedSessionTokenProvider( 1233 config=config, 1234 api_token=api_token_template, 1235 session_token_provider=token_provider, 1236 parameters=model.parameters or {}, 1237 ) 1238 return self.create_api_key_authenticator( 1239 ApiKeyAuthenticatorModel( 1240 type="ApiKeyAuthenticator", 1241 api_token="", 1242 inject_into=model.request_authentication.inject_into, 1243 ), # type: ignore # $parameters and headers default to None 1244 config=config, 1245 token_provider=final_token_provider, 1246 ) 1247 1248 @staticmethod 1249 def create_basic_http_authenticator( 1250 model: BasicHttpAuthenticatorModel, config: Config, **kwargs: Any 1251 ) -> BasicHttpAuthenticator: 1252 return BasicHttpAuthenticator( 1253 password=model.password or "", 1254 username=model.username, 1255 config=config, 1256 parameters=model.parameters or {}, 1257 ) 1258 1259 @staticmethod 1260 def create_bearer_authenticator( 1261 model: BearerAuthenticatorModel, 1262 config: Config, 1263 token_provider: Optional[TokenProvider] = None, 1264 **kwargs: Any, 1265 ) -> BearerAuthenticator: 1266 if token_provider is not None and model.api_token != "": 1267 raise ValueError( 1268 "If token_provider is set, api_token is ignored and has to be set to empty string." 1269 ) 1270 return BearerAuthenticator( 1271 token_provider=( 1272 token_provider 1273 if token_provider is not None 1274 else InterpolatedStringTokenProvider( 1275 api_token=model.api_token or "", 1276 config=config, 1277 parameters=model.parameters or {}, 1278 ) 1279 ), 1280 config=config, 1281 parameters=model.parameters or {}, 1282 ) 1283 1284 @staticmethod 1285 def create_dynamic_stream_check_config( 1286 model: DynamicStreamCheckConfigModel, config: Config, **kwargs: Any 1287 ) -> DynamicStreamCheckConfig: 1288 return DynamicStreamCheckConfig( 1289 dynamic_stream_name=model.dynamic_stream_name, 1290 stream_count=model.stream_count, 1291 ) 1292 1293 def create_check_stream( 1294 self, model: CheckStreamModel, config: Config, **kwargs: Any 1295 ) -> CheckStream: 1296 if model.dynamic_streams_check_configs is None and model.stream_names is None: 1297 raise ValueError( 1298 "Expected either stream_names or dynamic_streams_check_configs to be set for CheckStream" 1299 ) 1300 1301 dynamic_streams_check_configs = ( 1302 [ 1303 self._create_component_from_model(model=dynamic_stream_check_config, config=config) 1304 for dynamic_stream_check_config in model.dynamic_streams_check_configs 1305 ] 1306 if model.dynamic_streams_check_configs 1307 else [] 1308 ) 1309 1310 # `model.config_overrides` is deliberately not read here. The source applies it around the whole 1311 # check operation (`ConcurrentDeclarativeSource._config_overridden_for_check`), which is what makes 1312 # it work for every checker type rather than only this one. Do not wire it in a second time. 1313 return CheckStream( 1314 stream_names=model.stream_names or [], 1315 dynamic_streams_check_configs=dynamic_streams_check_configs, 1316 parameters={}, 1317 ) 1318 1319 @staticmethod 1320 def create_check_dynamic_stream( 1321 model: CheckDynamicStreamModel, config: Config, **kwargs: Any 1322 ) -> CheckDynamicStream: 1323 assert model.use_check_availability is not None # for mypy 1324 1325 use_check_availability = model.use_check_availability 1326 1327 # See `create_check_stream`: `model.config_overrides` is applied by the source, not here. 1328 return CheckDynamicStream( 1329 stream_count=model.stream_count, 1330 use_check_availability=use_check_availability, 1331 parameters={}, 1332 ) 1333 1334 def create_composite_error_handler( 1335 self, model: CompositeErrorHandlerModel, config: Config, **kwargs: Any 1336 ) -> CompositeErrorHandler: 1337 error_handlers = [ 1338 self._create_component_from_model(model=error_handler_model, config=config) 1339 for error_handler_model in model.error_handlers 1340 ] 1341 return CompositeErrorHandler( 1342 error_handlers=error_handlers, parameters=model.parameters or {} 1343 ) 1344 1345 @staticmethod 1346 def create_concurrency_level( 1347 model: ConcurrencyLevelModel, config: Config, **kwargs: Any 1348 ) -> ConcurrencyLevel: 1349 return ConcurrencyLevel( 1350 default_concurrency=model.default_concurrency, 1351 max_concurrency=model.max_concurrency, 1352 config=config, 1353 parameters={}, 1354 ) 1355 1356 @staticmethod 1357 def apply_stream_state_migrations( 1358 stream_state_migrations: List[Any] | None, stream_state: MutableMapping[str, Any] 1359 ) -> MutableMapping[str, Any]: 1360 if stream_state_migrations: 1361 for state_migration in stream_state_migrations: 1362 if state_migration.should_migrate(stream_state): 1363 # The state variable is expected to be mutable but the migrate method returns an immutable mapping. 1364 stream_state = dict(state_migration.migrate(stream_state)) 1365 return stream_state 1366 1367 def create_concurrent_cursor_from_datetime_based_cursor( 1368 self, 1369 model_type: Type[BaseModel], 1370 component_definition: ComponentDefinition, 1371 stream_name: str, 1372 stream_namespace: Optional[str], 1373 stream_state: MutableMapping[str, Any], 1374 config: Config, 1375 message_repository: Optional[MessageRepository] = None, 1376 runtime_lookback_window: Optional[datetime.timedelta] = None, 1377 **kwargs: Any, 1378 ) -> ConcurrentCursor: 1379 component_type = component_definition.get("type") 1380 if component_definition.get("type") != model_type.__name__: 1381 raise ValueError( 1382 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 1383 ) 1384 1385 # FIXME the interfaces of the concurrent cursor are kind of annoying as they take a `ComponentDefinition` instead of the actual model. This was done because the ConcurrentDeclarativeSource didn't have access to the models [here for example](https://github.com/airbytehq/airbyte-python-cdk/blob/f525803b3fec9329e4cc8478996a92bf884bfde9/airbyte_cdk/sources/declarative/concurrent_declarative_source.py#L354C54-L354C91). So now we have two cases: 1386 # * The ComponentDefinition comes from model.__dict__ in which case we have `parameters` 1387 # * The ComponentDefinition comes from the manifest as a dict in which case we have `$parameters` 1388 # We should change those interfaces to use the model once we clean up the code in CDS at which point the parameter propagation should happen as part of the ModelToComponentFactory. 1389 if "$parameters" not in component_definition and "parameters" in component_definition: 1390 component_definition["$parameters"] = component_definition.get("parameters") # type: ignore # This is a dict 1391 datetime_based_cursor_model = model_type.parse_obj(component_definition) 1392 1393 if not isinstance(datetime_based_cursor_model, DatetimeBasedCursorModel): 1394 raise ValueError( 1395 f"Expected {model_type.__name__} component, but received {datetime_based_cursor_model.__class__.__name__}" 1396 ) 1397 1398 model_parameters = datetime_based_cursor_model.parameters or {} 1399 1400 cursor_field = self._get_catalog_defined_cursor_field( 1401 stream_name=stream_name, 1402 allow_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1403 or False, 1404 ) 1405 1406 if not cursor_field: 1407 interpolated_cursor_field = InterpolatedString.create( 1408 datetime_based_cursor_model.cursor_field, 1409 parameters=model_parameters, 1410 ) 1411 cursor_field = CursorField( 1412 cursor_field_key=interpolated_cursor_field.eval(config=config), 1413 supports_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1414 or False, 1415 ) 1416 1417 interpolated_partition_field_start = InterpolatedString.create( 1418 datetime_based_cursor_model.partition_field_start or "start_time", 1419 parameters=model_parameters, 1420 ) 1421 interpolated_partition_field_end = InterpolatedString.create( 1422 datetime_based_cursor_model.partition_field_end or "end_time", 1423 parameters=model_parameters, 1424 ) 1425 1426 slice_boundary_fields = ( 1427 interpolated_partition_field_start.eval(config=config), 1428 interpolated_partition_field_end.eval(config=config), 1429 ) 1430 1431 datetime_format = datetime_based_cursor_model.datetime_format 1432 1433 cursor_granularity = ( 1434 parse_duration(datetime_based_cursor_model.cursor_granularity) 1435 if datetime_based_cursor_model.cursor_granularity 1436 else None 1437 ) 1438 1439 lookback_window = None 1440 interpolated_lookback_window = ( 1441 InterpolatedString.create( 1442 datetime_based_cursor_model.lookback_window, 1443 parameters=model_parameters, 1444 ) 1445 if datetime_based_cursor_model.lookback_window 1446 else None 1447 ) 1448 if interpolated_lookback_window: 1449 evaluated_lookback_window = interpolated_lookback_window.eval(config=config) 1450 if evaluated_lookback_window: 1451 lookback_window = parse_duration(evaluated_lookback_window) 1452 1453 connector_state_converter: DateTimeStreamStateConverter 1454 connector_state_converter = CustomFormatConcurrentStreamStateConverter( 1455 datetime_format=datetime_format, 1456 input_datetime_formats=datetime_based_cursor_model.cursor_datetime_formats, 1457 is_sequential_state=True, # ConcurrentPerPartitionCursor only works with sequential state 1458 cursor_granularity=cursor_granularity, 1459 ) 1460 1461 # Adjusts the stream state by applying the runtime lookback window. 1462 # This is used to ensure correct state handling in case of failed partitions. 1463 stream_state_value = stream_state.get(cursor_field.cursor_field_key) 1464 if runtime_lookback_window and stream_state_value: 1465 new_stream_state = ( 1466 connector_state_converter.parse_timestamp(stream_state_value) 1467 - runtime_lookback_window 1468 ) 1469 stream_state[cursor_field.cursor_field_key] = connector_state_converter.output_format( 1470 new_stream_state 1471 ) 1472 1473 start_date_runtime_value: Union[InterpolatedString, str, MinMaxDatetime] 1474 if isinstance(datetime_based_cursor_model.start_datetime, MinMaxDatetimeModel): 1475 start_date_runtime_value = self.create_min_max_datetime( 1476 model=datetime_based_cursor_model.start_datetime, config=config 1477 ) 1478 else: 1479 start_date_runtime_value = datetime_based_cursor_model.start_datetime 1480 1481 end_date_runtime_value: Optional[Union[InterpolatedString, str, MinMaxDatetime]] 1482 if isinstance(datetime_based_cursor_model.end_datetime, MinMaxDatetimeModel): 1483 end_date_runtime_value = self.create_min_max_datetime( 1484 model=datetime_based_cursor_model.end_datetime, config=config 1485 ) 1486 else: 1487 end_date_runtime_value = datetime_based_cursor_model.end_datetime 1488 1489 interpolated_start_date = MinMaxDatetime.create( 1490 interpolated_string_or_min_max_datetime=start_date_runtime_value, 1491 parameters=datetime_based_cursor_model.parameters, 1492 ) 1493 interpolated_end_date = ( 1494 None 1495 if not end_date_runtime_value 1496 else MinMaxDatetime.create( 1497 end_date_runtime_value, datetime_based_cursor_model.parameters 1498 ) 1499 ) 1500 1501 # If datetime format is not specified then start/end datetime should inherit it from the stream slicer 1502 if not interpolated_start_date.datetime_format: 1503 interpolated_start_date.datetime_format = datetime_format 1504 if interpolated_end_date and not interpolated_end_date.datetime_format: 1505 interpolated_end_date.datetime_format = datetime_format 1506 1507 start_date = interpolated_start_date.get_datetime(config=config) 1508 end_date_provider = ( 1509 partial(interpolated_end_date.get_datetime, config) 1510 if interpolated_end_date 1511 else connector_state_converter.get_end_provider() 1512 ) 1513 1514 if ( 1515 datetime_based_cursor_model.step and not datetime_based_cursor_model.cursor_granularity 1516 ) or ( 1517 not datetime_based_cursor_model.step and datetime_based_cursor_model.cursor_granularity 1518 ): 1519 raise ValueError( 1520 f"If step is defined, cursor_granularity should be as well and vice-versa. " 1521 f"Right now, step is `{datetime_based_cursor_model.step}` and cursor_granularity is `{datetime_based_cursor_model.cursor_granularity}`" 1522 ) 1523 1524 # When step is not defined, default to a step size from the starting date to the present moment 1525 step_length = datetime.timedelta.max 1526 interpolated_step = ( 1527 InterpolatedString.create( 1528 datetime_based_cursor_model.step, 1529 parameters=model_parameters, 1530 ) 1531 if datetime_based_cursor_model.step 1532 else None 1533 ) 1534 if interpolated_step: 1535 evaluated_step = interpolated_step.eval(config) 1536 if evaluated_step: 1537 step_length = parse_duration(evaluated_step) 1538 1539 clamping_strategy: ClampingStrategy = NoClamping() 1540 if datetime_based_cursor_model.clamping: 1541 # While it is undesirable to interpolate within the model factory (as opposed to at runtime), 1542 # it is still better than shifting interpolation low-code concept into the ConcurrentCursor runtime 1543 # object which we want to keep agnostic of being low-code 1544 target = InterpolatedString( 1545 string=datetime_based_cursor_model.clamping.target, 1546 parameters=model_parameters, 1547 ) 1548 evaluated_target = target.eval(config=config) 1549 match evaluated_target: 1550 case "DAY": 1551 clamping_strategy = DayClampingStrategy() 1552 end_date_provider = ClampingEndProvider( 1553 DayClampingStrategy(is_ceiling=False), 1554 end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1555 granularity=cursor_granularity or datetime.timedelta(seconds=1), 1556 ) 1557 case "WEEK": 1558 if ( 1559 not datetime_based_cursor_model.clamping.target_details 1560 or "weekday" not in datetime_based_cursor_model.clamping.target_details 1561 ): 1562 raise ValueError( 1563 "Given WEEK clamping, weekday needs to be provided as target_details" 1564 ) 1565 weekday = self._assemble_weekday( 1566 datetime_based_cursor_model.clamping.target_details["weekday"] 1567 ) 1568 clamping_strategy = WeekClampingStrategy(weekday) 1569 end_date_provider = ClampingEndProvider( 1570 WeekClampingStrategy(weekday, is_ceiling=False), 1571 end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1572 granularity=cursor_granularity or datetime.timedelta(days=1), 1573 ) 1574 case "MONTH": 1575 clamping_strategy = MonthClampingStrategy() 1576 end_date_provider = ClampingEndProvider( 1577 MonthClampingStrategy(is_ceiling=False), 1578 end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1579 granularity=cursor_granularity or datetime.timedelta(days=1), 1580 ) 1581 case _: 1582 raise ValueError( 1583 f"Invalid clamping target {evaluated_target}, expected DAY, WEEK, MONTH" 1584 ) 1585 1586 return ConcurrentCursor( 1587 stream_name=stream_name, 1588 stream_namespace=stream_namespace, 1589 stream_state=stream_state, 1590 message_repository=message_repository or self._message_repository, 1591 connector_state_manager=self._connector_state_manager, 1592 connector_state_converter=connector_state_converter, 1593 cursor_field=cursor_field, 1594 slice_boundary_fields=slice_boundary_fields, 1595 start=start_date, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1596 end_provider=end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1597 lookback_window=lookback_window, 1598 slice_range=step_length, 1599 cursor_granularity=cursor_granularity, 1600 clamping_strategy=clamping_strategy, 1601 ) 1602 1603 def create_concurrent_cursor_from_incrementing_count_cursor( 1604 self, 1605 model_type: Type[BaseModel], 1606 component_definition: ComponentDefinition, 1607 stream_name: str, 1608 stream_namespace: Optional[str], 1609 stream_state: MutableMapping[str, Any], 1610 config: Config, 1611 message_repository: Optional[MessageRepository] = None, 1612 **kwargs: Any, 1613 ) -> ConcurrentCursor: 1614 component_type = component_definition.get("type") 1615 if component_definition.get("type") != model_type.__name__: 1616 raise ValueError( 1617 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 1618 ) 1619 1620 incrementing_count_cursor_model = model_type.parse_obj(component_definition) 1621 1622 if not isinstance(incrementing_count_cursor_model, IncrementingCountCursorModel): 1623 raise ValueError( 1624 f"Expected {model_type.__name__} component, but received {incrementing_count_cursor_model.__class__.__name__}" 1625 ) 1626 1627 start_value: Union[int, str, None] = incrementing_count_cursor_model.start_value 1628 # Pydantic Union type coercion can convert int 0 to string '0' depending on Union order. 1629 # We need to handle both int and str representations of numeric values. 1630 # Evaluate the InterpolatedString and convert to int for the ConcurrentCursor. 1631 if start_value is not None: 1632 interpolated_start_value = InterpolatedString.create( 1633 str(start_value), # Ensure we pass a string to InterpolatedString.create 1634 parameters=incrementing_count_cursor_model.parameters or {}, 1635 ) 1636 evaluated_start_value: int = int(interpolated_start_value.eval(config=config)) 1637 else: 1638 evaluated_start_value = 0 1639 1640 cursor_field = self._get_catalog_defined_cursor_field( 1641 stream_name=stream_name, 1642 allow_catalog_defined_cursor_field=incrementing_count_cursor_model.allow_catalog_defined_cursor_field 1643 or False, 1644 ) 1645 1646 if not cursor_field: 1647 interpolated_cursor_field = InterpolatedString.create( 1648 incrementing_count_cursor_model.cursor_field, 1649 parameters=incrementing_count_cursor_model.parameters or {}, 1650 ) 1651 cursor_field = CursorField( 1652 cursor_field_key=interpolated_cursor_field.eval(config=config), 1653 supports_catalog_defined_cursor_field=incrementing_count_cursor_model.allow_catalog_defined_cursor_field 1654 or False, 1655 ) 1656 1657 connector_state_converter = IncrementingCountStreamStateConverter( 1658 is_sequential_state=True, # ConcurrentPerPartitionCursor only works with sequential state 1659 ) 1660 1661 return ConcurrentCursor( 1662 stream_name=stream_name, 1663 stream_namespace=stream_namespace, 1664 stream_state=stream_state, 1665 message_repository=message_repository or self._message_repository, 1666 connector_state_manager=self._connector_state_manager, 1667 connector_state_converter=connector_state_converter, 1668 cursor_field=cursor_field, 1669 slice_boundary_fields=None, 1670 start=evaluated_start_value, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1671 end_provider=connector_state_converter.get_end_provider(), # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1672 ) 1673 1674 def _assemble_weekday(self, weekday: str) -> Weekday: 1675 match weekday: 1676 case "MONDAY": 1677 return Weekday.MONDAY 1678 case "TUESDAY": 1679 return Weekday.TUESDAY 1680 case "WEDNESDAY": 1681 return Weekday.WEDNESDAY 1682 case "THURSDAY": 1683 return Weekday.THURSDAY 1684 case "FRIDAY": 1685 return Weekday.FRIDAY 1686 case "SATURDAY": 1687 return Weekday.SATURDAY 1688 case "SUNDAY": 1689 return Weekday.SUNDAY 1690 case _: 1691 raise ValueError(f"Unknown weekday {weekday}") 1692 1693 def create_concurrent_cursor_from_perpartition_cursor( 1694 self, 1695 state_manager: ConnectorStateManager, 1696 model_type: Type[BaseModel], 1697 component_definition: ComponentDefinition, 1698 stream_name: str, 1699 stream_namespace: Optional[str], 1700 config: Config, 1701 stream_state: MutableMapping[str, Any], 1702 partition_router: PartitionRouter, 1703 attempt_to_create_cursor_if_not_provided: bool = False, 1704 **kwargs: Any, 1705 ) -> ConcurrentPerPartitionCursor: 1706 component_type = component_definition.get("type") 1707 if component_definition.get("type") != model_type.__name__: 1708 raise ValueError( 1709 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 1710 ) 1711 1712 # FIXME the interfaces of the concurrent cursor are kind of annoying as they take a `ComponentDefinition` instead of the actual model. This was done because the ConcurrentDeclarativeSource didn't have access to the models [here for example](https://github.com/airbytehq/airbyte-python-cdk/blob/f525803b3fec9329e4cc8478996a92bf884bfde9/airbyte_cdk/sources/declarative/concurrent_declarative_source.py#L354C54-L354C91). So now we have two cases: 1713 # * The ComponentDefinition comes from model.__dict__ in which case we have `parameters` 1714 # * The ComponentDefinition comes from the manifest as a dict in which case we have `$parameters` 1715 # We should change those interfaces to use the model once we clean up the code in CDS at which point the parameter propagation should happen as part of the ModelToComponentFactory. 1716 if "$parameters" not in component_definition and "parameters" in component_definition: 1717 component_definition["$parameters"] = component_definition.get("parameters") # type: ignore # This is a dict 1718 datetime_based_cursor_model = model_type.parse_obj(component_definition) 1719 1720 if not isinstance(datetime_based_cursor_model, DatetimeBasedCursorModel): 1721 raise ValueError( 1722 f"Expected {model_type.__name__} component, but received {datetime_based_cursor_model.__class__.__name__}" 1723 ) 1724 1725 cursor_field = self._get_catalog_defined_cursor_field( 1726 stream_name=stream_name, 1727 allow_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1728 or False, 1729 ) 1730 1731 if not cursor_field: 1732 interpolated_cursor_field = InterpolatedString.create( 1733 datetime_based_cursor_model.cursor_field, 1734 # FIXME the interfaces of the concurrent cursor are kind of annoying as they take a `ComponentDefinition` instead of the actual model. This was done because the ConcurrentDeclarativeSource didn't have access to the models [here for example](https://github.com/airbytehq/airbyte-python-cdk/blob/f525803b3fec9329e4cc8478996a92bf884bfde9/airbyte_cdk/sources/declarative/concurrent_declarative_source.py#L354C54-L354C91). So now we have two cases: 1735 # * The ComponentDefinition comes from model.__dict__ in which case we have `parameters` 1736 # * The ComponentDefinition comes from the manifest as a dict in which case we have `$parameters` 1737 # We should change those interfaces to use the model once we clean up the code in CDS at which point the parameter propagation should happen as part of the ModelToComponentFactory. 1738 parameters=datetime_based_cursor_model.parameters or {}, 1739 ) 1740 cursor_field = CursorField( 1741 cursor_field_key=interpolated_cursor_field.eval(config=config), 1742 supports_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1743 or False, 1744 ) 1745 1746 datetime_format = datetime_based_cursor_model.datetime_format 1747 1748 cursor_granularity = ( 1749 parse_duration(datetime_based_cursor_model.cursor_granularity) 1750 if datetime_based_cursor_model.cursor_granularity 1751 else None 1752 ) 1753 1754 connector_state_converter: DateTimeStreamStateConverter 1755 connector_state_converter = CustomFormatConcurrentStreamStateConverter( 1756 datetime_format=datetime_format, 1757 input_datetime_formats=datetime_based_cursor_model.cursor_datetime_formats, 1758 is_sequential_state=True, # ConcurrentPerPartitionCursor only works with sequential state 1759 cursor_granularity=cursor_granularity, 1760 ) 1761 1762 # Create the cursor factory 1763 cursor_factory = ConcurrentCursorFactory( 1764 partial( 1765 self.create_concurrent_cursor_from_datetime_based_cursor, 1766 state_manager=state_manager, 1767 model_type=model_type, 1768 component_definition=component_definition, 1769 stream_name=stream_name, 1770 stream_namespace=stream_namespace, 1771 config=config, 1772 message_repository=NoopMessageRepository(), 1773 ) 1774 ) 1775 1776 # Per-partition state doesn't make sense for GroupingPartitionRouter, so force the global state 1777 use_global_cursor = isinstance( 1778 partition_router, GroupingPartitionRouter 1779 ) or component_definition.get("global_substream_cursor", False) 1780 1781 # Return the concurrent cursor and state converter 1782 return ConcurrentPerPartitionCursor( 1783 cursor_factory=cursor_factory, 1784 partition_router=partition_router, 1785 stream_name=stream_name, 1786 stream_namespace=stream_namespace, 1787 stream_state=stream_state, 1788 message_repository=self._message_repository, # type: ignore 1789 connector_state_manager=state_manager, 1790 connector_state_converter=connector_state_converter, 1791 cursor_field=cursor_field, 1792 use_global_cursor=use_global_cursor, 1793 attempt_to_create_cursor_if_not_provided=attempt_to_create_cursor_if_not_provided, 1794 ) 1795 1796 @staticmethod 1797 def create_constant_backoff_strategy( 1798 model: ConstantBackoffStrategyModel, config: Config, **kwargs: Any 1799 ) -> ConstantBackoffStrategy: 1800 ModelToComponentFactory._validate_jitter_range(model.jitter_range_in_seconds) 1801 return ConstantBackoffStrategy( 1802 backoff_time_in_seconds=model.backoff_time_in_seconds, 1803 jitter_range_in_seconds=model.jitter_range_in_seconds, 1804 config=config, 1805 parameters=model.parameters or {}, 1806 ) 1807 1808 @staticmethod 1809 def _validate_jitter_range(jitter_range_in_seconds: Optional[float]) -> None: 1810 if jitter_range_in_seconds is not None and jitter_range_in_seconds < 0: 1811 raise ValueError("jitter_range_in_seconds must be greater than or equal to 0") 1812 1813 def create_cursor_pagination( 1814 self, model: CursorPaginationModel, config: Config, decoder: Decoder, **kwargs: Any 1815 ) -> CursorPaginationStrategy: 1816 if isinstance(decoder, PaginationDecoderDecorator): 1817 inner_decoder = decoder.decoder 1818 else: 1819 inner_decoder = decoder 1820 decoder = PaginationDecoderDecorator(decoder=decoder) 1821 1822 if self._is_supported_decoder_for_pagination(inner_decoder): 1823 decoder_to_use = decoder 1824 else: 1825 raise ValueError( 1826 self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(inner_decoder)) 1827 ) 1828 1829 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 1830 # If page_size is a string that represents an integer (not an interpolation), convert it back. 1831 page_size = model.page_size 1832 if isinstance(page_size, str) and page_size.isdigit(): 1833 page_size = int(page_size) 1834 1835 return CursorPaginationStrategy( 1836 cursor_value=model.cursor_value, 1837 decoder=decoder_to_use, 1838 page_size=page_size, 1839 stop_condition=model.stop_condition, 1840 config=config, 1841 parameters=model.parameters or {}, 1842 ) 1843 1844 def create_custom_component(self, model: Any, config: Config, **kwargs: Any) -> Any: 1845 """ 1846 Generically creates a custom component based on the model type and a class_name reference to the custom Python class being 1847 instantiated. Only the model's additional properties that match the custom class definition are passed to the constructor 1848 :param model: The Pydantic model of the custom component being created 1849 :param config: The custom defined connector config 1850 :return: The declarative component built from the Pydantic model to be used at runtime 1851 """ 1852 # Instantiating a custom component means importing and executing arbitrary code referenced 1853 # by `class_name`. Manifests supplied by a caller, whether through the config or directly to 1854 # the manifest server, are untrusted input and could point `class_name` at any importable 1855 # callable, so they honor the same `AIRBYTE_ENABLE_UNSAFE_CODE` gate as injected 1856 # `components.py` code. Manifests bundled in a published connector image are trusted and may 1857 # always use their bundled custom components. 1858 manifest_is_untrusted = not self._custom_components_trusted or bool( 1859 config.get(INJECTED_MANIFEST) 1860 ) 1861 if manifest_is_untrusted and not custom_code_execution_permitted(): 1862 raise AirbyteCustomCodeNotPermittedError 1863 1864 custom_component_class = self._get_class_from_fully_qualified_class_name(model.class_name) 1865 component_fields = get_type_hints(custom_component_class) 1866 model_args = model.dict() 1867 model_args["config"] = config 1868 1869 # There are cases where a parent component will pass arguments to a child component via kwargs. When there are field collisions 1870 # we defer to these arguments over the component's definition 1871 for key, arg in kwargs.items(): 1872 model_args[key] = arg 1873 1874 # Pydantic is unable to parse a custom component's fields that are subcomponents into models because their fields and types are not 1875 # defined in the schema. The fields and types are defined within the Python class implementation. Pydantic can only parse down to 1876 # the custom component and this code performs a second parse to convert the sub-fields first into models, then declarative components 1877 for model_field, model_value in model_args.items(): 1878 # If a custom component field doesn't have a type set, we try to use the type hints to infer the type 1879 if ( 1880 isinstance(model_value, dict) 1881 and "type" not in model_value 1882 and model_field in component_fields 1883 ): 1884 derived_type = self._derive_component_type_from_type_hints( 1885 component_fields.get(model_field) 1886 ) 1887 if derived_type: 1888 model_value["type"] = derived_type 1889 1890 if self._is_component(model_value): 1891 model_args[model_field] = self._create_nested_component( 1892 model, 1893 model_field, 1894 model_value, 1895 config, 1896 **kwargs, 1897 ) 1898 elif isinstance(model_value, list): 1899 vals = [] 1900 for v in model_value: 1901 if isinstance(v, dict) and "type" not in v and model_field in component_fields: 1902 derived_type = self._derive_component_type_from_type_hints( 1903 component_fields.get(model_field) 1904 ) 1905 if derived_type: 1906 v["type"] = derived_type 1907 if self._is_component(v): 1908 vals.append( 1909 self._create_nested_component( 1910 model, 1911 model_field, 1912 v, 1913 config, 1914 **kwargs, 1915 ) 1916 ) 1917 else: 1918 vals.append(v) 1919 model_args[model_field] = vals 1920 1921 kwargs = { 1922 class_field: model_args[class_field] 1923 for class_field in component_fields.keys() 1924 if class_field in model_args 1925 } 1926 1927 if "api_budget" in component_fields and kwargs.get("api_budget") is None: 1928 kwargs["api_budget"] = self._api_budget 1929 1930 return custom_component_class(**kwargs) 1931 1932 @staticmethod 1933 def _get_class_from_fully_qualified_class_name( 1934 full_qualified_class_name: str, 1935 ) -> Any: 1936 """Get a class from its fully qualified name. 1937 1938 If a custom components module is needed, we assume it is already registered - probably 1939 as `source_declarative_manifest.components` or `components`. 1940 1941 Args: 1942 full_qualified_class_name (str): The fully qualified name of the class (e.g., "module.ClassName"). 1943 1944 Returns: 1945 Any: The class object. 1946 1947 Raises: 1948 ValueError: If the class cannot be loaded. 1949 """ 1950 split = full_qualified_class_name.split(".") 1951 module_name_full = ".".join(split[:-1]) 1952 class_name = split[-1] 1953 1954 try: 1955 module_ref = importlib.import_module(module_name_full) 1956 except ModuleNotFoundError as e: 1957 if split[0] == "source_declarative_manifest": 1958 # During testing, the modules containing the custom components are not moved to source_declarative_manifest. In order to run the test, add the source folder to your PYTHONPATH or add it runtime using sys.path.append 1959 try: 1960 import os 1961 1962 module_name_with_source_declarative_manifest = ".".join(split[1:-1]) 1963 module_ref = importlib.import_module( 1964 module_name_with_source_declarative_manifest 1965 ) 1966 except ModuleNotFoundError: 1967 raise ValueError(f"Could not load module `{module_name_full}`.") from e 1968 else: 1969 raise ValueError(f"Could not load module `{module_name_full}`.") from e 1970 1971 try: 1972 return getattr(module_ref, class_name) 1973 except AttributeError as e: 1974 raise ValueError( 1975 f"Could not load class `{class_name}` from module `{module_name_full}`.", 1976 ) from e 1977 1978 @staticmethod 1979 def _derive_component_type_from_type_hints(field_type: Any) -> Optional[str]: 1980 interface = field_type 1981 while True: 1982 origin = get_origin(interface) 1983 if origin: 1984 # Unnest types until we reach the raw type 1985 # List[T] -> T 1986 # Optional[List[T]] -> T 1987 args = get_args(interface) 1988 interface = args[0] 1989 else: 1990 break 1991 if isinstance(interface, type) and not ModelToComponentFactory.is_builtin_type(interface): 1992 return interface.__name__ 1993 return None 1994 1995 @staticmethod 1996 def is_builtin_type(cls: Optional[Type[Any]]) -> bool: 1997 if not cls: 1998 return False 1999 return cls.__module__ == "builtins" 2000 2001 @staticmethod 2002 def _extract_missing_parameters(error: TypeError) -> List[str]: 2003 parameter_search = re.search(r"keyword-only.*:\s(.*)", str(error)) 2004 if parameter_search: 2005 return re.findall(r"\'(.+?)\'", parameter_search.group(1)) 2006 else: 2007 return [] 2008 2009 def _create_nested_component( 2010 self, model: Any, model_field: str, model_value: Any, config: Config, **kwargs: Any 2011 ) -> Any: 2012 type_name = model_value.get("type", None) 2013 if not type_name: 2014 # If no type is specified, we can assume this is a dictionary object which can be returned instead of a subcomponent 2015 return model_value 2016 2017 model_type = self.TYPE_NAME_TO_MODEL.get(type_name, None) 2018 if model_type: 2019 parsed_model = model_type.parse_obj(model_value) 2020 try: 2021 # To improve usability of the language, certain fields are shared between components. This can come in the form of 2022 # a parent component passing some of its fields to a child component or the parent extracting fields from other child 2023 # components and passing it to others. One example is the DefaultPaginator referencing the HttpRequester url_base 2024 # while constructing a SimpleRetriever. However, custom components don't support this behavior because they are created 2025 # generically in create_custom_component(). This block allows developers to specify extra arguments in $parameters that 2026 # are needed by a component and could not be shared. 2027 model_constructor = self.PYDANTIC_MODEL_TO_CONSTRUCTOR.get(parsed_model.__class__) 2028 constructor_kwargs = inspect.getfullargspec(model_constructor).kwonlyargs 2029 model_parameters = model_value.get("$parameters", {}) 2030 matching_parameters = { 2031 kwarg: model_parameters[kwarg] 2032 for kwarg in constructor_kwargs 2033 if kwarg in model_parameters 2034 } 2035 matching_kwargs = { 2036 kwarg: kwargs[kwarg] for kwarg in constructor_kwargs if kwarg in kwargs 2037 } 2038 return self._create_component_from_model( 2039 model=parsed_model, config=config, **(matching_parameters | matching_kwargs) 2040 ) 2041 except TypeError as error: 2042 missing_parameters = self._extract_missing_parameters(error) 2043 if missing_parameters: 2044 raise ValueError( 2045 f"Error creating component '{type_name}' with parent custom component {model.class_name}: Please provide " 2046 + ", ".join( 2047 ( 2048 f"{type_name}.$parameters.{parameter}" 2049 for parameter in missing_parameters 2050 ) 2051 ) 2052 ) 2053 raise TypeError( 2054 f"Error creating component '{type_name}' with parent custom component {model.class_name}: {error}" 2055 ) 2056 else: 2057 raise ValueError( 2058 f"Error creating custom component {model.class_name}. Subcomponent creation has not been implemented for '{type_name}'" 2059 ) 2060 2061 @staticmethod 2062 def _is_component(model_value: Any) -> bool: 2063 return isinstance(model_value, dict) and model_value.get("type") is not None 2064 2065 def create_default_stream( 2066 self, model: DeclarativeStreamModel, config: Config, is_parent: bool = False, **kwargs: Any 2067 ) -> AbstractStream: 2068 primary_key = model.primary_key.__root__ if model.primary_key else None 2069 self._migrate_state(model, config) 2070 self._warn_on_ineffective_incremental_dependency(model) 2071 2072 partition_router = self._build_stream_slicer_from_partition_router( 2073 model.retriever, 2074 config, 2075 stream_name=model.name, 2076 **kwargs, 2077 ) 2078 concurrent_cursor = self._build_concurrent_cursor(model, partition_router, config) 2079 if model.incremental_sync and isinstance(model.incremental_sync, DatetimeBasedCursorModel): 2080 cursor_model: DatetimeBasedCursorModel = model.incremental_sync 2081 2082 end_time_option = ( 2083 self._create_component_from_model( 2084 cursor_model.end_time_option, config, parameters=cursor_model.parameters or {} 2085 ) 2086 if cursor_model.end_time_option 2087 else None 2088 ) 2089 start_time_option = ( 2090 self._create_component_from_model( 2091 cursor_model.start_time_option, config, parameters=cursor_model.parameters or {} 2092 ) 2093 if cursor_model.start_time_option 2094 else None 2095 ) 2096 2097 datetime_request_options_provider = DatetimeBasedRequestOptionsProvider( 2098 start_time_option=start_time_option, 2099 end_time_option=end_time_option, 2100 partition_field_start=cursor_model.partition_field_start, 2101 partition_field_end=cursor_model.partition_field_end, 2102 config=config, 2103 parameters=model.parameters or {}, 2104 ) 2105 request_options_provider = ( 2106 datetime_request_options_provider 2107 if not isinstance(concurrent_cursor, ConcurrentPerPartitionCursor) 2108 else PerPartitionRequestOptionsProvider( 2109 partition_router, datetime_request_options_provider 2110 ) 2111 ) 2112 elif model.incremental_sync and isinstance( 2113 model.incremental_sync, IncrementingCountCursorModel 2114 ): 2115 if isinstance(concurrent_cursor, ConcurrentPerPartitionCursor): 2116 raise ValueError( 2117 "PerPartition does not support per partition states because switching to global state is time based" 2118 ) 2119 2120 cursor_model: IncrementingCountCursorModel = model.incremental_sync # type: ignore 2121 2122 start_time_option = ( 2123 self._create_component_from_model( 2124 cursor_model.start_value_option, # type: ignore # mypy still thinks cursor_model of type DatetimeBasedCursor 2125 config, 2126 parameters=cursor_model.parameters or {}, 2127 ) 2128 if cursor_model.start_value_option # type: ignore # mypy still thinks cursor_model of type DatetimeBasedCursor 2129 else None 2130 ) 2131 2132 # The concurrent engine defaults the start/end fields on the slice to "start" and "end", but 2133 # the default DatetimeBasedRequestOptionsProvider() sets them to start_time/end_time 2134 partition_field_start = "start" 2135 2136 request_options_provider = DatetimeBasedRequestOptionsProvider( 2137 start_time_option=start_time_option, 2138 partition_field_start=partition_field_start, 2139 config=config, 2140 parameters=model.parameters or {}, 2141 ) 2142 else: 2143 request_options_provider = None 2144 2145 transformations = [] 2146 if model.transformations: 2147 for transformation_model in model.transformations: 2148 transformations.append( 2149 self._create_component_from_model(model=transformation_model, config=config) 2150 ) 2151 file_uploader = None 2152 if model.file_uploader: 2153 file_uploader = self._create_component_from_model( 2154 model=model.file_uploader, config=config 2155 ) 2156 2157 stream_slicer: ConcurrentStreamSlicer = ( 2158 partition_router 2159 if isinstance(concurrent_cursor, FinalStateCursor) 2160 else concurrent_cursor 2161 ) 2162 2163 retriever = self._create_component_from_model( 2164 model=model.retriever, 2165 config=config, 2166 name=model.name, 2167 primary_key=primary_key, 2168 request_options_provider=request_options_provider, 2169 stream_slicer=stream_slicer, 2170 partition_router=partition_router, 2171 has_stop_condition_cursor=self._is_stop_condition_on_cursor(model), 2172 is_client_side_incremental_sync=self._is_client_side_filtering_enabled(model), 2173 cursor=concurrent_cursor, 2174 transformations=transformations, 2175 file_uploader=file_uploader, 2176 incremental_sync=model.incremental_sync, 2177 ) 2178 if isinstance(retriever, AsyncRetriever): 2179 stream_slicer = retriever.stream_slicer 2180 2181 schema_loader: SchemaLoader 2182 if model.schema_loader and isinstance(model.schema_loader, list): 2183 nested_schema_loaders = [ 2184 self._create_component_from_model(model=nested_schema_loader, config=config) 2185 for nested_schema_loader in model.schema_loader 2186 ] 2187 schema_loader = CompositeSchemaLoader( 2188 schema_loaders=nested_schema_loaders, parameters={} 2189 ) 2190 elif model.schema_loader: 2191 schema_loader = self._create_component_from_model( 2192 model=model.schema_loader, # type: ignore # If defined, schema_loader is guaranteed not to be a list and will be one of the existing base models 2193 config=config, 2194 ) 2195 else: 2196 options = model.parameters or {} 2197 if "name" not in options: 2198 options["name"] = model.name 2199 schema_loader = DefaultSchemaLoader(config=config, parameters=options) 2200 schema_loader = CachingSchemaLoaderDecorator(schema_loader) 2201 2202 stream_name = model.name or "" 2203 return DefaultStream( 2204 partition_generator=StreamSlicerPartitionGenerator( 2205 DeclarativePartitionFactory( 2206 stream_name, 2207 schema_loader, 2208 retriever, 2209 self._message_repository, 2210 ), 2211 stream_slicer, 2212 slice_limit=self._limit_slices_fetched, 2213 ), 2214 name=stream_name, 2215 json_schema=schema_loader.get_json_schema, 2216 primary_key=get_primary_key_from_stream(primary_key), 2217 cursor_field=( 2218 concurrent_cursor.cursor_field 2219 if hasattr(concurrent_cursor, "cursor_field") 2220 else None 2221 ), 2222 logger=logging.getLogger(f"airbyte.{stream_name}"), 2223 cursor=concurrent_cursor, 2224 supports_file_transfer=hasattr(model, "file_uploader") and bool(model.file_uploader), 2225 ) 2226 2227 def _warn_on_ineffective_incremental_dependency(self, model: DeclarativeStreamModel) -> None: 2228 """ 2229 `incremental_dependency: true` only takes effect when the substream defines its own 2230 `incremental_sync`: the parent cursor is persisted under the `parent_state` key of the 2231 substream's state, which is only emitted by incremental substreams. On a stream without 2232 `incremental_sync`, the setting is silently ignored and all parent records are re-read on 2233 every sync, so we warn about the misconfiguration instead. 2234 """ 2235 if model.incremental_sync: 2236 return 2237 2238 partition_router = getattr(model.retriever, "partition_router", None) 2239 if not partition_router: 2240 return 2241 2242 routers = partition_router if isinstance(partition_router, list) else [partition_router] 2243 for router in routers: 2244 if isinstance(router, GroupingPartitionRouterModel): 2245 router = router.underlying_partition_router 2246 if isinstance(router, SubstreamPartitionRouterModel) and any( 2247 parent_stream_config.incremental_dependency 2248 for parent_stream_config in router.parent_stream_configs 2249 ): 2250 LOGGER.warning( 2251 f"Stream `{model.name}` has `incremental_dependency: true` in its parent stream configuration but does not define `incremental_sync`. " 2252 "The parent stream's cursor is only persisted in the state of an incremental substream, so this setting has no effect and all parent records will be re-read on every sync. " 2253 "Define `incremental_sync` on this stream or remove `incremental_dependency`." 2254 ) 2255 return 2256 2257 def _migrate_state(self, model: DeclarativeStreamModel, config: Config) -> None: 2258 stream_name = model.name or "" 2259 stream_state = self._connector_state_manager.get_stream_state( 2260 stream_name=stream_name, namespace=None 2261 ) 2262 if model.state_migrations: 2263 state_transformations = [ 2264 self._create_component_from_model(state_migration, config, declarative_stream=model) 2265 for state_migration in model.state_migrations 2266 ] 2267 else: 2268 state_transformations = [] 2269 stream_state = self.apply_stream_state_migrations(state_transformations, stream_state) 2270 self._connector_state_manager.update_state_for_stream( 2271 stream_name=stream_name, namespace=None, value=stream_state 2272 ) 2273 2274 def _is_stop_condition_on_cursor(self, model: DeclarativeStreamModel) -> bool: 2275 return bool( 2276 model.incremental_sync 2277 and hasattr(model.incremental_sync, "is_data_feed") 2278 and model.incremental_sync.is_data_feed 2279 ) 2280 2281 def _is_client_side_filtering_enabled(self, model: DeclarativeStreamModel) -> bool: 2282 return bool( 2283 model.incremental_sync 2284 and hasattr(model.incremental_sync, "is_client_side_incremental") 2285 and model.incremental_sync.is_client_side_incremental 2286 ) 2287 2288 def _build_stream_slicer_from_partition_router( 2289 self, 2290 model: Union[ 2291 AsyncRetrieverModel, 2292 CustomRetrieverModel, 2293 SimpleRetrieverModel, 2294 ], 2295 config: Config, 2296 stream_name: Optional[str] = None, 2297 **kwargs: Any, 2298 ) -> PartitionRouter: 2299 if ( 2300 hasattr(model, "partition_router") 2301 and isinstance(model, (SimpleRetrieverModel, AsyncRetrieverModel, CustomRetrieverModel)) 2302 and model.partition_router 2303 ): 2304 stream_slicer_model = model.partition_router 2305 if isinstance(stream_slicer_model, list): 2306 return CartesianProductStreamSlicer( 2307 [ 2308 self._create_component_from_model( 2309 model=slicer, config=config, stream_name=stream_name or "" 2310 ) 2311 for slicer in stream_slicer_model 2312 ], 2313 parameters={}, 2314 ) 2315 elif isinstance(stream_slicer_model, dict): 2316 # partition router comes from CustomRetrieverModel therefore has not been parsed as a model 2317 params = stream_slicer_model.get("$parameters") 2318 if not isinstance(params, dict): 2319 params = {} 2320 stream_slicer_model["$parameters"] = params 2321 2322 if stream_name is not None: 2323 params["stream_name"] = stream_name 2324 2325 return self._create_nested_component( # type: ignore[no-any-return] # There is no guarantee that this will return a stream slicer. If not, we expect an AttributeError during the call to `stream_slices` 2326 model, 2327 "partition_router", 2328 stream_slicer_model, 2329 config, 2330 **kwargs, 2331 ) 2332 else: 2333 return self._create_component_from_model( # type: ignore[no-any-return] # Will be created PartitionRouter as stream_slicer_model is model.partition_router 2334 model=stream_slicer_model, config=config, stream_name=stream_name or "" 2335 ) 2336 return SinglePartitionRouter(parameters={}) 2337 2338 def _build_concurrent_cursor( 2339 self, 2340 model: DeclarativeStreamModel, 2341 stream_slicer: Optional[PartitionRouter], 2342 config: Config, 2343 ) -> Cursor: 2344 stream_name = model.name or "" 2345 stream_state = self._connector_state_manager.get_stream_state(stream_name, None) 2346 2347 if ( 2348 model.incremental_sync 2349 and stream_slicer 2350 and not isinstance(stream_slicer, SinglePartitionRouter) 2351 ): 2352 if isinstance(model.incremental_sync, IncrementingCountCursorModel): 2353 # We don't currently support usage of partition routing and IncrementingCountCursor at the 2354 # same time because we didn't solve for design questions like what the lookback window would 2355 # be as well as global cursor fall backs. We have not seen customers that have needed both 2356 # at the same time yet and are currently punting on this until we need to solve it. 2357 raise ValueError( 2358 f"The low-code framework does not currently support usage of a PartitionRouter and an IncrementingCountCursor at the same time. Please specify only one of these options for stream {stream_name}." 2359 ) 2360 return self.create_concurrent_cursor_from_perpartition_cursor( # type: ignore # This is a known issue that we are creating and returning a ConcurrentCursor which does not technically implement the (low-code) StreamSlicer. However, (low-code) StreamSlicer and ConcurrentCursor both implement StreamSlicer.stream_slices() which is the primary method needed for checkpointing 2361 state_manager=self._connector_state_manager, 2362 model_type=DatetimeBasedCursorModel, 2363 component_definition=model.incremental_sync.__dict__, 2364 stream_name=stream_name, 2365 stream_state=stream_state, 2366 stream_namespace=None, 2367 config=config or {}, 2368 partition_router=stream_slicer, 2369 attempt_to_create_cursor_if_not_provided=True, # FIXME can we remove that now? 2370 ) 2371 elif model.incremental_sync: 2372 if type(model.incremental_sync) == IncrementingCountCursorModel: 2373 return self.create_concurrent_cursor_from_incrementing_count_cursor( # type: ignore # This is a known issue that we are creating and returning a ConcurrentCursor which does not technically implement the (low-code) StreamSlicer. However, (low-code) StreamSlicer and ConcurrentCursor both implement StreamSlicer.stream_slices() which is the primary method needed for checkpointing 2374 model_type=IncrementingCountCursorModel, 2375 component_definition=model.incremental_sync.__dict__, 2376 stream_name=stream_name, 2377 stream_namespace=None, 2378 stream_state=stream_state, 2379 config=config or {}, 2380 ) 2381 elif type(model.incremental_sync) == DatetimeBasedCursorModel: 2382 return self.create_concurrent_cursor_from_datetime_based_cursor( # type: ignore # This is a known issue that we are creating and returning a ConcurrentCursor which does not technically implement the (low-code) StreamSlicer. However, (low-code) StreamSlicer and ConcurrentCursor both implement StreamSlicer.stream_slices() which is the primary method needed for checkpointing 2383 model_type=type(model.incremental_sync), 2384 component_definition=model.incremental_sync.__dict__, 2385 stream_name=stream_name, 2386 stream_namespace=None, 2387 stream_state=stream_state, 2388 config=config or {}, 2389 attempt_to_create_cursor_if_not_provided=True, 2390 ) 2391 else: 2392 raise ValueError( 2393 f"Incremental sync of type {type(model.incremental_sync)} is not supported" 2394 ) 2395 return FinalStateCursor(stream_name, None, self._message_repository) 2396 2397 def create_default_error_handler( 2398 self, model: DefaultErrorHandlerModel, config: Config, **kwargs: Any 2399 ) -> DefaultErrorHandler: 2400 backoff_strategies = [] 2401 if model.backoff_strategies: 2402 for backoff_strategy_model in model.backoff_strategies: 2403 backoff_strategies.append( 2404 self._create_component_from_model(model=backoff_strategy_model, config=config) 2405 ) 2406 2407 response_filters = [] 2408 if model.response_filters: 2409 for response_filter_model in model.response_filters: 2410 response_filters.append( 2411 self._create_component_from_model(model=response_filter_model, config=config) 2412 ) 2413 response_filters.append( 2414 HttpResponseFilter(config=config, parameters=model.parameters or {}) 2415 ) 2416 2417 return DefaultErrorHandler( 2418 backoff_strategies=backoff_strategies, 2419 max_retries=model.max_retries, 2420 response_filters=response_filters, 2421 config=config, 2422 parameters=model.parameters or {}, 2423 ) 2424 2425 def create_default_paginator( 2426 self, 2427 model: DefaultPaginatorModel, 2428 config: Config, 2429 *, 2430 url_base: str, 2431 extractor_model: Optional[Union[CustomRecordExtractorModel, DpathExtractorModel]] = None, 2432 decoder: Optional[Decoder] = None, 2433 cursor_used_for_stop_condition: Optional[Cursor] = None, 2434 ) -> Union[DefaultPaginator, PaginatorTestReadDecorator]: 2435 if decoder: 2436 if self._is_supported_decoder_for_pagination(decoder): 2437 decoder_to_use = PaginationDecoderDecorator(decoder=decoder) 2438 else: 2439 raise ValueError(self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(decoder))) 2440 else: 2441 decoder_to_use = PaginationDecoderDecorator(decoder=JsonDecoder(parameters={})) 2442 page_size_option = ( 2443 self._create_component_from_model(model=model.page_size_option, config=config) 2444 if model.page_size_option 2445 else None 2446 ) 2447 page_token_option = ( 2448 self._create_component_from_model(model=model.page_token_option, config=config) 2449 if model.page_token_option 2450 else None 2451 ) 2452 pagination_strategy = self._create_component_from_model( 2453 model=model.pagination_strategy, 2454 config=config, 2455 decoder=decoder_to_use, 2456 extractor_model=extractor_model, 2457 ) 2458 if cursor_used_for_stop_condition: 2459 pagination_strategy = StopConditionPaginationStrategyDecorator( 2460 pagination_strategy, CursorStopCondition(cursor_used_for_stop_condition) 2461 ) 2462 paginator = DefaultPaginator( 2463 decoder=decoder_to_use, 2464 page_size_option=page_size_option, 2465 page_token_option=page_token_option, 2466 pagination_strategy=pagination_strategy, 2467 url_base=url_base, 2468 config=config, 2469 parameters=model.parameters or {}, 2470 ) 2471 if self._limit_pages_fetched_per_slice: 2472 return PaginatorTestReadDecorator(paginator, self._limit_pages_fetched_per_slice) 2473 return paginator 2474 2475 def create_dpath_extractor( 2476 self, 2477 model: DpathExtractorModel, 2478 config: Config, 2479 decoder: Optional[Decoder] = None, 2480 **kwargs: Any, 2481 ) -> DpathExtractor: 2482 if decoder: 2483 decoder_to_use = decoder 2484 else: 2485 decoder_to_use = JsonDecoder(parameters={}) 2486 model_field_path: List[Union[InterpolatedString, str]] = [x for x in model.field_path] 2487 2488 record_expander = None 2489 if model.record_expander: 2490 record_expander = self._create_component_from_model( 2491 model=model.record_expander, 2492 config=config, 2493 ) 2494 2495 return DpathExtractor( 2496 decoder=decoder_to_use, 2497 field_path=model_field_path, 2498 config=config, 2499 parameters=model.parameters or {}, 2500 record_expander=record_expander, 2501 ) 2502 2503 def create_record_expander( 2504 self, 2505 model: RecordExpanderModel, 2506 config: Config, 2507 **kwargs: Any, 2508 ) -> RecordExpander: 2509 truncated_list_retriever = None 2510 suppress_incomplete_fetch_warning = False 2511 if model.truncated_list_retriever: 2512 retriever_model = model.truncated_list_retriever 2513 name = "record_expander_truncated_list" 2514 # `CustomRetriever` allows extra fields, so read from the dumped model to cover both types. 2515 retriever_fields = retriever_model.dict() 2516 for unsupported_option in ("partition_router", "pagination_reset"): 2517 if retriever_fields.get(unsupported_option): 2518 raise ValueError( 2519 f"`{unsupported_option}` is not supported on `truncated_list_retriever`." 2520 ) 2521 log_formatter = lambda response: format_http_message( 2522 response, 2523 f"Record expander '{name}' request", 2524 "Request performed in order to fetch the complete nested list of a truncated record.", 2525 name, 2526 is_auxiliary=True, 2527 ) 2528 # `name`/`primary_key`/`transformations` are also what a `CustomRetriever` forwards to its 2529 # nested `requester`/`record_selector`, so they are passed for both retriever types. 2530 truncated_list_retriever = self._create_component_from_model( 2531 model=retriever_model, 2532 config=config, 2533 name=name, 2534 primary_key=None, 2535 transformations=[], 2536 log_formatter=log_formatter, 2537 ) 2538 # Only a capped paginator makes a shortfall expected; `NoPagination` and retrievers 2539 # without a paginator are never capped. 2540 suppress_incomplete_fetch_warning = isinstance( 2541 truncated_list_retriever, SimpleRetriever 2542 ) and isinstance(truncated_list_retriever.paginator, PaginatorTestReadDecorator) 2543 return RecordExpander( 2544 expand_records_from_field=model.expand_records_from_field, 2545 config=config, 2546 parameters=model.parameters or {}, 2547 remain_original_record=model.remain_original_record or False, 2548 on_no_records=OnNoRecords(model.on_no_records.value) 2549 if model.on_no_records 2550 else OnNoRecords.skip, 2551 truncation_indicator_path=model.truncation_indicator_path, 2552 truncated_list_retriever=truncated_list_retriever, 2553 message_repository=self._message_repository, 2554 suppress_incomplete_fetch_warning=suppress_incomplete_fetch_warning, 2555 ) 2556 2557 @staticmethod 2558 def create_response_to_file_extractor( 2559 model: ResponseToFileExtractorModel, 2560 **kwargs: Any, 2561 ) -> ResponseToFileExtractor: 2562 return ResponseToFileExtractor( 2563 parameters=model.parameters or {}, 2564 preserve_na_values=model.preserve_na_values or False, 2565 ) 2566 2567 @staticmethod 2568 def create_exponential_backoff_strategy( 2569 model: ExponentialBackoffStrategyModel, config: Config 2570 ) -> ExponentialBackoffStrategy: 2571 ModelToComponentFactory._validate_jitter_range(model.jitter_range_in_seconds) 2572 return ExponentialBackoffStrategy( 2573 factor=model.factor or 5, 2574 jitter_range_in_seconds=model.jitter_range_in_seconds, 2575 parameters=model.parameters or {}, 2576 config=config, 2577 ) 2578 2579 @staticmethod 2580 def create_group_by_key(model: GroupByKeyMergeStrategyModel, config: Config) -> GroupByKey: 2581 return GroupByKey(model.key, config=config, parameters=model.parameters or {}) 2582 2583 def create_http_requester( 2584 self, 2585 model: HttpRequesterModel, 2586 config: Config, 2587 decoder: Decoder = JsonDecoder(parameters={}), 2588 query_properties_key: Optional[str] = None, 2589 use_cache: Optional[bool] = None, 2590 *, 2591 name: str, 2592 ) -> HttpRequester: 2593 authenticator = ( 2594 self._create_component_from_model( 2595 model=model.authenticator, 2596 config=config, 2597 url_base=model.url or model.url_base, 2598 name=name, 2599 decoder=decoder, 2600 ) 2601 if model.authenticator 2602 else None 2603 ) 2604 error_handler = ( 2605 self._create_component_from_model(model=model.error_handler, config=config) 2606 if model.error_handler 2607 else DefaultErrorHandler( 2608 backoff_strategies=[], 2609 response_filters=[], 2610 config=config, 2611 parameters=model.parameters or {}, 2612 ) 2613 ) 2614 2615 api_budget = self._api_budget 2616 2617 request_options_provider = InterpolatedRequestOptionsProvider( 2618 request_body=model.request_body, 2619 request_body_data=model.request_body_data, 2620 request_body_json=model.request_body_json, 2621 request_headers=model.request_headers, 2622 request_parameters=model.request_parameters, # type: ignore # QueryProperties have been removed in `create_simple_retriever` 2623 query_properties_key=query_properties_key, 2624 config=config, 2625 parameters=model.parameters or {}, 2626 ) 2627 2628 assert model.use_cache is not None # for mypy 2629 assert model.http_method is not None # for mypy 2630 2631 should_use_cache = (model.use_cache or bool(use_cache)) and not self._disable_cache 2632 2633 return HttpRequester( 2634 name=name, 2635 url=model.url, 2636 url_base=model.url_base, 2637 path=model.path, 2638 authenticator=authenticator, 2639 error_handler=error_handler, 2640 api_budget=api_budget, 2641 http_method=HttpMethod[model.http_method.value], 2642 request_options_provider=request_options_provider, 2643 config=config, 2644 disable_retries=self._disable_retries, 2645 parameters=model.parameters or {}, 2646 message_repository=self._message_repository, 2647 use_cache=should_use_cache, 2648 decoder=decoder, 2649 stream_response=decoder.is_stream_response() if decoder else False, 2650 ) 2651 2652 @staticmethod 2653 def create_http_response_filter( 2654 model: HttpResponseFilterModel, config: Config, **kwargs: Any 2655 ) -> HttpResponseFilter: 2656 if model.action: 2657 action = ResponseAction(model.action.value) 2658 else: 2659 action = None 2660 2661 failure_type = FailureType(model.failure_type.value) if model.failure_type else None 2662 2663 http_codes = ( 2664 set(model.http_codes) if model.http_codes else set() 2665 ) # JSON schema notation has no set data type. The schema enforces an array of unique elements 2666 2667 return HttpResponseFilter( 2668 action=action, 2669 failure_type=failure_type, 2670 error_message=model.error_message or "", 2671 error_message_contains=model.error_message_contains or "", 2672 http_codes=http_codes, 2673 predicate=model.predicate or "", 2674 config=config, 2675 parameters=model.parameters or {}, 2676 ) 2677 2678 @staticmethod 2679 def create_inline_schema_loader( 2680 model: InlineSchemaLoaderModel, config: Config, **kwargs: Any 2681 ) -> InlineSchemaLoader: 2682 return InlineSchemaLoader(schema=model.schema_ or {}, parameters={}) 2683 2684 def create_complex_field_type( 2685 self, model: ComplexFieldTypeModel, config: Config, **kwargs: Any 2686 ) -> ComplexFieldType: 2687 items = ( 2688 self._create_component_from_model(model=model.items, config=config) 2689 if isinstance(model.items, ComplexFieldTypeModel) 2690 else model.items 2691 ) 2692 2693 return ComplexFieldType(field_type=model.field_type, items=items) 2694 2695 def create_types_map(self, model: TypesMapModel, config: Config, **kwargs: Any) -> TypesMap: 2696 target_type = ( 2697 self._create_component_from_model(model=model.target_type, config=config) 2698 if isinstance(model.target_type, ComplexFieldTypeModel) 2699 else model.target_type 2700 ) 2701 2702 return TypesMap( 2703 target_type=target_type, 2704 current_type=model.current_type, 2705 condition=model.condition if model.condition is not None else "True", 2706 ) 2707 2708 def create_schema_type_identifier( 2709 self, model: SchemaTypeIdentifierModel, config: Config, **kwargs: Any 2710 ) -> SchemaTypeIdentifier: 2711 types_mapping = [] 2712 if model.types_mapping: 2713 types_mapping.extend( 2714 [ 2715 self._create_component_from_model(types_map, config=config) 2716 for types_map in model.types_mapping 2717 ] 2718 ) 2719 model_schema_pointer: List[Union[InterpolatedString, str]] = ( 2720 [x for x in model.schema_pointer] if model.schema_pointer else [] 2721 ) 2722 model_key_pointer: List[Union[InterpolatedString, str]] = [x for x in model.key_pointer] 2723 model_type_pointer: Optional[List[Union[InterpolatedString, str]]] = ( 2724 [x for x in model.type_pointer] if model.type_pointer else None 2725 ) 2726 2727 return SchemaTypeIdentifier( 2728 schema_pointer=model_schema_pointer, 2729 key_pointer=model_key_pointer, 2730 type_pointer=model_type_pointer, 2731 types_mapping=types_mapping, 2732 parameters=model.parameters or {}, 2733 ) 2734 2735 def create_dynamic_schema_loader( 2736 self, model: DynamicSchemaLoaderModel, config: Config, **kwargs: Any 2737 ) -> DynamicSchemaLoader: 2738 schema_transformations = [] 2739 if model.schema_transformations: 2740 for transformation_model in model.schema_transformations: 2741 schema_transformations.append( 2742 self._create_component_from_model(model=transformation_model, config=config) 2743 ) 2744 name = "dynamic_properties" 2745 retriever = self._create_component_from_model( 2746 model=model.retriever, 2747 config=config, 2748 name=name, 2749 primary_key=None, 2750 partition_router=self._build_stream_slicer_from_partition_router( 2751 model.retriever, config 2752 ), 2753 transformations=[], 2754 use_cache=True, 2755 log_formatter=( 2756 lambda response: format_http_message( 2757 response, 2758 f"Schema loader '{name}' request", 2759 f"Request performed in order to extract schema.", 2760 name, 2761 is_auxiliary=True, 2762 ) 2763 ), 2764 ) 2765 schema_type_identifier = self._create_component_from_model( 2766 model.schema_type_identifier, config=config, parameters=model.parameters or {} 2767 ) 2768 schema_filter = ( 2769 self._create_component_from_model( 2770 model.schema_filter, config=config, parameters=model.parameters or {} 2771 ) 2772 if model.schema_filter is not None 2773 else None 2774 ) 2775 2776 return DynamicSchemaLoader( 2777 retriever=retriever, 2778 config=config, 2779 schema_transformations=schema_transformations, 2780 schema_filter=schema_filter, 2781 schema_type_identifier=schema_type_identifier, 2782 parameters=model.parameters or {}, 2783 ) 2784 2785 @staticmethod 2786 def create_json_decoder(model: JsonDecoderModel, config: Config, **kwargs: Any) -> Decoder: 2787 return JsonDecoder(parameters={}) 2788 2789 def create_csv_decoder(self, model: CsvDecoderModel, config: Config, **kwargs: Any) -> Decoder: 2790 return CompositeRawDecoder( 2791 parser=ModelToComponentFactory._get_parser(model, config), 2792 stream_response=False if self._emit_connector_builder_messages else True, 2793 ) 2794 2795 def create_jsonl_decoder( 2796 self, model: JsonlDecoderModel, config: Config, **kwargs: Any 2797 ) -> Decoder: 2798 return CompositeRawDecoder( 2799 parser=ModelToComponentFactory._get_parser(model, config), 2800 stream_response=False if self._emit_connector_builder_messages else True, 2801 ) 2802 2803 def create_json_items_decoder( 2804 self, model: JsonItemsDecoderModel, config: Config, **kwargs: Any 2805 ) -> Decoder: 2806 return CompositeRawDecoder( 2807 parser=ModelToComponentFactory._get_parser(model, config), 2808 stream_response=False if self._emit_connector_builder_messages else True, 2809 ) 2810 2811 def create_gzip_decoder( 2812 self, model: GzipDecoderModel, config: Config, **kwargs: Any 2813 ) -> Decoder: 2814 _compressed_response_types = { 2815 "gzip", 2816 "x-gzip", 2817 "gzip, deflate", 2818 "x-gzip, deflate", 2819 "application/zip", 2820 "application/gzip", 2821 "application/x-gzip", 2822 "application/x-zip-compressed", 2823 } 2824 2825 gzip_parser: GzipParser = ModelToComponentFactory._get_parser(model, config) # type: ignore # based on the model, we know this will be a GzipParser 2826 2827 if self._emit_connector_builder_messages: 2828 return CompositeRawDecoder(gzip_parser, False) 2829 2830 transport_gzip_parser = GzipParser(inner_parser=gzip_parser) 2831 return CompositeRawDecoder.by_headers( 2832 [ 2833 ({"Content-Encoding"}, {"gzip"}, transport_gzip_parser), 2834 ({"Content-Type"}, _compressed_response_types, gzip_parser), 2835 ], 2836 stream_response=True, 2837 fallback_parser=gzip_parser, 2838 ) 2839 2840 @staticmethod 2841 def create_iterable_decoder( 2842 model: IterableDecoderModel, config: Config, **kwargs: Any 2843 ) -> IterableDecoder: 2844 return IterableDecoder(parameters={}) 2845 2846 @staticmethod 2847 def create_xml_decoder(model: XmlDecoderModel, config: Config, **kwargs: Any) -> XmlDecoder: 2848 return XmlDecoder(parameters={}) 2849 2850 def create_zipfile_decoder( 2851 self, model: ZipfileDecoderModel, config: Config, **kwargs: Any 2852 ) -> ZipfileDecoder: 2853 return ZipfileDecoder(parser=ModelToComponentFactory._get_parser(model.decoder, config)) 2854 2855 @staticmethod 2856 def _get_parser(model: BaseModel, config: Config) -> Parser: 2857 if isinstance(model, JsonDecoderModel): 2858 # Note that the logic is a bit different from the JsonDecoder as there is some legacy that is maintained to return {} on error cases 2859 return JsonParser() 2860 elif isinstance(model, JsonItemsDecoderModel): 2861 return JsonItemsParser( 2862 items_path=model.items_path, 2863 encoding=model.encoding, 2864 ) 2865 elif isinstance(model, JsonlDecoderModel): 2866 return JsonLineParser() 2867 elif isinstance(model, CsvDecoderModel): 2868 return CsvParser( 2869 encoding=model.encoding, 2870 delimiter=model.delimiter, 2871 set_values_to_none=model.set_values_to_none, 2872 ) 2873 elif isinstance(model, GzipDecoderModel): 2874 return GzipParser( 2875 inner_parser=ModelToComponentFactory._get_parser(model.decoder, config) 2876 ) 2877 elif isinstance( 2878 model, (CustomDecoderModel, IterableDecoderModel, XmlDecoderModel, ZipfileDecoderModel) 2879 ): 2880 raise ValueError(f"Decoder type {model} does not have parser associated to it") 2881 2882 raise ValueError(f"Unknown decoder type {model}") 2883 2884 @staticmethod 2885 def create_json_file_schema_loader( 2886 model: JsonFileSchemaLoaderModel, config: Config, **kwargs: Any 2887 ) -> JsonFileSchemaLoader: 2888 return JsonFileSchemaLoader( 2889 file_path=model.file_path or "", config=config, parameters=model.parameters or {} 2890 ) 2891 2892 def create_jwt_authenticator( 2893 self, model: JwtAuthenticatorModel, config: Config, **kwargs: Any 2894 ) -> JwtAuthenticator: 2895 jwt_headers = model.jwt_headers or JwtHeadersModel(kid=None, typ="JWT", cty=None) 2896 jwt_payload = model.jwt_payload or JwtPayloadModel(iss=None, sub=None, aud=None) 2897 request_option = ( 2898 self._create_component_from_model(model.request_option, config) 2899 if model.request_option 2900 else None 2901 ) 2902 return JwtAuthenticator( 2903 config=config, 2904 parameters=model.parameters or {}, 2905 algorithm=JwtAlgorithm(model.algorithm.value), 2906 secret_key=model.secret_key, 2907 base64_encode_secret_key=model.base64_encode_secret_key, 2908 token_duration=model.token_duration, 2909 header_prefix=model.header_prefix, 2910 kid=jwt_headers.kid, 2911 typ=jwt_headers.typ, 2912 cty=jwt_headers.cty, 2913 iss=jwt_payload.iss, 2914 sub=jwt_payload.sub, 2915 aud=jwt_payload.aud, 2916 additional_jwt_headers=model.additional_jwt_headers, 2917 additional_jwt_payload=model.additional_jwt_payload, 2918 passphrase=model.passphrase, 2919 request_option=request_option, 2920 ) 2921 2922 def create_list_partition_router( 2923 self, model: ListPartitionRouterModel, config: Config, **kwargs: Any 2924 ) -> ListPartitionRouter: 2925 request_option = ( 2926 self._create_component_from_model(model.request_option, config) 2927 if model.request_option 2928 else None 2929 ) 2930 return ListPartitionRouter( 2931 cursor_field=model.cursor_field, 2932 request_option=request_option, 2933 values=model.values, 2934 config=config, 2935 parameters=model.parameters or {}, 2936 ) 2937 2938 @staticmethod 2939 def create_min_max_datetime( 2940 model: MinMaxDatetimeModel, config: Config, **kwargs: Any 2941 ) -> MinMaxDatetime: 2942 return MinMaxDatetime( 2943 datetime=model.datetime, 2944 datetime_format=model.datetime_format or "", 2945 max_datetime=model.max_datetime or "", 2946 min_datetime=model.min_datetime or "", 2947 parameters=model.parameters or {}, 2948 ) 2949 2950 @staticmethod 2951 def create_no_auth(model: NoAuthModel, config: Config, **kwargs: Any) -> NoAuth: 2952 return NoAuth(parameters=model.parameters or {}) 2953 2954 @staticmethod 2955 def create_no_pagination( 2956 model: NoPaginationModel, config: Config, **kwargs: Any 2957 ) -> NoPagination: 2958 return NoPagination(parameters={}) 2959 2960 def create_oauth_authenticator( 2961 self, model: OAuthAuthenticatorModel, config: Config, **kwargs: Any 2962 ) -> DeclarativeOauth2Authenticator: 2963 profile_assertion = ( 2964 self._create_component_from_model(model.profile_assertion, config=config) 2965 if model.profile_assertion 2966 else None 2967 ) 2968 2969 refresh_token_error_status_codes, refresh_token_error_key, refresh_token_error_values = ( 2970 self._get_refresh_token_error_information(model) 2971 ) 2972 if model.refresh_token_updater: 2973 # ignore type error because fixing it would have a lot of dependencies, revisit later 2974 return DeclarativeSingleUseRefreshTokenOauth2Authenticator( # type: ignore 2975 config, 2976 InterpolatedString.create( 2977 model.token_refresh_endpoint, # type: ignore 2978 parameters=model.parameters or {}, 2979 ).eval(config), 2980 access_token_name=InterpolatedString.create( 2981 model.access_token_name or "access_token", parameters=model.parameters or {} 2982 ).eval(config), 2983 refresh_token_name=model.refresh_token_updater.refresh_token_name, 2984 expires_in_name=InterpolatedString.create( 2985 model.expires_in_name or "expires_in", parameters=model.parameters or {} 2986 ).eval(config), 2987 client_id_name=InterpolatedString.create( 2988 model.client_id_name or "client_id", parameters=model.parameters or {} 2989 ).eval(config), 2990 client_id=InterpolatedString.create( 2991 model.client_id, parameters=model.parameters or {} 2992 ).eval(config) 2993 if model.client_id 2994 else model.client_id, 2995 client_secret_name=InterpolatedString.create( 2996 model.client_secret_name or "client_secret", parameters=model.parameters or {} 2997 ).eval(config), 2998 client_secret=InterpolatedString.create( 2999 model.client_secret, parameters=model.parameters or {} 3000 ).eval(config) 3001 if model.client_secret 3002 else model.client_secret, 3003 access_token_config_path=model.refresh_token_updater.access_token_config_path, 3004 refresh_token_config_path=model.refresh_token_updater.refresh_token_config_path, 3005 token_expiry_date_config_path=model.refresh_token_updater.token_expiry_date_config_path, 3006 grant_type_name=InterpolatedString.create( 3007 model.grant_type_name or "grant_type", parameters=model.parameters or {} 3008 ).eval(config), 3009 grant_type=InterpolatedString.create( 3010 model.grant_type or "refresh_token", parameters=model.parameters or {} 3011 ).eval(config), 3012 refresh_request_body=InterpolatedMapping( 3013 model.refresh_request_body or {}, parameters=model.parameters or {} 3014 ).eval(config), 3015 refresh_request_headers=InterpolatedMapping( 3016 model.refresh_request_headers or {}, parameters=model.parameters or {} 3017 ).eval(config), 3018 send_refresh_request_as_query_params=bool( 3019 model.send_refresh_request_as_query_params 3020 ), 3021 scopes=model.scopes, 3022 token_expiry_date_format=model.token_expiry_date_format, 3023 token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), 3024 message_repository=self._message_repository, 3025 refresh_token_error_status_codes=refresh_token_error_status_codes, 3026 refresh_token_error_key=refresh_token_error_key, 3027 refresh_token_error_values=refresh_token_error_values, 3028 ) 3029 # ignore type error because fixing it would have a lot of dependencies, revisit later 3030 return DeclarativeOauth2Authenticator( # type: ignore 3031 access_token_name=model.access_token_name or "access_token", 3032 access_token_value=model.access_token_value, 3033 client_id_name=model.client_id_name or "client_id", 3034 client_id=model.client_id, 3035 client_secret_name=model.client_secret_name or "client_secret", 3036 client_secret=model.client_secret, 3037 expires_in_name=model.expires_in_name or "expires_in", 3038 grant_type_name=model.grant_type_name or "grant_type", 3039 grant_type=model.grant_type or "refresh_token", 3040 refresh_request_body=model.refresh_request_body, 3041 refresh_request_headers=model.refresh_request_headers, 3042 send_refresh_request_as_query_params=bool(model.send_refresh_request_as_query_params), 3043 refresh_token_name=model.refresh_token_name or "refresh_token", 3044 refresh_token=model.refresh_token, 3045 scopes=model.scopes, 3046 token_expiry_date=model.token_expiry_date, 3047 token_expiry_date_format=model.token_expiry_date_format, 3048 token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), 3049 token_refresh_endpoint=model.token_refresh_endpoint, 3050 config=config, 3051 parameters=model.parameters or {}, 3052 message_repository=self._message_repository, 3053 profile_assertion=profile_assertion, 3054 use_profile_assertion=model.use_profile_assertion, 3055 refresh_token_error_status_codes=refresh_token_error_status_codes, 3056 refresh_token_error_key=refresh_token_error_key, 3057 refresh_token_error_values=refresh_token_error_values, 3058 ) 3059 3060 @staticmethod 3061 def _get_refresh_token_error_information( 3062 model: OAuthAuthenticatorModel, 3063 ) -> Tuple[Tuple[int, ...], str, Tuple[str, ...]]: 3064 """ 3065 In a previous version of the CDK, the auth error as config_error was only done if a refresh token updater was 3066 defined. As a transition, we added those fields on the OAuthAuthenticatorModel. This method ensures that the 3067 information is defined only once and return the right fields. 3068 """ 3069 refresh_token_updater = model.refresh_token_updater 3070 is_defined_on_refresh_token_updated = refresh_token_updater and ( 3071 refresh_token_updater.refresh_token_error_status_codes 3072 or refresh_token_updater.refresh_token_error_key 3073 or refresh_token_updater.refresh_token_error_values 3074 ) 3075 is_defined_on_oauth_authenticator = ( 3076 model.refresh_token_error_status_codes 3077 or model.refresh_token_error_key 3078 or model.refresh_token_error_values 3079 ) 3080 if is_defined_on_refresh_token_updated and is_defined_on_oauth_authenticator: 3081 raise ValueError( 3082 "refresh_token_error should either be defined on the OAuthAuthenticatorModel or the RefreshTokenUpdaterModel, not both" 3083 ) 3084 3085 if is_defined_on_refresh_token_updated: 3086 not_optional_refresh_token_updater: RefreshTokenUpdaterModel = refresh_token_updater # type: ignore # we know from the condition that this is not None 3087 return ( 3088 tuple(not_optional_refresh_token_updater.refresh_token_error_status_codes) 3089 if not_optional_refresh_token_updater.refresh_token_error_status_codes 3090 else (), 3091 not_optional_refresh_token_updater.refresh_token_error_key or "", 3092 tuple(not_optional_refresh_token_updater.refresh_token_error_values) 3093 if not_optional_refresh_token_updater.refresh_token_error_values 3094 else (), 3095 ) 3096 elif is_defined_on_oauth_authenticator: 3097 return ( 3098 tuple(model.refresh_token_error_status_codes) 3099 if model.refresh_token_error_status_codes 3100 else (), 3101 model.refresh_token_error_key or "", 3102 tuple(model.refresh_token_error_values) if model.refresh_token_error_values else (), 3103 ) 3104 3105 # returning default values we think cover most cases 3106 return (400,), "error", ("invalid_grant", "invalid_permissions") 3107 3108 def create_offset_increment( 3109 self, 3110 model: OffsetIncrementModel, 3111 config: Config, 3112 decoder: Decoder, 3113 extractor_model: Optional[Union[CustomRecordExtractorModel, DpathExtractorModel]] = None, 3114 **kwargs: Any, 3115 ) -> OffsetIncrement: 3116 if isinstance(decoder, PaginationDecoderDecorator): 3117 inner_decoder = decoder.decoder 3118 else: 3119 inner_decoder = decoder 3120 decoder = PaginationDecoderDecorator(decoder=decoder) 3121 3122 if self._is_supported_decoder_for_pagination(inner_decoder): 3123 decoder_to_use = decoder 3124 else: 3125 raise ValueError( 3126 self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(inner_decoder)) 3127 ) 3128 3129 # Ideally we would instantiate the runtime extractor from highest most level (in this case the SimpleRetriever) 3130 # so that it can be shared by OffSetIncrement and RecordSelector. However, due to how we instantiate the 3131 # decoder with various decorators here, but not in create_record_selector, it is simpler to retain existing 3132 # behavior by having two separate extractors with identical behavior since they use the same extractor model. 3133 # When we have more time to investigate we can look into reusing the same component. 3134 extractor = ( 3135 self._create_component_from_model( 3136 model=extractor_model, config=config, decoder=decoder_to_use 3137 ) 3138 if extractor_model 3139 else None 3140 ) 3141 3142 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 3143 # If page_size is a string that represents an integer (not an interpolation), convert it back. 3144 page_size = model.page_size 3145 if isinstance(page_size, str) and page_size.isdigit(): 3146 page_size = int(page_size) 3147 3148 return OffsetIncrement( 3149 page_size=page_size, 3150 config=config, 3151 decoder=decoder_to_use, 3152 extractor=extractor, 3153 inject_on_first_request=model.inject_on_first_request or False, 3154 parameters=model.parameters or {}, 3155 ) 3156 3157 def create_page_increment( 3158 self, 3159 model: PageIncrementModel, 3160 config: Config, 3161 decoder: Optional[Decoder] = None, 3162 extractor_model: Optional[Union[CustomRecordExtractorModel, DpathExtractorModel]] = None, 3163 **kwargs: Any, 3164 ) -> PageIncrement: 3165 # Like OffsetIncrement, we instantiate a separate extractor with identical behavior to the 3166 # RecordSelector's so the strategy can count the raw records in the response. This ensures 3167 # pagination is driven by the API's page size, not the post-filter record count. 3168 extractor = ( 3169 self._create_component_from_model(model=extractor_model, config=config, decoder=decoder) 3170 if extractor_model 3171 else None 3172 ) 3173 3174 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 3175 # If page_size is a string that represents an integer (not an interpolation), convert it back. 3176 page_size = model.page_size 3177 if isinstance(page_size, str) and page_size.isdigit(): 3178 page_size = int(page_size) 3179 3180 return PageIncrement( 3181 page_size=page_size, 3182 config=config, 3183 start_from_page=model.start_from_page or 0, 3184 inject_on_first_request=model.inject_on_first_request or False, 3185 extractor=extractor, 3186 parameters=model.parameters or {}, 3187 ) 3188 3189 def create_parent_stream_config( 3190 self, model: ParentStreamConfigModel, config: Config, *, stream_name: str, **kwargs: Any 3191 ) -> ParentStreamConfig: 3192 declarative_stream = self._create_component_from_model( 3193 model.stream, 3194 config=config, 3195 is_parent=True, 3196 **kwargs, 3197 ) 3198 request_option = ( 3199 self._create_component_from_model(model.request_option, config=config) 3200 if model.request_option 3201 else None 3202 ) 3203 3204 if model.lazy_read_pointer and any("*" in pointer for pointer in model.lazy_read_pointer): 3205 raise ValueError( 3206 "The '*' wildcard in 'lazy_read_pointer' is not supported — only direct paths are allowed." 3207 ) 3208 3209 model_lazy_read_pointer: List[Union[InterpolatedString, str]] = ( 3210 [x for x in model.lazy_read_pointer] if model.lazy_read_pointer else [] 3211 ) 3212 3213 return ParentStreamConfig( 3214 parent_key=model.parent_key, 3215 request_option=request_option, 3216 stream=declarative_stream, 3217 partition_field=model.partition_field, 3218 config=config, 3219 incremental_dependency=model.incremental_dependency or False, 3220 parameters=model.parameters or {}, 3221 extra_fields=model.extra_fields, 3222 lazy_read_pointer=model_lazy_read_pointer, 3223 ) 3224 3225 def create_properties_from_endpoint( 3226 self, model: PropertiesFromEndpointModel, config: Config, **kwargs: Any 3227 ) -> PropertiesFromEndpoint: 3228 retriever = self._create_component_from_model( 3229 model=model.retriever, 3230 config=config, 3231 name="dynamic_properties", 3232 primary_key=None, 3233 stream_slicer=None, 3234 transformations=[], 3235 use_cache=True, # Enable caching on the HttpRequester/HttpClient because the properties endpoint will be called for every slice being processed, and it is highly unlikely for the response to different 3236 ) 3237 return PropertiesFromEndpoint( 3238 property_field_path=model.property_field_path, 3239 retriever=retriever, 3240 config=config, 3241 parameters=model.parameters or {}, 3242 ) 3243 3244 def create_property_chunking( 3245 self, model: PropertyChunkingModel, config: Config, **kwargs: Any 3246 ) -> PropertyChunking: 3247 record_merge_strategy = ( 3248 self._create_component_from_model( 3249 model=model.record_merge_strategy, config=config, **kwargs 3250 ) 3251 if model.record_merge_strategy 3252 else None 3253 ) 3254 3255 property_limit_type: PropertyLimitType 3256 match model.property_limit_type: 3257 case PropertyLimitTypeModel.property_count: 3258 property_limit_type = PropertyLimitType.property_count 3259 case PropertyLimitTypeModel.characters: 3260 property_limit_type = PropertyLimitType.characters 3261 case _: 3262 raise ValueError(f"Invalid PropertyLimitType {property_limit_type}") 3263 3264 return PropertyChunking( 3265 property_limit_type=property_limit_type, 3266 property_limit=model.property_limit, 3267 record_merge_strategy=record_merge_strategy, 3268 config=config, 3269 parameters=model.parameters or {}, 3270 ) 3271 3272 def create_query_properties( 3273 self, model: QueryPropertiesModel, config: Config, *, stream_name: str, **kwargs: Any 3274 ) -> QueryProperties: 3275 if isinstance(model.property_list, list): 3276 property_list = model.property_list 3277 else: 3278 property_list = self._create_component_from_model( 3279 model=model.property_list, config=config, **kwargs 3280 ) 3281 3282 property_chunking = ( 3283 self._create_component_from_model( 3284 model=model.property_chunking, config=config, **kwargs 3285 ) 3286 if model.property_chunking 3287 else None 3288 ) 3289 3290 property_selector = ( 3291 self._create_component_from_model( 3292 model=model.property_selector, config=config, stream_name=stream_name, **kwargs 3293 ) 3294 if model.property_selector 3295 else None 3296 ) 3297 3298 return QueryProperties( 3299 property_list=property_list, 3300 always_include_properties=model.always_include_properties, 3301 property_chunking=property_chunking, 3302 property_selector=property_selector, 3303 config=config, 3304 parameters=model.parameters or {}, 3305 ) 3306 3307 def create_json_schema_property_selector( 3308 self, 3309 model: JsonSchemaPropertySelectorModel, 3310 config: Config, 3311 *, 3312 stream_name: str, 3313 **kwargs: Any, 3314 ) -> JsonSchemaPropertySelector: 3315 configured_stream = self._stream_name_to_configured_stream.get(stream_name) 3316 3317 transformations = [] 3318 if model.transformations: 3319 for transformation_model in model.transformations: 3320 transformations.append( 3321 self._create_component_from_model(model=transformation_model, config=config) 3322 ) 3323 3324 return JsonSchemaPropertySelector( 3325 configured_stream=configured_stream, 3326 properties_transformations=transformations, 3327 config=config, 3328 parameters=model.parameters or {}, 3329 ) 3330 3331 @staticmethod 3332 def create_record_filter( 3333 model: RecordFilterModel, config: Config, **kwargs: Any 3334 ) -> RecordFilter: 3335 return RecordFilter( 3336 condition=model.condition or "", config=config, parameters=model.parameters or {} 3337 ) 3338 3339 @staticmethod 3340 def create_request_path(model: RequestPathModel, config: Config, **kwargs: Any) -> RequestPath: 3341 return RequestPath(parameters={}) 3342 3343 @staticmethod 3344 def create_request_option( 3345 model: RequestOptionModel, config: Config, **kwargs: Any 3346 ) -> RequestOption: 3347 inject_into = RequestOptionType(model.inject_into.value) 3348 field_path: Optional[List[Union[InterpolatedString, str]]] = ( 3349 [ 3350 InterpolatedString.create(segment, parameters=kwargs.get("parameters", {})) 3351 for segment in model.field_path 3352 ] 3353 if model.field_path 3354 else None 3355 ) 3356 field_name = ( 3357 InterpolatedString.create(model.field_name, parameters=kwargs.get("parameters", {})) 3358 if model.field_name 3359 else None 3360 ) 3361 return RequestOption( 3362 field_name=field_name, 3363 field_path=field_path, 3364 inject_into=inject_into, 3365 parameters=kwargs.get("parameters", {}), 3366 ) 3367 3368 def create_record_selector( 3369 self, 3370 model: RecordSelectorModel, 3371 config: Config, 3372 *, 3373 name: str, 3374 transformations: List[RecordTransformation] | None = None, 3375 decoder: Decoder | None = None, 3376 client_side_incremental_sync_cursor: Optional[Cursor] = None, 3377 is_client_side_incremental_sync: bool = False, 3378 file_uploader: Optional[DefaultFileUploader] = None, 3379 **kwargs: Any, 3380 ) -> RecordSelector: 3381 extractor = self._create_component_from_model( 3382 model=model.extractor, decoder=decoder, config=config 3383 ) 3384 record_filter = ( 3385 self._create_component_from_model(model.record_filter, config=config) 3386 if model.record_filter 3387 else None 3388 ) 3389 3390 # A client-side incremental stream transforms before filtering by default. That default belongs to the flag, 3391 # not to the component that ends up doing the cursor comparison: a data feed does it in the retriever and 3392 # receives no cursor here, but its `record_filter` condition must keep running after the transformations. 3393 default_transform_before_filtering = bool( 3394 client_side_incremental_sync_cursor or is_client_side_incremental_sync 3395 ) 3396 transform_before_filtering = ( 3397 default_transform_before_filtering 3398 if model.transform_before_filtering is None 3399 else model.transform_before_filtering 3400 ) 3401 if client_side_incremental_sync_cursor: 3402 record_filter = ClientSideIncrementalRecordFilterDecorator( 3403 config=config, 3404 parameters=model.parameters, 3405 condition=model.record_filter.condition 3406 if (model.record_filter and hasattr(model.record_filter, "condition")) 3407 else None, 3408 cursor=client_side_incremental_sync_cursor, 3409 ) 3410 3411 if model.schema_normalization is None: 3412 # default to no schema normalization if not set 3413 model.schema_normalization = SchemaNormalizationModel.None_ 3414 3415 schema_normalization = ( 3416 TypeTransformer(SCHEMA_TRANSFORMER_TYPE_MAPPING[model.schema_normalization]) 3417 if isinstance(model.schema_normalization, SchemaNormalizationModel) 3418 else self._create_component_from_model(model.schema_normalization, config=config) # type: ignore[arg-type] # custom normalization model expected here 3419 ) 3420 3421 return RecordSelector( 3422 extractor=extractor, 3423 name=name, 3424 config=config, 3425 record_filter=record_filter, 3426 transformations=transformations or [], 3427 file_uploader=file_uploader, 3428 schema_normalization=schema_normalization, 3429 parameters=model.parameters or {}, 3430 transform_before_filtering=transform_before_filtering, 3431 ) 3432 3433 @staticmethod 3434 def create_remove_fields( 3435 model: RemoveFieldsModel, config: Config, **kwargs: Any 3436 ) -> RemoveFields: 3437 return RemoveFields( 3438 field_pointers=model.field_pointers, condition=model.condition or "", parameters={} 3439 ) 3440 3441 def create_selective_authenticator( 3442 self, model: SelectiveAuthenticatorModel, config: Config, **kwargs: Any 3443 ) -> DeclarativeAuthenticator: 3444 authenticators = { 3445 name: self._create_component_from_model(model=auth, config=config) 3446 for name, auth in model.authenticators.items() 3447 } 3448 # SelectiveAuthenticator will return instance of DeclarativeAuthenticator or raise ValueError error 3449 return SelectiveAuthenticator( # type: ignore[abstract] 3450 config=config, 3451 authenticators=authenticators, 3452 authenticator_selection_path=model.authenticator_selection_path, 3453 **kwargs, 3454 ) 3455 3456 @staticmethod 3457 def create_legacy_session_token_authenticator( 3458 model: LegacySessionTokenAuthenticatorModel, config: Config, *, url_base: str, **kwargs: Any 3459 ) -> LegacySessionTokenAuthenticator: 3460 return LegacySessionTokenAuthenticator( 3461 api_url=url_base, 3462 header=model.header, 3463 login_url=model.login_url, 3464 password=model.password or "", 3465 session_token=model.session_token or "", 3466 session_token_response_key=model.session_token_response_key or "", 3467 username=model.username or "", 3468 validate_session_url=model.validate_session_url, 3469 config=config, 3470 parameters=model.parameters or {}, 3471 ) 3472 3473 def create_simple_retriever( 3474 self, 3475 model: SimpleRetrieverModel, 3476 config: Config, 3477 *, 3478 name: str, 3479 primary_key: Optional[Union[str, List[str], List[List[str]]]], 3480 request_options_provider: Optional[RequestOptionsProvider] = None, 3481 cursor: Optional[Cursor] = None, 3482 has_stop_condition_cursor: bool = False, 3483 is_client_side_incremental_sync: bool = False, 3484 transformations: List[RecordTransformation], 3485 file_uploader: Optional[DefaultFileUploader] = None, 3486 incremental_sync: Optional[ 3487 Union[IncrementingCountCursorModel, DatetimeBasedCursorModel] 3488 ] = None, 3489 use_cache: Optional[bool] = None, 3490 log_formatter: Optional[Callable[[Response], Any]] = None, 3491 partition_router: Optional[PartitionRouter] = None, 3492 **kwargs: Any, 3493 ) -> SimpleRetriever: 3494 def _get_url(req: Requester) -> str: 3495 """ 3496 Closure to get the URL from the requester. This is used to get the URL in the case of a lazy retriever. 3497 This is needed because the URL is not set until the requester is created. 3498 """ 3499 3500 _url: str = ( 3501 model.requester.url 3502 if hasattr(model.requester, "url") and model.requester.url is not None 3503 else req.get_url(stream_state=None, stream_slice=None, next_page_token=None) 3504 ) 3505 _url_base: str = ( 3506 model.requester.url_base 3507 if hasattr(model.requester, "url_base") and model.requester.url_base is not None 3508 else req.get_url_base(stream_state=None, stream_slice=None, next_page_token=None) 3509 ) 3510 3511 return _url or _url_base 3512 3513 if cursor is None: 3514 cursor = FinalStateCursor(name, None, self._message_repository) 3515 3516 # A data feed drops the records the cursor considers already synced in the retriever, which sits downstream of 3517 # the paginator. Letting the record selector drop them as well would be redundant and would hide them from the 3518 # pagination stop condition, so a data feed never delegates that filtering to the record selector, whether 3519 # `is_client_side_incremental` is set or not. The `condition` from `record_filter` is intentionally left out of 3520 # the post-pagination filter and stays in the record selector, which preserves the existing behaviour: the 3521 # selector runs inside the page loop, so the records the condition rejects never reach the paginator's 3522 # accounting. Moving it downstream would start counting them. 3523 post_pagination_filter = ( 3524 ClientSideIncrementalRecordFilterDecorator( 3525 config=config, 3526 parameters=model.parameters or {}, 3527 condition=None, 3528 cursor=cursor, 3529 ) 3530 if has_stop_condition_cursor 3531 else None 3532 ) 3533 client_side_incremental_cursor = ( 3534 cursor if is_client_side_incremental_sync and not post_pagination_filter else None 3535 ) 3536 if post_pagination_filter and is_client_side_incremental_sync: 3537 LOGGER.warning( 3538 f"Stream {name}: `is_client_side_incremental` adds no record filtering when `is_data_feed` is set, " 3539 "as a data feed already filters on the cursor value. It still makes the record selector apply the " 3540 "transformations before the `record_filter` condition." 3541 ) 3542 3543 decoder = ( 3544 self._create_component_from_model(model=model.decoder, config=config) 3545 if model.decoder 3546 else JsonDecoder(parameters={}) 3547 ) 3548 record_selector = self._create_component_from_model( 3549 model=model.record_selector, 3550 name=name, 3551 config=config, 3552 decoder=decoder, 3553 transformations=transformations, 3554 client_side_incremental_sync_cursor=client_side_incremental_cursor, 3555 is_client_side_incremental_sync=is_client_side_incremental_sync, 3556 file_uploader=file_uploader, 3557 ) 3558 3559 query_properties: Optional[QueryProperties] = None 3560 query_properties_key: Optional[str] = None 3561 self._ensure_query_properties_to_model(model.requester) 3562 if self._has_query_properties_in_request_parameters(model.requester): 3563 # It is better to be explicit about an error if PropertiesFromEndpoint is defined in multiple 3564 # places instead of default to request_parameters which isn't clearly documented 3565 if ( 3566 hasattr(model.requester, "fetch_properties_from_endpoint") 3567 and model.requester.fetch_properties_from_endpoint 3568 ): 3569 raise ValueError( 3570 f"PropertiesFromEndpoint should only be specified once per stream, but found in {model.requester.type}.fetch_properties_from_endpoint and {model.requester.type}.request_parameters" 3571 ) 3572 3573 query_properties_definitions = [] 3574 for key, request_parameter in model.requester.request_parameters.items(): # type: ignore # request_parameters is already validated to be a Mapping using _has_query_properties_in_request_parameters() 3575 if isinstance(request_parameter, QueryPropertiesModel): 3576 query_properties_key = key 3577 query_properties_definitions.append(request_parameter) 3578 3579 if len(query_properties_definitions) > 1: 3580 raise ValueError( 3581 f"request_parameters only supports defining one QueryProperties field, but found {len(query_properties_definitions)} usages" 3582 ) 3583 3584 if len(query_properties_definitions) == 1: 3585 query_properties = self._create_component_from_model( 3586 model=query_properties_definitions[0], stream_name=name, config=config 3587 ) 3588 3589 # Removes QueryProperties components from the interpolated mappings because it has been designed 3590 # to be used by the SimpleRetriever and will be resolved from the provider from the slice directly 3591 # instead of through jinja interpolation 3592 if hasattr(model.requester, "request_parameters") and isinstance( 3593 model.requester.request_parameters, Mapping 3594 ): 3595 model.requester.request_parameters = self._remove_query_properties( 3596 model.requester.request_parameters 3597 ) 3598 elif ( 3599 hasattr(model.requester, "fetch_properties_from_endpoint") 3600 and model.requester.fetch_properties_from_endpoint 3601 ): 3602 # todo: Deprecate this condition once dependent connectors migrate to query_properties 3603 query_properties_definition = QueryPropertiesModel( 3604 type="QueryProperties", 3605 property_list=model.requester.fetch_properties_from_endpoint, 3606 always_include_properties=None, 3607 property_chunking=None, 3608 ) # type: ignore # $parameters has a default value 3609 3610 query_properties = self.create_query_properties( 3611 model=query_properties_definition, 3612 stream_name=name, 3613 config=config, 3614 ) 3615 elif hasattr(model.requester, "query_properties") and model.requester.query_properties: 3616 query_properties = self.create_query_properties( 3617 model=model.requester.query_properties, 3618 stream_name=name, 3619 config=config, 3620 ) 3621 3622 requester = self._create_component_from_model( 3623 model=model.requester, 3624 decoder=decoder, 3625 name=name, 3626 query_properties_key=query_properties_key, 3627 use_cache=use_cache, 3628 config=config, 3629 ) 3630 3631 if not request_options_provider: 3632 request_options_provider = DefaultRequestOptionsProvider(parameters={}) 3633 if isinstance(request_options_provider, DefaultRequestOptionsProvider) and isinstance( 3634 partition_router, PartitionRouter 3635 ): 3636 request_options_provider = partition_router 3637 3638 paginator = ( 3639 self._create_component_from_model( 3640 model=model.paginator, 3641 config=config, 3642 url_base=_get_url(requester), 3643 extractor_model=model.record_selector.extractor, 3644 decoder=decoder, 3645 cursor_used_for_stop_condition=cursor if has_stop_condition_cursor else None, 3646 ) 3647 if model.paginator 3648 else NoPagination(parameters={}) 3649 ) 3650 3651 ignore_stream_slicer_parameters_on_paginated_requests = ( 3652 model.ignore_stream_slicer_parameters_on_paginated_requests or False 3653 ) 3654 3655 if ( 3656 model.partition_router 3657 and isinstance(model.partition_router, SubstreamPartitionRouterModel) 3658 and not bool(self._connector_state_manager.get_stream_state(name, None)) 3659 and any( 3660 parent_stream_config.lazy_read_pointer 3661 for parent_stream_config in model.partition_router.parent_stream_configs 3662 ) 3663 ): 3664 if incremental_sync: 3665 if incremental_sync.type != "DatetimeBasedCursor": 3666 raise ValueError( 3667 f"LazySimpleRetriever only supports DatetimeBasedCursor. Found: {incremental_sync.type}." 3668 ) 3669 3670 elif incremental_sync.step or incremental_sync.cursor_granularity: 3671 raise ValueError( 3672 f"Found more that one slice per parent. LazySimpleRetriever only supports single slice read for stream - {name}." 3673 ) 3674 3675 if model.decoder and model.decoder.type != "JsonDecoder": 3676 raise ValueError( 3677 f"LazySimpleRetriever only supports JsonDecoder. Found: {model.decoder.type}." 3678 ) 3679 3680 return LazySimpleRetriever( 3681 name=name, 3682 paginator=paginator, 3683 primary_key=primary_key, 3684 requester=requester, 3685 record_selector=record_selector, 3686 stream_slicer=_NO_STREAM_SLICING, 3687 request_option_provider=request_options_provider, 3688 config=config, 3689 ignore_stream_slicer_parameters_on_paginated_requests=ignore_stream_slicer_parameters_on_paginated_requests, 3690 post_pagination_filter=post_pagination_filter, 3691 parameters=model.parameters or {}, 3692 ) 3693 3694 if ( 3695 model.record_selector.record_filter 3696 and model.pagination_reset 3697 and model.pagination_reset.limits 3698 ): 3699 raise ValueError("PaginationResetLimits are not supported while having record filter.") 3700 3701 return SimpleRetriever( 3702 name=name, 3703 paginator=paginator, 3704 primary_key=primary_key, 3705 requester=requester, 3706 record_selector=record_selector, 3707 stream_slicer=_NO_STREAM_SLICING, 3708 request_option_provider=request_options_provider, 3709 config=config, 3710 ignore_stream_slicer_parameters_on_paginated_requests=ignore_stream_slicer_parameters_on_paginated_requests, 3711 additional_query_properties=query_properties, 3712 log_formatter=self._get_log_formatter(log_formatter, name), 3713 pagination_tracker_factory=self._create_pagination_tracker_factory( 3714 model.pagination_reset, cursor 3715 ), 3716 post_pagination_filter=post_pagination_filter, 3717 parameters=model.parameters or {}, 3718 ) 3719 3720 def _create_pagination_tracker_factory( 3721 self, model: Optional[PaginationResetModel], cursor: Cursor 3722 ) -> Callable[[], PaginationTracker]: 3723 if model is None: 3724 return lambda: PaginationTracker() 3725 3726 # Until we figure out a way to use any cursor for PaginationTracker, we will have to have this cursor selector logic 3727 cursor_factory: Callable[[], Optional[ConcurrentCursor]] = lambda: None 3728 if model.action == PaginationResetActionModel.RESET: 3729 # in that case, we will let cursor_factory to return None even if the stream has a cursor 3730 pass 3731 elif model.action == PaginationResetActionModel.SPLIT_USING_CURSOR: 3732 if isinstance(cursor, ConcurrentCursor): 3733 cursor_factory = lambda: cursor.copy_without_state() # type: ignore # the if condition validates that it is a ConcurrentCursor 3734 elif isinstance(cursor, ConcurrentPerPartitionCursor): 3735 cursor_factory = lambda: cursor._cursor_factory.create( # type: ignore # if this becomes a problem, we would need to extract the cursor_factory instantiation logic and make it accessible here 3736 {}, datetime.timedelta(0) 3737 ) 3738 elif not isinstance(cursor, FinalStateCursor): 3739 LOGGER.warning( 3740 "Unknown cursor for PaginationTracker. Pagination resets might not work properly" 3741 ) 3742 else: 3743 raise ValueError(f"Unknown PaginationReset action: {model.action}") 3744 3745 limit = model.limits.number_of_records if model and model.limits else None 3746 return lambda: PaginationTracker(cursor_factory(), limit) 3747 3748 def _get_log_formatter( 3749 self, log_formatter: Callable[[Response], Any] | None, name: str 3750 ) -> Callable[[Response], Any] | None: 3751 if self._should_limit_slices_fetched(): 3752 return ( 3753 ( 3754 lambda response: format_http_message( 3755 response, 3756 f"Stream '{name}' request", 3757 f"Request performed in order to extract records for stream '{name}'", 3758 name, 3759 ) 3760 ) 3761 if not log_formatter 3762 else log_formatter 3763 ) 3764 return None 3765 3766 def _should_limit_slices_fetched(self) -> bool: 3767 """ 3768 Returns True if the number of slices fetched should be limited, False otherwise. 3769 This is used to limit the number of slices fetched during tests. 3770 """ 3771 return bool(self._limit_slices_fetched or self._emit_connector_builder_messages) 3772 3773 @staticmethod 3774 def _has_query_properties_in_request_parameters( 3775 requester: Union[HttpRequesterModel, CustomRequesterModel], 3776 ) -> bool: 3777 if not hasattr(requester, "request_parameters"): 3778 return False 3779 request_parameters = requester.request_parameters 3780 if request_parameters and isinstance(request_parameters, Mapping): 3781 for request_parameter in request_parameters.values(): 3782 if isinstance(request_parameter, QueryPropertiesModel): 3783 return True 3784 return False 3785 3786 @staticmethod 3787 def _remove_query_properties( 3788 request_parameters: Mapping[str, Union[str, QueryPropertiesModel]], 3789 ) -> Mapping[str, str]: 3790 return { 3791 parameter_field: request_parameter 3792 for parameter_field, request_parameter in request_parameters.items() 3793 if not isinstance(request_parameter, QueryPropertiesModel) 3794 } 3795 3796 def create_state_delegating_stream( 3797 self, 3798 model: StateDelegatingStreamModel, 3799 config: Config, 3800 **kwargs: Any, 3801 ) -> DefaultStream: 3802 if ( 3803 model.full_refresh_stream.name != model.name 3804 or model.name != model.incremental_stream.name 3805 ): 3806 raise ValueError( 3807 f"state_delegating_stream, full_refresh_stream name and incremental_stream must have equal names. Instead has {model.name}, {model.full_refresh_stream.name} and {model.incremental_stream.name}." 3808 ) 3809 3810 # Resolve api_retention_period with config context (supports Jinja2 interpolation) 3811 resolved_retention_period: Optional[str] = None 3812 if model.api_retention_period: 3813 interpolated_retention = InterpolatedString.create( 3814 model.api_retention_period, parameters=model.parameters or {} 3815 ) 3816 resolved_value = interpolated_retention.eval(config=config) 3817 if resolved_value: 3818 resolved_retention_period = str(resolved_value) 3819 3820 if resolved_retention_period: 3821 for stream_model in (model.full_refresh_stream, model.incremental_stream): 3822 if isinstance(stream_model.incremental_sync, IncrementingCountCursorModel): 3823 raise ValueError( 3824 f"Stream '{model.name}' uses IncrementingCountCursor which is not supported " 3825 f"with api_retention_period. IncrementingCountCursor does not use datetime-based " 3826 f"cursors, so cursor age validation cannot be performed." 3827 ) 3828 3829 stream_state = self._connector_state_manager.get_stream_state(model.name, None) 3830 3831 if not stream_state: 3832 return self._create_component_from_model( # type: ignore[no-any-return] 3833 model.full_refresh_stream, config=config, **kwargs 3834 ) 3835 3836 incremental_stream: DefaultStream = self._create_component_from_model( 3837 model.incremental_stream, config=config, **kwargs 3838 ) # type: ignore[assignment] 3839 3840 # Only run cursor age validation for streams that are in the configured 3841 # catalog (or when no catalog was provided, e.g. during discover / connector 3842 # builder). Streams not selected by the user but instantiated as parent-stream 3843 # dependencies must not go through this path because it emits state messages 3844 # that the destination does not know about, causing "Stream not found" crashes. 3845 stream_is_in_catalog = ( 3846 not self._stream_name_to_configured_stream # no catalog → validate by default 3847 or model.name in self._stream_name_to_configured_stream 3848 ) 3849 if resolved_retention_period and stream_is_in_catalog: 3850 full_refresh_stream: DefaultStream = self._create_component_from_model( 3851 model.full_refresh_stream, config=config, **kwargs 3852 ) # type: ignore[assignment] 3853 if self._is_cursor_older_than_retention_period( 3854 stream_state, 3855 full_refresh_stream.cursor, 3856 incremental_stream.cursor, 3857 resolved_retention_period, 3858 model.name, 3859 ): 3860 # Clear state BEFORE constructing the full_refresh_stream so that 3861 # its cursor starts from start_date instead of the stale cursor. 3862 self._connector_state_manager.update_state_for_stream(model.name, None, {}) 3863 state_message = self._connector_state_manager.create_state_message(model.name, None) 3864 self._message_repository.emit_message(state_message) 3865 return self._create_component_from_model( # type: ignore[no-any-return] 3866 model.full_refresh_stream, config=config, **kwargs 3867 ) 3868 3869 return incremental_stream 3870 3871 @staticmethod 3872 def _is_cursor_older_than_retention_period( 3873 stream_state: Mapping[str, Any], 3874 full_refresh_cursor: Cursor, 3875 incremental_cursor: Cursor, 3876 api_retention_period: str, 3877 stream_name: str, 3878 ) -> bool: 3879 """Check if the cursor value in the state is older than the API's retention period. 3880 3881 Checks cursors in sequence: full refresh cursor first, then incremental cursor. 3882 FinalStateCursor returns now() for completed full refresh state (NO_CURSOR_STATE_KEY), 3883 which is always within retention, so we use incremental. For other states, it returns 3884 None and we fall back to checking the incremental cursor. 3885 3886 Returns True if the cursor is older than the retention period (should use full refresh). 3887 Returns False if the cursor is within the retention period (safe to use incremental). 3888 """ 3889 retention_duration = parse_duration(api_retention_period) 3890 retention_cutoff = datetime.datetime.now(datetime.timezone.utc) - retention_duration 3891 3892 # Check full refresh cursor first 3893 cursor_datetime = full_refresh_cursor.get_cursor_datetime_from_state(stream_state) 3894 3895 # If full refresh cursor returns None, check incremental cursor 3896 if cursor_datetime is None: 3897 cursor_datetime = incremental_cursor.get_cursor_datetime_from_state(stream_state) 3898 3899 if cursor_datetime is None: 3900 # Neither cursor could parse the state - fall back to full refresh to be safe 3901 return True 3902 3903 if cursor_datetime < retention_cutoff: 3904 logging.warning( 3905 f"Stream '{stream_name}' has a cursor value older than " 3906 f"the API's retention period of {api_retention_period} " 3907 f"(cutoff: {retention_cutoff.isoformat()}). " 3908 f"Falling back to full refresh to avoid data loss." 3909 ) 3910 return True 3911 3912 return False 3913 3914 def _get_state_delegating_stream_model( 3915 self, 3916 model: StateDelegatingStreamModel, 3917 parent_state: Optional[Mapping[str, Any]] = None, 3918 ) -> DeclarativeStreamModel: 3919 """Return the appropriate underlying stream model based on state.""" 3920 return ( 3921 model.incremental_stream 3922 if self._connector_state_manager.get_stream_state(model.name, None) or parent_state 3923 else model.full_refresh_stream 3924 ) 3925 3926 _OPTIONAL_ASYNC_STATUS_FIELDS = {"skipped"} 3927 3928 def _create_async_job_status_mapping( 3929 self, model: AsyncJobStatusMapModel, config: Config, **kwargs: Any 3930 ) -> Mapping[str, AsyncJobStatus]: 3931 api_status_to_cdk_status = {} 3932 for cdk_status, api_statuses in model.dict().items(): 3933 if cdk_status == "type": 3934 # This is an element of the dict because of the typing of the CDK but it is not a CDK status 3935 continue 3936 3937 if api_statuses is None: 3938 if cdk_status in self._OPTIONAL_ASYNC_STATUS_FIELDS: 3939 continue 3940 raise ValueError( 3941 f"Required CDK status '{cdk_status}' has no API statuses mapped. " 3942 f"Please provide at least an empty list for required status fields." 3943 ) 3944 3945 for status in api_statuses: 3946 if status in api_status_to_cdk_status: 3947 raise ValueError( 3948 f"API status {status} is already set for CDK status {cdk_status}. Please ensure API statuses are only provided once" 3949 ) 3950 api_status_to_cdk_status[status] = self._get_async_job_status(cdk_status) 3951 return api_status_to_cdk_status 3952 3953 def _get_async_job_status(self, status: str) -> AsyncJobStatus: 3954 match status: 3955 case "running": 3956 return AsyncJobStatus.RUNNING 3957 case "completed": 3958 return AsyncJobStatus.COMPLETED 3959 case "failed": 3960 return AsyncJobStatus.FAILED 3961 case "timeout": 3962 return AsyncJobStatus.TIMED_OUT 3963 case "skipped": 3964 return AsyncJobStatus.SKIPPED 3965 case _: 3966 raise ValueError(f"Unsupported CDK status {status}") 3967 3968 def create_async_retriever( 3969 self, 3970 model: AsyncRetrieverModel, 3971 config: Config, 3972 *, 3973 name: str, 3974 primary_key: Optional[ 3975 Union[str, List[str], List[List[str]]] 3976 ], # this seems to be needed to match create_simple_retriever 3977 stream_slicer: Optional[StreamSlicer], 3978 client_side_incremental_sync: Optional[Dict[str, Any]] = None, 3979 transformations: List[RecordTransformation], 3980 **kwargs: Any, 3981 ) -> AsyncRetriever: 3982 if model.download_target_requester and not model.download_target_extractor: 3983 raise ValueError( 3984 f"`download_target_extractor` required if using a `download_target_requester`" 3985 ) 3986 3987 def _get_download_retriever( 3988 requester: Requester, extractor: RecordExtractor, _decoder: Decoder 3989 ) -> SimpleRetriever: 3990 # We create a record selector for the download retriever 3991 # with no schema normalization and no transformations, neither record filter 3992 # as all this occurs in the record_selector of the AsyncRetriever 3993 record_selector = RecordSelector( 3994 extractor=extractor, 3995 name=name, 3996 record_filter=None, 3997 transformations=[], 3998 schema_normalization=TypeTransformer(TransformConfig.NoTransform), 3999 config=config, 4000 parameters={}, 4001 ) 4002 paginator = ( 4003 self._create_component_from_model( 4004 model=model.download_paginator, 4005 decoder=_decoder, 4006 config=config, 4007 url_base="", 4008 ) 4009 if model.download_paginator 4010 else NoPagination(parameters={}) 4011 ) 4012 4013 return SimpleRetriever( 4014 requester=requester, 4015 record_selector=record_selector, 4016 primary_key=None, 4017 name=name, 4018 paginator=paginator, 4019 config=config, 4020 parameters={}, 4021 log_formatter=self._get_log_formatter(None, name), 4022 ) 4023 4024 def _get_job_timeout() -> datetime.timedelta: 4025 user_defined_timeout: Optional[int] = ( 4026 int( 4027 InterpolatedString.create( 4028 str(model.polling_job_timeout), 4029 parameters={}, 4030 ).eval(config) 4031 ) 4032 if model.polling_job_timeout 4033 else None 4034 ) 4035 4036 # check for user defined timeout during the test read or 15 minutes 4037 test_read_timeout = datetime.timedelta(minutes=user_defined_timeout or 15) 4038 # default value for non-connector builder is 60 minutes. 4039 default_sync_timeout = datetime.timedelta(minutes=user_defined_timeout or 60) 4040 4041 return ( 4042 test_read_timeout if self._emit_connector_builder_messages else default_sync_timeout 4043 ) 4044 4045 decoder = ( 4046 self._create_component_from_model(model=model.decoder, config=config) 4047 if model.decoder 4048 else JsonDecoder(parameters={}) 4049 ) 4050 record_selector = self._create_component_from_model( 4051 model=model.record_selector, 4052 config=config, 4053 decoder=decoder, 4054 name=name, 4055 transformations=transformations, 4056 client_side_incremental_sync=client_side_incremental_sync, 4057 ) 4058 4059 stream_slicer = stream_slicer or SinglePartitionRouter(parameters={}) 4060 if self._should_limit_slices_fetched(): 4061 stream_slicer = cast( 4062 StreamSlicer, 4063 StreamSlicerTestReadDecorator( 4064 wrapped_slicer=stream_slicer, 4065 maximum_number_of_slices=self._limit_slices_fetched or 5, 4066 ), 4067 ) 4068 4069 creation_requester = self._create_component_from_model( 4070 model=model.creation_requester, 4071 decoder=decoder, 4072 config=config, 4073 name=f"job creation - {name}", 4074 ) 4075 polling_requester = self._create_component_from_model( 4076 model=model.polling_requester, 4077 decoder=decoder, 4078 config=config, 4079 name=f"job polling - {name}", 4080 ) 4081 job_download_components_name = f"job download - {name}" 4082 download_decoder = ( 4083 self._create_component_from_model(model=model.download_decoder, config=config) 4084 if model.download_decoder 4085 else JsonDecoder(parameters={}) 4086 ) 4087 download_extractor = ( 4088 self._create_component_from_model( 4089 model=model.download_extractor, 4090 config=config, 4091 decoder=download_decoder, 4092 parameters=model.parameters, 4093 ) 4094 if model.download_extractor 4095 else DpathExtractor( 4096 [], 4097 config=config, 4098 decoder=download_decoder, 4099 parameters=model.parameters or {}, 4100 ) 4101 ) 4102 download_requester = self._create_component_from_model( 4103 model=model.download_requester, 4104 decoder=download_decoder, 4105 config=config, 4106 name=job_download_components_name, 4107 ) 4108 download_retriever = _get_download_retriever( 4109 download_requester, download_extractor, download_decoder 4110 ) 4111 abort_requester = ( 4112 self._create_component_from_model( 4113 model=model.abort_requester, 4114 decoder=decoder, 4115 config=config, 4116 name=f"job abort - {name}", 4117 ) 4118 if model.abort_requester 4119 else None 4120 ) 4121 delete_requester = ( 4122 self._create_component_from_model( 4123 model=model.delete_requester, 4124 decoder=decoder, 4125 config=config, 4126 name=f"job delete - {name}", 4127 ) 4128 if model.delete_requester 4129 else None 4130 ) 4131 download_target_requester = ( 4132 self._create_component_from_model( 4133 model=model.download_target_requester, 4134 decoder=decoder, 4135 config=config, 4136 name=f"job extract_url - {name}", 4137 ) 4138 if model.download_target_requester 4139 else None 4140 ) 4141 status_extractor = self._create_component_from_model( 4142 model=model.status_extractor, decoder=decoder, config=config, name=name 4143 ) 4144 download_target_extractor = ( 4145 self._create_component_from_model( 4146 model=model.download_target_extractor, 4147 decoder=decoder, 4148 config=config, 4149 name=name, 4150 ) 4151 if model.download_target_extractor 4152 else None 4153 ) 4154 4155 job_repository: AsyncJobRepository = AsyncHttpJobRepository( 4156 creation_requester=creation_requester, 4157 polling_requester=polling_requester, 4158 download_retriever=download_retriever, 4159 download_target_requester=download_target_requester, 4160 abort_requester=abort_requester, 4161 delete_requester=delete_requester, 4162 status_extractor=status_extractor, 4163 status_mapping=self._create_async_job_status_mapping(model.status_mapping, config), 4164 download_target_extractor=download_target_extractor, 4165 job_timeout=_get_job_timeout(), 4166 ) 4167 4168 failed_retry_wait_time_in_seconds: Optional[int] = ( 4169 int( 4170 InterpolatedString.create( 4171 str(model.failed_retry_wait_time_in_seconds), 4172 parameters={}, 4173 ).eval(config) 4174 ) 4175 if model.failed_retry_wait_time_in_seconds 4176 else None 4177 ) 4178 4179 async_job_partition_router = AsyncJobPartitionRouter( 4180 job_orchestrator_factory=lambda stream_slices: AsyncJobOrchestrator( 4181 job_repository, 4182 stream_slices, 4183 self._job_tracker, 4184 self._message_repository, 4185 # FIXME work would need to be done here in order to detect if a stream as a parent stream that is bulk 4186 has_bulk_parent=False, 4187 # set the `job_max_retry` to 1 for the `Connector Builder`` use-case. 4188 # `None` == default retry is set to 3 attempts, under the hood. 4189 job_max_retry=1 if self._emit_connector_builder_messages else None, 4190 failed_retry_wait_time_in_seconds=failed_retry_wait_time_in_seconds, 4191 ), 4192 stream_slicer=stream_slicer, 4193 config=config, 4194 parameters=model.parameters or {}, 4195 ) 4196 4197 return AsyncRetriever( 4198 record_selector=record_selector, 4199 stream_slicer=async_job_partition_router, 4200 config=config, 4201 parameters=model.parameters or {}, 4202 ) 4203 4204 def create_spec(self, model: SpecModel, config: Config, **kwargs: Any) -> Spec: 4205 config_migrations = [ 4206 self._create_component_from_model(migration, config) 4207 for migration in ( 4208 model.config_normalization_rules.config_migrations 4209 if ( 4210 model.config_normalization_rules 4211 and model.config_normalization_rules.config_migrations 4212 ) 4213 else [] 4214 ) 4215 ] 4216 config_transformations = [ 4217 self._create_component_from_model(transformation, config) 4218 for transformation in ( 4219 model.config_normalization_rules.transformations 4220 if ( 4221 model.config_normalization_rules 4222 and model.config_normalization_rules.transformations 4223 ) 4224 else [] 4225 ) 4226 ] 4227 config_validations = [ 4228 self._create_component_from_model(validation, config) 4229 for validation in ( 4230 model.config_normalization_rules.validations 4231 if ( 4232 model.config_normalization_rules 4233 and model.config_normalization_rules.validations 4234 ) 4235 else [] 4236 ) 4237 ] 4238 4239 return Spec( 4240 connection_specification=model.connection_specification, 4241 documentation_url=model.documentation_url, 4242 advanced_auth=model.advanced_auth, 4243 parameters={}, 4244 config_migrations=config_migrations, 4245 config_transformations=config_transformations, 4246 config_validations=config_validations, 4247 ) 4248 4249 def create_substream_partition_router( 4250 self, 4251 model: SubstreamPartitionRouterModel, 4252 config: Config, 4253 *, 4254 stream_name: str, 4255 **kwargs: Any, 4256 ) -> SubstreamPartitionRouter: 4257 parent_stream_configs = [] 4258 if model.parent_stream_configs: 4259 parent_stream_configs.extend( 4260 [ 4261 self.create_parent_stream_config_with_substream_wrapper( 4262 model=parent_stream_config, config=config, stream_name=stream_name, **kwargs 4263 ) 4264 for parent_stream_config in model.parent_stream_configs 4265 ] 4266 ) 4267 4268 return SubstreamPartitionRouter( 4269 parent_stream_configs=parent_stream_configs, 4270 parameters=model.parameters or {}, 4271 config=config, 4272 ) 4273 4274 def create_parent_stream_config_with_substream_wrapper( 4275 self, model: ParentStreamConfigModel, config: Config, *, stream_name: str, **kwargs: Any 4276 ) -> Any: 4277 child_state = self._connector_state_manager.get_stream_state(stream_name, None) 4278 if NO_CURSOR_STATE_KEY in child_state: 4279 # Full refresh streams checkpoint a `{NO_CURSOR_STATE_KEY: true}` sentinel. When such a 4280 # stream is later converted to incremental with an incremental_dependency parent, 4281 # `_instantiate_parent_stream_state_manager` would treat the sentinel's boolean as a legacy 4282 # cursor value and re-key it under the parent's cursor field, crashing cursor initialization. 4283 child_state = { 4284 key: value for key, value in child_state.items() if key != NO_CURSOR_STATE_KEY 4285 } 4286 4287 parent_state: Optional[Mapping[str, Any]] = ( 4288 child_state if model.incremental_dependency and child_state else None 4289 ) 4290 connector_state_manager = self._instantiate_parent_stream_state_manager( 4291 child_state, config, model, parent_state 4292 ) 4293 4294 substream_factory = ModelToComponentFactory( 4295 custom_components_trusted=self._custom_components_trusted, 4296 connector_state_manager=connector_state_manager, 4297 limit_pages_fetched_per_slice=self._limit_pages_fetched_per_slice, 4298 limit_slices_fetched=self._limit_slices_fetched, 4299 emit_connector_builder_messages=self._emit_connector_builder_messages, 4300 disable_retries=self._disable_retries, 4301 disable_cache=self._disable_cache, 4302 message_repository=StateFilteringMessageRepository( 4303 LogAppenderMessageRepositoryDecorator( 4304 { 4305 "airbyte_cdk": {"stream": {"is_substream": True}}, 4306 "http": {"is_auxiliary": True}, 4307 }, 4308 self._message_repository, 4309 self._evaluate_log_level(self._emit_connector_builder_messages), 4310 ), 4311 ), 4312 api_budget=self._api_budget, 4313 # Share the authenticator registry so parent and child streams draw from the 4314 # same token quota counters 4315 rate_limited_authenticators=self._rate_limited_authenticators, 4316 ) 4317 4318 return substream_factory.create_parent_stream_config( 4319 model=model, config=config, stream_name=stream_name, **kwargs 4320 ) 4321 4322 def _instantiate_parent_stream_state_manager( 4323 self, 4324 child_state: MutableMapping[str, Any], 4325 config: Config, 4326 model: ParentStreamConfigModel, 4327 parent_state: Optional[Mapping[str, Any]] = None, 4328 ) -> ConnectorStateManager: 4329 """ 4330 With DefaultStream, the state needs to be provided during __init__ of the cursor as opposed to the 4331 `set_initial_state` flow that existed for the declarative cursors. This state is taken from 4332 self._connector_state_manager.get_stream_state (`self` being a newly created ModelToComponentFactory to account 4333 for the MessageRepository being different). So we need to pass a ConnectorStateManager to the 4334 ModelToComponentFactory that has the parent states. This method populates this if there is a child state and if 4335 incremental_dependency is set. 4336 """ 4337 if model.incremental_dependency and child_state: 4338 parent_stream_name = model.stream.name or "" 4339 extracted_parent_state = ConcurrentPerPartitionCursor.get_parent_state( 4340 child_state, parent_stream_name 4341 ) 4342 4343 if not extracted_parent_state: 4344 extracted_parent_state = ConcurrentPerPartitionCursor.get_global_state( 4345 child_state, parent_stream_name 4346 ) 4347 4348 if not extracted_parent_state and not isinstance(extracted_parent_state, dict): 4349 cursor_values = child_state.values() 4350 if cursor_values and len(cursor_values) == 1: 4351 incremental_sync_model: Union[ 4352 DatetimeBasedCursorModel, 4353 IncrementingCountCursorModel, 4354 ] = ( 4355 model.stream.incremental_sync # type: ignore # if we are there, it is because there is incremental_dependency and therefore there is an incremental_sync on the parent stream 4356 if isinstance(model.stream, DeclarativeStreamModel) 4357 else self._get_state_delegating_stream_model( 4358 model.stream, parent_state=parent_state 4359 ).incremental_sync 4360 ) 4361 cursor_field = InterpolatedString.create( 4362 incremental_sync_model.cursor_field, 4363 parameters=incremental_sync_model.parameters or {}, 4364 ).eval(config) 4365 extracted_parent_state = AirbyteStateMessage( 4366 type=AirbyteStateType.STREAM, 4367 stream=AirbyteStreamState( 4368 stream_descriptor=StreamDescriptor( 4369 name=parent_stream_name, namespace=None 4370 ), 4371 stream_state=AirbyteStateBlob( 4372 {cursor_field: list(cursor_values)[0]} 4373 ), 4374 ), 4375 ) 4376 return ConnectorStateManager([extracted_parent_state] if extracted_parent_state else []) 4377 4378 return ConnectorStateManager([]) 4379 4380 @staticmethod 4381 def create_wait_time_from_header( 4382 model: WaitTimeFromHeaderModel, config: Config, **kwargs: Any 4383 ) -> WaitTimeFromHeaderBackoffStrategy: 4384 return WaitTimeFromHeaderBackoffStrategy( 4385 header=model.header, 4386 parameters=model.parameters or {}, 4387 config=config, 4388 regex=model.regex, 4389 max_waiting_time_in_seconds=model.max_waiting_time_in_seconds, 4390 ) 4391 4392 @staticmethod 4393 def create_wait_until_time_from_header( 4394 model: WaitUntilTimeFromHeaderModel, config: Config, **kwargs: Any 4395 ) -> WaitUntilTimeFromHeaderBackoffStrategy: 4396 return WaitUntilTimeFromHeaderBackoffStrategy( 4397 header=model.header, 4398 parameters=model.parameters or {}, 4399 config=config, 4400 min_wait=model.min_wait, 4401 regex=model.regex, 4402 max_waiting_time_in_seconds=model.max_waiting_time_in_seconds, 4403 ) 4404 4405 def get_message_repository(self) -> MessageRepository: 4406 return self._message_repository 4407 4408 def _evaluate_log_level(self, emit_connector_builder_messages: bool) -> Level: 4409 return Level.DEBUG if emit_connector_builder_messages else Level.INFO 4410 4411 @staticmethod 4412 def create_components_mapping_definition( 4413 model: ComponentMappingDefinitionModel, config: Config, **kwargs: Any 4414 ) -> ComponentMappingDefinition: 4415 interpolated_value = InterpolatedString.create( 4416 model.value, parameters=model.parameters or {} 4417 ) 4418 field_path = [ 4419 InterpolatedString.create(path, parameters=model.parameters or {}) 4420 for path in model.field_path 4421 ] 4422 return ComponentMappingDefinition( 4423 field_path=field_path, # type: ignore[arg-type] # field_path can be str and InterpolatedString 4424 value=interpolated_value, 4425 value_type=ModelToComponentFactory._json_schema_type_name_to_type(model.value_type), 4426 create_or_update=model.create_or_update, 4427 condition=model.condition, 4428 parameters=model.parameters or {}, 4429 ) 4430 4431 def create_http_components_resolver( 4432 self, model: HttpComponentsResolverModel, config: Config, stream_name: Optional[str] = None 4433 ) -> Any: 4434 retriever = self._create_component_from_model( 4435 model=model.retriever, 4436 config=config, 4437 name=f"{stream_name if stream_name else '__http_components_resolver'}", 4438 primary_key=None, 4439 stream_slicer=self._build_stream_slicer_from_partition_router(model.retriever, config), 4440 transformations=[], 4441 ) 4442 4443 components_mapping = [] 4444 for component_mapping_definition_model in model.components_mapping: 4445 if component_mapping_definition_model.condition: 4446 raise ValueError("`condition` is only supported for `ConfigComponentsResolver`") 4447 components_mapping.append( 4448 self._create_component_from_model( 4449 model=component_mapping_definition_model, 4450 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 4451 component_mapping_definition_model.value_type 4452 ), 4453 config=config, 4454 ) 4455 ) 4456 4457 return HttpComponentsResolver( 4458 retriever=retriever, 4459 stream_slicer=self._build_stream_slicer_from_partition_router(model.retriever, config), 4460 config=config, 4461 components_mapping=components_mapping, 4462 parameters=model.parameters or {}, 4463 ) 4464 4465 @staticmethod 4466 def create_stream_config( 4467 model: StreamConfigModel, config: Config, **kwargs: Any 4468 ) -> StreamConfig: 4469 model_configs_pointer: List[Union[InterpolatedString, str]] = ( 4470 [x for x in model.configs_pointer] if model.configs_pointer else [] 4471 ) 4472 4473 return StreamConfig( 4474 configs_pointer=model_configs_pointer, 4475 default_values=model.default_values, 4476 parameters=model.parameters or {}, 4477 ) 4478 4479 def create_config_components_resolver( 4480 self, 4481 model: ConfigComponentsResolverModel, 4482 config: Config, 4483 ) -> Any: 4484 model_stream_configs = ( 4485 model.stream_config if isinstance(model.stream_config, list) else [model.stream_config] 4486 ) 4487 4488 stream_configs = [ 4489 self._create_component_from_model( 4490 stream_config, config=config, parameters=model.parameters or {} 4491 ) 4492 for stream_config in model_stream_configs 4493 ] 4494 4495 components_mapping = [ 4496 self._create_component_from_model( 4497 model=components_mapping_definition_model, 4498 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 4499 components_mapping_definition_model.value_type 4500 ), 4501 config=config, 4502 parameters=model.parameters, 4503 ) 4504 for components_mapping_definition_model in model.components_mapping 4505 ] 4506 4507 return ConfigComponentsResolver( 4508 stream_configs=stream_configs, 4509 config=config, 4510 components_mapping=components_mapping, 4511 parameters=model.parameters or {}, 4512 ) 4513 4514 def create_parametrized_components_resolver( 4515 self, 4516 model: ParametrizedComponentsResolverModel, 4517 config: Config, 4518 ) -> ParametrizedComponentsResolver: 4519 stream_parameters = StreamParametersDefinition( 4520 list_of_parameters_for_stream=model.stream_parameters.list_of_parameters_for_stream 4521 ) 4522 4523 components_mapping = [] 4524 for components_mapping_definition_model in model.components_mapping: 4525 if components_mapping_definition_model.condition: 4526 raise ValueError("`condition` is only supported for `ConfigComponentsResolver`") 4527 components_mapping.append( 4528 self._create_component_from_model( 4529 model=components_mapping_definition_model, 4530 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 4531 components_mapping_definition_model.value_type 4532 ), 4533 config=config, 4534 ) 4535 ) 4536 return ParametrizedComponentsResolver( 4537 stream_parameters=stream_parameters, 4538 config=config, 4539 components_mapping=components_mapping, 4540 parameters=model.parameters or {}, 4541 ) 4542 4543 _UNSUPPORTED_DECODER_ERROR = ( 4544 "Specified decoder of {decoder_type} is not supported for pagination." 4545 "Please set as `JsonDecoder`, `XmlDecoder`, or a `CompositeRawDecoder` with an inner_parser of `JsonParser` or `GzipParser` instead." 4546 "If using `GzipParser`, please ensure that the lowest level inner_parser is a `JsonParser`." 4547 ) 4548 4549 def _is_supported_decoder_for_pagination(self, decoder: Decoder) -> bool: 4550 if isinstance(decoder, (JsonDecoder, XmlDecoder)): 4551 return True 4552 elif isinstance(decoder, CompositeRawDecoder): 4553 return self._is_supported_parser_for_pagination(decoder.parser) 4554 else: 4555 return False 4556 4557 def _is_supported_parser_for_pagination(self, parser: Parser) -> bool: 4558 if isinstance(parser, JsonParser): 4559 return True 4560 elif isinstance(parser, GzipParser): 4561 return isinstance(parser.inner_parser, JsonParser) 4562 else: 4563 return False 4564 4565 def create_http_api_budget( 4566 self, model: HTTPAPIBudgetModel, config: Config, **kwargs: Any 4567 ) -> HttpAPIBudget: 4568 policies = [ 4569 self._create_component_from_model(model=policy, config=config) 4570 for policy in model.policies 4571 ] 4572 4573 return HttpAPIBudget( 4574 policies=policies, 4575 ratelimit_reset_header=model.ratelimit_reset_header or "ratelimit-reset", 4576 ratelimit_remaining_header=model.ratelimit_remaining_header or "ratelimit-remaining", 4577 status_codes_for_ratelimit_hit=model.status_codes_for_ratelimit_hit or [429], 4578 ) 4579 4580 def create_fixed_window_call_rate_policy( 4581 self, model: FixedWindowCallRatePolicyModel, config: Config, **kwargs: Any 4582 ) -> FixedWindowCallRatePolicy: 4583 matchers = [ 4584 self._create_component_from_model(model=matcher, config=config) 4585 for matcher in model.matchers 4586 ] 4587 4588 # Set the initial reset timestamp to 10 days from now. 4589 # This value will be updated by the first request. 4590 return FixedWindowCallRatePolicy( 4591 next_reset_ts=datetime.datetime.now() + datetime.timedelta(days=10), 4592 period=parse_duration(model.period), 4593 call_limit=model.call_limit, 4594 matchers=matchers, 4595 ) 4596 4597 def create_file_uploader( 4598 self, model: FileUploaderModel, config: Config, **kwargs: Any 4599 ) -> FileUploader: 4600 name = "File Uploader" 4601 requester = self._create_component_from_model( 4602 model=model.requester, 4603 config=config, 4604 name=name, 4605 **kwargs, 4606 ) 4607 download_target_extractor = self._create_component_from_model( 4608 model=model.download_target_extractor, 4609 config=config, 4610 name=name, 4611 **kwargs, 4612 ) 4613 emit_connector_builder_messages = self._emit_connector_builder_messages 4614 file_uploader = DefaultFileUploader( 4615 requester=requester, 4616 download_target_extractor=download_target_extractor, 4617 config=config, 4618 file_writer=NoopFileWriter() 4619 if emit_connector_builder_messages 4620 else LocalFileSystemFileWriter(), 4621 parameters=model.parameters or {}, 4622 filename_extractor=model.filename_extractor if model.filename_extractor else None, 4623 ) 4624 4625 return ( 4626 ConnectorBuilderFileUploader(file_uploader) 4627 if emit_connector_builder_messages 4628 else file_uploader 4629 ) 4630 4631 def create_moving_window_call_rate_policy( 4632 self, model: MovingWindowCallRatePolicyModel, config: Config, **kwargs: Any 4633 ) -> MovingWindowCallRatePolicy: 4634 rates = [ 4635 self._create_component_from_model(model=rate, config=config) for rate in model.rates 4636 ] 4637 matchers = [ 4638 self._create_component_from_model(model=matcher, config=config) 4639 for matcher in model.matchers 4640 ] 4641 return MovingWindowCallRatePolicy( 4642 rates=rates, 4643 matchers=matchers, 4644 ) 4645 4646 def create_unlimited_call_rate_policy( 4647 self, model: UnlimitedCallRatePolicyModel, config: Config, **kwargs: Any 4648 ) -> UnlimitedCallRatePolicy: 4649 matchers = [ 4650 self._create_component_from_model(model=matcher, config=config) 4651 for matcher in model.matchers 4652 ] 4653 4654 return UnlimitedCallRatePolicy( 4655 matchers=matchers, 4656 ) 4657 4658 def create_rate(self, model: RateModel, config: Config, **kwargs: Any) -> Rate: 4659 interpolated_limit = InterpolatedString.create(str(model.limit), parameters={}) 4660 return Rate( 4661 limit=int(interpolated_limit.eval(config=config)), 4662 interval=parse_duration(model.interval), 4663 ) 4664 4665 def create_http_request_matcher( 4666 self, model: HttpRequestRegexMatcherModel, config: Config, **kwargs: Any 4667 ) -> HttpRequestRegexMatcher: 4668 weight = model.weight 4669 if weight is not None: 4670 if isinstance(weight, str): 4671 weight = int(InterpolatedString.create(weight, parameters={}).eval(config)) 4672 else: 4673 weight = int(weight) 4674 if weight < 1: 4675 raise ValueError(f"weight must be >= 1, got {weight}") 4676 return HttpRequestRegexMatcher( 4677 method=model.method, 4678 url_base=model.url_base, 4679 url_path_pattern=model.url_path_pattern, 4680 params=model.params, 4681 headers=model.headers, 4682 weight=weight, 4683 ) 4684 4685 def create_rate_limited_multiple_token_authenticator( 4686 self, 4687 model: RateLimitedMultipleTokenAuthenticatorModel, 4688 config: Config, 4689 **kwargs: Any, 4690 ) -> RateLimitedMultipleTokenAuthenticator: 4691 if isinstance(model.tokens, str): 4692 tokens_value = InterpolatedString.create(model.tokens, parameters={}).eval(config) 4693 delimiter = model.token_delimiter or "," 4694 tokens = [ 4695 token.strip() for token in str(tokens_value).split(delimiter) if token.strip() 4696 ] 4697 else: 4698 tokens = [ 4699 token_value 4700 for token in model.tokens 4701 if ( 4702 token_value := str( 4703 InterpolatedString.create(token, parameters={}).eval(config) 4704 ).strip() 4705 ) 4706 ] 4707 4708 quota_specs = [ 4709 { 4710 "name": quota_model.name, 4711 "remaining_path": quota_model.remaining_path, 4712 "reset_path": quota_model.reset_path, 4713 "limit_path": quota_model.limit_path, 4714 "remaining_header": quota_model.remaining_header, 4715 "reset_header": quota_model.reset_header, 4716 "limit_header": quota_model.limit_header, 4717 # Normalize the same way as the runtime TokenQuota below, so an omitted field 4718 # and an explicit `[]` key identically and keep sharing one set of counters. 4719 "exhaustion_status_codes": quota_model.exhaustion_status_codes or [], 4720 "matchers": [ 4721 { 4722 "method": matcher_model.method, 4723 "url_base": matcher_model.url_base, 4724 "url_path_pattern": matcher_model.url_path_pattern, 4725 "params": matcher_model.params, 4726 "headers": matcher_model.headers, 4727 "weight": matcher_model.weight, 4728 } 4729 for matcher_model in quota_model.matchers or [] 4730 ], 4731 } 4732 for quota_model in model.quotas 4733 ] 4734 4735 quota_status_url = str( 4736 InterpolatedString.create(model.quota_status_source.url, parameters={}).eval(config) 4737 ) 4738 quota_status_http_method = ( 4739 model.quota_status_source.http_method.value 4740 if model.quota_status_source.http_method 4741 else "GET" 4742 ) 4743 quota_status_headers = { 4744 key: str(InterpolatedString.create(value, parameters={}).eval(config)) 4745 for key, value in (model.quota_status_source.request_headers or {}).items() 4746 } 4747 # Normalize the same way as the quota specs above, so an omitted field and an explicit 4748 # `[]` key identically and keep sharing one set of counters. Deduplicated as well as 4749 # sorted, because the runtime turns this into a set: without it `[404]` and `[404, 404]` 4750 # would key differently and stop sharing counters while behaving identically. 4751 quota_status_unavailable_status_codes = sorted( 4752 set(model.quota_status_source.unavailable_status_codes or []) 4753 ) 4754 auth_method = model.auth_method or "Bearer" 4755 header = model.header or "Authorization" 4756 max_wait_time_str = str( 4757 InterpolatedString.create(model.max_wait_time or "PT2H", parameters={}).eval(config) 4758 ) 4759 max_wait_time = parse_duration(max_wait_time_str) 4760 if not isinstance(max_wait_time, datetime.timedelta): 4761 raise ValueError( 4762 f"max_wait_time must be a fixed-length ISO 8601 duration (e.g. 'PT2H'); " 4763 f"calendar-unit durations like '{max_wait_time_str}' are not supported" 4764 ) 4765 budget_reserve_fraction = ( 4766 model.budget_reserve_fraction if model.budget_reserve_fraction is not None else 0.1 4767 ) 4768 budget_min_reserve = ( 4769 model.budget_min_reserve if model.budget_min_reserve is not None else 50 4770 ) 4771 4772 # Reuse the same instance for identical definitions so that all streams share the 4773 # same token quota counters (similar to how api_budget is shared). The key is built 4774 # from the resolved constructor arguments rather than the raw model so that 4775 # stream-specific `$parameters` propagated onto the model (and its nested components) 4776 # cannot break instance sharing. 4777 cache_key = json.dumps( 4778 { 4779 "tokens": tokens, 4780 "quotas": quota_specs, 4781 "quota_status_url": quota_status_url, 4782 "quota_status_http_method": quota_status_http_method, 4783 "quota_status_headers": quota_status_headers, 4784 "quota_status_unavailable_status_codes": quota_status_unavailable_status_codes, 4785 "auth_method": auth_method, 4786 "header": header, 4787 "max_wait_time": max_wait_time.total_seconds(), 4788 "budget_reserve_fraction": budget_reserve_fraction, 4789 "budget_min_reserve": budget_min_reserve, 4790 }, 4791 sort_keys=True, 4792 ) 4793 if cache_key in self._rate_limited_authenticators: 4794 return self._rate_limited_authenticators[cache_key] 4795 4796 quotas = [ 4797 TokenQuota( 4798 name=quota_model.name, 4799 remaining_path=quota_model.remaining_path, 4800 reset_path=quota_model.reset_path, 4801 limit_path=quota_model.limit_path, 4802 remaining_header=quota_model.remaining_header, 4803 reset_header=quota_model.reset_header, 4804 limit_header=quota_model.limit_header, 4805 exhaustion_status_codes=quota_model.exhaustion_status_codes or [], 4806 matchers=[ 4807 self.create_http_request_matcher(matcher_model, config) 4808 for matcher_model in quota_model.matchers or [] 4809 ], 4810 ) 4811 for quota_model in model.quotas 4812 ] 4813 4814 authenticator = RateLimitedMultipleTokenAuthenticator( 4815 tokens=tokens, 4816 quotas=quotas, 4817 quota_status_url=quota_status_url, 4818 quota_status_http_method=quota_status_http_method, 4819 quota_status_headers=quota_status_headers, 4820 quota_status_unavailable_status_codes=quota_status_unavailable_status_codes, 4821 auth_method=auth_method, 4822 header=header, 4823 max_wait_time=max_wait_time, 4824 budget_reserve_fraction=budget_reserve_fraction, 4825 budget_min_reserve=budget_min_reserve, 4826 ) 4827 self._rate_limited_authenticators[cache_key] = authenticator 4828 return authenticator 4829 4830 def set_api_budget(self, component_definition: ComponentDefinition, config: Config) -> None: 4831 self._api_budget = self.create_component( 4832 model_type=HTTPAPIBudgetModel, component_definition=component_definition, config=config 4833 ) 4834 4835 def create_grouping_partition_router( 4836 self, 4837 model: GroupingPartitionRouterModel, 4838 config: Config, 4839 *, 4840 stream_name: str, 4841 **kwargs: Any, 4842 ) -> GroupingPartitionRouter: 4843 underlying_router = self._create_component_from_model( 4844 model=model.underlying_partition_router, 4845 config=config, 4846 stream_name=stream_name, 4847 **kwargs, 4848 ) 4849 if model.group_size < 1: 4850 raise ValueError(f"Group size must be greater than 0, got {model.group_size}") 4851 4852 # Request options in underlying partition routers are not supported for GroupingPartitionRouter 4853 # because they are specific to individual partitions and cannot be aggregated or handled 4854 # when grouping, potentially leading to incorrect API calls. Any request customization 4855 # should be managed at the stream level through the requester's configuration. 4856 if isinstance(underlying_router, SubstreamPartitionRouter): 4857 if any( 4858 parent_config.request_option 4859 for parent_config in underlying_router.parent_stream_configs 4860 ): 4861 raise ValueError("Request options are not supported for GroupingPartitionRouter.") 4862 4863 if isinstance(underlying_router, ListPartitionRouter): 4864 if underlying_router.request_option: 4865 raise ValueError("Request options are not supported for GroupingPartitionRouter.") 4866 4867 return GroupingPartitionRouter( 4868 group_size=model.group_size, 4869 underlying_partition_router=underlying_router, 4870 deduplicate=model.deduplicate if model.deduplicate is not None else True, 4871 config=config, 4872 ) 4873 4874 def create_union_partition_router( 4875 self, 4876 model: UnionPartitionRouterModel, 4877 config: Config, 4878 *, 4879 stream_name: str, 4880 **kwargs: Any, 4881 ) -> UnionPartitionRouter: 4882 # The schema enforces minItems: 2 for manifests; this guard covers construction paths 4883 # that bypass JSON-schema validation (the generated model carries no min_items constraint). 4884 if len(model.partition_routers) < 2: 4885 raise ValueError( 4886 f"UnionPartitionRouter for stream {stream_name} needs at least 2 child partition routers" 4887 ) 4888 4889 partition_routers = [ 4890 self._create_component_from_model( 4891 model=child, 4892 config=config, 4893 stream_name=stream_name, 4894 **kwargs, 4895 ) 4896 for child in model.partition_routers 4897 ] 4898 4899 # partition_field depends only on config/parameters, so it is evaluated once at build 4900 # time; the runtime component always receives a plain string. 4901 partition_field = InterpolatedString.create( 4902 model.partition_field, parameters=model.parameters or {} 4903 ).eval(config) 4904 4905 # Fail fast at build time when a built-in child router is statically known to emit a 4906 # partition field different from the union's. CustomPartitionRouter children are opaque 4907 # and can only be validated at runtime. 4908 for child_model in model.partition_routers: 4909 child_partition_fields: List[str] = [] 4910 if isinstance(child_model, ListPartitionRouterModel): 4911 child_partition_fields.append( 4912 InterpolatedString.create( 4913 child_model.cursor_field, parameters=child_model.parameters or {} 4914 ).eval(config) 4915 ) 4916 elif isinstance(child_model, SubstreamPartitionRouterModel): 4917 for parent_stream_config in child_model.parent_stream_configs: 4918 child_partition_fields.append( 4919 InterpolatedString.create( 4920 parent_stream_config.partition_field, 4921 parameters=parent_stream_config.parameters 4922 or child_model.parameters 4923 or {}, 4924 ).eval(config) 4925 ) 4926 elif isinstance(child_model, UnionPartitionRouterModel): 4927 child_partition_fields.append( 4928 InterpolatedString.create( 4929 child_model.partition_field, parameters=child_model.parameters or {} 4930 ).eval(config) 4931 ) 4932 for child_partition_field in child_partition_fields: 4933 if child_partition_field != partition_field: 4934 raise ValueError( 4935 f"UnionPartitionRouter expects all child partition routers to emit the " 4936 f"partition field '{partition_field}', but a " 4937 f"{child_model.type} child emits '{child_partition_field}'." 4938 ) 4939 4940 # A union slice comes from exactly one child partition router, so request options 4941 # declared on children cannot be applied consistently to requests built from the 4942 # normalized union slices. Partition values should be consumed via interpolation 4943 # (e.g. stream_partition) instead. Note that this validation only covers built-in 4944 # router types; CustomPartitionRouter children are opaque, so any request options 4945 # they implement internally cannot be detected or rejected here. 4946 for router in partition_routers: 4947 if isinstance(router, SubstreamPartitionRouter): 4948 if any( 4949 parent_config.request_option for parent_config in router.parent_stream_configs 4950 ): 4951 raise ValueError("Request options are not supported for UnionPartitionRouter.") 4952 if isinstance(router, ListPartitionRouter) and router.request_option: 4953 raise ValueError("Request options are not supported for UnionPartitionRouter.") 4954 4955 return UnionPartitionRouter( 4956 partition_routers=partition_routers, 4957 partition_field=partition_field, 4958 parameters=model.parameters or {}, 4959 ) 4960 4961 def _ensure_query_properties_to_model( 4962 self, requester: Union[HttpRequesterModel, CustomRequesterModel] 4963 ) -> None: 4964 """ 4965 For some reason, it seems like CustomRequesterModel request_parameters stays as dictionaries which means that 4966 the other conditions relying on it being QueryPropertiesModel instead of a dict fail. Here, we migrate them to 4967 proper model. 4968 """ 4969 if not hasattr(requester, "request_parameters"): 4970 return 4971 4972 request_parameters = requester.request_parameters 4973 if request_parameters and isinstance(request_parameters, Dict): 4974 for request_parameter_key in request_parameters.keys(): 4975 request_parameter = request_parameters[request_parameter_key] 4976 if ( 4977 isinstance(request_parameter, Dict) 4978 and request_parameter.get("type") == "QueryProperties" 4979 ): 4980 request_parameters[request_parameter_key] = QueryPropertiesModel.parse_obj( 4981 request_parameter 4982 ) 4983 4984 def _get_catalog_defined_cursor_field( 4985 self, stream_name: str, allow_catalog_defined_cursor_field: bool 4986 ) -> Optional[CursorField]: 4987 if not allow_catalog_defined_cursor_field: 4988 return None 4989 4990 configured_stream = self._stream_name_to_configured_stream.get(stream_name) 4991 4992 # Depending on the operation is being performed, there may not be a configured stream yet. In this 4993 # case we return None which will then use the default cursor field defined on the cursor model. 4994 # We also treat cursor_field: [""] (list with empty string) as no cursor field, since this can 4995 # occur when the platform serializes "no cursor configured" streams incorrectly. 4996 if ( 4997 not configured_stream 4998 or not configured_stream.cursor_field 4999 or not configured_stream.cursor_field[0] 5000 ): 5001 return None 5002 elif len(configured_stream.cursor_field) > 1: 5003 raise ValueError( 5004 f"The `{stream_name}` stream does not support nested cursor_field. Please specify only a single cursor_field for the stream in the configured catalog." 5005 ) 5006 else: 5007 return CursorField( 5008 cursor_field_key=configured_stream.cursor_field[0], 5009 supports_catalog_defined_cursor_field=allow_catalog_defined_cursor_field, 5010 )
707class ModelToComponentFactory: 708 EPOCH_DATETIME_FORMAT = "%s" 709 710 def __init__( 711 self, 712 limit_pages_fetched_per_slice: Optional[int] = None, 713 limit_slices_fetched: Optional[int] = None, 714 emit_connector_builder_messages: bool = False, 715 disable_retries: bool = False, 716 disable_cache: bool = False, 717 message_repository: Optional[MessageRepository] = None, 718 connector_state_manager: Optional[ConnectorStateManager] = None, 719 max_concurrent_async_job_count: Optional[int] = None, 720 configured_catalog: Optional[ConfiguredAirbyteCatalog] = None, 721 api_budget: Optional[APIBudget] = None, 722 rate_limited_authenticators: Optional[ 723 Dict[str, RateLimitedMultipleTokenAuthenticator] 724 ] = None, 725 custom_components_trusted: bool = True, 726 ): 727 self._init_mappings() 728 self._custom_components_trusted = custom_components_trusted 729 self._limit_pages_fetched_per_slice = limit_pages_fetched_per_slice 730 self._limit_slices_fetched = limit_slices_fetched 731 self._emit_connector_builder_messages = emit_connector_builder_messages 732 self._disable_retries = disable_retries 733 self._disable_cache = disable_cache 734 self._message_repository = message_repository or InMemoryMessageRepository( 735 self._evaluate_log_level(emit_connector_builder_messages) 736 ) 737 self._stream_name_to_configured_stream = self._create_stream_name_to_configured_stream( 738 configured_catalog 739 ) 740 self._connector_state_manager = connector_state_manager or ConnectorStateManager() 741 self._api_budget: Optional[Union[APIBudget]] = api_budget 742 # Shared instances so all streams see the same token quota counters (like api_budget) 743 self._rate_limited_authenticators: Dict[str, RateLimitedMultipleTokenAuthenticator] = ( 744 rate_limited_authenticators if rate_limited_authenticators is not None else {} 745 ) 746 self._job_tracker: JobTracker = JobTracker(max_concurrent_async_job_count or 1) 747 # placeholder for deprecation warnings 748 self._collected_deprecation_logs: List[ConnectorBuilderLogMessage] = [] 749 750 def _init_mappings(self) -> None: 751 self.PYDANTIC_MODEL_TO_CONSTRUCTOR: Mapping[Type[BaseModel], Callable[..., Any]] = { 752 AddedFieldDefinitionModel: self.create_added_field_definition, 753 AddFieldsModel: self.create_add_fields, 754 ApiKeyAuthenticatorModel: self.create_api_key_authenticator, 755 BasicHttpAuthenticatorModel: self.create_basic_http_authenticator, 756 BearerAuthenticatorModel: self.create_bearer_authenticator, 757 CheckStreamModel: self.create_check_stream, 758 DynamicStreamCheckConfigModel: self.create_dynamic_stream_check_config, 759 CheckDynamicStreamModel: self.create_check_dynamic_stream, 760 CompositeErrorHandlerModel: self.create_composite_error_handler, 761 ConcurrencyLevelModel: self.create_concurrency_level, 762 ConfigMigrationModel: self.create_config_migration, 763 ConfigAddFieldsModel: self.create_config_add_fields, 764 ConfigRemapFieldModel: self.create_config_remap_field, 765 ConfigRemoveFieldsModel: self.create_config_remove_fields, 766 ConstantBackoffStrategyModel: self.create_constant_backoff_strategy, 767 CsvDecoderModel: self.create_csv_decoder, 768 CursorPaginationModel: self.create_cursor_pagination, 769 CustomAuthenticatorModel: self.create_custom_component, 770 CustomBackoffStrategyModel: self.create_custom_component, 771 CustomDecoderModel: self.create_custom_component, 772 CustomErrorHandlerModel: self.create_custom_component, 773 CustomRecordExtractorModel: self.create_custom_component, 774 CustomRecordFilterModel: self.create_custom_component, 775 CustomRequesterModel: self.create_custom_component, 776 CustomRetrieverModel: self.create_custom_component, 777 CustomSchemaLoader: self.create_custom_component, 778 CustomSchemaNormalizationModel: self.create_custom_component, 779 CustomStateMigration: self.create_custom_component, 780 CustomPaginationStrategyModel: self.create_custom_component, 781 CustomPartitionRouterModel: self.create_custom_component, 782 CustomTransformationModel: self.create_custom_component, 783 CustomValidationStrategyModel: self.create_custom_component, 784 CustomConfigTransformationModel: self.create_custom_component, 785 DeclarativeStreamModel: self.create_default_stream, 786 DefaultErrorHandlerModel: self.create_default_error_handler, 787 DefaultPaginatorModel: self.create_default_paginator, 788 DpathExtractorModel: self.create_dpath_extractor, 789 DpathValidatorModel: self.create_dpath_validator, 790 ResponseToFileExtractorModel: self.create_response_to_file_extractor, 791 ExponentialBackoffStrategyModel: self.create_exponential_backoff_strategy, 792 SessionTokenAuthenticatorModel: self.create_session_token_authenticator, 793 GroupByKeyMergeStrategyModel: self.create_group_by_key, 794 HttpRequesterModel: self.create_http_requester, 795 HttpResponseFilterModel: self.create_http_response_filter, 796 InlineSchemaLoaderModel: self.create_inline_schema_loader, 797 JsonDecoderModel: self.create_json_decoder, 798 JsonItemsDecoderModel: self.create_json_items_decoder, 799 JsonlDecoderModel: self.create_jsonl_decoder, 800 JsonSchemaPropertySelectorModel: self.create_json_schema_property_selector, 801 GzipDecoderModel: self.create_gzip_decoder, 802 KeysToLowerModel: self.create_keys_to_lower_transformation, 803 KeysToSnakeCaseModel: self.create_keys_to_snake_transformation, 804 KeysReplaceModel: self.create_keys_replace_transformation, 805 FlattenFieldsModel: self.create_flatten_fields, 806 DpathFlattenFieldsModel: self.create_dpath_flatten_fields, 807 IterableDecoderModel: self.create_iterable_decoder, 808 XmlDecoderModel: self.create_xml_decoder, 809 JsonFileSchemaLoaderModel: self.create_json_file_schema_loader, 810 DynamicSchemaLoaderModel: self.create_dynamic_schema_loader, 811 SchemaTypeIdentifierModel: self.create_schema_type_identifier, 812 TypesMapModel: self.create_types_map, 813 ComplexFieldTypeModel: self.create_complex_field_type, 814 JwtAuthenticatorModel: self.create_jwt_authenticator, 815 LegacyToPerPartitionStateMigrationModel: self.create_legacy_to_per_partition_state_migration, 816 ListPartitionRouterModel: self.create_list_partition_router, 817 MinMaxDatetimeModel: self.create_min_max_datetime, 818 NoAuthModel: self.create_no_auth, 819 NoPaginationModel: self.create_no_pagination, 820 OAuthAuthenticatorModel: self.create_oauth_authenticator, 821 OffsetIncrementModel: self.create_offset_increment, 822 PageIncrementModel: self.create_page_increment, 823 ParentStreamConfigModel: self.create_parent_stream_config_with_substream_wrapper, 824 PredicateValidatorModel: self.create_predicate_validator, 825 PropertiesFromEndpointModel: self.create_properties_from_endpoint, 826 PropertyChunkingModel: self.create_property_chunking, 827 QueryPropertiesModel: self.create_query_properties, 828 RecordExpanderModel: self.create_record_expander, 829 RecordFilterModel: self.create_record_filter, 830 RecordSelectorModel: self.create_record_selector, 831 RemoveFieldsModel: self.create_remove_fields, 832 RequestPathModel: self.create_request_path, 833 RequestOptionModel: self.create_request_option, 834 LegacySessionTokenAuthenticatorModel: self.create_legacy_session_token_authenticator, 835 SelectiveAuthenticatorModel: self.create_selective_authenticator, 836 SimpleRetrieverModel: self.create_simple_retriever, 837 StateDelegatingStreamModel: self.create_state_delegating_stream, 838 SpecModel: self.create_spec, 839 SubstreamPartitionRouterModel: self.create_substream_partition_router, 840 ValidateAdheresToSchemaModel: self.create_validate_adheres_to_schema, 841 WaitTimeFromHeaderModel: self.create_wait_time_from_header, 842 WaitUntilTimeFromHeaderModel: self.create_wait_until_time_from_header, 843 AsyncRetrieverModel: self.create_async_retriever, 844 HttpComponentsResolverModel: self.create_http_components_resolver, 845 ConfigComponentsResolverModel: self.create_config_components_resolver, 846 ParametrizedComponentsResolverModel: self.create_parametrized_components_resolver, 847 StreamConfigModel: self.create_stream_config, 848 ComponentMappingDefinitionModel: self.create_components_mapping_definition, 849 ZipfileDecoderModel: self.create_zipfile_decoder, 850 HTTPAPIBudgetModel: self.create_http_api_budget, 851 FileUploaderModel: self.create_file_uploader, 852 FixedWindowCallRatePolicyModel: self.create_fixed_window_call_rate_policy, 853 MovingWindowCallRatePolicyModel: self.create_moving_window_call_rate_policy, 854 UnlimitedCallRatePolicyModel: self.create_unlimited_call_rate_policy, 855 RateModel: self.create_rate, 856 HttpRequestRegexMatcherModel: self.create_http_request_matcher, 857 RateLimitedMultipleTokenAuthenticatorModel: self.create_rate_limited_multiple_token_authenticator, 858 GroupingPartitionRouterModel: self.create_grouping_partition_router, 859 UnionPartitionRouterModel: self.create_union_partition_router, 860 } 861 862 # Needed for the case where we need to perform a second parse on the fields of a custom component 863 self.TYPE_NAME_TO_MODEL = {cls.__name__: cls for cls in self.PYDANTIC_MODEL_TO_CONSTRUCTOR} 864 865 @staticmethod 866 def _create_stream_name_to_configured_stream( 867 configured_catalog: Optional[ConfiguredAirbyteCatalog], 868 ) -> Mapping[str, ConfiguredAirbyteStream]: 869 return ( 870 {stream.stream.name: stream for stream in configured_catalog.streams} 871 if configured_catalog 872 else {} 873 ) 874 875 def create_component( 876 self, 877 model_type: Type[BaseModel], 878 component_definition: ComponentDefinition, 879 config: Config, 880 **kwargs: Any, 881 ) -> Any: 882 """ 883 Takes a given Pydantic model type and Mapping representing a component definition and creates a declarative component and 884 subcomponents which will be used at runtime. This is done by first parsing the mapping into a Pydantic model and then creating 885 creating declarative components from that model. 886 887 :param model_type: The type of declarative component that is being initialized 888 :param component_definition: The mapping that represents a declarative component 889 :param config: The connector config that is provided by the customer 890 :return: The declarative component to be used at runtime 891 """ 892 893 component_type = component_definition.get("type") 894 if component_definition.get("type") != model_type.__name__: 895 raise ValueError( 896 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 897 ) 898 899 declarative_component_model = model_type.parse_obj(component_definition) 900 901 if not isinstance(declarative_component_model, model_type): 902 raise ValueError( 903 f"Expected {model_type.__name__} component, but received {declarative_component_model.__class__.__name__}" 904 ) 905 906 return self._create_component_from_model( 907 model=declarative_component_model, config=config, **kwargs 908 ) 909 910 def _create_component_from_model(self, model: BaseModel, config: Config, **kwargs: Any) -> Any: 911 if model.__class__ not in self.PYDANTIC_MODEL_TO_CONSTRUCTOR: 912 raise ValueError( 913 f"{model.__class__} with attributes {model} is not a valid component type" 914 ) 915 component_constructor = self.PYDANTIC_MODEL_TO_CONSTRUCTOR.get(model.__class__) 916 if not component_constructor: 917 raise ValueError(f"Could not find constructor for {model.__class__}") 918 919 # collect deprecation warnings for supported models. 920 if isinstance(model, BaseModelWithDeprecations): 921 self._collect_model_deprecations(model) 922 923 return component_constructor(model=model, config=config, **kwargs) 924 925 def get_model_deprecations(self) -> List[ConnectorBuilderLogMessage]: 926 """ 927 Returns the deprecation warnings that were collected during the creation of components. 928 """ 929 return self._collected_deprecation_logs 930 931 def _collect_model_deprecations(self, model: BaseModelWithDeprecations) -> None: 932 """ 933 Collects deprecation logs from the given model and appends any new logs to the internal collection. 934 935 This method checks if the provided model has deprecation logs (identified by the presence of the DEPRECATION_LOGS_TAG attribute and a non-None `_deprecation_logs` property). It iterates through each deprecation log in the model and appends it to the `_collected_deprecation_logs` list if it has not already been collected, ensuring that duplicate logs are avoided. 936 937 Args: 938 model (BaseModelWithDeprecations): The model instance from which to collect deprecation logs. 939 """ 940 if hasattr(model, DEPRECATION_LOGS_TAG) and model._deprecation_logs is not None: 941 for log in model._deprecation_logs: 942 # avoid duplicates for deprecation logs observed. 943 if log not in self._collected_deprecation_logs: 944 self._collected_deprecation_logs.append(log) 945 946 def create_config_migration( 947 self, model: ConfigMigrationModel, config: Config 948 ) -> ConfigMigration: 949 transformations: List[ConfigTransformation] = [ 950 self._create_component_from_model(transformation, config) 951 for transformation in model.transformations 952 ] 953 954 return ConfigMigration( 955 description=model.description, 956 transformations=transformations, 957 ) 958 959 def create_config_add_fields( 960 self, model: ConfigAddFieldsModel, config: Config, **kwargs: Any 961 ) -> ConfigAddFields: 962 fields = [self._create_component_from_model(field, config) for field in model.fields] 963 return ConfigAddFields( 964 fields=fields, 965 condition=model.condition or "", 966 ) 967 968 @staticmethod 969 def create_config_remove_fields( 970 model: ConfigRemoveFieldsModel, config: Config, **kwargs: Any 971 ) -> ConfigRemoveFields: 972 return ConfigRemoveFields( 973 field_pointers=model.field_pointers, 974 condition=model.condition or "", 975 ) 976 977 @staticmethod 978 def create_config_remap_field( 979 model: ConfigRemapFieldModel, config: Config, **kwargs: Any 980 ) -> ConfigRemapField: 981 mapping = cast(Mapping[str, Any], model.map) 982 return ConfigRemapField( 983 map=mapping, 984 field_path=model.field_path, 985 config=config, 986 ) 987 988 def create_dpath_validator(self, model: DpathValidatorModel, config: Config) -> DpathValidator: 989 strategy = self._create_component_from_model(model.validation_strategy, config) 990 991 return DpathValidator( 992 field_path=model.field_path, 993 strategy=strategy, 994 ) 995 996 def create_predicate_validator( 997 self, model: PredicateValidatorModel, config: Config 998 ) -> PredicateValidator: 999 strategy = self._create_component_from_model(model.validation_strategy, config) 1000 1001 return PredicateValidator( 1002 value=model.value, 1003 strategy=strategy, 1004 ) 1005 1006 @staticmethod 1007 def create_validate_adheres_to_schema( 1008 model: ValidateAdheresToSchemaModel, config: Config, **kwargs: Any 1009 ) -> ValidateAdheresToSchema: 1010 base_schema = cast(Mapping[str, Any], model.base_schema) 1011 return ValidateAdheresToSchema( 1012 schema=base_schema, 1013 ) 1014 1015 @staticmethod 1016 def create_added_field_definition( 1017 model: AddedFieldDefinitionModel, config: Config, **kwargs: Any 1018 ) -> AddedFieldDefinition: 1019 interpolated_value = InterpolatedString.create( 1020 model.value, parameters=model.parameters or {} 1021 ) 1022 return AddedFieldDefinition( 1023 path=model.path, 1024 value=interpolated_value, 1025 value_type=ModelToComponentFactory._json_schema_type_name_to_type(model.value_type), 1026 parameters=model.parameters or {}, 1027 ) 1028 1029 def create_add_fields(self, model: AddFieldsModel, config: Config, **kwargs: Any) -> AddFields: 1030 added_field_definitions = [ 1031 self._create_component_from_model( 1032 model=added_field_definition_model, 1033 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 1034 added_field_definition_model.value_type 1035 ), 1036 config=config, 1037 ) 1038 for added_field_definition_model in model.fields 1039 ] 1040 return AddFields( 1041 fields=added_field_definitions, 1042 condition=model.condition or "", 1043 parameters=model.parameters or {}, 1044 ) 1045 1046 def create_keys_to_lower_transformation( 1047 self, model: KeysToLowerModel, config: Config, **kwargs: Any 1048 ) -> KeysToLowerTransformation: 1049 return KeysToLowerTransformation() 1050 1051 def create_keys_to_snake_transformation( 1052 self, model: KeysToSnakeCaseModel, config: Config, **kwargs: Any 1053 ) -> KeysToSnakeCaseTransformation: 1054 return KeysToSnakeCaseTransformation() 1055 1056 def create_keys_replace_transformation( 1057 self, model: KeysReplaceModel, config: Config, **kwargs: Any 1058 ) -> KeysReplaceTransformation: 1059 return KeysReplaceTransformation( 1060 old=model.old, new=model.new, parameters=model.parameters or {} 1061 ) 1062 1063 def create_flatten_fields( 1064 self, model: FlattenFieldsModel, config: Config, **kwargs: Any 1065 ) -> FlattenFields: 1066 return FlattenFields( 1067 flatten_lists=model.flatten_lists if model.flatten_lists is not None else True 1068 ) 1069 1070 def create_dpath_flatten_fields( 1071 self, model: DpathFlattenFieldsModel, config: Config, **kwargs: Any 1072 ) -> DpathFlattenFields: 1073 model_field_path: List[Union[InterpolatedString, str]] = [x for x in model.field_path] 1074 key_transformation = ( 1075 KeyTransformation( 1076 config=config, 1077 prefix=model.key_transformation.prefix, 1078 suffix=model.key_transformation.suffix, 1079 parameters=model.parameters or {}, 1080 ) 1081 if model.key_transformation is not None 1082 else None 1083 ) 1084 return DpathFlattenFields( 1085 config=config, 1086 field_path=model_field_path, 1087 delete_origin_value=model.delete_origin_value 1088 if model.delete_origin_value is not None 1089 else False, 1090 replace_record=model.replace_record if model.replace_record is not None else False, 1091 key_transformation=key_transformation, 1092 parameters=model.parameters or {}, 1093 ) 1094 1095 @staticmethod 1096 def _json_schema_type_name_to_type(value_type: Optional[ValueType]) -> Optional[Type[Any]]: 1097 if not value_type: 1098 return None 1099 names_to_types = { 1100 ValueType.string: str, 1101 ValueType.number: float, 1102 ValueType.integer: int, 1103 ValueType.boolean: bool, 1104 } 1105 return names_to_types[value_type] 1106 1107 def create_api_key_authenticator( 1108 self, 1109 model: ApiKeyAuthenticatorModel, 1110 config: Config, 1111 token_provider: Optional[TokenProvider] = None, 1112 **kwargs: Any, 1113 ) -> ApiKeyAuthenticator: 1114 if model.inject_into is None and model.header is None: 1115 raise ValueError( 1116 "Expected either inject_into or header to be set for ApiKeyAuthenticator" 1117 ) 1118 1119 if model.inject_into is not None and model.header is not None: 1120 raise ValueError( 1121 "inject_into and header cannot be set both for ApiKeyAuthenticator - remove the deprecated header option" 1122 ) 1123 1124 if token_provider is not None and model.api_token != "": 1125 raise ValueError( 1126 "If token_provider is set, api_token is ignored and has to be set to empty string." 1127 ) 1128 1129 request_option = ( 1130 self._create_component_from_model( 1131 model.inject_into, config, parameters=model.parameters or {} 1132 ) 1133 if model.inject_into 1134 else RequestOption( 1135 inject_into=RequestOptionType.header, 1136 field_name=model.header or "", 1137 parameters=model.parameters or {}, 1138 ) 1139 ) 1140 1141 return ApiKeyAuthenticator( 1142 token_provider=( 1143 token_provider 1144 if token_provider is not None 1145 else InterpolatedStringTokenProvider( 1146 api_token=model.api_token or "", 1147 config=config, 1148 parameters=model.parameters or {}, 1149 ) 1150 ), 1151 request_option=request_option, 1152 config=config, 1153 parameters=model.parameters or {}, 1154 ) 1155 1156 def create_legacy_to_per_partition_state_migration( 1157 self, 1158 model: LegacyToPerPartitionStateMigrationModel, 1159 config: Mapping[str, Any], 1160 declarative_stream: DeclarativeStreamModel, 1161 ) -> LegacyToPerPartitionStateMigration: 1162 retriever = declarative_stream.retriever 1163 if not isinstance(retriever, (SimpleRetrieverModel, AsyncRetrieverModel)): 1164 raise ValueError( 1165 f"LegacyToPerPartitionStateMigrations can only be applied on a DeclarativeStream with a SimpleRetriever or AsyncRetriever. Got {type(retriever)}" 1166 ) 1167 partition_router = retriever.partition_router 1168 if not isinstance( 1169 partition_router, 1170 ( 1171 SubstreamPartitionRouterModel, 1172 CustomPartitionRouterModel, 1173 UnionPartitionRouterModel, 1174 ), 1175 ): 1176 raise ValueError( 1177 f"LegacyToPerPartitionStateMigrations can only be applied on a SimpleRetriever with a SubstreamPartitionRouter, UnionPartitionRouter or CustomPartitionRouter. Got {type(partition_router)}" 1178 ) 1179 if not isinstance(partition_router, UnionPartitionRouterModel) and not hasattr( 1180 partition_router, "parent_stream_configs" 1181 ): 1182 raise ValueError( 1183 "LegacyToPerPartitionStateMigrations can only be applied with a parent stream configuration." 1184 ) 1185 1186 if not hasattr(declarative_stream, "incremental_sync"): 1187 raise ValueError( 1188 "LegacyToPerPartitionStateMigrations can only be applied with an incremental_sync configuration." 1189 ) 1190 1191 return LegacyToPerPartitionStateMigration( 1192 partition_router, # type: ignore # was already checked above 1193 declarative_stream.incremental_sync, # type: ignore # was already checked. Migration can be applied only to incremental streams. 1194 config, 1195 declarative_stream.parameters, # type: ignore # different type is expected here Mapping[str, Any], got Dict[str, Any] 1196 ) 1197 1198 def create_session_token_authenticator( 1199 self, model: SessionTokenAuthenticatorModel, config: Config, name: str, **kwargs: Any 1200 ) -> Union[ApiKeyAuthenticator, BearerAuthenticator]: 1201 decoder = ( 1202 self._create_component_from_model(model=model.decoder, config=config) 1203 if model.decoder 1204 else JsonDecoder(parameters={}) 1205 ) 1206 login_requester = self._create_component_from_model( 1207 model=model.login_requester, 1208 config=config, 1209 name=f"{name}_login_requester", 1210 decoder=decoder, 1211 ) 1212 token_provider = SessionTokenProvider( 1213 login_requester=login_requester, 1214 session_token_path=model.session_token_path, 1215 expiration_duration=parse_duration(model.expiration_duration) 1216 if model.expiration_duration 1217 else None, 1218 parameters=model.parameters or {}, 1219 message_repository=self._message_repository, 1220 decoder=decoder, 1221 ) 1222 if model.request_authentication.type == "Bearer": 1223 return ModelToComponentFactory.create_bearer_authenticator( 1224 BearerAuthenticatorModel(type="BearerAuthenticator", api_token=""), # type: ignore # $parameters has a default value 1225 config, 1226 token_provider=token_provider, 1227 ) 1228 else: 1229 # Get the api_token template if specified, default to just the session token 1230 api_token_template = ( 1231 getattr(model.request_authentication, "api_token", None) or "{{ session_token }}" 1232 ) 1233 final_token_provider: TokenProvider = InterpolatedSessionTokenProvider( 1234 config=config, 1235 api_token=api_token_template, 1236 session_token_provider=token_provider, 1237 parameters=model.parameters or {}, 1238 ) 1239 return self.create_api_key_authenticator( 1240 ApiKeyAuthenticatorModel( 1241 type="ApiKeyAuthenticator", 1242 api_token="", 1243 inject_into=model.request_authentication.inject_into, 1244 ), # type: ignore # $parameters and headers default to None 1245 config=config, 1246 token_provider=final_token_provider, 1247 ) 1248 1249 @staticmethod 1250 def create_basic_http_authenticator( 1251 model: BasicHttpAuthenticatorModel, config: Config, **kwargs: Any 1252 ) -> BasicHttpAuthenticator: 1253 return BasicHttpAuthenticator( 1254 password=model.password or "", 1255 username=model.username, 1256 config=config, 1257 parameters=model.parameters or {}, 1258 ) 1259 1260 @staticmethod 1261 def create_bearer_authenticator( 1262 model: BearerAuthenticatorModel, 1263 config: Config, 1264 token_provider: Optional[TokenProvider] = None, 1265 **kwargs: Any, 1266 ) -> BearerAuthenticator: 1267 if token_provider is not None and model.api_token != "": 1268 raise ValueError( 1269 "If token_provider is set, api_token is ignored and has to be set to empty string." 1270 ) 1271 return BearerAuthenticator( 1272 token_provider=( 1273 token_provider 1274 if token_provider is not None 1275 else InterpolatedStringTokenProvider( 1276 api_token=model.api_token or "", 1277 config=config, 1278 parameters=model.parameters or {}, 1279 ) 1280 ), 1281 config=config, 1282 parameters=model.parameters or {}, 1283 ) 1284 1285 @staticmethod 1286 def create_dynamic_stream_check_config( 1287 model: DynamicStreamCheckConfigModel, config: Config, **kwargs: Any 1288 ) -> DynamicStreamCheckConfig: 1289 return DynamicStreamCheckConfig( 1290 dynamic_stream_name=model.dynamic_stream_name, 1291 stream_count=model.stream_count, 1292 ) 1293 1294 def create_check_stream( 1295 self, model: CheckStreamModel, config: Config, **kwargs: Any 1296 ) -> CheckStream: 1297 if model.dynamic_streams_check_configs is None and model.stream_names is None: 1298 raise ValueError( 1299 "Expected either stream_names or dynamic_streams_check_configs to be set for CheckStream" 1300 ) 1301 1302 dynamic_streams_check_configs = ( 1303 [ 1304 self._create_component_from_model(model=dynamic_stream_check_config, config=config) 1305 for dynamic_stream_check_config in model.dynamic_streams_check_configs 1306 ] 1307 if model.dynamic_streams_check_configs 1308 else [] 1309 ) 1310 1311 # `model.config_overrides` is deliberately not read here. The source applies it around the whole 1312 # check operation (`ConcurrentDeclarativeSource._config_overridden_for_check`), which is what makes 1313 # it work for every checker type rather than only this one. Do not wire it in a second time. 1314 return CheckStream( 1315 stream_names=model.stream_names or [], 1316 dynamic_streams_check_configs=dynamic_streams_check_configs, 1317 parameters={}, 1318 ) 1319 1320 @staticmethod 1321 def create_check_dynamic_stream( 1322 model: CheckDynamicStreamModel, config: Config, **kwargs: Any 1323 ) -> CheckDynamicStream: 1324 assert model.use_check_availability is not None # for mypy 1325 1326 use_check_availability = model.use_check_availability 1327 1328 # See `create_check_stream`: `model.config_overrides` is applied by the source, not here. 1329 return CheckDynamicStream( 1330 stream_count=model.stream_count, 1331 use_check_availability=use_check_availability, 1332 parameters={}, 1333 ) 1334 1335 def create_composite_error_handler( 1336 self, model: CompositeErrorHandlerModel, config: Config, **kwargs: Any 1337 ) -> CompositeErrorHandler: 1338 error_handlers = [ 1339 self._create_component_from_model(model=error_handler_model, config=config) 1340 for error_handler_model in model.error_handlers 1341 ] 1342 return CompositeErrorHandler( 1343 error_handlers=error_handlers, parameters=model.parameters or {} 1344 ) 1345 1346 @staticmethod 1347 def create_concurrency_level( 1348 model: ConcurrencyLevelModel, config: Config, **kwargs: Any 1349 ) -> ConcurrencyLevel: 1350 return ConcurrencyLevel( 1351 default_concurrency=model.default_concurrency, 1352 max_concurrency=model.max_concurrency, 1353 config=config, 1354 parameters={}, 1355 ) 1356 1357 @staticmethod 1358 def apply_stream_state_migrations( 1359 stream_state_migrations: List[Any] | None, stream_state: MutableMapping[str, Any] 1360 ) -> MutableMapping[str, Any]: 1361 if stream_state_migrations: 1362 for state_migration in stream_state_migrations: 1363 if state_migration.should_migrate(stream_state): 1364 # The state variable is expected to be mutable but the migrate method returns an immutable mapping. 1365 stream_state = dict(state_migration.migrate(stream_state)) 1366 return stream_state 1367 1368 def create_concurrent_cursor_from_datetime_based_cursor( 1369 self, 1370 model_type: Type[BaseModel], 1371 component_definition: ComponentDefinition, 1372 stream_name: str, 1373 stream_namespace: Optional[str], 1374 stream_state: MutableMapping[str, Any], 1375 config: Config, 1376 message_repository: Optional[MessageRepository] = None, 1377 runtime_lookback_window: Optional[datetime.timedelta] = None, 1378 **kwargs: Any, 1379 ) -> ConcurrentCursor: 1380 component_type = component_definition.get("type") 1381 if component_definition.get("type") != model_type.__name__: 1382 raise ValueError( 1383 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 1384 ) 1385 1386 # FIXME the interfaces of the concurrent cursor are kind of annoying as they take a `ComponentDefinition` instead of the actual model. This was done because the ConcurrentDeclarativeSource didn't have access to the models [here for example](https://github.com/airbytehq/airbyte-python-cdk/blob/f525803b3fec9329e4cc8478996a92bf884bfde9/airbyte_cdk/sources/declarative/concurrent_declarative_source.py#L354C54-L354C91). So now we have two cases: 1387 # * The ComponentDefinition comes from model.__dict__ in which case we have `parameters` 1388 # * The ComponentDefinition comes from the manifest as a dict in which case we have `$parameters` 1389 # We should change those interfaces to use the model once we clean up the code in CDS at which point the parameter propagation should happen as part of the ModelToComponentFactory. 1390 if "$parameters" not in component_definition and "parameters" in component_definition: 1391 component_definition["$parameters"] = component_definition.get("parameters") # type: ignore # This is a dict 1392 datetime_based_cursor_model = model_type.parse_obj(component_definition) 1393 1394 if not isinstance(datetime_based_cursor_model, DatetimeBasedCursorModel): 1395 raise ValueError( 1396 f"Expected {model_type.__name__} component, but received {datetime_based_cursor_model.__class__.__name__}" 1397 ) 1398 1399 model_parameters = datetime_based_cursor_model.parameters or {} 1400 1401 cursor_field = self._get_catalog_defined_cursor_field( 1402 stream_name=stream_name, 1403 allow_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1404 or False, 1405 ) 1406 1407 if not cursor_field: 1408 interpolated_cursor_field = InterpolatedString.create( 1409 datetime_based_cursor_model.cursor_field, 1410 parameters=model_parameters, 1411 ) 1412 cursor_field = CursorField( 1413 cursor_field_key=interpolated_cursor_field.eval(config=config), 1414 supports_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1415 or False, 1416 ) 1417 1418 interpolated_partition_field_start = InterpolatedString.create( 1419 datetime_based_cursor_model.partition_field_start or "start_time", 1420 parameters=model_parameters, 1421 ) 1422 interpolated_partition_field_end = InterpolatedString.create( 1423 datetime_based_cursor_model.partition_field_end or "end_time", 1424 parameters=model_parameters, 1425 ) 1426 1427 slice_boundary_fields = ( 1428 interpolated_partition_field_start.eval(config=config), 1429 interpolated_partition_field_end.eval(config=config), 1430 ) 1431 1432 datetime_format = datetime_based_cursor_model.datetime_format 1433 1434 cursor_granularity = ( 1435 parse_duration(datetime_based_cursor_model.cursor_granularity) 1436 if datetime_based_cursor_model.cursor_granularity 1437 else None 1438 ) 1439 1440 lookback_window = None 1441 interpolated_lookback_window = ( 1442 InterpolatedString.create( 1443 datetime_based_cursor_model.lookback_window, 1444 parameters=model_parameters, 1445 ) 1446 if datetime_based_cursor_model.lookback_window 1447 else None 1448 ) 1449 if interpolated_lookback_window: 1450 evaluated_lookback_window = interpolated_lookback_window.eval(config=config) 1451 if evaluated_lookback_window: 1452 lookback_window = parse_duration(evaluated_lookback_window) 1453 1454 connector_state_converter: DateTimeStreamStateConverter 1455 connector_state_converter = CustomFormatConcurrentStreamStateConverter( 1456 datetime_format=datetime_format, 1457 input_datetime_formats=datetime_based_cursor_model.cursor_datetime_formats, 1458 is_sequential_state=True, # ConcurrentPerPartitionCursor only works with sequential state 1459 cursor_granularity=cursor_granularity, 1460 ) 1461 1462 # Adjusts the stream state by applying the runtime lookback window. 1463 # This is used to ensure correct state handling in case of failed partitions. 1464 stream_state_value = stream_state.get(cursor_field.cursor_field_key) 1465 if runtime_lookback_window and stream_state_value: 1466 new_stream_state = ( 1467 connector_state_converter.parse_timestamp(stream_state_value) 1468 - runtime_lookback_window 1469 ) 1470 stream_state[cursor_field.cursor_field_key] = connector_state_converter.output_format( 1471 new_stream_state 1472 ) 1473 1474 start_date_runtime_value: Union[InterpolatedString, str, MinMaxDatetime] 1475 if isinstance(datetime_based_cursor_model.start_datetime, MinMaxDatetimeModel): 1476 start_date_runtime_value = self.create_min_max_datetime( 1477 model=datetime_based_cursor_model.start_datetime, config=config 1478 ) 1479 else: 1480 start_date_runtime_value = datetime_based_cursor_model.start_datetime 1481 1482 end_date_runtime_value: Optional[Union[InterpolatedString, str, MinMaxDatetime]] 1483 if isinstance(datetime_based_cursor_model.end_datetime, MinMaxDatetimeModel): 1484 end_date_runtime_value = self.create_min_max_datetime( 1485 model=datetime_based_cursor_model.end_datetime, config=config 1486 ) 1487 else: 1488 end_date_runtime_value = datetime_based_cursor_model.end_datetime 1489 1490 interpolated_start_date = MinMaxDatetime.create( 1491 interpolated_string_or_min_max_datetime=start_date_runtime_value, 1492 parameters=datetime_based_cursor_model.parameters, 1493 ) 1494 interpolated_end_date = ( 1495 None 1496 if not end_date_runtime_value 1497 else MinMaxDatetime.create( 1498 end_date_runtime_value, datetime_based_cursor_model.parameters 1499 ) 1500 ) 1501 1502 # If datetime format is not specified then start/end datetime should inherit it from the stream slicer 1503 if not interpolated_start_date.datetime_format: 1504 interpolated_start_date.datetime_format = datetime_format 1505 if interpolated_end_date and not interpolated_end_date.datetime_format: 1506 interpolated_end_date.datetime_format = datetime_format 1507 1508 start_date = interpolated_start_date.get_datetime(config=config) 1509 end_date_provider = ( 1510 partial(interpolated_end_date.get_datetime, config) 1511 if interpolated_end_date 1512 else connector_state_converter.get_end_provider() 1513 ) 1514 1515 if ( 1516 datetime_based_cursor_model.step and not datetime_based_cursor_model.cursor_granularity 1517 ) or ( 1518 not datetime_based_cursor_model.step and datetime_based_cursor_model.cursor_granularity 1519 ): 1520 raise ValueError( 1521 f"If step is defined, cursor_granularity should be as well and vice-versa. " 1522 f"Right now, step is `{datetime_based_cursor_model.step}` and cursor_granularity is `{datetime_based_cursor_model.cursor_granularity}`" 1523 ) 1524 1525 # When step is not defined, default to a step size from the starting date to the present moment 1526 step_length = datetime.timedelta.max 1527 interpolated_step = ( 1528 InterpolatedString.create( 1529 datetime_based_cursor_model.step, 1530 parameters=model_parameters, 1531 ) 1532 if datetime_based_cursor_model.step 1533 else None 1534 ) 1535 if interpolated_step: 1536 evaluated_step = interpolated_step.eval(config) 1537 if evaluated_step: 1538 step_length = parse_duration(evaluated_step) 1539 1540 clamping_strategy: ClampingStrategy = NoClamping() 1541 if datetime_based_cursor_model.clamping: 1542 # While it is undesirable to interpolate within the model factory (as opposed to at runtime), 1543 # it is still better than shifting interpolation low-code concept into the ConcurrentCursor runtime 1544 # object which we want to keep agnostic of being low-code 1545 target = InterpolatedString( 1546 string=datetime_based_cursor_model.clamping.target, 1547 parameters=model_parameters, 1548 ) 1549 evaluated_target = target.eval(config=config) 1550 match evaluated_target: 1551 case "DAY": 1552 clamping_strategy = DayClampingStrategy() 1553 end_date_provider = ClampingEndProvider( 1554 DayClampingStrategy(is_ceiling=False), 1555 end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1556 granularity=cursor_granularity or datetime.timedelta(seconds=1), 1557 ) 1558 case "WEEK": 1559 if ( 1560 not datetime_based_cursor_model.clamping.target_details 1561 or "weekday" not in datetime_based_cursor_model.clamping.target_details 1562 ): 1563 raise ValueError( 1564 "Given WEEK clamping, weekday needs to be provided as target_details" 1565 ) 1566 weekday = self._assemble_weekday( 1567 datetime_based_cursor_model.clamping.target_details["weekday"] 1568 ) 1569 clamping_strategy = WeekClampingStrategy(weekday) 1570 end_date_provider = ClampingEndProvider( 1571 WeekClampingStrategy(weekday, is_ceiling=False), 1572 end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1573 granularity=cursor_granularity or datetime.timedelta(days=1), 1574 ) 1575 case "MONTH": 1576 clamping_strategy = MonthClampingStrategy() 1577 end_date_provider = ClampingEndProvider( 1578 MonthClampingStrategy(is_ceiling=False), 1579 end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1580 granularity=cursor_granularity or datetime.timedelta(days=1), 1581 ) 1582 case _: 1583 raise ValueError( 1584 f"Invalid clamping target {evaluated_target}, expected DAY, WEEK, MONTH" 1585 ) 1586 1587 return ConcurrentCursor( 1588 stream_name=stream_name, 1589 stream_namespace=stream_namespace, 1590 stream_state=stream_state, 1591 message_repository=message_repository or self._message_repository, 1592 connector_state_manager=self._connector_state_manager, 1593 connector_state_converter=connector_state_converter, 1594 cursor_field=cursor_field, 1595 slice_boundary_fields=slice_boundary_fields, 1596 start=start_date, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1597 end_provider=end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1598 lookback_window=lookback_window, 1599 slice_range=step_length, 1600 cursor_granularity=cursor_granularity, 1601 clamping_strategy=clamping_strategy, 1602 ) 1603 1604 def create_concurrent_cursor_from_incrementing_count_cursor( 1605 self, 1606 model_type: Type[BaseModel], 1607 component_definition: ComponentDefinition, 1608 stream_name: str, 1609 stream_namespace: Optional[str], 1610 stream_state: MutableMapping[str, Any], 1611 config: Config, 1612 message_repository: Optional[MessageRepository] = None, 1613 **kwargs: Any, 1614 ) -> ConcurrentCursor: 1615 component_type = component_definition.get("type") 1616 if component_definition.get("type") != model_type.__name__: 1617 raise ValueError( 1618 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 1619 ) 1620 1621 incrementing_count_cursor_model = model_type.parse_obj(component_definition) 1622 1623 if not isinstance(incrementing_count_cursor_model, IncrementingCountCursorModel): 1624 raise ValueError( 1625 f"Expected {model_type.__name__} component, but received {incrementing_count_cursor_model.__class__.__name__}" 1626 ) 1627 1628 start_value: Union[int, str, None] = incrementing_count_cursor_model.start_value 1629 # Pydantic Union type coercion can convert int 0 to string '0' depending on Union order. 1630 # We need to handle both int and str representations of numeric values. 1631 # Evaluate the InterpolatedString and convert to int for the ConcurrentCursor. 1632 if start_value is not None: 1633 interpolated_start_value = InterpolatedString.create( 1634 str(start_value), # Ensure we pass a string to InterpolatedString.create 1635 parameters=incrementing_count_cursor_model.parameters or {}, 1636 ) 1637 evaluated_start_value: int = int(interpolated_start_value.eval(config=config)) 1638 else: 1639 evaluated_start_value = 0 1640 1641 cursor_field = self._get_catalog_defined_cursor_field( 1642 stream_name=stream_name, 1643 allow_catalog_defined_cursor_field=incrementing_count_cursor_model.allow_catalog_defined_cursor_field 1644 or False, 1645 ) 1646 1647 if not cursor_field: 1648 interpolated_cursor_field = InterpolatedString.create( 1649 incrementing_count_cursor_model.cursor_field, 1650 parameters=incrementing_count_cursor_model.parameters or {}, 1651 ) 1652 cursor_field = CursorField( 1653 cursor_field_key=interpolated_cursor_field.eval(config=config), 1654 supports_catalog_defined_cursor_field=incrementing_count_cursor_model.allow_catalog_defined_cursor_field 1655 or False, 1656 ) 1657 1658 connector_state_converter = IncrementingCountStreamStateConverter( 1659 is_sequential_state=True, # ConcurrentPerPartitionCursor only works with sequential state 1660 ) 1661 1662 return ConcurrentCursor( 1663 stream_name=stream_name, 1664 stream_namespace=stream_namespace, 1665 stream_state=stream_state, 1666 message_repository=message_repository or self._message_repository, 1667 connector_state_manager=self._connector_state_manager, 1668 connector_state_converter=connector_state_converter, 1669 cursor_field=cursor_field, 1670 slice_boundary_fields=None, 1671 start=evaluated_start_value, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1672 end_provider=connector_state_converter.get_end_provider(), # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1673 ) 1674 1675 def _assemble_weekday(self, weekday: str) -> Weekday: 1676 match weekday: 1677 case "MONDAY": 1678 return Weekday.MONDAY 1679 case "TUESDAY": 1680 return Weekday.TUESDAY 1681 case "WEDNESDAY": 1682 return Weekday.WEDNESDAY 1683 case "THURSDAY": 1684 return Weekday.THURSDAY 1685 case "FRIDAY": 1686 return Weekday.FRIDAY 1687 case "SATURDAY": 1688 return Weekday.SATURDAY 1689 case "SUNDAY": 1690 return Weekday.SUNDAY 1691 case _: 1692 raise ValueError(f"Unknown weekday {weekday}") 1693 1694 def create_concurrent_cursor_from_perpartition_cursor( 1695 self, 1696 state_manager: ConnectorStateManager, 1697 model_type: Type[BaseModel], 1698 component_definition: ComponentDefinition, 1699 stream_name: str, 1700 stream_namespace: Optional[str], 1701 config: Config, 1702 stream_state: MutableMapping[str, Any], 1703 partition_router: PartitionRouter, 1704 attempt_to_create_cursor_if_not_provided: bool = False, 1705 **kwargs: Any, 1706 ) -> ConcurrentPerPartitionCursor: 1707 component_type = component_definition.get("type") 1708 if component_definition.get("type") != model_type.__name__: 1709 raise ValueError( 1710 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 1711 ) 1712 1713 # FIXME the interfaces of the concurrent cursor are kind of annoying as they take a `ComponentDefinition` instead of the actual model. This was done because the ConcurrentDeclarativeSource didn't have access to the models [here for example](https://github.com/airbytehq/airbyte-python-cdk/blob/f525803b3fec9329e4cc8478996a92bf884bfde9/airbyte_cdk/sources/declarative/concurrent_declarative_source.py#L354C54-L354C91). So now we have two cases: 1714 # * The ComponentDefinition comes from model.__dict__ in which case we have `parameters` 1715 # * The ComponentDefinition comes from the manifest as a dict in which case we have `$parameters` 1716 # We should change those interfaces to use the model once we clean up the code in CDS at which point the parameter propagation should happen as part of the ModelToComponentFactory. 1717 if "$parameters" not in component_definition and "parameters" in component_definition: 1718 component_definition["$parameters"] = component_definition.get("parameters") # type: ignore # This is a dict 1719 datetime_based_cursor_model = model_type.parse_obj(component_definition) 1720 1721 if not isinstance(datetime_based_cursor_model, DatetimeBasedCursorModel): 1722 raise ValueError( 1723 f"Expected {model_type.__name__} component, but received {datetime_based_cursor_model.__class__.__name__}" 1724 ) 1725 1726 cursor_field = self._get_catalog_defined_cursor_field( 1727 stream_name=stream_name, 1728 allow_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1729 or False, 1730 ) 1731 1732 if not cursor_field: 1733 interpolated_cursor_field = InterpolatedString.create( 1734 datetime_based_cursor_model.cursor_field, 1735 # FIXME the interfaces of the concurrent cursor are kind of annoying as they take a `ComponentDefinition` instead of the actual model. This was done because the ConcurrentDeclarativeSource didn't have access to the models [here for example](https://github.com/airbytehq/airbyte-python-cdk/blob/f525803b3fec9329e4cc8478996a92bf884bfde9/airbyte_cdk/sources/declarative/concurrent_declarative_source.py#L354C54-L354C91). So now we have two cases: 1736 # * The ComponentDefinition comes from model.__dict__ in which case we have `parameters` 1737 # * The ComponentDefinition comes from the manifest as a dict in which case we have `$parameters` 1738 # We should change those interfaces to use the model once we clean up the code in CDS at which point the parameter propagation should happen as part of the ModelToComponentFactory. 1739 parameters=datetime_based_cursor_model.parameters or {}, 1740 ) 1741 cursor_field = CursorField( 1742 cursor_field_key=interpolated_cursor_field.eval(config=config), 1743 supports_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1744 or False, 1745 ) 1746 1747 datetime_format = datetime_based_cursor_model.datetime_format 1748 1749 cursor_granularity = ( 1750 parse_duration(datetime_based_cursor_model.cursor_granularity) 1751 if datetime_based_cursor_model.cursor_granularity 1752 else None 1753 ) 1754 1755 connector_state_converter: DateTimeStreamStateConverter 1756 connector_state_converter = CustomFormatConcurrentStreamStateConverter( 1757 datetime_format=datetime_format, 1758 input_datetime_formats=datetime_based_cursor_model.cursor_datetime_formats, 1759 is_sequential_state=True, # ConcurrentPerPartitionCursor only works with sequential state 1760 cursor_granularity=cursor_granularity, 1761 ) 1762 1763 # Create the cursor factory 1764 cursor_factory = ConcurrentCursorFactory( 1765 partial( 1766 self.create_concurrent_cursor_from_datetime_based_cursor, 1767 state_manager=state_manager, 1768 model_type=model_type, 1769 component_definition=component_definition, 1770 stream_name=stream_name, 1771 stream_namespace=stream_namespace, 1772 config=config, 1773 message_repository=NoopMessageRepository(), 1774 ) 1775 ) 1776 1777 # Per-partition state doesn't make sense for GroupingPartitionRouter, so force the global state 1778 use_global_cursor = isinstance( 1779 partition_router, GroupingPartitionRouter 1780 ) or component_definition.get("global_substream_cursor", False) 1781 1782 # Return the concurrent cursor and state converter 1783 return ConcurrentPerPartitionCursor( 1784 cursor_factory=cursor_factory, 1785 partition_router=partition_router, 1786 stream_name=stream_name, 1787 stream_namespace=stream_namespace, 1788 stream_state=stream_state, 1789 message_repository=self._message_repository, # type: ignore 1790 connector_state_manager=state_manager, 1791 connector_state_converter=connector_state_converter, 1792 cursor_field=cursor_field, 1793 use_global_cursor=use_global_cursor, 1794 attempt_to_create_cursor_if_not_provided=attempt_to_create_cursor_if_not_provided, 1795 ) 1796 1797 @staticmethod 1798 def create_constant_backoff_strategy( 1799 model: ConstantBackoffStrategyModel, config: Config, **kwargs: Any 1800 ) -> ConstantBackoffStrategy: 1801 ModelToComponentFactory._validate_jitter_range(model.jitter_range_in_seconds) 1802 return ConstantBackoffStrategy( 1803 backoff_time_in_seconds=model.backoff_time_in_seconds, 1804 jitter_range_in_seconds=model.jitter_range_in_seconds, 1805 config=config, 1806 parameters=model.parameters or {}, 1807 ) 1808 1809 @staticmethod 1810 def _validate_jitter_range(jitter_range_in_seconds: Optional[float]) -> None: 1811 if jitter_range_in_seconds is not None and jitter_range_in_seconds < 0: 1812 raise ValueError("jitter_range_in_seconds must be greater than or equal to 0") 1813 1814 def create_cursor_pagination( 1815 self, model: CursorPaginationModel, config: Config, decoder: Decoder, **kwargs: Any 1816 ) -> CursorPaginationStrategy: 1817 if isinstance(decoder, PaginationDecoderDecorator): 1818 inner_decoder = decoder.decoder 1819 else: 1820 inner_decoder = decoder 1821 decoder = PaginationDecoderDecorator(decoder=decoder) 1822 1823 if self._is_supported_decoder_for_pagination(inner_decoder): 1824 decoder_to_use = decoder 1825 else: 1826 raise ValueError( 1827 self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(inner_decoder)) 1828 ) 1829 1830 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 1831 # If page_size is a string that represents an integer (not an interpolation), convert it back. 1832 page_size = model.page_size 1833 if isinstance(page_size, str) and page_size.isdigit(): 1834 page_size = int(page_size) 1835 1836 return CursorPaginationStrategy( 1837 cursor_value=model.cursor_value, 1838 decoder=decoder_to_use, 1839 page_size=page_size, 1840 stop_condition=model.stop_condition, 1841 config=config, 1842 parameters=model.parameters or {}, 1843 ) 1844 1845 def create_custom_component(self, model: Any, config: Config, **kwargs: Any) -> Any: 1846 """ 1847 Generically creates a custom component based on the model type and a class_name reference to the custom Python class being 1848 instantiated. Only the model's additional properties that match the custom class definition are passed to the constructor 1849 :param model: The Pydantic model of the custom component being created 1850 :param config: The custom defined connector config 1851 :return: The declarative component built from the Pydantic model to be used at runtime 1852 """ 1853 # Instantiating a custom component means importing and executing arbitrary code referenced 1854 # by `class_name`. Manifests supplied by a caller, whether through the config or directly to 1855 # the manifest server, are untrusted input and could point `class_name` at any importable 1856 # callable, so they honor the same `AIRBYTE_ENABLE_UNSAFE_CODE` gate as injected 1857 # `components.py` code. Manifests bundled in a published connector image are trusted and may 1858 # always use their bundled custom components. 1859 manifest_is_untrusted = not self._custom_components_trusted or bool( 1860 config.get(INJECTED_MANIFEST) 1861 ) 1862 if manifest_is_untrusted and not custom_code_execution_permitted(): 1863 raise AirbyteCustomCodeNotPermittedError 1864 1865 custom_component_class = self._get_class_from_fully_qualified_class_name(model.class_name) 1866 component_fields = get_type_hints(custom_component_class) 1867 model_args = model.dict() 1868 model_args["config"] = config 1869 1870 # There are cases where a parent component will pass arguments to a child component via kwargs. When there are field collisions 1871 # we defer to these arguments over the component's definition 1872 for key, arg in kwargs.items(): 1873 model_args[key] = arg 1874 1875 # Pydantic is unable to parse a custom component's fields that are subcomponents into models because their fields and types are not 1876 # defined in the schema. The fields and types are defined within the Python class implementation. Pydantic can only parse down to 1877 # the custom component and this code performs a second parse to convert the sub-fields first into models, then declarative components 1878 for model_field, model_value in model_args.items(): 1879 # If a custom component field doesn't have a type set, we try to use the type hints to infer the type 1880 if ( 1881 isinstance(model_value, dict) 1882 and "type" not in model_value 1883 and model_field in component_fields 1884 ): 1885 derived_type = self._derive_component_type_from_type_hints( 1886 component_fields.get(model_field) 1887 ) 1888 if derived_type: 1889 model_value["type"] = derived_type 1890 1891 if self._is_component(model_value): 1892 model_args[model_field] = self._create_nested_component( 1893 model, 1894 model_field, 1895 model_value, 1896 config, 1897 **kwargs, 1898 ) 1899 elif isinstance(model_value, list): 1900 vals = [] 1901 for v in model_value: 1902 if isinstance(v, dict) and "type" not in v and model_field in component_fields: 1903 derived_type = self._derive_component_type_from_type_hints( 1904 component_fields.get(model_field) 1905 ) 1906 if derived_type: 1907 v["type"] = derived_type 1908 if self._is_component(v): 1909 vals.append( 1910 self._create_nested_component( 1911 model, 1912 model_field, 1913 v, 1914 config, 1915 **kwargs, 1916 ) 1917 ) 1918 else: 1919 vals.append(v) 1920 model_args[model_field] = vals 1921 1922 kwargs = { 1923 class_field: model_args[class_field] 1924 for class_field in component_fields.keys() 1925 if class_field in model_args 1926 } 1927 1928 if "api_budget" in component_fields and kwargs.get("api_budget") is None: 1929 kwargs["api_budget"] = self._api_budget 1930 1931 return custom_component_class(**kwargs) 1932 1933 @staticmethod 1934 def _get_class_from_fully_qualified_class_name( 1935 full_qualified_class_name: str, 1936 ) -> Any: 1937 """Get a class from its fully qualified name. 1938 1939 If a custom components module is needed, we assume it is already registered - probably 1940 as `source_declarative_manifest.components` or `components`. 1941 1942 Args: 1943 full_qualified_class_name (str): The fully qualified name of the class (e.g., "module.ClassName"). 1944 1945 Returns: 1946 Any: The class object. 1947 1948 Raises: 1949 ValueError: If the class cannot be loaded. 1950 """ 1951 split = full_qualified_class_name.split(".") 1952 module_name_full = ".".join(split[:-1]) 1953 class_name = split[-1] 1954 1955 try: 1956 module_ref = importlib.import_module(module_name_full) 1957 except ModuleNotFoundError as e: 1958 if split[0] == "source_declarative_manifest": 1959 # During testing, the modules containing the custom components are not moved to source_declarative_manifest. In order to run the test, add the source folder to your PYTHONPATH or add it runtime using sys.path.append 1960 try: 1961 import os 1962 1963 module_name_with_source_declarative_manifest = ".".join(split[1:-1]) 1964 module_ref = importlib.import_module( 1965 module_name_with_source_declarative_manifest 1966 ) 1967 except ModuleNotFoundError: 1968 raise ValueError(f"Could not load module `{module_name_full}`.") from e 1969 else: 1970 raise ValueError(f"Could not load module `{module_name_full}`.") from e 1971 1972 try: 1973 return getattr(module_ref, class_name) 1974 except AttributeError as e: 1975 raise ValueError( 1976 f"Could not load class `{class_name}` from module `{module_name_full}`.", 1977 ) from e 1978 1979 @staticmethod 1980 def _derive_component_type_from_type_hints(field_type: Any) -> Optional[str]: 1981 interface = field_type 1982 while True: 1983 origin = get_origin(interface) 1984 if origin: 1985 # Unnest types until we reach the raw type 1986 # List[T] -> T 1987 # Optional[List[T]] -> T 1988 args = get_args(interface) 1989 interface = args[0] 1990 else: 1991 break 1992 if isinstance(interface, type) and not ModelToComponentFactory.is_builtin_type(interface): 1993 return interface.__name__ 1994 return None 1995 1996 @staticmethod 1997 def is_builtin_type(cls: Optional[Type[Any]]) -> bool: 1998 if not cls: 1999 return False 2000 return cls.__module__ == "builtins" 2001 2002 @staticmethod 2003 def _extract_missing_parameters(error: TypeError) -> List[str]: 2004 parameter_search = re.search(r"keyword-only.*:\s(.*)", str(error)) 2005 if parameter_search: 2006 return re.findall(r"\'(.+?)\'", parameter_search.group(1)) 2007 else: 2008 return [] 2009 2010 def _create_nested_component( 2011 self, model: Any, model_field: str, model_value: Any, config: Config, **kwargs: Any 2012 ) -> Any: 2013 type_name = model_value.get("type", None) 2014 if not type_name: 2015 # If no type is specified, we can assume this is a dictionary object which can be returned instead of a subcomponent 2016 return model_value 2017 2018 model_type = self.TYPE_NAME_TO_MODEL.get(type_name, None) 2019 if model_type: 2020 parsed_model = model_type.parse_obj(model_value) 2021 try: 2022 # To improve usability of the language, certain fields are shared between components. This can come in the form of 2023 # a parent component passing some of its fields to a child component or the parent extracting fields from other child 2024 # components and passing it to others. One example is the DefaultPaginator referencing the HttpRequester url_base 2025 # while constructing a SimpleRetriever. However, custom components don't support this behavior because they are created 2026 # generically in create_custom_component(). This block allows developers to specify extra arguments in $parameters that 2027 # are needed by a component and could not be shared. 2028 model_constructor = self.PYDANTIC_MODEL_TO_CONSTRUCTOR.get(parsed_model.__class__) 2029 constructor_kwargs = inspect.getfullargspec(model_constructor).kwonlyargs 2030 model_parameters = model_value.get("$parameters", {}) 2031 matching_parameters = { 2032 kwarg: model_parameters[kwarg] 2033 for kwarg in constructor_kwargs 2034 if kwarg in model_parameters 2035 } 2036 matching_kwargs = { 2037 kwarg: kwargs[kwarg] for kwarg in constructor_kwargs if kwarg in kwargs 2038 } 2039 return self._create_component_from_model( 2040 model=parsed_model, config=config, **(matching_parameters | matching_kwargs) 2041 ) 2042 except TypeError as error: 2043 missing_parameters = self._extract_missing_parameters(error) 2044 if missing_parameters: 2045 raise ValueError( 2046 f"Error creating component '{type_name}' with parent custom component {model.class_name}: Please provide " 2047 + ", ".join( 2048 ( 2049 f"{type_name}.$parameters.{parameter}" 2050 for parameter in missing_parameters 2051 ) 2052 ) 2053 ) 2054 raise TypeError( 2055 f"Error creating component '{type_name}' with parent custom component {model.class_name}: {error}" 2056 ) 2057 else: 2058 raise ValueError( 2059 f"Error creating custom component {model.class_name}. Subcomponent creation has not been implemented for '{type_name}'" 2060 ) 2061 2062 @staticmethod 2063 def _is_component(model_value: Any) -> bool: 2064 return isinstance(model_value, dict) and model_value.get("type") is not None 2065 2066 def create_default_stream( 2067 self, model: DeclarativeStreamModel, config: Config, is_parent: bool = False, **kwargs: Any 2068 ) -> AbstractStream: 2069 primary_key = model.primary_key.__root__ if model.primary_key else None 2070 self._migrate_state(model, config) 2071 self._warn_on_ineffective_incremental_dependency(model) 2072 2073 partition_router = self._build_stream_slicer_from_partition_router( 2074 model.retriever, 2075 config, 2076 stream_name=model.name, 2077 **kwargs, 2078 ) 2079 concurrent_cursor = self._build_concurrent_cursor(model, partition_router, config) 2080 if model.incremental_sync and isinstance(model.incremental_sync, DatetimeBasedCursorModel): 2081 cursor_model: DatetimeBasedCursorModel = model.incremental_sync 2082 2083 end_time_option = ( 2084 self._create_component_from_model( 2085 cursor_model.end_time_option, config, parameters=cursor_model.parameters or {} 2086 ) 2087 if cursor_model.end_time_option 2088 else None 2089 ) 2090 start_time_option = ( 2091 self._create_component_from_model( 2092 cursor_model.start_time_option, config, parameters=cursor_model.parameters or {} 2093 ) 2094 if cursor_model.start_time_option 2095 else None 2096 ) 2097 2098 datetime_request_options_provider = DatetimeBasedRequestOptionsProvider( 2099 start_time_option=start_time_option, 2100 end_time_option=end_time_option, 2101 partition_field_start=cursor_model.partition_field_start, 2102 partition_field_end=cursor_model.partition_field_end, 2103 config=config, 2104 parameters=model.parameters or {}, 2105 ) 2106 request_options_provider = ( 2107 datetime_request_options_provider 2108 if not isinstance(concurrent_cursor, ConcurrentPerPartitionCursor) 2109 else PerPartitionRequestOptionsProvider( 2110 partition_router, datetime_request_options_provider 2111 ) 2112 ) 2113 elif model.incremental_sync and isinstance( 2114 model.incremental_sync, IncrementingCountCursorModel 2115 ): 2116 if isinstance(concurrent_cursor, ConcurrentPerPartitionCursor): 2117 raise ValueError( 2118 "PerPartition does not support per partition states because switching to global state is time based" 2119 ) 2120 2121 cursor_model: IncrementingCountCursorModel = model.incremental_sync # type: ignore 2122 2123 start_time_option = ( 2124 self._create_component_from_model( 2125 cursor_model.start_value_option, # type: ignore # mypy still thinks cursor_model of type DatetimeBasedCursor 2126 config, 2127 parameters=cursor_model.parameters or {}, 2128 ) 2129 if cursor_model.start_value_option # type: ignore # mypy still thinks cursor_model of type DatetimeBasedCursor 2130 else None 2131 ) 2132 2133 # The concurrent engine defaults the start/end fields on the slice to "start" and "end", but 2134 # the default DatetimeBasedRequestOptionsProvider() sets them to start_time/end_time 2135 partition_field_start = "start" 2136 2137 request_options_provider = DatetimeBasedRequestOptionsProvider( 2138 start_time_option=start_time_option, 2139 partition_field_start=partition_field_start, 2140 config=config, 2141 parameters=model.parameters or {}, 2142 ) 2143 else: 2144 request_options_provider = None 2145 2146 transformations = [] 2147 if model.transformations: 2148 for transformation_model in model.transformations: 2149 transformations.append( 2150 self._create_component_from_model(model=transformation_model, config=config) 2151 ) 2152 file_uploader = None 2153 if model.file_uploader: 2154 file_uploader = self._create_component_from_model( 2155 model=model.file_uploader, config=config 2156 ) 2157 2158 stream_slicer: ConcurrentStreamSlicer = ( 2159 partition_router 2160 if isinstance(concurrent_cursor, FinalStateCursor) 2161 else concurrent_cursor 2162 ) 2163 2164 retriever = self._create_component_from_model( 2165 model=model.retriever, 2166 config=config, 2167 name=model.name, 2168 primary_key=primary_key, 2169 request_options_provider=request_options_provider, 2170 stream_slicer=stream_slicer, 2171 partition_router=partition_router, 2172 has_stop_condition_cursor=self._is_stop_condition_on_cursor(model), 2173 is_client_side_incremental_sync=self._is_client_side_filtering_enabled(model), 2174 cursor=concurrent_cursor, 2175 transformations=transformations, 2176 file_uploader=file_uploader, 2177 incremental_sync=model.incremental_sync, 2178 ) 2179 if isinstance(retriever, AsyncRetriever): 2180 stream_slicer = retriever.stream_slicer 2181 2182 schema_loader: SchemaLoader 2183 if model.schema_loader and isinstance(model.schema_loader, list): 2184 nested_schema_loaders = [ 2185 self._create_component_from_model(model=nested_schema_loader, config=config) 2186 for nested_schema_loader in model.schema_loader 2187 ] 2188 schema_loader = CompositeSchemaLoader( 2189 schema_loaders=nested_schema_loaders, parameters={} 2190 ) 2191 elif model.schema_loader: 2192 schema_loader = self._create_component_from_model( 2193 model=model.schema_loader, # type: ignore # If defined, schema_loader is guaranteed not to be a list and will be one of the existing base models 2194 config=config, 2195 ) 2196 else: 2197 options = model.parameters or {} 2198 if "name" not in options: 2199 options["name"] = model.name 2200 schema_loader = DefaultSchemaLoader(config=config, parameters=options) 2201 schema_loader = CachingSchemaLoaderDecorator(schema_loader) 2202 2203 stream_name = model.name or "" 2204 return DefaultStream( 2205 partition_generator=StreamSlicerPartitionGenerator( 2206 DeclarativePartitionFactory( 2207 stream_name, 2208 schema_loader, 2209 retriever, 2210 self._message_repository, 2211 ), 2212 stream_slicer, 2213 slice_limit=self._limit_slices_fetched, 2214 ), 2215 name=stream_name, 2216 json_schema=schema_loader.get_json_schema, 2217 primary_key=get_primary_key_from_stream(primary_key), 2218 cursor_field=( 2219 concurrent_cursor.cursor_field 2220 if hasattr(concurrent_cursor, "cursor_field") 2221 else None 2222 ), 2223 logger=logging.getLogger(f"airbyte.{stream_name}"), 2224 cursor=concurrent_cursor, 2225 supports_file_transfer=hasattr(model, "file_uploader") and bool(model.file_uploader), 2226 ) 2227 2228 def _warn_on_ineffective_incremental_dependency(self, model: DeclarativeStreamModel) -> None: 2229 """ 2230 `incremental_dependency: true` only takes effect when the substream defines its own 2231 `incremental_sync`: the parent cursor is persisted under the `parent_state` key of the 2232 substream's state, which is only emitted by incremental substreams. On a stream without 2233 `incremental_sync`, the setting is silently ignored and all parent records are re-read on 2234 every sync, so we warn about the misconfiguration instead. 2235 """ 2236 if model.incremental_sync: 2237 return 2238 2239 partition_router = getattr(model.retriever, "partition_router", None) 2240 if not partition_router: 2241 return 2242 2243 routers = partition_router if isinstance(partition_router, list) else [partition_router] 2244 for router in routers: 2245 if isinstance(router, GroupingPartitionRouterModel): 2246 router = router.underlying_partition_router 2247 if isinstance(router, SubstreamPartitionRouterModel) and any( 2248 parent_stream_config.incremental_dependency 2249 for parent_stream_config in router.parent_stream_configs 2250 ): 2251 LOGGER.warning( 2252 f"Stream `{model.name}` has `incremental_dependency: true` in its parent stream configuration but does not define `incremental_sync`. " 2253 "The parent stream's cursor is only persisted in the state of an incremental substream, so this setting has no effect and all parent records will be re-read on every sync. " 2254 "Define `incremental_sync` on this stream or remove `incremental_dependency`." 2255 ) 2256 return 2257 2258 def _migrate_state(self, model: DeclarativeStreamModel, config: Config) -> None: 2259 stream_name = model.name or "" 2260 stream_state = self._connector_state_manager.get_stream_state( 2261 stream_name=stream_name, namespace=None 2262 ) 2263 if model.state_migrations: 2264 state_transformations = [ 2265 self._create_component_from_model(state_migration, config, declarative_stream=model) 2266 for state_migration in model.state_migrations 2267 ] 2268 else: 2269 state_transformations = [] 2270 stream_state = self.apply_stream_state_migrations(state_transformations, stream_state) 2271 self._connector_state_manager.update_state_for_stream( 2272 stream_name=stream_name, namespace=None, value=stream_state 2273 ) 2274 2275 def _is_stop_condition_on_cursor(self, model: DeclarativeStreamModel) -> bool: 2276 return bool( 2277 model.incremental_sync 2278 and hasattr(model.incremental_sync, "is_data_feed") 2279 and model.incremental_sync.is_data_feed 2280 ) 2281 2282 def _is_client_side_filtering_enabled(self, model: DeclarativeStreamModel) -> bool: 2283 return bool( 2284 model.incremental_sync 2285 and hasattr(model.incremental_sync, "is_client_side_incremental") 2286 and model.incremental_sync.is_client_side_incremental 2287 ) 2288 2289 def _build_stream_slicer_from_partition_router( 2290 self, 2291 model: Union[ 2292 AsyncRetrieverModel, 2293 CustomRetrieverModel, 2294 SimpleRetrieverModel, 2295 ], 2296 config: Config, 2297 stream_name: Optional[str] = None, 2298 **kwargs: Any, 2299 ) -> PartitionRouter: 2300 if ( 2301 hasattr(model, "partition_router") 2302 and isinstance(model, (SimpleRetrieverModel, AsyncRetrieverModel, CustomRetrieverModel)) 2303 and model.partition_router 2304 ): 2305 stream_slicer_model = model.partition_router 2306 if isinstance(stream_slicer_model, list): 2307 return CartesianProductStreamSlicer( 2308 [ 2309 self._create_component_from_model( 2310 model=slicer, config=config, stream_name=stream_name or "" 2311 ) 2312 for slicer in stream_slicer_model 2313 ], 2314 parameters={}, 2315 ) 2316 elif isinstance(stream_slicer_model, dict): 2317 # partition router comes from CustomRetrieverModel therefore has not been parsed as a model 2318 params = stream_slicer_model.get("$parameters") 2319 if not isinstance(params, dict): 2320 params = {} 2321 stream_slicer_model["$parameters"] = params 2322 2323 if stream_name is not None: 2324 params["stream_name"] = stream_name 2325 2326 return self._create_nested_component( # type: ignore[no-any-return] # There is no guarantee that this will return a stream slicer. If not, we expect an AttributeError during the call to `stream_slices` 2327 model, 2328 "partition_router", 2329 stream_slicer_model, 2330 config, 2331 **kwargs, 2332 ) 2333 else: 2334 return self._create_component_from_model( # type: ignore[no-any-return] # Will be created PartitionRouter as stream_slicer_model is model.partition_router 2335 model=stream_slicer_model, config=config, stream_name=stream_name or "" 2336 ) 2337 return SinglePartitionRouter(parameters={}) 2338 2339 def _build_concurrent_cursor( 2340 self, 2341 model: DeclarativeStreamModel, 2342 stream_slicer: Optional[PartitionRouter], 2343 config: Config, 2344 ) -> Cursor: 2345 stream_name = model.name or "" 2346 stream_state = self._connector_state_manager.get_stream_state(stream_name, None) 2347 2348 if ( 2349 model.incremental_sync 2350 and stream_slicer 2351 and not isinstance(stream_slicer, SinglePartitionRouter) 2352 ): 2353 if isinstance(model.incremental_sync, IncrementingCountCursorModel): 2354 # We don't currently support usage of partition routing and IncrementingCountCursor at the 2355 # same time because we didn't solve for design questions like what the lookback window would 2356 # be as well as global cursor fall backs. We have not seen customers that have needed both 2357 # at the same time yet and are currently punting on this until we need to solve it. 2358 raise ValueError( 2359 f"The low-code framework does not currently support usage of a PartitionRouter and an IncrementingCountCursor at the same time. Please specify only one of these options for stream {stream_name}." 2360 ) 2361 return self.create_concurrent_cursor_from_perpartition_cursor( # type: ignore # This is a known issue that we are creating and returning a ConcurrentCursor which does not technically implement the (low-code) StreamSlicer. However, (low-code) StreamSlicer and ConcurrentCursor both implement StreamSlicer.stream_slices() which is the primary method needed for checkpointing 2362 state_manager=self._connector_state_manager, 2363 model_type=DatetimeBasedCursorModel, 2364 component_definition=model.incremental_sync.__dict__, 2365 stream_name=stream_name, 2366 stream_state=stream_state, 2367 stream_namespace=None, 2368 config=config or {}, 2369 partition_router=stream_slicer, 2370 attempt_to_create_cursor_if_not_provided=True, # FIXME can we remove that now? 2371 ) 2372 elif model.incremental_sync: 2373 if type(model.incremental_sync) == IncrementingCountCursorModel: 2374 return self.create_concurrent_cursor_from_incrementing_count_cursor( # type: ignore # This is a known issue that we are creating and returning a ConcurrentCursor which does not technically implement the (low-code) StreamSlicer. However, (low-code) StreamSlicer and ConcurrentCursor both implement StreamSlicer.stream_slices() which is the primary method needed for checkpointing 2375 model_type=IncrementingCountCursorModel, 2376 component_definition=model.incremental_sync.__dict__, 2377 stream_name=stream_name, 2378 stream_namespace=None, 2379 stream_state=stream_state, 2380 config=config or {}, 2381 ) 2382 elif type(model.incremental_sync) == DatetimeBasedCursorModel: 2383 return self.create_concurrent_cursor_from_datetime_based_cursor( # type: ignore # This is a known issue that we are creating and returning a ConcurrentCursor which does not technically implement the (low-code) StreamSlicer. However, (low-code) StreamSlicer and ConcurrentCursor both implement StreamSlicer.stream_slices() which is the primary method needed for checkpointing 2384 model_type=type(model.incremental_sync), 2385 component_definition=model.incremental_sync.__dict__, 2386 stream_name=stream_name, 2387 stream_namespace=None, 2388 stream_state=stream_state, 2389 config=config or {}, 2390 attempt_to_create_cursor_if_not_provided=True, 2391 ) 2392 else: 2393 raise ValueError( 2394 f"Incremental sync of type {type(model.incremental_sync)} is not supported" 2395 ) 2396 return FinalStateCursor(stream_name, None, self._message_repository) 2397 2398 def create_default_error_handler( 2399 self, model: DefaultErrorHandlerModel, config: Config, **kwargs: Any 2400 ) -> DefaultErrorHandler: 2401 backoff_strategies = [] 2402 if model.backoff_strategies: 2403 for backoff_strategy_model in model.backoff_strategies: 2404 backoff_strategies.append( 2405 self._create_component_from_model(model=backoff_strategy_model, config=config) 2406 ) 2407 2408 response_filters = [] 2409 if model.response_filters: 2410 for response_filter_model in model.response_filters: 2411 response_filters.append( 2412 self._create_component_from_model(model=response_filter_model, config=config) 2413 ) 2414 response_filters.append( 2415 HttpResponseFilter(config=config, parameters=model.parameters or {}) 2416 ) 2417 2418 return DefaultErrorHandler( 2419 backoff_strategies=backoff_strategies, 2420 max_retries=model.max_retries, 2421 response_filters=response_filters, 2422 config=config, 2423 parameters=model.parameters or {}, 2424 ) 2425 2426 def create_default_paginator( 2427 self, 2428 model: DefaultPaginatorModel, 2429 config: Config, 2430 *, 2431 url_base: str, 2432 extractor_model: Optional[Union[CustomRecordExtractorModel, DpathExtractorModel]] = None, 2433 decoder: Optional[Decoder] = None, 2434 cursor_used_for_stop_condition: Optional[Cursor] = None, 2435 ) -> Union[DefaultPaginator, PaginatorTestReadDecorator]: 2436 if decoder: 2437 if self._is_supported_decoder_for_pagination(decoder): 2438 decoder_to_use = PaginationDecoderDecorator(decoder=decoder) 2439 else: 2440 raise ValueError(self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(decoder))) 2441 else: 2442 decoder_to_use = PaginationDecoderDecorator(decoder=JsonDecoder(parameters={})) 2443 page_size_option = ( 2444 self._create_component_from_model(model=model.page_size_option, config=config) 2445 if model.page_size_option 2446 else None 2447 ) 2448 page_token_option = ( 2449 self._create_component_from_model(model=model.page_token_option, config=config) 2450 if model.page_token_option 2451 else None 2452 ) 2453 pagination_strategy = self._create_component_from_model( 2454 model=model.pagination_strategy, 2455 config=config, 2456 decoder=decoder_to_use, 2457 extractor_model=extractor_model, 2458 ) 2459 if cursor_used_for_stop_condition: 2460 pagination_strategy = StopConditionPaginationStrategyDecorator( 2461 pagination_strategy, CursorStopCondition(cursor_used_for_stop_condition) 2462 ) 2463 paginator = DefaultPaginator( 2464 decoder=decoder_to_use, 2465 page_size_option=page_size_option, 2466 page_token_option=page_token_option, 2467 pagination_strategy=pagination_strategy, 2468 url_base=url_base, 2469 config=config, 2470 parameters=model.parameters or {}, 2471 ) 2472 if self._limit_pages_fetched_per_slice: 2473 return PaginatorTestReadDecorator(paginator, self._limit_pages_fetched_per_slice) 2474 return paginator 2475 2476 def create_dpath_extractor( 2477 self, 2478 model: DpathExtractorModel, 2479 config: Config, 2480 decoder: Optional[Decoder] = None, 2481 **kwargs: Any, 2482 ) -> DpathExtractor: 2483 if decoder: 2484 decoder_to_use = decoder 2485 else: 2486 decoder_to_use = JsonDecoder(parameters={}) 2487 model_field_path: List[Union[InterpolatedString, str]] = [x for x in model.field_path] 2488 2489 record_expander = None 2490 if model.record_expander: 2491 record_expander = self._create_component_from_model( 2492 model=model.record_expander, 2493 config=config, 2494 ) 2495 2496 return DpathExtractor( 2497 decoder=decoder_to_use, 2498 field_path=model_field_path, 2499 config=config, 2500 parameters=model.parameters or {}, 2501 record_expander=record_expander, 2502 ) 2503 2504 def create_record_expander( 2505 self, 2506 model: RecordExpanderModel, 2507 config: Config, 2508 **kwargs: Any, 2509 ) -> RecordExpander: 2510 truncated_list_retriever = None 2511 suppress_incomplete_fetch_warning = False 2512 if model.truncated_list_retriever: 2513 retriever_model = model.truncated_list_retriever 2514 name = "record_expander_truncated_list" 2515 # `CustomRetriever` allows extra fields, so read from the dumped model to cover both types. 2516 retriever_fields = retriever_model.dict() 2517 for unsupported_option in ("partition_router", "pagination_reset"): 2518 if retriever_fields.get(unsupported_option): 2519 raise ValueError( 2520 f"`{unsupported_option}` is not supported on `truncated_list_retriever`." 2521 ) 2522 log_formatter = lambda response: format_http_message( 2523 response, 2524 f"Record expander '{name}' request", 2525 "Request performed in order to fetch the complete nested list of a truncated record.", 2526 name, 2527 is_auxiliary=True, 2528 ) 2529 # `name`/`primary_key`/`transformations` are also what a `CustomRetriever` forwards to its 2530 # nested `requester`/`record_selector`, so they are passed for both retriever types. 2531 truncated_list_retriever = self._create_component_from_model( 2532 model=retriever_model, 2533 config=config, 2534 name=name, 2535 primary_key=None, 2536 transformations=[], 2537 log_formatter=log_formatter, 2538 ) 2539 # Only a capped paginator makes a shortfall expected; `NoPagination` and retrievers 2540 # without a paginator are never capped. 2541 suppress_incomplete_fetch_warning = isinstance( 2542 truncated_list_retriever, SimpleRetriever 2543 ) and isinstance(truncated_list_retriever.paginator, PaginatorTestReadDecorator) 2544 return RecordExpander( 2545 expand_records_from_field=model.expand_records_from_field, 2546 config=config, 2547 parameters=model.parameters or {}, 2548 remain_original_record=model.remain_original_record or False, 2549 on_no_records=OnNoRecords(model.on_no_records.value) 2550 if model.on_no_records 2551 else OnNoRecords.skip, 2552 truncation_indicator_path=model.truncation_indicator_path, 2553 truncated_list_retriever=truncated_list_retriever, 2554 message_repository=self._message_repository, 2555 suppress_incomplete_fetch_warning=suppress_incomplete_fetch_warning, 2556 ) 2557 2558 @staticmethod 2559 def create_response_to_file_extractor( 2560 model: ResponseToFileExtractorModel, 2561 **kwargs: Any, 2562 ) -> ResponseToFileExtractor: 2563 return ResponseToFileExtractor( 2564 parameters=model.parameters or {}, 2565 preserve_na_values=model.preserve_na_values or False, 2566 ) 2567 2568 @staticmethod 2569 def create_exponential_backoff_strategy( 2570 model: ExponentialBackoffStrategyModel, config: Config 2571 ) -> ExponentialBackoffStrategy: 2572 ModelToComponentFactory._validate_jitter_range(model.jitter_range_in_seconds) 2573 return ExponentialBackoffStrategy( 2574 factor=model.factor or 5, 2575 jitter_range_in_seconds=model.jitter_range_in_seconds, 2576 parameters=model.parameters or {}, 2577 config=config, 2578 ) 2579 2580 @staticmethod 2581 def create_group_by_key(model: GroupByKeyMergeStrategyModel, config: Config) -> GroupByKey: 2582 return GroupByKey(model.key, config=config, parameters=model.parameters or {}) 2583 2584 def create_http_requester( 2585 self, 2586 model: HttpRequesterModel, 2587 config: Config, 2588 decoder: Decoder = JsonDecoder(parameters={}), 2589 query_properties_key: Optional[str] = None, 2590 use_cache: Optional[bool] = None, 2591 *, 2592 name: str, 2593 ) -> HttpRequester: 2594 authenticator = ( 2595 self._create_component_from_model( 2596 model=model.authenticator, 2597 config=config, 2598 url_base=model.url or model.url_base, 2599 name=name, 2600 decoder=decoder, 2601 ) 2602 if model.authenticator 2603 else None 2604 ) 2605 error_handler = ( 2606 self._create_component_from_model(model=model.error_handler, config=config) 2607 if model.error_handler 2608 else DefaultErrorHandler( 2609 backoff_strategies=[], 2610 response_filters=[], 2611 config=config, 2612 parameters=model.parameters or {}, 2613 ) 2614 ) 2615 2616 api_budget = self._api_budget 2617 2618 request_options_provider = InterpolatedRequestOptionsProvider( 2619 request_body=model.request_body, 2620 request_body_data=model.request_body_data, 2621 request_body_json=model.request_body_json, 2622 request_headers=model.request_headers, 2623 request_parameters=model.request_parameters, # type: ignore # QueryProperties have been removed in `create_simple_retriever` 2624 query_properties_key=query_properties_key, 2625 config=config, 2626 parameters=model.parameters or {}, 2627 ) 2628 2629 assert model.use_cache is not None # for mypy 2630 assert model.http_method is not None # for mypy 2631 2632 should_use_cache = (model.use_cache or bool(use_cache)) and not self._disable_cache 2633 2634 return HttpRequester( 2635 name=name, 2636 url=model.url, 2637 url_base=model.url_base, 2638 path=model.path, 2639 authenticator=authenticator, 2640 error_handler=error_handler, 2641 api_budget=api_budget, 2642 http_method=HttpMethod[model.http_method.value], 2643 request_options_provider=request_options_provider, 2644 config=config, 2645 disable_retries=self._disable_retries, 2646 parameters=model.parameters or {}, 2647 message_repository=self._message_repository, 2648 use_cache=should_use_cache, 2649 decoder=decoder, 2650 stream_response=decoder.is_stream_response() if decoder else False, 2651 ) 2652 2653 @staticmethod 2654 def create_http_response_filter( 2655 model: HttpResponseFilterModel, config: Config, **kwargs: Any 2656 ) -> HttpResponseFilter: 2657 if model.action: 2658 action = ResponseAction(model.action.value) 2659 else: 2660 action = None 2661 2662 failure_type = FailureType(model.failure_type.value) if model.failure_type else None 2663 2664 http_codes = ( 2665 set(model.http_codes) if model.http_codes else set() 2666 ) # JSON schema notation has no set data type. The schema enforces an array of unique elements 2667 2668 return HttpResponseFilter( 2669 action=action, 2670 failure_type=failure_type, 2671 error_message=model.error_message or "", 2672 error_message_contains=model.error_message_contains or "", 2673 http_codes=http_codes, 2674 predicate=model.predicate or "", 2675 config=config, 2676 parameters=model.parameters or {}, 2677 ) 2678 2679 @staticmethod 2680 def create_inline_schema_loader( 2681 model: InlineSchemaLoaderModel, config: Config, **kwargs: Any 2682 ) -> InlineSchemaLoader: 2683 return InlineSchemaLoader(schema=model.schema_ or {}, parameters={}) 2684 2685 def create_complex_field_type( 2686 self, model: ComplexFieldTypeModel, config: Config, **kwargs: Any 2687 ) -> ComplexFieldType: 2688 items = ( 2689 self._create_component_from_model(model=model.items, config=config) 2690 if isinstance(model.items, ComplexFieldTypeModel) 2691 else model.items 2692 ) 2693 2694 return ComplexFieldType(field_type=model.field_type, items=items) 2695 2696 def create_types_map(self, model: TypesMapModel, config: Config, **kwargs: Any) -> TypesMap: 2697 target_type = ( 2698 self._create_component_from_model(model=model.target_type, config=config) 2699 if isinstance(model.target_type, ComplexFieldTypeModel) 2700 else model.target_type 2701 ) 2702 2703 return TypesMap( 2704 target_type=target_type, 2705 current_type=model.current_type, 2706 condition=model.condition if model.condition is not None else "True", 2707 ) 2708 2709 def create_schema_type_identifier( 2710 self, model: SchemaTypeIdentifierModel, config: Config, **kwargs: Any 2711 ) -> SchemaTypeIdentifier: 2712 types_mapping = [] 2713 if model.types_mapping: 2714 types_mapping.extend( 2715 [ 2716 self._create_component_from_model(types_map, config=config) 2717 for types_map in model.types_mapping 2718 ] 2719 ) 2720 model_schema_pointer: List[Union[InterpolatedString, str]] = ( 2721 [x for x in model.schema_pointer] if model.schema_pointer else [] 2722 ) 2723 model_key_pointer: List[Union[InterpolatedString, str]] = [x for x in model.key_pointer] 2724 model_type_pointer: Optional[List[Union[InterpolatedString, str]]] = ( 2725 [x for x in model.type_pointer] if model.type_pointer else None 2726 ) 2727 2728 return SchemaTypeIdentifier( 2729 schema_pointer=model_schema_pointer, 2730 key_pointer=model_key_pointer, 2731 type_pointer=model_type_pointer, 2732 types_mapping=types_mapping, 2733 parameters=model.parameters or {}, 2734 ) 2735 2736 def create_dynamic_schema_loader( 2737 self, model: DynamicSchemaLoaderModel, config: Config, **kwargs: Any 2738 ) -> DynamicSchemaLoader: 2739 schema_transformations = [] 2740 if model.schema_transformations: 2741 for transformation_model in model.schema_transformations: 2742 schema_transformations.append( 2743 self._create_component_from_model(model=transformation_model, config=config) 2744 ) 2745 name = "dynamic_properties" 2746 retriever = self._create_component_from_model( 2747 model=model.retriever, 2748 config=config, 2749 name=name, 2750 primary_key=None, 2751 partition_router=self._build_stream_slicer_from_partition_router( 2752 model.retriever, config 2753 ), 2754 transformations=[], 2755 use_cache=True, 2756 log_formatter=( 2757 lambda response: format_http_message( 2758 response, 2759 f"Schema loader '{name}' request", 2760 f"Request performed in order to extract schema.", 2761 name, 2762 is_auxiliary=True, 2763 ) 2764 ), 2765 ) 2766 schema_type_identifier = self._create_component_from_model( 2767 model.schema_type_identifier, config=config, parameters=model.parameters or {} 2768 ) 2769 schema_filter = ( 2770 self._create_component_from_model( 2771 model.schema_filter, config=config, parameters=model.parameters or {} 2772 ) 2773 if model.schema_filter is not None 2774 else None 2775 ) 2776 2777 return DynamicSchemaLoader( 2778 retriever=retriever, 2779 config=config, 2780 schema_transformations=schema_transformations, 2781 schema_filter=schema_filter, 2782 schema_type_identifier=schema_type_identifier, 2783 parameters=model.parameters or {}, 2784 ) 2785 2786 @staticmethod 2787 def create_json_decoder(model: JsonDecoderModel, config: Config, **kwargs: Any) -> Decoder: 2788 return JsonDecoder(parameters={}) 2789 2790 def create_csv_decoder(self, model: CsvDecoderModel, config: Config, **kwargs: Any) -> Decoder: 2791 return CompositeRawDecoder( 2792 parser=ModelToComponentFactory._get_parser(model, config), 2793 stream_response=False if self._emit_connector_builder_messages else True, 2794 ) 2795 2796 def create_jsonl_decoder( 2797 self, model: JsonlDecoderModel, config: Config, **kwargs: Any 2798 ) -> Decoder: 2799 return CompositeRawDecoder( 2800 parser=ModelToComponentFactory._get_parser(model, config), 2801 stream_response=False if self._emit_connector_builder_messages else True, 2802 ) 2803 2804 def create_json_items_decoder( 2805 self, model: JsonItemsDecoderModel, config: Config, **kwargs: Any 2806 ) -> Decoder: 2807 return CompositeRawDecoder( 2808 parser=ModelToComponentFactory._get_parser(model, config), 2809 stream_response=False if self._emit_connector_builder_messages else True, 2810 ) 2811 2812 def create_gzip_decoder( 2813 self, model: GzipDecoderModel, config: Config, **kwargs: Any 2814 ) -> Decoder: 2815 _compressed_response_types = { 2816 "gzip", 2817 "x-gzip", 2818 "gzip, deflate", 2819 "x-gzip, deflate", 2820 "application/zip", 2821 "application/gzip", 2822 "application/x-gzip", 2823 "application/x-zip-compressed", 2824 } 2825 2826 gzip_parser: GzipParser = ModelToComponentFactory._get_parser(model, config) # type: ignore # based on the model, we know this will be a GzipParser 2827 2828 if self._emit_connector_builder_messages: 2829 return CompositeRawDecoder(gzip_parser, False) 2830 2831 transport_gzip_parser = GzipParser(inner_parser=gzip_parser) 2832 return CompositeRawDecoder.by_headers( 2833 [ 2834 ({"Content-Encoding"}, {"gzip"}, transport_gzip_parser), 2835 ({"Content-Type"}, _compressed_response_types, gzip_parser), 2836 ], 2837 stream_response=True, 2838 fallback_parser=gzip_parser, 2839 ) 2840 2841 @staticmethod 2842 def create_iterable_decoder( 2843 model: IterableDecoderModel, config: Config, **kwargs: Any 2844 ) -> IterableDecoder: 2845 return IterableDecoder(parameters={}) 2846 2847 @staticmethod 2848 def create_xml_decoder(model: XmlDecoderModel, config: Config, **kwargs: Any) -> XmlDecoder: 2849 return XmlDecoder(parameters={}) 2850 2851 def create_zipfile_decoder( 2852 self, model: ZipfileDecoderModel, config: Config, **kwargs: Any 2853 ) -> ZipfileDecoder: 2854 return ZipfileDecoder(parser=ModelToComponentFactory._get_parser(model.decoder, config)) 2855 2856 @staticmethod 2857 def _get_parser(model: BaseModel, config: Config) -> Parser: 2858 if isinstance(model, JsonDecoderModel): 2859 # Note that the logic is a bit different from the JsonDecoder as there is some legacy that is maintained to return {} on error cases 2860 return JsonParser() 2861 elif isinstance(model, JsonItemsDecoderModel): 2862 return JsonItemsParser( 2863 items_path=model.items_path, 2864 encoding=model.encoding, 2865 ) 2866 elif isinstance(model, JsonlDecoderModel): 2867 return JsonLineParser() 2868 elif isinstance(model, CsvDecoderModel): 2869 return CsvParser( 2870 encoding=model.encoding, 2871 delimiter=model.delimiter, 2872 set_values_to_none=model.set_values_to_none, 2873 ) 2874 elif isinstance(model, GzipDecoderModel): 2875 return GzipParser( 2876 inner_parser=ModelToComponentFactory._get_parser(model.decoder, config) 2877 ) 2878 elif isinstance( 2879 model, (CustomDecoderModel, IterableDecoderModel, XmlDecoderModel, ZipfileDecoderModel) 2880 ): 2881 raise ValueError(f"Decoder type {model} does not have parser associated to it") 2882 2883 raise ValueError(f"Unknown decoder type {model}") 2884 2885 @staticmethod 2886 def create_json_file_schema_loader( 2887 model: JsonFileSchemaLoaderModel, config: Config, **kwargs: Any 2888 ) -> JsonFileSchemaLoader: 2889 return JsonFileSchemaLoader( 2890 file_path=model.file_path or "", config=config, parameters=model.parameters or {} 2891 ) 2892 2893 def create_jwt_authenticator( 2894 self, model: JwtAuthenticatorModel, config: Config, **kwargs: Any 2895 ) -> JwtAuthenticator: 2896 jwt_headers = model.jwt_headers or JwtHeadersModel(kid=None, typ="JWT", cty=None) 2897 jwt_payload = model.jwt_payload or JwtPayloadModel(iss=None, sub=None, aud=None) 2898 request_option = ( 2899 self._create_component_from_model(model.request_option, config) 2900 if model.request_option 2901 else None 2902 ) 2903 return JwtAuthenticator( 2904 config=config, 2905 parameters=model.parameters or {}, 2906 algorithm=JwtAlgorithm(model.algorithm.value), 2907 secret_key=model.secret_key, 2908 base64_encode_secret_key=model.base64_encode_secret_key, 2909 token_duration=model.token_duration, 2910 header_prefix=model.header_prefix, 2911 kid=jwt_headers.kid, 2912 typ=jwt_headers.typ, 2913 cty=jwt_headers.cty, 2914 iss=jwt_payload.iss, 2915 sub=jwt_payload.sub, 2916 aud=jwt_payload.aud, 2917 additional_jwt_headers=model.additional_jwt_headers, 2918 additional_jwt_payload=model.additional_jwt_payload, 2919 passphrase=model.passphrase, 2920 request_option=request_option, 2921 ) 2922 2923 def create_list_partition_router( 2924 self, model: ListPartitionRouterModel, config: Config, **kwargs: Any 2925 ) -> ListPartitionRouter: 2926 request_option = ( 2927 self._create_component_from_model(model.request_option, config) 2928 if model.request_option 2929 else None 2930 ) 2931 return ListPartitionRouter( 2932 cursor_field=model.cursor_field, 2933 request_option=request_option, 2934 values=model.values, 2935 config=config, 2936 parameters=model.parameters or {}, 2937 ) 2938 2939 @staticmethod 2940 def create_min_max_datetime( 2941 model: MinMaxDatetimeModel, config: Config, **kwargs: Any 2942 ) -> MinMaxDatetime: 2943 return MinMaxDatetime( 2944 datetime=model.datetime, 2945 datetime_format=model.datetime_format or "", 2946 max_datetime=model.max_datetime or "", 2947 min_datetime=model.min_datetime or "", 2948 parameters=model.parameters or {}, 2949 ) 2950 2951 @staticmethod 2952 def create_no_auth(model: NoAuthModel, config: Config, **kwargs: Any) -> NoAuth: 2953 return NoAuth(parameters=model.parameters or {}) 2954 2955 @staticmethod 2956 def create_no_pagination( 2957 model: NoPaginationModel, config: Config, **kwargs: Any 2958 ) -> NoPagination: 2959 return NoPagination(parameters={}) 2960 2961 def create_oauth_authenticator( 2962 self, model: OAuthAuthenticatorModel, config: Config, **kwargs: Any 2963 ) -> DeclarativeOauth2Authenticator: 2964 profile_assertion = ( 2965 self._create_component_from_model(model.profile_assertion, config=config) 2966 if model.profile_assertion 2967 else None 2968 ) 2969 2970 refresh_token_error_status_codes, refresh_token_error_key, refresh_token_error_values = ( 2971 self._get_refresh_token_error_information(model) 2972 ) 2973 if model.refresh_token_updater: 2974 # ignore type error because fixing it would have a lot of dependencies, revisit later 2975 return DeclarativeSingleUseRefreshTokenOauth2Authenticator( # type: ignore 2976 config, 2977 InterpolatedString.create( 2978 model.token_refresh_endpoint, # type: ignore 2979 parameters=model.parameters or {}, 2980 ).eval(config), 2981 access_token_name=InterpolatedString.create( 2982 model.access_token_name or "access_token", parameters=model.parameters or {} 2983 ).eval(config), 2984 refresh_token_name=model.refresh_token_updater.refresh_token_name, 2985 expires_in_name=InterpolatedString.create( 2986 model.expires_in_name or "expires_in", parameters=model.parameters or {} 2987 ).eval(config), 2988 client_id_name=InterpolatedString.create( 2989 model.client_id_name or "client_id", parameters=model.parameters or {} 2990 ).eval(config), 2991 client_id=InterpolatedString.create( 2992 model.client_id, parameters=model.parameters or {} 2993 ).eval(config) 2994 if model.client_id 2995 else model.client_id, 2996 client_secret_name=InterpolatedString.create( 2997 model.client_secret_name or "client_secret", parameters=model.parameters or {} 2998 ).eval(config), 2999 client_secret=InterpolatedString.create( 3000 model.client_secret, parameters=model.parameters or {} 3001 ).eval(config) 3002 if model.client_secret 3003 else model.client_secret, 3004 access_token_config_path=model.refresh_token_updater.access_token_config_path, 3005 refresh_token_config_path=model.refresh_token_updater.refresh_token_config_path, 3006 token_expiry_date_config_path=model.refresh_token_updater.token_expiry_date_config_path, 3007 grant_type_name=InterpolatedString.create( 3008 model.grant_type_name or "grant_type", parameters=model.parameters or {} 3009 ).eval(config), 3010 grant_type=InterpolatedString.create( 3011 model.grant_type or "refresh_token", parameters=model.parameters or {} 3012 ).eval(config), 3013 refresh_request_body=InterpolatedMapping( 3014 model.refresh_request_body or {}, parameters=model.parameters or {} 3015 ).eval(config), 3016 refresh_request_headers=InterpolatedMapping( 3017 model.refresh_request_headers or {}, parameters=model.parameters or {} 3018 ).eval(config), 3019 send_refresh_request_as_query_params=bool( 3020 model.send_refresh_request_as_query_params 3021 ), 3022 scopes=model.scopes, 3023 token_expiry_date_format=model.token_expiry_date_format, 3024 token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), 3025 message_repository=self._message_repository, 3026 refresh_token_error_status_codes=refresh_token_error_status_codes, 3027 refresh_token_error_key=refresh_token_error_key, 3028 refresh_token_error_values=refresh_token_error_values, 3029 ) 3030 # ignore type error because fixing it would have a lot of dependencies, revisit later 3031 return DeclarativeOauth2Authenticator( # type: ignore 3032 access_token_name=model.access_token_name or "access_token", 3033 access_token_value=model.access_token_value, 3034 client_id_name=model.client_id_name or "client_id", 3035 client_id=model.client_id, 3036 client_secret_name=model.client_secret_name or "client_secret", 3037 client_secret=model.client_secret, 3038 expires_in_name=model.expires_in_name or "expires_in", 3039 grant_type_name=model.grant_type_name or "grant_type", 3040 grant_type=model.grant_type or "refresh_token", 3041 refresh_request_body=model.refresh_request_body, 3042 refresh_request_headers=model.refresh_request_headers, 3043 send_refresh_request_as_query_params=bool(model.send_refresh_request_as_query_params), 3044 refresh_token_name=model.refresh_token_name or "refresh_token", 3045 refresh_token=model.refresh_token, 3046 scopes=model.scopes, 3047 token_expiry_date=model.token_expiry_date, 3048 token_expiry_date_format=model.token_expiry_date_format, 3049 token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), 3050 token_refresh_endpoint=model.token_refresh_endpoint, 3051 config=config, 3052 parameters=model.parameters or {}, 3053 message_repository=self._message_repository, 3054 profile_assertion=profile_assertion, 3055 use_profile_assertion=model.use_profile_assertion, 3056 refresh_token_error_status_codes=refresh_token_error_status_codes, 3057 refresh_token_error_key=refresh_token_error_key, 3058 refresh_token_error_values=refresh_token_error_values, 3059 ) 3060 3061 @staticmethod 3062 def _get_refresh_token_error_information( 3063 model: OAuthAuthenticatorModel, 3064 ) -> Tuple[Tuple[int, ...], str, Tuple[str, ...]]: 3065 """ 3066 In a previous version of the CDK, the auth error as config_error was only done if a refresh token updater was 3067 defined. As a transition, we added those fields on the OAuthAuthenticatorModel. This method ensures that the 3068 information is defined only once and return the right fields. 3069 """ 3070 refresh_token_updater = model.refresh_token_updater 3071 is_defined_on_refresh_token_updated = refresh_token_updater and ( 3072 refresh_token_updater.refresh_token_error_status_codes 3073 or refresh_token_updater.refresh_token_error_key 3074 or refresh_token_updater.refresh_token_error_values 3075 ) 3076 is_defined_on_oauth_authenticator = ( 3077 model.refresh_token_error_status_codes 3078 or model.refresh_token_error_key 3079 or model.refresh_token_error_values 3080 ) 3081 if is_defined_on_refresh_token_updated and is_defined_on_oauth_authenticator: 3082 raise ValueError( 3083 "refresh_token_error should either be defined on the OAuthAuthenticatorModel or the RefreshTokenUpdaterModel, not both" 3084 ) 3085 3086 if is_defined_on_refresh_token_updated: 3087 not_optional_refresh_token_updater: RefreshTokenUpdaterModel = refresh_token_updater # type: ignore # we know from the condition that this is not None 3088 return ( 3089 tuple(not_optional_refresh_token_updater.refresh_token_error_status_codes) 3090 if not_optional_refresh_token_updater.refresh_token_error_status_codes 3091 else (), 3092 not_optional_refresh_token_updater.refresh_token_error_key or "", 3093 tuple(not_optional_refresh_token_updater.refresh_token_error_values) 3094 if not_optional_refresh_token_updater.refresh_token_error_values 3095 else (), 3096 ) 3097 elif is_defined_on_oauth_authenticator: 3098 return ( 3099 tuple(model.refresh_token_error_status_codes) 3100 if model.refresh_token_error_status_codes 3101 else (), 3102 model.refresh_token_error_key or "", 3103 tuple(model.refresh_token_error_values) if model.refresh_token_error_values else (), 3104 ) 3105 3106 # returning default values we think cover most cases 3107 return (400,), "error", ("invalid_grant", "invalid_permissions") 3108 3109 def create_offset_increment( 3110 self, 3111 model: OffsetIncrementModel, 3112 config: Config, 3113 decoder: Decoder, 3114 extractor_model: Optional[Union[CustomRecordExtractorModel, DpathExtractorModel]] = None, 3115 **kwargs: Any, 3116 ) -> OffsetIncrement: 3117 if isinstance(decoder, PaginationDecoderDecorator): 3118 inner_decoder = decoder.decoder 3119 else: 3120 inner_decoder = decoder 3121 decoder = PaginationDecoderDecorator(decoder=decoder) 3122 3123 if self._is_supported_decoder_for_pagination(inner_decoder): 3124 decoder_to_use = decoder 3125 else: 3126 raise ValueError( 3127 self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(inner_decoder)) 3128 ) 3129 3130 # Ideally we would instantiate the runtime extractor from highest most level (in this case the SimpleRetriever) 3131 # so that it can be shared by OffSetIncrement and RecordSelector. However, due to how we instantiate the 3132 # decoder with various decorators here, but not in create_record_selector, it is simpler to retain existing 3133 # behavior by having two separate extractors with identical behavior since they use the same extractor model. 3134 # When we have more time to investigate we can look into reusing the same component. 3135 extractor = ( 3136 self._create_component_from_model( 3137 model=extractor_model, config=config, decoder=decoder_to_use 3138 ) 3139 if extractor_model 3140 else None 3141 ) 3142 3143 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 3144 # If page_size is a string that represents an integer (not an interpolation), convert it back. 3145 page_size = model.page_size 3146 if isinstance(page_size, str) and page_size.isdigit(): 3147 page_size = int(page_size) 3148 3149 return OffsetIncrement( 3150 page_size=page_size, 3151 config=config, 3152 decoder=decoder_to_use, 3153 extractor=extractor, 3154 inject_on_first_request=model.inject_on_first_request or False, 3155 parameters=model.parameters or {}, 3156 ) 3157 3158 def create_page_increment( 3159 self, 3160 model: PageIncrementModel, 3161 config: Config, 3162 decoder: Optional[Decoder] = None, 3163 extractor_model: Optional[Union[CustomRecordExtractorModel, DpathExtractorModel]] = None, 3164 **kwargs: Any, 3165 ) -> PageIncrement: 3166 # Like OffsetIncrement, we instantiate a separate extractor with identical behavior to the 3167 # RecordSelector's so the strategy can count the raw records in the response. This ensures 3168 # pagination is driven by the API's page size, not the post-filter record count. 3169 extractor = ( 3170 self._create_component_from_model(model=extractor_model, config=config, decoder=decoder) 3171 if extractor_model 3172 else None 3173 ) 3174 3175 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 3176 # If page_size is a string that represents an integer (not an interpolation), convert it back. 3177 page_size = model.page_size 3178 if isinstance(page_size, str) and page_size.isdigit(): 3179 page_size = int(page_size) 3180 3181 return PageIncrement( 3182 page_size=page_size, 3183 config=config, 3184 start_from_page=model.start_from_page or 0, 3185 inject_on_first_request=model.inject_on_first_request or False, 3186 extractor=extractor, 3187 parameters=model.parameters or {}, 3188 ) 3189 3190 def create_parent_stream_config( 3191 self, model: ParentStreamConfigModel, config: Config, *, stream_name: str, **kwargs: Any 3192 ) -> ParentStreamConfig: 3193 declarative_stream = self._create_component_from_model( 3194 model.stream, 3195 config=config, 3196 is_parent=True, 3197 **kwargs, 3198 ) 3199 request_option = ( 3200 self._create_component_from_model(model.request_option, config=config) 3201 if model.request_option 3202 else None 3203 ) 3204 3205 if model.lazy_read_pointer and any("*" in pointer for pointer in model.lazy_read_pointer): 3206 raise ValueError( 3207 "The '*' wildcard in 'lazy_read_pointer' is not supported — only direct paths are allowed." 3208 ) 3209 3210 model_lazy_read_pointer: List[Union[InterpolatedString, str]] = ( 3211 [x for x in model.lazy_read_pointer] if model.lazy_read_pointer else [] 3212 ) 3213 3214 return ParentStreamConfig( 3215 parent_key=model.parent_key, 3216 request_option=request_option, 3217 stream=declarative_stream, 3218 partition_field=model.partition_field, 3219 config=config, 3220 incremental_dependency=model.incremental_dependency or False, 3221 parameters=model.parameters or {}, 3222 extra_fields=model.extra_fields, 3223 lazy_read_pointer=model_lazy_read_pointer, 3224 ) 3225 3226 def create_properties_from_endpoint( 3227 self, model: PropertiesFromEndpointModel, config: Config, **kwargs: Any 3228 ) -> PropertiesFromEndpoint: 3229 retriever = self._create_component_from_model( 3230 model=model.retriever, 3231 config=config, 3232 name="dynamic_properties", 3233 primary_key=None, 3234 stream_slicer=None, 3235 transformations=[], 3236 use_cache=True, # Enable caching on the HttpRequester/HttpClient because the properties endpoint will be called for every slice being processed, and it is highly unlikely for the response to different 3237 ) 3238 return PropertiesFromEndpoint( 3239 property_field_path=model.property_field_path, 3240 retriever=retriever, 3241 config=config, 3242 parameters=model.parameters or {}, 3243 ) 3244 3245 def create_property_chunking( 3246 self, model: PropertyChunkingModel, config: Config, **kwargs: Any 3247 ) -> PropertyChunking: 3248 record_merge_strategy = ( 3249 self._create_component_from_model( 3250 model=model.record_merge_strategy, config=config, **kwargs 3251 ) 3252 if model.record_merge_strategy 3253 else None 3254 ) 3255 3256 property_limit_type: PropertyLimitType 3257 match model.property_limit_type: 3258 case PropertyLimitTypeModel.property_count: 3259 property_limit_type = PropertyLimitType.property_count 3260 case PropertyLimitTypeModel.characters: 3261 property_limit_type = PropertyLimitType.characters 3262 case _: 3263 raise ValueError(f"Invalid PropertyLimitType {property_limit_type}") 3264 3265 return PropertyChunking( 3266 property_limit_type=property_limit_type, 3267 property_limit=model.property_limit, 3268 record_merge_strategy=record_merge_strategy, 3269 config=config, 3270 parameters=model.parameters or {}, 3271 ) 3272 3273 def create_query_properties( 3274 self, model: QueryPropertiesModel, config: Config, *, stream_name: str, **kwargs: Any 3275 ) -> QueryProperties: 3276 if isinstance(model.property_list, list): 3277 property_list = model.property_list 3278 else: 3279 property_list = self._create_component_from_model( 3280 model=model.property_list, config=config, **kwargs 3281 ) 3282 3283 property_chunking = ( 3284 self._create_component_from_model( 3285 model=model.property_chunking, config=config, **kwargs 3286 ) 3287 if model.property_chunking 3288 else None 3289 ) 3290 3291 property_selector = ( 3292 self._create_component_from_model( 3293 model=model.property_selector, config=config, stream_name=stream_name, **kwargs 3294 ) 3295 if model.property_selector 3296 else None 3297 ) 3298 3299 return QueryProperties( 3300 property_list=property_list, 3301 always_include_properties=model.always_include_properties, 3302 property_chunking=property_chunking, 3303 property_selector=property_selector, 3304 config=config, 3305 parameters=model.parameters or {}, 3306 ) 3307 3308 def create_json_schema_property_selector( 3309 self, 3310 model: JsonSchemaPropertySelectorModel, 3311 config: Config, 3312 *, 3313 stream_name: str, 3314 **kwargs: Any, 3315 ) -> JsonSchemaPropertySelector: 3316 configured_stream = self._stream_name_to_configured_stream.get(stream_name) 3317 3318 transformations = [] 3319 if model.transformations: 3320 for transformation_model in model.transformations: 3321 transformations.append( 3322 self._create_component_from_model(model=transformation_model, config=config) 3323 ) 3324 3325 return JsonSchemaPropertySelector( 3326 configured_stream=configured_stream, 3327 properties_transformations=transformations, 3328 config=config, 3329 parameters=model.parameters or {}, 3330 ) 3331 3332 @staticmethod 3333 def create_record_filter( 3334 model: RecordFilterModel, config: Config, **kwargs: Any 3335 ) -> RecordFilter: 3336 return RecordFilter( 3337 condition=model.condition or "", config=config, parameters=model.parameters or {} 3338 ) 3339 3340 @staticmethod 3341 def create_request_path(model: RequestPathModel, config: Config, **kwargs: Any) -> RequestPath: 3342 return RequestPath(parameters={}) 3343 3344 @staticmethod 3345 def create_request_option( 3346 model: RequestOptionModel, config: Config, **kwargs: Any 3347 ) -> RequestOption: 3348 inject_into = RequestOptionType(model.inject_into.value) 3349 field_path: Optional[List[Union[InterpolatedString, str]]] = ( 3350 [ 3351 InterpolatedString.create(segment, parameters=kwargs.get("parameters", {})) 3352 for segment in model.field_path 3353 ] 3354 if model.field_path 3355 else None 3356 ) 3357 field_name = ( 3358 InterpolatedString.create(model.field_name, parameters=kwargs.get("parameters", {})) 3359 if model.field_name 3360 else None 3361 ) 3362 return RequestOption( 3363 field_name=field_name, 3364 field_path=field_path, 3365 inject_into=inject_into, 3366 parameters=kwargs.get("parameters", {}), 3367 ) 3368 3369 def create_record_selector( 3370 self, 3371 model: RecordSelectorModel, 3372 config: Config, 3373 *, 3374 name: str, 3375 transformations: List[RecordTransformation] | None = None, 3376 decoder: Decoder | None = None, 3377 client_side_incremental_sync_cursor: Optional[Cursor] = None, 3378 is_client_side_incremental_sync: bool = False, 3379 file_uploader: Optional[DefaultFileUploader] = None, 3380 **kwargs: Any, 3381 ) -> RecordSelector: 3382 extractor = self._create_component_from_model( 3383 model=model.extractor, decoder=decoder, config=config 3384 ) 3385 record_filter = ( 3386 self._create_component_from_model(model.record_filter, config=config) 3387 if model.record_filter 3388 else None 3389 ) 3390 3391 # A client-side incremental stream transforms before filtering by default. That default belongs to the flag, 3392 # not to the component that ends up doing the cursor comparison: a data feed does it in the retriever and 3393 # receives no cursor here, but its `record_filter` condition must keep running after the transformations. 3394 default_transform_before_filtering = bool( 3395 client_side_incremental_sync_cursor or is_client_side_incremental_sync 3396 ) 3397 transform_before_filtering = ( 3398 default_transform_before_filtering 3399 if model.transform_before_filtering is None 3400 else model.transform_before_filtering 3401 ) 3402 if client_side_incremental_sync_cursor: 3403 record_filter = ClientSideIncrementalRecordFilterDecorator( 3404 config=config, 3405 parameters=model.parameters, 3406 condition=model.record_filter.condition 3407 if (model.record_filter and hasattr(model.record_filter, "condition")) 3408 else None, 3409 cursor=client_side_incremental_sync_cursor, 3410 ) 3411 3412 if model.schema_normalization is None: 3413 # default to no schema normalization if not set 3414 model.schema_normalization = SchemaNormalizationModel.None_ 3415 3416 schema_normalization = ( 3417 TypeTransformer(SCHEMA_TRANSFORMER_TYPE_MAPPING[model.schema_normalization]) 3418 if isinstance(model.schema_normalization, SchemaNormalizationModel) 3419 else self._create_component_from_model(model.schema_normalization, config=config) # type: ignore[arg-type] # custom normalization model expected here 3420 ) 3421 3422 return RecordSelector( 3423 extractor=extractor, 3424 name=name, 3425 config=config, 3426 record_filter=record_filter, 3427 transformations=transformations or [], 3428 file_uploader=file_uploader, 3429 schema_normalization=schema_normalization, 3430 parameters=model.parameters or {}, 3431 transform_before_filtering=transform_before_filtering, 3432 ) 3433 3434 @staticmethod 3435 def create_remove_fields( 3436 model: RemoveFieldsModel, config: Config, **kwargs: Any 3437 ) -> RemoveFields: 3438 return RemoveFields( 3439 field_pointers=model.field_pointers, condition=model.condition or "", parameters={} 3440 ) 3441 3442 def create_selective_authenticator( 3443 self, model: SelectiveAuthenticatorModel, config: Config, **kwargs: Any 3444 ) -> DeclarativeAuthenticator: 3445 authenticators = { 3446 name: self._create_component_from_model(model=auth, config=config) 3447 for name, auth in model.authenticators.items() 3448 } 3449 # SelectiveAuthenticator will return instance of DeclarativeAuthenticator or raise ValueError error 3450 return SelectiveAuthenticator( # type: ignore[abstract] 3451 config=config, 3452 authenticators=authenticators, 3453 authenticator_selection_path=model.authenticator_selection_path, 3454 **kwargs, 3455 ) 3456 3457 @staticmethod 3458 def create_legacy_session_token_authenticator( 3459 model: LegacySessionTokenAuthenticatorModel, config: Config, *, url_base: str, **kwargs: Any 3460 ) -> LegacySessionTokenAuthenticator: 3461 return LegacySessionTokenAuthenticator( 3462 api_url=url_base, 3463 header=model.header, 3464 login_url=model.login_url, 3465 password=model.password or "", 3466 session_token=model.session_token or "", 3467 session_token_response_key=model.session_token_response_key or "", 3468 username=model.username or "", 3469 validate_session_url=model.validate_session_url, 3470 config=config, 3471 parameters=model.parameters or {}, 3472 ) 3473 3474 def create_simple_retriever( 3475 self, 3476 model: SimpleRetrieverModel, 3477 config: Config, 3478 *, 3479 name: str, 3480 primary_key: Optional[Union[str, List[str], List[List[str]]]], 3481 request_options_provider: Optional[RequestOptionsProvider] = None, 3482 cursor: Optional[Cursor] = None, 3483 has_stop_condition_cursor: bool = False, 3484 is_client_side_incremental_sync: bool = False, 3485 transformations: List[RecordTransformation], 3486 file_uploader: Optional[DefaultFileUploader] = None, 3487 incremental_sync: Optional[ 3488 Union[IncrementingCountCursorModel, DatetimeBasedCursorModel] 3489 ] = None, 3490 use_cache: Optional[bool] = None, 3491 log_formatter: Optional[Callable[[Response], Any]] = None, 3492 partition_router: Optional[PartitionRouter] = None, 3493 **kwargs: Any, 3494 ) -> SimpleRetriever: 3495 def _get_url(req: Requester) -> str: 3496 """ 3497 Closure to get the URL from the requester. This is used to get the URL in the case of a lazy retriever. 3498 This is needed because the URL is not set until the requester is created. 3499 """ 3500 3501 _url: str = ( 3502 model.requester.url 3503 if hasattr(model.requester, "url") and model.requester.url is not None 3504 else req.get_url(stream_state=None, stream_slice=None, next_page_token=None) 3505 ) 3506 _url_base: str = ( 3507 model.requester.url_base 3508 if hasattr(model.requester, "url_base") and model.requester.url_base is not None 3509 else req.get_url_base(stream_state=None, stream_slice=None, next_page_token=None) 3510 ) 3511 3512 return _url or _url_base 3513 3514 if cursor is None: 3515 cursor = FinalStateCursor(name, None, self._message_repository) 3516 3517 # A data feed drops the records the cursor considers already synced in the retriever, which sits downstream of 3518 # the paginator. Letting the record selector drop them as well would be redundant and would hide them from the 3519 # pagination stop condition, so a data feed never delegates that filtering to the record selector, whether 3520 # `is_client_side_incremental` is set or not. The `condition` from `record_filter` is intentionally left out of 3521 # the post-pagination filter and stays in the record selector, which preserves the existing behaviour: the 3522 # selector runs inside the page loop, so the records the condition rejects never reach the paginator's 3523 # accounting. Moving it downstream would start counting them. 3524 post_pagination_filter = ( 3525 ClientSideIncrementalRecordFilterDecorator( 3526 config=config, 3527 parameters=model.parameters or {}, 3528 condition=None, 3529 cursor=cursor, 3530 ) 3531 if has_stop_condition_cursor 3532 else None 3533 ) 3534 client_side_incremental_cursor = ( 3535 cursor if is_client_side_incremental_sync and not post_pagination_filter else None 3536 ) 3537 if post_pagination_filter and is_client_side_incremental_sync: 3538 LOGGER.warning( 3539 f"Stream {name}: `is_client_side_incremental` adds no record filtering when `is_data_feed` is set, " 3540 "as a data feed already filters on the cursor value. It still makes the record selector apply the " 3541 "transformations before the `record_filter` condition." 3542 ) 3543 3544 decoder = ( 3545 self._create_component_from_model(model=model.decoder, config=config) 3546 if model.decoder 3547 else JsonDecoder(parameters={}) 3548 ) 3549 record_selector = self._create_component_from_model( 3550 model=model.record_selector, 3551 name=name, 3552 config=config, 3553 decoder=decoder, 3554 transformations=transformations, 3555 client_side_incremental_sync_cursor=client_side_incremental_cursor, 3556 is_client_side_incremental_sync=is_client_side_incremental_sync, 3557 file_uploader=file_uploader, 3558 ) 3559 3560 query_properties: Optional[QueryProperties] = None 3561 query_properties_key: Optional[str] = None 3562 self._ensure_query_properties_to_model(model.requester) 3563 if self._has_query_properties_in_request_parameters(model.requester): 3564 # It is better to be explicit about an error if PropertiesFromEndpoint is defined in multiple 3565 # places instead of default to request_parameters which isn't clearly documented 3566 if ( 3567 hasattr(model.requester, "fetch_properties_from_endpoint") 3568 and model.requester.fetch_properties_from_endpoint 3569 ): 3570 raise ValueError( 3571 f"PropertiesFromEndpoint should only be specified once per stream, but found in {model.requester.type}.fetch_properties_from_endpoint and {model.requester.type}.request_parameters" 3572 ) 3573 3574 query_properties_definitions = [] 3575 for key, request_parameter in model.requester.request_parameters.items(): # type: ignore # request_parameters is already validated to be a Mapping using _has_query_properties_in_request_parameters() 3576 if isinstance(request_parameter, QueryPropertiesModel): 3577 query_properties_key = key 3578 query_properties_definitions.append(request_parameter) 3579 3580 if len(query_properties_definitions) > 1: 3581 raise ValueError( 3582 f"request_parameters only supports defining one QueryProperties field, but found {len(query_properties_definitions)} usages" 3583 ) 3584 3585 if len(query_properties_definitions) == 1: 3586 query_properties = self._create_component_from_model( 3587 model=query_properties_definitions[0], stream_name=name, config=config 3588 ) 3589 3590 # Removes QueryProperties components from the interpolated mappings because it has been designed 3591 # to be used by the SimpleRetriever and will be resolved from the provider from the slice directly 3592 # instead of through jinja interpolation 3593 if hasattr(model.requester, "request_parameters") and isinstance( 3594 model.requester.request_parameters, Mapping 3595 ): 3596 model.requester.request_parameters = self._remove_query_properties( 3597 model.requester.request_parameters 3598 ) 3599 elif ( 3600 hasattr(model.requester, "fetch_properties_from_endpoint") 3601 and model.requester.fetch_properties_from_endpoint 3602 ): 3603 # todo: Deprecate this condition once dependent connectors migrate to query_properties 3604 query_properties_definition = QueryPropertiesModel( 3605 type="QueryProperties", 3606 property_list=model.requester.fetch_properties_from_endpoint, 3607 always_include_properties=None, 3608 property_chunking=None, 3609 ) # type: ignore # $parameters has a default value 3610 3611 query_properties = self.create_query_properties( 3612 model=query_properties_definition, 3613 stream_name=name, 3614 config=config, 3615 ) 3616 elif hasattr(model.requester, "query_properties") and model.requester.query_properties: 3617 query_properties = self.create_query_properties( 3618 model=model.requester.query_properties, 3619 stream_name=name, 3620 config=config, 3621 ) 3622 3623 requester = self._create_component_from_model( 3624 model=model.requester, 3625 decoder=decoder, 3626 name=name, 3627 query_properties_key=query_properties_key, 3628 use_cache=use_cache, 3629 config=config, 3630 ) 3631 3632 if not request_options_provider: 3633 request_options_provider = DefaultRequestOptionsProvider(parameters={}) 3634 if isinstance(request_options_provider, DefaultRequestOptionsProvider) and isinstance( 3635 partition_router, PartitionRouter 3636 ): 3637 request_options_provider = partition_router 3638 3639 paginator = ( 3640 self._create_component_from_model( 3641 model=model.paginator, 3642 config=config, 3643 url_base=_get_url(requester), 3644 extractor_model=model.record_selector.extractor, 3645 decoder=decoder, 3646 cursor_used_for_stop_condition=cursor if has_stop_condition_cursor else None, 3647 ) 3648 if model.paginator 3649 else NoPagination(parameters={}) 3650 ) 3651 3652 ignore_stream_slicer_parameters_on_paginated_requests = ( 3653 model.ignore_stream_slicer_parameters_on_paginated_requests or False 3654 ) 3655 3656 if ( 3657 model.partition_router 3658 and isinstance(model.partition_router, SubstreamPartitionRouterModel) 3659 and not bool(self._connector_state_manager.get_stream_state(name, None)) 3660 and any( 3661 parent_stream_config.lazy_read_pointer 3662 for parent_stream_config in model.partition_router.parent_stream_configs 3663 ) 3664 ): 3665 if incremental_sync: 3666 if incremental_sync.type != "DatetimeBasedCursor": 3667 raise ValueError( 3668 f"LazySimpleRetriever only supports DatetimeBasedCursor. Found: {incremental_sync.type}." 3669 ) 3670 3671 elif incremental_sync.step or incremental_sync.cursor_granularity: 3672 raise ValueError( 3673 f"Found more that one slice per parent. LazySimpleRetriever only supports single slice read for stream - {name}." 3674 ) 3675 3676 if model.decoder and model.decoder.type != "JsonDecoder": 3677 raise ValueError( 3678 f"LazySimpleRetriever only supports JsonDecoder. Found: {model.decoder.type}." 3679 ) 3680 3681 return LazySimpleRetriever( 3682 name=name, 3683 paginator=paginator, 3684 primary_key=primary_key, 3685 requester=requester, 3686 record_selector=record_selector, 3687 stream_slicer=_NO_STREAM_SLICING, 3688 request_option_provider=request_options_provider, 3689 config=config, 3690 ignore_stream_slicer_parameters_on_paginated_requests=ignore_stream_slicer_parameters_on_paginated_requests, 3691 post_pagination_filter=post_pagination_filter, 3692 parameters=model.parameters or {}, 3693 ) 3694 3695 if ( 3696 model.record_selector.record_filter 3697 and model.pagination_reset 3698 and model.pagination_reset.limits 3699 ): 3700 raise ValueError("PaginationResetLimits are not supported while having record filter.") 3701 3702 return SimpleRetriever( 3703 name=name, 3704 paginator=paginator, 3705 primary_key=primary_key, 3706 requester=requester, 3707 record_selector=record_selector, 3708 stream_slicer=_NO_STREAM_SLICING, 3709 request_option_provider=request_options_provider, 3710 config=config, 3711 ignore_stream_slicer_parameters_on_paginated_requests=ignore_stream_slicer_parameters_on_paginated_requests, 3712 additional_query_properties=query_properties, 3713 log_formatter=self._get_log_formatter(log_formatter, name), 3714 pagination_tracker_factory=self._create_pagination_tracker_factory( 3715 model.pagination_reset, cursor 3716 ), 3717 post_pagination_filter=post_pagination_filter, 3718 parameters=model.parameters or {}, 3719 ) 3720 3721 def _create_pagination_tracker_factory( 3722 self, model: Optional[PaginationResetModel], cursor: Cursor 3723 ) -> Callable[[], PaginationTracker]: 3724 if model is None: 3725 return lambda: PaginationTracker() 3726 3727 # Until we figure out a way to use any cursor for PaginationTracker, we will have to have this cursor selector logic 3728 cursor_factory: Callable[[], Optional[ConcurrentCursor]] = lambda: None 3729 if model.action == PaginationResetActionModel.RESET: 3730 # in that case, we will let cursor_factory to return None even if the stream has a cursor 3731 pass 3732 elif model.action == PaginationResetActionModel.SPLIT_USING_CURSOR: 3733 if isinstance(cursor, ConcurrentCursor): 3734 cursor_factory = lambda: cursor.copy_without_state() # type: ignore # the if condition validates that it is a ConcurrentCursor 3735 elif isinstance(cursor, ConcurrentPerPartitionCursor): 3736 cursor_factory = lambda: cursor._cursor_factory.create( # type: ignore # if this becomes a problem, we would need to extract the cursor_factory instantiation logic and make it accessible here 3737 {}, datetime.timedelta(0) 3738 ) 3739 elif not isinstance(cursor, FinalStateCursor): 3740 LOGGER.warning( 3741 "Unknown cursor for PaginationTracker. Pagination resets might not work properly" 3742 ) 3743 else: 3744 raise ValueError(f"Unknown PaginationReset action: {model.action}") 3745 3746 limit = model.limits.number_of_records if model and model.limits else None 3747 return lambda: PaginationTracker(cursor_factory(), limit) 3748 3749 def _get_log_formatter( 3750 self, log_formatter: Callable[[Response], Any] | None, name: str 3751 ) -> Callable[[Response], Any] | None: 3752 if self._should_limit_slices_fetched(): 3753 return ( 3754 ( 3755 lambda response: format_http_message( 3756 response, 3757 f"Stream '{name}' request", 3758 f"Request performed in order to extract records for stream '{name}'", 3759 name, 3760 ) 3761 ) 3762 if not log_formatter 3763 else log_formatter 3764 ) 3765 return None 3766 3767 def _should_limit_slices_fetched(self) -> bool: 3768 """ 3769 Returns True if the number of slices fetched should be limited, False otherwise. 3770 This is used to limit the number of slices fetched during tests. 3771 """ 3772 return bool(self._limit_slices_fetched or self._emit_connector_builder_messages) 3773 3774 @staticmethod 3775 def _has_query_properties_in_request_parameters( 3776 requester: Union[HttpRequesterModel, CustomRequesterModel], 3777 ) -> bool: 3778 if not hasattr(requester, "request_parameters"): 3779 return False 3780 request_parameters = requester.request_parameters 3781 if request_parameters and isinstance(request_parameters, Mapping): 3782 for request_parameter in request_parameters.values(): 3783 if isinstance(request_parameter, QueryPropertiesModel): 3784 return True 3785 return False 3786 3787 @staticmethod 3788 def _remove_query_properties( 3789 request_parameters: Mapping[str, Union[str, QueryPropertiesModel]], 3790 ) -> Mapping[str, str]: 3791 return { 3792 parameter_field: request_parameter 3793 for parameter_field, request_parameter in request_parameters.items() 3794 if not isinstance(request_parameter, QueryPropertiesModel) 3795 } 3796 3797 def create_state_delegating_stream( 3798 self, 3799 model: StateDelegatingStreamModel, 3800 config: Config, 3801 **kwargs: Any, 3802 ) -> DefaultStream: 3803 if ( 3804 model.full_refresh_stream.name != model.name 3805 or model.name != model.incremental_stream.name 3806 ): 3807 raise ValueError( 3808 f"state_delegating_stream, full_refresh_stream name and incremental_stream must have equal names. Instead has {model.name}, {model.full_refresh_stream.name} and {model.incremental_stream.name}." 3809 ) 3810 3811 # Resolve api_retention_period with config context (supports Jinja2 interpolation) 3812 resolved_retention_period: Optional[str] = None 3813 if model.api_retention_period: 3814 interpolated_retention = InterpolatedString.create( 3815 model.api_retention_period, parameters=model.parameters or {} 3816 ) 3817 resolved_value = interpolated_retention.eval(config=config) 3818 if resolved_value: 3819 resolved_retention_period = str(resolved_value) 3820 3821 if resolved_retention_period: 3822 for stream_model in (model.full_refresh_stream, model.incremental_stream): 3823 if isinstance(stream_model.incremental_sync, IncrementingCountCursorModel): 3824 raise ValueError( 3825 f"Stream '{model.name}' uses IncrementingCountCursor which is not supported " 3826 f"with api_retention_period. IncrementingCountCursor does not use datetime-based " 3827 f"cursors, so cursor age validation cannot be performed." 3828 ) 3829 3830 stream_state = self._connector_state_manager.get_stream_state(model.name, None) 3831 3832 if not stream_state: 3833 return self._create_component_from_model( # type: ignore[no-any-return] 3834 model.full_refresh_stream, config=config, **kwargs 3835 ) 3836 3837 incremental_stream: DefaultStream = self._create_component_from_model( 3838 model.incremental_stream, config=config, **kwargs 3839 ) # type: ignore[assignment] 3840 3841 # Only run cursor age validation for streams that are in the configured 3842 # catalog (or when no catalog was provided, e.g. during discover / connector 3843 # builder). Streams not selected by the user but instantiated as parent-stream 3844 # dependencies must not go through this path because it emits state messages 3845 # that the destination does not know about, causing "Stream not found" crashes. 3846 stream_is_in_catalog = ( 3847 not self._stream_name_to_configured_stream # no catalog → validate by default 3848 or model.name in self._stream_name_to_configured_stream 3849 ) 3850 if resolved_retention_period and stream_is_in_catalog: 3851 full_refresh_stream: DefaultStream = self._create_component_from_model( 3852 model.full_refresh_stream, config=config, **kwargs 3853 ) # type: ignore[assignment] 3854 if self._is_cursor_older_than_retention_period( 3855 stream_state, 3856 full_refresh_stream.cursor, 3857 incremental_stream.cursor, 3858 resolved_retention_period, 3859 model.name, 3860 ): 3861 # Clear state BEFORE constructing the full_refresh_stream so that 3862 # its cursor starts from start_date instead of the stale cursor. 3863 self._connector_state_manager.update_state_for_stream(model.name, None, {}) 3864 state_message = self._connector_state_manager.create_state_message(model.name, None) 3865 self._message_repository.emit_message(state_message) 3866 return self._create_component_from_model( # type: ignore[no-any-return] 3867 model.full_refresh_stream, config=config, **kwargs 3868 ) 3869 3870 return incremental_stream 3871 3872 @staticmethod 3873 def _is_cursor_older_than_retention_period( 3874 stream_state: Mapping[str, Any], 3875 full_refresh_cursor: Cursor, 3876 incremental_cursor: Cursor, 3877 api_retention_period: str, 3878 stream_name: str, 3879 ) -> bool: 3880 """Check if the cursor value in the state is older than the API's retention period. 3881 3882 Checks cursors in sequence: full refresh cursor first, then incremental cursor. 3883 FinalStateCursor returns now() for completed full refresh state (NO_CURSOR_STATE_KEY), 3884 which is always within retention, so we use incremental. For other states, it returns 3885 None and we fall back to checking the incremental cursor. 3886 3887 Returns True if the cursor is older than the retention period (should use full refresh). 3888 Returns False if the cursor is within the retention period (safe to use incremental). 3889 """ 3890 retention_duration = parse_duration(api_retention_period) 3891 retention_cutoff = datetime.datetime.now(datetime.timezone.utc) - retention_duration 3892 3893 # Check full refresh cursor first 3894 cursor_datetime = full_refresh_cursor.get_cursor_datetime_from_state(stream_state) 3895 3896 # If full refresh cursor returns None, check incremental cursor 3897 if cursor_datetime is None: 3898 cursor_datetime = incremental_cursor.get_cursor_datetime_from_state(stream_state) 3899 3900 if cursor_datetime is None: 3901 # Neither cursor could parse the state - fall back to full refresh to be safe 3902 return True 3903 3904 if cursor_datetime < retention_cutoff: 3905 logging.warning( 3906 f"Stream '{stream_name}' has a cursor value older than " 3907 f"the API's retention period of {api_retention_period} " 3908 f"(cutoff: {retention_cutoff.isoformat()}). " 3909 f"Falling back to full refresh to avoid data loss." 3910 ) 3911 return True 3912 3913 return False 3914 3915 def _get_state_delegating_stream_model( 3916 self, 3917 model: StateDelegatingStreamModel, 3918 parent_state: Optional[Mapping[str, Any]] = None, 3919 ) -> DeclarativeStreamModel: 3920 """Return the appropriate underlying stream model based on state.""" 3921 return ( 3922 model.incremental_stream 3923 if self._connector_state_manager.get_stream_state(model.name, None) or parent_state 3924 else model.full_refresh_stream 3925 ) 3926 3927 _OPTIONAL_ASYNC_STATUS_FIELDS = {"skipped"} 3928 3929 def _create_async_job_status_mapping( 3930 self, model: AsyncJobStatusMapModel, config: Config, **kwargs: Any 3931 ) -> Mapping[str, AsyncJobStatus]: 3932 api_status_to_cdk_status = {} 3933 for cdk_status, api_statuses in model.dict().items(): 3934 if cdk_status == "type": 3935 # This is an element of the dict because of the typing of the CDK but it is not a CDK status 3936 continue 3937 3938 if api_statuses is None: 3939 if cdk_status in self._OPTIONAL_ASYNC_STATUS_FIELDS: 3940 continue 3941 raise ValueError( 3942 f"Required CDK status '{cdk_status}' has no API statuses mapped. " 3943 f"Please provide at least an empty list for required status fields." 3944 ) 3945 3946 for status in api_statuses: 3947 if status in api_status_to_cdk_status: 3948 raise ValueError( 3949 f"API status {status} is already set for CDK status {cdk_status}. Please ensure API statuses are only provided once" 3950 ) 3951 api_status_to_cdk_status[status] = self._get_async_job_status(cdk_status) 3952 return api_status_to_cdk_status 3953 3954 def _get_async_job_status(self, status: str) -> AsyncJobStatus: 3955 match status: 3956 case "running": 3957 return AsyncJobStatus.RUNNING 3958 case "completed": 3959 return AsyncJobStatus.COMPLETED 3960 case "failed": 3961 return AsyncJobStatus.FAILED 3962 case "timeout": 3963 return AsyncJobStatus.TIMED_OUT 3964 case "skipped": 3965 return AsyncJobStatus.SKIPPED 3966 case _: 3967 raise ValueError(f"Unsupported CDK status {status}") 3968 3969 def create_async_retriever( 3970 self, 3971 model: AsyncRetrieverModel, 3972 config: Config, 3973 *, 3974 name: str, 3975 primary_key: Optional[ 3976 Union[str, List[str], List[List[str]]] 3977 ], # this seems to be needed to match create_simple_retriever 3978 stream_slicer: Optional[StreamSlicer], 3979 client_side_incremental_sync: Optional[Dict[str, Any]] = None, 3980 transformations: List[RecordTransformation], 3981 **kwargs: Any, 3982 ) -> AsyncRetriever: 3983 if model.download_target_requester and not model.download_target_extractor: 3984 raise ValueError( 3985 f"`download_target_extractor` required if using a `download_target_requester`" 3986 ) 3987 3988 def _get_download_retriever( 3989 requester: Requester, extractor: RecordExtractor, _decoder: Decoder 3990 ) -> SimpleRetriever: 3991 # We create a record selector for the download retriever 3992 # with no schema normalization and no transformations, neither record filter 3993 # as all this occurs in the record_selector of the AsyncRetriever 3994 record_selector = RecordSelector( 3995 extractor=extractor, 3996 name=name, 3997 record_filter=None, 3998 transformations=[], 3999 schema_normalization=TypeTransformer(TransformConfig.NoTransform), 4000 config=config, 4001 parameters={}, 4002 ) 4003 paginator = ( 4004 self._create_component_from_model( 4005 model=model.download_paginator, 4006 decoder=_decoder, 4007 config=config, 4008 url_base="", 4009 ) 4010 if model.download_paginator 4011 else NoPagination(parameters={}) 4012 ) 4013 4014 return SimpleRetriever( 4015 requester=requester, 4016 record_selector=record_selector, 4017 primary_key=None, 4018 name=name, 4019 paginator=paginator, 4020 config=config, 4021 parameters={}, 4022 log_formatter=self._get_log_formatter(None, name), 4023 ) 4024 4025 def _get_job_timeout() -> datetime.timedelta: 4026 user_defined_timeout: Optional[int] = ( 4027 int( 4028 InterpolatedString.create( 4029 str(model.polling_job_timeout), 4030 parameters={}, 4031 ).eval(config) 4032 ) 4033 if model.polling_job_timeout 4034 else None 4035 ) 4036 4037 # check for user defined timeout during the test read or 15 minutes 4038 test_read_timeout = datetime.timedelta(minutes=user_defined_timeout or 15) 4039 # default value for non-connector builder is 60 minutes. 4040 default_sync_timeout = datetime.timedelta(minutes=user_defined_timeout or 60) 4041 4042 return ( 4043 test_read_timeout if self._emit_connector_builder_messages else default_sync_timeout 4044 ) 4045 4046 decoder = ( 4047 self._create_component_from_model(model=model.decoder, config=config) 4048 if model.decoder 4049 else JsonDecoder(parameters={}) 4050 ) 4051 record_selector = self._create_component_from_model( 4052 model=model.record_selector, 4053 config=config, 4054 decoder=decoder, 4055 name=name, 4056 transformations=transformations, 4057 client_side_incremental_sync=client_side_incremental_sync, 4058 ) 4059 4060 stream_slicer = stream_slicer or SinglePartitionRouter(parameters={}) 4061 if self._should_limit_slices_fetched(): 4062 stream_slicer = cast( 4063 StreamSlicer, 4064 StreamSlicerTestReadDecorator( 4065 wrapped_slicer=stream_slicer, 4066 maximum_number_of_slices=self._limit_slices_fetched or 5, 4067 ), 4068 ) 4069 4070 creation_requester = self._create_component_from_model( 4071 model=model.creation_requester, 4072 decoder=decoder, 4073 config=config, 4074 name=f"job creation - {name}", 4075 ) 4076 polling_requester = self._create_component_from_model( 4077 model=model.polling_requester, 4078 decoder=decoder, 4079 config=config, 4080 name=f"job polling - {name}", 4081 ) 4082 job_download_components_name = f"job download - {name}" 4083 download_decoder = ( 4084 self._create_component_from_model(model=model.download_decoder, config=config) 4085 if model.download_decoder 4086 else JsonDecoder(parameters={}) 4087 ) 4088 download_extractor = ( 4089 self._create_component_from_model( 4090 model=model.download_extractor, 4091 config=config, 4092 decoder=download_decoder, 4093 parameters=model.parameters, 4094 ) 4095 if model.download_extractor 4096 else DpathExtractor( 4097 [], 4098 config=config, 4099 decoder=download_decoder, 4100 parameters=model.parameters or {}, 4101 ) 4102 ) 4103 download_requester = self._create_component_from_model( 4104 model=model.download_requester, 4105 decoder=download_decoder, 4106 config=config, 4107 name=job_download_components_name, 4108 ) 4109 download_retriever = _get_download_retriever( 4110 download_requester, download_extractor, download_decoder 4111 ) 4112 abort_requester = ( 4113 self._create_component_from_model( 4114 model=model.abort_requester, 4115 decoder=decoder, 4116 config=config, 4117 name=f"job abort - {name}", 4118 ) 4119 if model.abort_requester 4120 else None 4121 ) 4122 delete_requester = ( 4123 self._create_component_from_model( 4124 model=model.delete_requester, 4125 decoder=decoder, 4126 config=config, 4127 name=f"job delete - {name}", 4128 ) 4129 if model.delete_requester 4130 else None 4131 ) 4132 download_target_requester = ( 4133 self._create_component_from_model( 4134 model=model.download_target_requester, 4135 decoder=decoder, 4136 config=config, 4137 name=f"job extract_url - {name}", 4138 ) 4139 if model.download_target_requester 4140 else None 4141 ) 4142 status_extractor = self._create_component_from_model( 4143 model=model.status_extractor, decoder=decoder, config=config, name=name 4144 ) 4145 download_target_extractor = ( 4146 self._create_component_from_model( 4147 model=model.download_target_extractor, 4148 decoder=decoder, 4149 config=config, 4150 name=name, 4151 ) 4152 if model.download_target_extractor 4153 else None 4154 ) 4155 4156 job_repository: AsyncJobRepository = AsyncHttpJobRepository( 4157 creation_requester=creation_requester, 4158 polling_requester=polling_requester, 4159 download_retriever=download_retriever, 4160 download_target_requester=download_target_requester, 4161 abort_requester=abort_requester, 4162 delete_requester=delete_requester, 4163 status_extractor=status_extractor, 4164 status_mapping=self._create_async_job_status_mapping(model.status_mapping, config), 4165 download_target_extractor=download_target_extractor, 4166 job_timeout=_get_job_timeout(), 4167 ) 4168 4169 failed_retry_wait_time_in_seconds: Optional[int] = ( 4170 int( 4171 InterpolatedString.create( 4172 str(model.failed_retry_wait_time_in_seconds), 4173 parameters={}, 4174 ).eval(config) 4175 ) 4176 if model.failed_retry_wait_time_in_seconds 4177 else None 4178 ) 4179 4180 async_job_partition_router = AsyncJobPartitionRouter( 4181 job_orchestrator_factory=lambda stream_slices: AsyncJobOrchestrator( 4182 job_repository, 4183 stream_slices, 4184 self._job_tracker, 4185 self._message_repository, 4186 # FIXME work would need to be done here in order to detect if a stream as a parent stream that is bulk 4187 has_bulk_parent=False, 4188 # set the `job_max_retry` to 1 for the `Connector Builder`` use-case. 4189 # `None` == default retry is set to 3 attempts, under the hood. 4190 job_max_retry=1 if self._emit_connector_builder_messages else None, 4191 failed_retry_wait_time_in_seconds=failed_retry_wait_time_in_seconds, 4192 ), 4193 stream_slicer=stream_slicer, 4194 config=config, 4195 parameters=model.parameters or {}, 4196 ) 4197 4198 return AsyncRetriever( 4199 record_selector=record_selector, 4200 stream_slicer=async_job_partition_router, 4201 config=config, 4202 parameters=model.parameters or {}, 4203 ) 4204 4205 def create_spec(self, model: SpecModel, config: Config, **kwargs: Any) -> Spec: 4206 config_migrations = [ 4207 self._create_component_from_model(migration, config) 4208 for migration in ( 4209 model.config_normalization_rules.config_migrations 4210 if ( 4211 model.config_normalization_rules 4212 and model.config_normalization_rules.config_migrations 4213 ) 4214 else [] 4215 ) 4216 ] 4217 config_transformations = [ 4218 self._create_component_from_model(transformation, config) 4219 for transformation in ( 4220 model.config_normalization_rules.transformations 4221 if ( 4222 model.config_normalization_rules 4223 and model.config_normalization_rules.transformations 4224 ) 4225 else [] 4226 ) 4227 ] 4228 config_validations = [ 4229 self._create_component_from_model(validation, config) 4230 for validation in ( 4231 model.config_normalization_rules.validations 4232 if ( 4233 model.config_normalization_rules 4234 and model.config_normalization_rules.validations 4235 ) 4236 else [] 4237 ) 4238 ] 4239 4240 return Spec( 4241 connection_specification=model.connection_specification, 4242 documentation_url=model.documentation_url, 4243 advanced_auth=model.advanced_auth, 4244 parameters={}, 4245 config_migrations=config_migrations, 4246 config_transformations=config_transformations, 4247 config_validations=config_validations, 4248 ) 4249 4250 def create_substream_partition_router( 4251 self, 4252 model: SubstreamPartitionRouterModel, 4253 config: Config, 4254 *, 4255 stream_name: str, 4256 **kwargs: Any, 4257 ) -> SubstreamPartitionRouter: 4258 parent_stream_configs = [] 4259 if model.parent_stream_configs: 4260 parent_stream_configs.extend( 4261 [ 4262 self.create_parent_stream_config_with_substream_wrapper( 4263 model=parent_stream_config, config=config, stream_name=stream_name, **kwargs 4264 ) 4265 for parent_stream_config in model.parent_stream_configs 4266 ] 4267 ) 4268 4269 return SubstreamPartitionRouter( 4270 parent_stream_configs=parent_stream_configs, 4271 parameters=model.parameters or {}, 4272 config=config, 4273 ) 4274 4275 def create_parent_stream_config_with_substream_wrapper( 4276 self, model: ParentStreamConfigModel, config: Config, *, stream_name: str, **kwargs: Any 4277 ) -> Any: 4278 child_state = self._connector_state_manager.get_stream_state(stream_name, None) 4279 if NO_CURSOR_STATE_KEY in child_state: 4280 # Full refresh streams checkpoint a `{NO_CURSOR_STATE_KEY: true}` sentinel. When such a 4281 # stream is later converted to incremental with an incremental_dependency parent, 4282 # `_instantiate_parent_stream_state_manager` would treat the sentinel's boolean as a legacy 4283 # cursor value and re-key it under the parent's cursor field, crashing cursor initialization. 4284 child_state = { 4285 key: value for key, value in child_state.items() if key != NO_CURSOR_STATE_KEY 4286 } 4287 4288 parent_state: Optional[Mapping[str, Any]] = ( 4289 child_state if model.incremental_dependency and child_state else None 4290 ) 4291 connector_state_manager = self._instantiate_parent_stream_state_manager( 4292 child_state, config, model, parent_state 4293 ) 4294 4295 substream_factory = ModelToComponentFactory( 4296 custom_components_trusted=self._custom_components_trusted, 4297 connector_state_manager=connector_state_manager, 4298 limit_pages_fetched_per_slice=self._limit_pages_fetched_per_slice, 4299 limit_slices_fetched=self._limit_slices_fetched, 4300 emit_connector_builder_messages=self._emit_connector_builder_messages, 4301 disable_retries=self._disable_retries, 4302 disable_cache=self._disable_cache, 4303 message_repository=StateFilteringMessageRepository( 4304 LogAppenderMessageRepositoryDecorator( 4305 { 4306 "airbyte_cdk": {"stream": {"is_substream": True}}, 4307 "http": {"is_auxiliary": True}, 4308 }, 4309 self._message_repository, 4310 self._evaluate_log_level(self._emit_connector_builder_messages), 4311 ), 4312 ), 4313 api_budget=self._api_budget, 4314 # Share the authenticator registry so parent and child streams draw from the 4315 # same token quota counters 4316 rate_limited_authenticators=self._rate_limited_authenticators, 4317 ) 4318 4319 return substream_factory.create_parent_stream_config( 4320 model=model, config=config, stream_name=stream_name, **kwargs 4321 ) 4322 4323 def _instantiate_parent_stream_state_manager( 4324 self, 4325 child_state: MutableMapping[str, Any], 4326 config: Config, 4327 model: ParentStreamConfigModel, 4328 parent_state: Optional[Mapping[str, Any]] = None, 4329 ) -> ConnectorStateManager: 4330 """ 4331 With DefaultStream, the state needs to be provided during __init__ of the cursor as opposed to the 4332 `set_initial_state` flow that existed for the declarative cursors. This state is taken from 4333 self._connector_state_manager.get_stream_state (`self` being a newly created ModelToComponentFactory to account 4334 for the MessageRepository being different). So we need to pass a ConnectorStateManager to the 4335 ModelToComponentFactory that has the parent states. This method populates this if there is a child state and if 4336 incremental_dependency is set. 4337 """ 4338 if model.incremental_dependency and child_state: 4339 parent_stream_name = model.stream.name or "" 4340 extracted_parent_state = ConcurrentPerPartitionCursor.get_parent_state( 4341 child_state, parent_stream_name 4342 ) 4343 4344 if not extracted_parent_state: 4345 extracted_parent_state = ConcurrentPerPartitionCursor.get_global_state( 4346 child_state, parent_stream_name 4347 ) 4348 4349 if not extracted_parent_state and not isinstance(extracted_parent_state, dict): 4350 cursor_values = child_state.values() 4351 if cursor_values and len(cursor_values) == 1: 4352 incremental_sync_model: Union[ 4353 DatetimeBasedCursorModel, 4354 IncrementingCountCursorModel, 4355 ] = ( 4356 model.stream.incremental_sync # type: ignore # if we are there, it is because there is incremental_dependency and therefore there is an incremental_sync on the parent stream 4357 if isinstance(model.stream, DeclarativeStreamModel) 4358 else self._get_state_delegating_stream_model( 4359 model.stream, parent_state=parent_state 4360 ).incremental_sync 4361 ) 4362 cursor_field = InterpolatedString.create( 4363 incremental_sync_model.cursor_field, 4364 parameters=incremental_sync_model.parameters or {}, 4365 ).eval(config) 4366 extracted_parent_state = AirbyteStateMessage( 4367 type=AirbyteStateType.STREAM, 4368 stream=AirbyteStreamState( 4369 stream_descriptor=StreamDescriptor( 4370 name=parent_stream_name, namespace=None 4371 ), 4372 stream_state=AirbyteStateBlob( 4373 {cursor_field: list(cursor_values)[0]} 4374 ), 4375 ), 4376 ) 4377 return ConnectorStateManager([extracted_parent_state] if extracted_parent_state else []) 4378 4379 return ConnectorStateManager([]) 4380 4381 @staticmethod 4382 def create_wait_time_from_header( 4383 model: WaitTimeFromHeaderModel, config: Config, **kwargs: Any 4384 ) -> WaitTimeFromHeaderBackoffStrategy: 4385 return WaitTimeFromHeaderBackoffStrategy( 4386 header=model.header, 4387 parameters=model.parameters or {}, 4388 config=config, 4389 regex=model.regex, 4390 max_waiting_time_in_seconds=model.max_waiting_time_in_seconds, 4391 ) 4392 4393 @staticmethod 4394 def create_wait_until_time_from_header( 4395 model: WaitUntilTimeFromHeaderModel, config: Config, **kwargs: Any 4396 ) -> WaitUntilTimeFromHeaderBackoffStrategy: 4397 return WaitUntilTimeFromHeaderBackoffStrategy( 4398 header=model.header, 4399 parameters=model.parameters or {}, 4400 config=config, 4401 min_wait=model.min_wait, 4402 regex=model.regex, 4403 max_waiting_time_in_seconds=model.max_waiting_time_in_seconds, 4404 ) 4405 4406 def get_message_repository(self) -> MessageRepository: 4407 return self._message_repository 4408 4409 def _evaluate_log_level(self, emit_connector_builder_messages: bool) -> Level: 4410 return Level.DEBUG if emit_connector_builder_messages else Level.INFO 4411 4412 @staticmethod 4413 def create_components_mapping_definition( 4414 model: ComponentMappingDefinitionModel, config: Config, **kwargs: Any 4415 ) -> ComponentMappingDefinition: 4416 interpolated_value = InterpolatedString.create( 4417 model.value, parameters=model.parameters or {} 4418 ) 4419 field_path = [ 4420 InterpolatedString.create(path, parameters=model.parameters or {}) 4421 for path in model.field_path 4422 ] 4423 return ComponentMappingDefinition( 4424 field_path=field_path, # type: ignore[arg-type] # field_path can be str and InterpolatedString 4425 value=interpolated_value, 4426 value_type=ModelToComponentFactory._json_schema_type_name_to_type(model.value_type), 4427 create_or_update=model.create_or_update, 4428 condition=model.condition, 4429 parameters=model.parameters or {}, 4430 ) 4431 4432 def create_http_components_resolver( 4433 self, model: HttpComponentsResolverModel, config: Config, stream_name: Optional[str] = None 4434 ) -> Any: 4435 retriever = self._create_component_from_model( 4436 model=model.retriever, 4437 config=config, 4438 name=f"{stream_name if stream_name else '__http_components_resolver'}", 4439 primary_key=None, 4440 stream_slicer=self._build_stream_slicer_from_partition_router(model.retriever, config), 4441 transformations=[], 4442 ) 4443 4444 components_mapping = [] 4445 for component_mapping_definition_model in model.components_mapping: 4446 if component_mapping_definition_model.condition: 4447 raise ValueError("`condition` is only supported for `ConfigComponentsResolver`") 4448 components_mapping.append( 4449 self._create_component_from_model( 4450 model=component_mapping_definition_model, 4451 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 4452 component_mapping_definition_model.value_type 4453 ), 4454 config=config, 4455 ) 4456 ) 4457 4458 return HttpComponentsResolver( 4459 retriever=retriever, 4460 stream_slicer=self._build_stream_slicer_from_partition_router(model.retriever, config), 4461 config=config, 4462 components_mapping=components_mapping, 4463 parameters=model.parameters or {}, 4464 ) 4465 4466 @staticmethod 4467 def create_stream_config( 4468 model: StreamConfigModel, config: Config, **kwargs: Any 4469 ) -> StreamConfig: 4470 model_configs_pointer: List[Union[InterpolatedString, str]] = ( 4471 [x for x in model.configs_pointer] if model.configs_pointer else [] 4472 ) 4473 4474 return StreamConfig( 4475 configs_pointer=model_configs_pointer, 4476 default_values=model.default_values, 4477 parameters=model.parameters or {}, 4478 ) 4479 4480 def create_config_components_resolver( 4481 self, 4482 model: ConfigComponentsResolverModel, 4483 config: Config, 4484 ) -> Any: 4485 model_stream_configs = ( 4486 model.stream_config if isinstance(model.stream_config, list) else [model.stream_config] 4487 ) 4488 4489 stream_configs = [ 4490 self._create_component_from_model( 4491 stream_config, config=config, parameters=model.parameters or {} 4492 ) 4493 for stream_config in model_stream_configs 4494 ] 4495 4496 components_mapping = [ 4497 self._create_component_from_model( 4498 model=components_mapping_definition_model, 4499 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 4500 components_mapping_definition_model.value_type 4501 ), 4502 config=config, 4503 parameters=model.parameters, 4504 ) 4505 for components_mapping_definition_model in model.components_mapping 4506 ] 4507 4508 return ConfigComponentsResolver( 4509 stream_configs=stream_configs, 4510 config=config, 4511 components_mapping=components_mapping, 4512 parameters=model.parameters or {}, 4513 ) 4514 4515 def create_parametrized_components_resolver( 4516 self, 4517 model: ParametrizedComponentsResolverModel, 4518 config: Config, 4519 ) -> ParametrizedComponentsResolver: 4520 stream_parameters = StreamParametersDefinition( 4521 list_of_parameters_for_stream=model.stream_parameters.list_of_parameters_for_stream 4522 ) 4523 4524 components_mapping = [] 4525 for components_mapping_definition_model in model.components_mapping: 4526 if components_mapping_definition_model.condition: 4527 raise ValueError("`condition` is only supported for `ConfigComponentsResolver`") 4528 components_mapping.append( 4529 self._create_component_from_model( 4530 model=components_mapping_definition_model, 4531 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 4532 components_mapping_definition_model.value_type 4533 ), 4534 config=config, 4535 ) 4536 ) 4537 return ParametrizedComponentsResolver( 4538 stream_parameters=stream_parameters, 4539 config=config, 4540 components_mapping=components_mapping, 4541 parameters=model.parameters or {}, 4542 ) 4543 4544 _UNSUPPORTED_DECODER_ERROR = ( 4545 "Specified decoder of {decoder_type} is not supported for pagination." 4546 "Please set as `JsonDecoder`, `XmlDecoder`, or a `CompositeRawDecoder` with an inner_parser of `JsonParser` or `GzipParser` instead." 4547 "If using `GzipParser`, please ensure that the lowest level inner_parser is a `JsonParser`." 4548 ) 4549 4550 def _is_supported_decoder_for_pagination(self, decoder: Decoder) -> bool: 4551 if isinstance(decoder, (JsonDecoder, XmlDecoder)): 4552 return True 4553 elif isinstance(decoder, CompositeRawDecoder): 4554 return self._is_supported_parser_for_pagination(decoder.parser) 4555 else: 4556 return False 4557 4558 def _is_supported_parser_for_pagination(self, parser: Parser) -> bool: 4559 if isinstance(parser, JsonParser): 4560 return True 4561 elif isinstance(parser, GzipParser): 4562 return isinstance(parser.inner_parser, JsonParser) 4563 else: 4564 return False 4565 4566 def create_http_api_budget( 4567 self, model: HTTPAPIBudgetModel, config: Config, **kwargs: Any 4568 ) -> HttpAPIBudget: 4569 policies = [ 4570 self._create_component_from_model(model=policy, config=config) 4571 for policy in model.policies 4572 ] 4573 4574 return HttpAPIBudget( 4575 policies=policies, 4576 ratelimit_reset_header=model.ratelimit_reset_header or "ratelimit-reset", 4577 ratelimit_remaining_header=model.ratelimit_remaining_header or "ratelimit-remaining", 4578 status_codes_for_ratelimit_hit=model.status_codes_for_ratelimit_hit or [429], 4579 ) 4580 4581 def create_fixed_window_call_rate_policy( 4582 self, model: FixedWindowCallRatePolicyModel, config: Config, **kwargs: Any 4583 ) -> FixedWindowCallRatePolicy: 4584 matchers = [ 4585 self._create_component_from_model(model=matcher, config=config) 4586 for matcher in model.matchers 4587 ] 4588 4589 # Set the initial reset timestamp to 10 days from now. 4590 # This value will be updated by the first request. 4591 return FixedWindowCallRatePolicy( 4592 next_reset_ts=datetime.datetime.now() + datetime.timedelta(days=10), 4593 period=parse_duration(model.period), 4594 call_limit=model.call_limit, 4595 matchers=matchers, 4596 ) 4597 4598 def create_file_uploader( 4599 self, model: FileUploaderModel, config: Config, **kwargs: Any 4600 ) -> FileUploader: 4601 name = "File Uploader" 4602 requester = self._create_component_from_model( 4603 model=model.requester, 4604 config=config, 4605 name=name, 4606 **kwargs, 4607 ) 4608 download_target_extractor = self._create_component_from_model( 4609 model=model.download_target_extractor, 4610 config=config, 4611 name=name, 4612 **kwargs, 4613 ) 4614 emit_connector_builder_messages = self._emit_connector_builder_messages 4615 file_uploader = DefaultFileUploader( 4616 requester=requester, 4617 download_target_extractor=download_target_extractor, 4618 config=config, 4619 file_writer=NoopFileWriter() 4620 if emit_connector_builder_messages 4621 else LocalFileSystemFileWriter(), 4622 parameters=model.parameters or {}, 4623 filename_extractor=model.filename_extractor if model.filename_extractor else None, 4624 ) 4625 4626 return ( 4627 ConnectorBuilderFileUploader(file_uploader) 4628 if emit_connector_builder_messages 4629 else file_uploader 4630 ) 4631 4632 def create_moving_window_call_rate_policy( 4633 self, model: MovingWindowCallRatePolicyModel, config: Config, **kwargs: Any 4634 ) -> MovingWindowCallRatePolicy: 4635 rates = [ 4636 self._create_component_from_model(model=rate, config=config) for rate in model.rates 4637 ] 4638 matchers = [ 4639 self._create_component_from_model(model=matcher, config=config) 4640 for matcher in model.matchers 4641 ] 4642 return MovingWindowCallRatePolicy( 4643 rates=rates, 4644 matchers=matchers, 4645 ) 4646 4647 def create_unlimited_call_rate_policy( 4648 self, model: UnlimitedCallRatePolicyModel, config: Config, **kwargs: Any 4649 ) -> UnlimitedCallRatePolicy: 4650 matchers = [ 4651 self._create_component_from_model(model=matcher, config=config) 4652 for matcher in model.matchers 4653 ] 4654 4655 return UnlimitedCallRatePolicy( 4656 matchers=matchers, 4657 ) 4658 4659 def create_rate(self, model: RateModel, config: Config, **kwargs: Any) -> Rate: 4660 interpolated_limit = InterpolatedString.create(str(model.limit), parameters={}) 4661 return Rate( 4662 limit=int(interpolated_limit.eval(config=config)), 4663 interval=parse_duration(model.interval), 4664 ) 4665 4666 def create_http_request_matcher( 4667 self, model: HttpRequestRegexMatcherModel, config: Config, **kwargs: Any 4668 ) -> HttpRequestRegexMatcher: 4669 weight = model.weight 4670 if weight is not None: 4671 if isinstance(weight, str): 4672 weight = int(InterpolatedString.create(weight, parameters={}).eval(config)) 4673 else: 4674 weight = int(weight) 4675 if weight < 1: 4676 raise ValueError(f"weight must be >= 1, got {weight}") 4677 return HttpRequestRegexMatcher( 4678 method=model.method, 4679 url_base=model.url_base, 4680 url_path_pattern=model.url_path_pattern, 4681 params=model.params, 4682 headers=model.headers, 4683 weight=weight, 4684 ) 4685 4686 def create_rate_limited_multiple_token_authenticator( 4687 self, 4688 model: RateLimitedMultipleTokenAuthenticatorModel, 4689 config: Config, 4690 **kwargs: Any, 4691 ) -> RateLimitedMultipleTokenAuthenticator: 4692 if isinstance(model.tokens, str): 4693 tokens_value = InterpolatedString.create(model.tokens, parameters={}).eval(config) 4694 delimiter = model.token_delimiter or "," 4695 tokens = [ 4696 token.strip() for token in str(tokens_value).split(delimiter) if token.strip() 4697 ] 4698 else: 4699 tokens = [ 4700 token_value 4701 for token in model.tokens 4702 if ( 4703 token_value := str( 4704 InterpolatedString.create(token, parameters={}).eval(config) 4705 ).strip() 4706 ) 4707 ] 4708 4709 quota_specs = [ 4710 { 4711 "name": quota_model.name, 4712 "remaining_path": quota_model.remaining_path, 4713 "reset_path": quota_model.reset_path, 4714 "limit_path": quota_model.limit_path, 4715 "remaining_header": quota_model.remaining_header, 4716 "reset_header": quota_model.reset_header, 4717 "limit_header": quota_model.limit_header, 4718 # Normalize the same way as the runtime TokenQuota below, so an omitted field 4719 # and an explicit `[]` key identically and keep sharing one set of counters. 4720 "exhaustion_status_codes": quota_model.exhaustion_status_codes or [], 4721 "matchers": [ 4722 { 4723 "method": matcher_model.method, 4724 "url_base": matcher_model.url_base, 4725 "url_path_pattern": matcher_model.url_path_pattern, 4726 "params": matcher_model.params, 4727 "headers": matcher_model.headers, 4728 "weight": matcher_model.weight, 4729 } 4730 for matcher_model in quota_model.matchers or [] 4731 ], 4732 } 4733 for quota_model in model.quotas 4734 ] 4735 4736 quota_status_url = str( 4737 InterpolatedString.create(model.quota_status_source.url, parameters={}).eval(config) 4738 ) 4739 quota_status_http_method = ( 4740 model.quota_status_source.http_method.value 4741 if model.quota_status_source.http_method 4742 else "GET" 4743 ) 4744 quota_status_headers = { 4745 key: str(InterpolatedString.create(value, parameters={}).eval(config)) 4746 for key, value in (model.quota_status_source.request_headers or {}).items() 4747 } 4748 # Normalize the same way as the quota specs above, so an omitted field and an explicit 4749 # `[]` key identically and keep sharing one set of counters. Deduplicated as well as 4750 # sorted, because the runtime turns this into a set: without it `[404]` and `[404, 404]` 4751 # would key differently and stop sharing counters while behaving identically. 4752 quota_status_unavailable_status_codes = sorted( 4753 set(model.quota_status_source.unavailable_status_codes or []) 4754 ) 4755 auth_method = model.auth_method or "Bearer" 4756 header = model.header or "Authorization" 4757 max_wait_time_str = str( 4758 InterpolatedString.create(model.max_wait_time or "PT2H", parameters={}).eval(config) 4759 ) 4760 max_wait_time = parse_duration(max_wait_time_str) 4761 if not isinstance(max_wait_time, datetime.timedelta): 4762 raise ValueError( 4763 f"max_wait_time must be a fixed-length ISO 8601 duration (e.g. 'PT2H'); " 4764 f"calendar-unit durations like '{max_wait_time_str}' are not supported" 4765 ) 4766 budget_reserve_fraction = ( 4767 model.budget_reserve_fraction if model.budget_reserve_fraction is not None else 0.1 4768 ) 4769 budget_min_reserve = ( 4770 model.budget_min_reserve if model.budget_min_reserve is not None else 50 4771 ) 4772 4773 # Reuse the same instance for identical definitions so that all streams share the 4774 # same token quota counters (similar to how api_budget is shared). The key is built 4775 # from the resolved constructor arguments rather than the raw model so that 4776 # stream-specific `$parameters` propagated onto the model (and its nested components) 4777 # cannot break instance sharing. 4778 cache_key = json.dumps( 4779 { 4780 "tokens": tokens, 4781 "quotas": quota_specs, 4782 "quota_status_url": quota_status_url, 4783 "quota_status_http_method": quota_status_http_method, 4784 "quota_status_headers": quota_status_headers, 4785 "quota_status_unavailable_status_codes": quota_status_unavailable_status_codes, 4786 "auth_method": auth_method, 4787 "header": header, 4788 "max_wait_time": max_wait_time.total_seconds(), 4789 "budget_reserve_fraction": budget_reserve_fraction, 4790 "budget_min_reserve": budget_min_reserve, 4791 }, 4792 sort_keys=True, 4793 ) 4794 if cache_key in self._rate_limited_authenticators: 4795 return self._rate_limited_authenticators[cache_key] 4796 4797 quotas = [ 4798 TokenQuota( 4799 name=quota_model.name, 4800 remaining_path=quota_model.remaining_path, 4801 reset_path=quota_model.reset_path, 4802 limit_path=quota_model.limit_path, 4803 remaining_header=quota_model.remaining_header, 4804 reset_header=quota_model.reset_header, 4805 limit_header=quota_model.limit_header, 4806 exhaustion_status_codes=quota_model.exhaustion_status_codes or [], 4807 matchers=[ 4808 self.create_http_request_matcher(matcher_model, config) 4809 for matcher_model in quota_model.matchers or [] 4810 ], 4811 ) 4812 for quota_model in model.quotas 4813 ] 4814 4815 authenticator = RateLimitedMultipleTokenAuthenticator( 4816 tokens=tokens, 4817 quotas=quotas, 4818 quota_status_url=quota_status_url, 4819 quota_status_http_method=quota_status_http_method, 4820 quota_status_headers=quota_status_headers, 4821 quota_status_unavailable_status_codes=quota_status_unavailable_status_codes, 4822 auth_method=auth_method, 4823 header=header, 4824 max_wait_time=max_wait_time, 4825 budget_reserve_fraction=budget_reserve_fraction, 4826 budget_min_reserve=budget_min_reserve, 4827 ) 4828 self._rate_limited_authenticators[cache_key] = authenticator 4829 return authenticator 4830 4831 def set_api_budget(self, component_definition: ComponentDefinition, config: Config) -> None: 4832 self._api_budget = self.create_component( 4833 model_type=HTTPAPIBudgetModel, component_definition=component_definition, config=config 4834 ) 4835 4836 def create_grouping_partition_router( 4837 self, 4838 model: GroupingPartitionRouterModel, 4839 config: Config, 4840 *, 4841 stream_name: str, 4842 **kwargs: Any, 4843 ) -> GroupingPartitionRouter: 4844 underlying_router = self._create_component_from_model( 4845 model=model.underlying_partition_router, 4846 config=config, 4847 stream_name=stream_name, 4848 **kwargs, 4849 ) 4850 if model.group_size < 1: 4851 raise ValueError(f"Group size must be greater than 0, got {model.group_size}") 4852 4853 # Request options in underlying partition routers are not supported for GroupingPartitionRouter 4854 # because they are specific to individual partitions and cannot be aggregated or handled 4855 # when grouping, potentially leading to incorrect API calls. Any request customization 4856 # should be managed at the stream level through the requester's configuration. 4857 if isinstance(underlying_router, SubstreamPartitionRouter): 4858 if any( 4859 parent_config.request_option 4860 for parent_config in underlying_router.parent_stream_configs 4861 ): 4862 raise ValueError("Request options are not supported for GroupingPartitionRouter.") 4863 4864 if isinstance(underlying_router, ListPartitionRouter): 4865 if underlying_router.request_option: 4866 raise ValueError("Request options are not supported for GroupingPartitionRouter.") 4867 4868 return GroupingPartitionRouter( 4869 group_size=model.group_size, 4870 underlying_partition_router=underlying_router, 4871 deduplicate=model.deduplicate if model.deduplicate is not None else True, 4872 config=config, 4873 ) 4874 4875 def create_union_partition_router( 4876 self, 4877 model: UnionPartitionRouterModel, 4878 config: Config, 4879 *, 4880 stream_name: str, 4881 **kwargs: Any, 4882 ) -> UnionPartitionRouter: 4883 # The schema enforces minItems: 2 for manifests; this guard covers construction paths 4884 # that bypass JSON-schema validation (the generated model carries no min_items constraint). 4885 if len(model.partition_routers) < 2: 4886 raise ValueError( 4887 f"UnionPartitionRouter for stream {stream_name} needs at least 2 child partition routers" 4888 ) 4889 4890 partition_routers = [ 4891 self._create_component_from_model( 4892 model=child, 4893 config=config, 4894 stream_name=stream_name, 4895 **kwargs, 4896 ) 4897 for child in model.partition_routers 4898 ] 4899 4900 # partition_field depends only on config/parameters, so it is evaluated once at build 4901 # time; the runtime component always receives a plain string. 4902 partition_field = InterpolatedString.create( 4903 model.partition_field, parameters=model.parameters or {} 4904 ).eval(config) 4905 4906 # Fail fast at build time when a built-in child router is statically known to emit a 4907 # partition field different from the union's. CustomPartitionRouter children are opaque 4908 # and can only be validated at runtime. 4909 for child_model in model.partition_routers: 4910 child_partition_fields: List[str] = [] 4911 if isinstance(child_model, ListPartitionRouterModel): 4912 child_partition_fields.append( 4913 InterpolatedString.create( 4914 child_model.cursor_field, parameters=child_model.parameters or {} 4915 ).eval(config) 4916 ) 4917 elif isinstance(child_model, SubstreamPartitionRouterModel): 4918 for parent_stream_config in child_model.parent_stream_configs: 4919 child_partition_fields.append( 4920 InterpolatedString.create( 4921 parent_stream_config.partition_field, 4922 parameters=parent_stream_config.parameters 4923 or child_model.parameters 4924 or {}, 4925 ).eval(config) 4926 ) 4927 elif isinstance(child_model, UnionPartitionRouterModel): 4928 child_partition_fields.append( 4929 InterpolatedString.create( 4930 child_model.partition_field, parameters=child_model.parameters or {} 4931 ).eval(config) 4932 ) 4933 for child_partition_field in child_partition_fields: 4934 if child_partition_field != partition_field: 4935 raise ValueError( 4936 f"UnionPartitionRouter expects all child partition routers to emit the " 4937 f"partition field '{partition_field}', but a " 4938 f"{child_model.type} child emits '{child_partition_field}'." 4939 ) 4940 4941 # A union slice comes from exactly one child partition router, so request options 4942 # declared on children cannot be applied consistently to requests built from the 4943 # normalized union slices. Partition values should be consumed via interpolation 4944 # (e.g. stream_partition) instead. Note that this validation only covers built-in 4945 # router types; CustomPartitionRouter children are opaque, so any request options 4946 # they implement internally cannot be detected or rejected here. 4947 for router in partition_routers: 4948 if isinstance(router, SubstreamPartitionRouter): 4949 if any( 4950 parent_config.request_option for parent_config in router.parent_stream_configs 4951 ): 4952 raise ValueError("Request options are not supported for UnionPartitionRouter.") 4953 if isinstance(router, ListPartitionRouter) and router.request_option: 4954 raise ValueError("Request options are not supported for UnionPartitionRouter.") 4955 4956 return UnionPartitionRouter( 4957 partition_routers=partition_routers, 4958 partition_field=partition_field, 4959 parameters=model.parameters or {}, 4960 ) 4961 4962 def _ensure_query_properties_to_model( 4963 self, requester: Union[HttpRequesterModel, CustomRequesterModel] 4964 ) -> None: 4965 """ 4966 For some reason, it seems like CustomRequesterModel request_parameters stays as dictionaries which means that 4967 the other conditions relying on it being QueryPropertiesModel instead of a dict fail. Here, we migrate them to 4968 proper model. 4969 """ 4970 if not hasattr(requester, "request_parameters"): 4971 return 4972 4973 request_parameters = requester.request_parameters 4974 if request_parameters and isinstance(request_parameters, Dict): 4975 for request_parameter_key in request_parameters.keys(): 4976 request_parameter = request_parameters[request_parameter_key] 4977 if ( 4978 isinstance(request_parameter, Dict) 4979 and request_parameter.get("type") == "QueryProperties" 4980 ): 4981 request_parameters[request_parameter_key] = QueryPropertiesModel.parse_obj( 4982 request_parameter 4983 ) 4984 4985 def _get_catalog_defined_cursor_field( 4986 self, stream_name: str, allow_catalog_defined_cursor_field: bool 4987 ) -> Optional[CursorField]: 4988 if not allow_catalog_defined_cursor_field: 4989 return None 4990 4991 configured_stream = self._stream_name_to_configured_stream.get(stream_name) 4992 4993 # Depending on the operation is being performed, there may not be a configured stream yet. In this 4994 # case we return None which will then use the default cursor field defined on the cursor model. 4995 # We also treat cursor_field: [""] (list with empty string) as no cursor field, since this can 4996 # occur when the platform serializes "no cursor configured" streams incorrectly. 4997 if ( 4998 not configured_stream 4999 or not configured_stream.cursor_field 5000 or not configured_stream.cursor_field[0] 5001 ): 5002 return None 5003 elif len(configured_stream.cursor_field) > 1: 5004 raise ValueError( 5005 f"The `{stream_name}` stream does not support nested cursor_field. Please specify only a single cursor_field for the stream in the configured catalog." 5006 ) 5007 else: 5008 return CursorField( 5009 cursor_field_key=configured_stream.cursor_field[0], 5010 supports_catalog_defined_cursor_field=allow_catalog_defined_cursor_field, 5011 )
710 def __init__( 711 self, 712 limit_pages_fetched_per_slice: Optional[int] = None, 713 limit_slices_fetched: Optional[int] = None, 714 emit_connector_builder_messages: bool = False, 715 disable_retries: bool = False, 716 disable_cache: bool = False, 717 message_repository: Optional[MessageRepository] = None, 718 connector_state_manager: Optional[ConnectorStateManager] = None, 719 max_concurrent_async_job_count: Optional[int] = None, 720 configured_catalog: Optional[ConfiguredAirbyteCatalog] = None, 721 api_budget: Optional[APIBudget] = None, 722 rate_limited_authenticators: Optional[ 723 Dict[str, RateLimitedMultipleTokenAuthenticator] 724 ] = None, 725 custom_components_trusted: bool = True, 726 ): 727 self._init_mappings() 728 self._custom_components_trusted = custom_components_trusted 729 self._limit_pages_fetched_per_slice = limit_pages_fetched_per_slice 730 self._limit_slices_fetched = limit_slices_fetched 731 self._emit_connector_builder_messages = emit_connector_builder_messages 732 self._disable_retries = disable_retries 733 self._disable_cache = disable_cache 734 self._message_repository = message_repository or InMemoryMessageRepository( 735 self._evaluate_log_level(emit_connector_builder_messages) 736 ) 737 self._stream_name_to_configured_stream = self._create_stream_name_to_configured_stream( 738 configured_catalog 739 ) 740 self._connector_state_manager = connector_state_manager or ConnectorStateManager() 741 self._api_budget: Optional[Union[APIBudget]] = api_budget 742 # Shared instances so all streams see the same token quota counters (like api_budget) 743 self._rate_limited_authenticators: Dict[str, RateLimitedMultipleTokenAuthenticator] = ( 744 rate_limited_authenticators if rate_limited_authenticators is not None else {} 745 ) 746 self._job_tracker: JobTracker = JobTracker(max_concurrent_async_job_count or 1) 747 # placeholder for deprecation warnings 748 self._collected_deprecation_logs: List[ConnectorBuilderLogMessage] = []
875 def create_component( 876 self, 877 model_type: Type[BaseModel], 878 component_definition: ComponentDefinition, 879 config: Config, 880 **kwargs: Any, 881 ) -> Any: 882 """ 883 Takes a given Pydantic model type and Mapping representing a component definition and creates a declarative component and 884 subcomponents which will be used at runtime. This is done by first parsing the mapping into a Pydantic model and then creating 885 creating declarative components from that model. 886 887 :param model_type: The type of declarative component that is being initialized 888 :param component_definition: The mapping that represents a declarative component 889 :param config: The connector config that is provided by the customer 890 :return: The declarative component to be used at runtime 891 """ 892 893 component_type = component_definition.get("type") 894 if component_definition.get("type") != model_type.__name__: 895 raise ValueError( 896 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 897 ) 898 899 declarative_component_model = model_type.parse_obj(component_definition) 900 901 if not isinstance(declarative_component_model, model_type): 902 raise ValueError( 903 f"Expected {model_type.__name__} component, but received {declarative_component_model.__class__.__name__}" 904 ) 905 906 return self._create_component_from_model( 907 model=declarative_component_model, config=config, **kwargs 908 )
Takes a given Pydantic model type and Mapping representing a component definition and creates a declarative component and subcomponents which will be used at runtime. This is done by first parsing the mapping into a Pydantic model and then creating creating declarative components from that model.
Parameters
- model_type: The type of declarative component that is being initialized
- component_definition: The mapping that represents a declarative component
- config: The connector config that is provided by the customer
Returns
The declarative component to be used at runtime
925 def get_model_deprecations(self) -> List[ConnectorBuilderLogMessage]: 926 """ 927 Returns the deprecation warnings that were collected during the creation of components. 928 """ 929 return self._collected_deprecation_logs
Returns the deprecation warnings that were collected during the creation of components.
946 def create_config_migration( 947 self, model: ConfigMigrationModel, config: Config 948 ) -> ConfigMigration: 949 transformations: List[ConfigTransformation] = [ 950 self._create_component_from_model(transformation, config) 951 for transformation in model.transformations 952 ] 953 954 return ConfigMigration( 955 description=model.description, 956 transformations=transformations, 957 )
959 def create_config_add_fields( 960 self, model: ConfigAddFieldsModel, config: Config, **kwargs: Any 961 ) -> ConfigAddFields: 962 fields = [self._create_component_from_model(field, config) for field in model.fields] 963 return ConfigAddFields( 964 fields=fields, 965 condition=model.condition or "", 966 )
1006 @staticmethod 1007 def create_validate_adheres_to_schema( 1008 model: ValidateAdheresToSchemaModel, config: Config, **kwargs: Any 1009 ) -> ValidateAdheresToSchema: 1010 base_schema = cast(Mapping[str, Any], model.base_schema) 1011 return ValidateAdheresToSchema( 1012 schema=base_schema, 1013 )
1015 @staticmethod 1016 def create_added_field_definition( 1017 model: AddedFieldDefinitionModel, config: Config, **kwargs: Any 1018 ) -> AddedFieldDefinition: 1019 interpolated_value = InterpolatedString.create( 1020 model.value, parameters=model.parameters or {} 1021 ) 1022 return AddedFieldDefinition( 1023 path=model.path, 1024 value=interpolated_value, 1025 value_type=ModelToComponentFactory._json_schema_type_name_to_type(model.value_type), 1026 parameters=model.parameters or {}, 1027 )
1029 def create_add_fields(self, model: AddFieldsModel, config: Config, **kwargs: Any) -> AddFields: 1030 added_field_definitions = [ 1031 self._create_component_from_model( 1032 model=added_field_definition_model, 1033 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 1034 added_field_definition_model.value_type 1035 ), 1036 config=config, 1037 ) 1038 for added_field_definition_model in model.fields 1039 ] 1040 return AddFields( 1041 fields=added_field_definitions, 1042 condition=model.condition or "", 1043 parameters=model.parameters or {}, 1044 )
1070 def create_dpath_flatten_fields( 1071 self, model: DpathFlattenFieldsModel, config: Config, **kwargs: Any 1072 ) -> DpathFlattenFields: 1073 model_field_path: List[Union[InterpolatedString, str]] = [x for x in model.field_path] 1074 key_transformation = ( 1075 KeyTransformation( 1076 config=config, 1077 prefix=model.key_transformation.prefix, 1078 suffix=model.key_transformation.suffix, 1079 parameters=model.parameters or {}, 1080 ) 1081 if model.key_transformation is not None 1082 else None 1083 ) 1084 return DpathFlattenFields( 1085 config=config, 1086 field_path=model_field_path, 1087 delete_origin_value=model.delete_origin_value 1088 if model.delete_origin_value is not None 1089 else False, 1090 replace_record=model.replace_record if model.replace_record is not None else False, 1091 key_transformation=key_transformation, 1092 parameters=model.parameters or {}, 1093 )
1107 def create_api_key_authenticator( 1108 self, 1109 model: ApiKeyAuthenticatorModel, 1110 config: Config, 1111 token_provider: Optional[TokenProvider] = None, 1112 **kwargs: Any, 1113 ) -> ApiKeyAuthenticator: 1114 if model.inject_into is None and model.header is None: 1115 raise ValueError( 1116 "Expected either inject_into or header to be set for ApiKeyAuthenticator" 1117 ) 1118 1119 if model.inject_into is not None and model.header is not None: 1120 raise ValueError( 1121 "inject_into and header cannot be set both for ApiKeyAuthenticator - remove the deprecated header option" 1122 ) 1123 1124 if token_provider is not None and model.api_token != "": 1125 raise ValueError( 1126 "If token_provider is set, api_token is ignored and has to be set to empty string." 1127 ) 1128 1129 request_option = ( 1130 self._create_component_from_model( 1131 model.inject_into, config, parameters=model.parameters or {} 1132 ) 1133 if model.inject_into 1134 else RequestOption( 1135 inject_into=RequestOptionType.header, 1136 field_name=model.header or "", 1137 parameters=model.parameters or {}, 1138 ) 1139 ) 1140 1141 return ApiKeyAuthenticator( 1142 token_provider=( 1143 token_provider 1144 if token_provider is not None 1145 else InterpolatedStringTokenProvider( 1146 api_token=model.api_token or "", 1147 config=config, 1148 parameters=model.parameters or {}, 1149 ) 1150 ), 1151 request_option=request_option, 1152 config=config, 1153 parameters=model.parameters or {}, 1154 )
1156 def create_legacy_to_per_partition_state_migration( 1157 self, 1158 model: LegacyToPerPartitionStateMigrationModel, 1159 config: Mapping[str, Any], 1160 declarative_stream: DeclarativeStreamModel, 1161 ) -> LegacyToPerPartitionStateMigration: 1162 retriever = declarative_stream.retriever 1163 if not isinstance(retriever, (SimpleRetrieverModel, AsyncRetrieverModel)): 1164 raise ValueError( 1165 f"LegacyToPerPartitionStateMigrations can only be applied on a DeclarativeStream with a SimpleRetriever or AsyncRetriever. Got {type(retriever)}" 1166 ) 1167 partition_router = retriever.partition_router 1168 if not isinstance( 1169 partition_router, 1170 ( 1171 SubstreamPartitionRouterModel, 1172 CustomPartitionRouterModel, 1173 UnionPartitionRouterModel, 1174 ), 1175 ): 1176 raise ValueError( 1177 f"LegacyToPerPartitionStateMigrations can only be applied on a SimpleRetriever with a SubstreamPartitionRouter, UnionPartitionRouter or CustomPartitionRouter. Got {type(partition_router)}" 1178 ) 1179 if not isinstance(partition_router, UnionPartitionRouterModel) and not hasattr( 1180 partition_router, "parent_stream_configs" 1181 ): 1182 raise ValueError( 1183 "LegacyToPerPartitionStateMigrations can only be applied with a parent stream configuration." 1184 ) 1185 1186 if not hasattr(declarative_stream, "incremental_sync"): 1187 raise ValueError( 1188 "LegacyToPerPartitionStateMigrations can only be applied with an incremental_sync configuration." 1189 ) 1190 1191 return LegacyToPerPartitionStateMigration( 1192 partition_router, # type: ignore # was already checked above 1193 declarative_stream.incremental_sync, # type: ignore # was already checked. Migration can be applied only to incremental streams. 1194 config, 1195 declarative_stream.parameters, # type: ignore # different type is expected here Mapping[str, Any], got Dict[str, Any] 1196 )
1198 def create_session_token_authenticator( 1199 self, model: SessionTokenAuthenticatorModel, config: Config, name: str, **kwargs: Any 1200 ) -> Union[ApiKeyAuthenticator, BearerAuthenticator]: 1201 decoder = ( 1202 self._create_component_from_model(model=model.decoder, config=config) 1203 if model.decoder 1204 else JsonDecoder(parameters={}) 1205 ) 1206 login_requester = self._create_component_from_model( 1207 model=model.login_requester, 1208 config=config, 1209 name=f"{name}_login_requester", 1210 decoder=decoder, 1211 ) 1212 token_provider = SessionTokenProvider( 1213 login_requester=login_requester, 1214 session_token_path=model.session_token_path, 1215 expiration_duration=parse_duration(model.expiration_duration) 1216 if model.expiration_duration 1217 else None, 1218 parameters=model.parameters or {}, 1219 message_repository=self._message_repository, 1220 decoder=decoder, 1221 ) 1222 if model.request_authentication.type == "Bearer": 1223 return ModelToComponentFactory.create_bearer_authenticator( 1224 BearerAuthenticatorModel(type="BearerAuthenticator", api_token=""), # type: ignore # $parameters has a default value 1225 config, 1226 token_provider=token_provider, 1227 ) 1228 else: 1229 # Get the api_token template if specified, default to just the session token 1230 api_token_template = ( 1231 getattr(model.request_authentication, "api_token", None) or "{{ session_token }}" 1232 ) 1233 final_token_provider: TokenProvider = InterpolatedSessionTokenProvider( 1234 config=config, 1235 api_token=api_token_template, 1236 session_token_provider=token_provider, 1237 parameters=model.parameters or {}, 1238 ) 1239 return self.create_api_key_authenticator( 1240 ApiKeyAuthenticatorModel( 1241 type="ApiKeyAuthenticator", 1242 api_token="", 1243 inject_into=model.request_authentication.inject_into, 1244 ), # type: ignore # $parameters and headers default to None 1245 config=config, 1246 token_provider=final_token_provider, 1247 )
1249 @staticmethod 1250 def create_basic_http_authenticator( 1251 model: BasicHttpAuthenticatorModel, config: Config, **kwargs: Any 1252 ) -> BasicHttpAuthenticator: 1253 return BasicHttpAuthenticator( 1254 password=model.password or "", 1255 username=model.username, 1256 config=config, 1257 parameters=model.parameters or {}, 1258 )
1260 @staticmethod 1261 def create_bearer_authenticator( 1262 model: BearerAuthenticatorModel, 1263 config: Config, 1264 token_provider: Optional[TokenProvider] = None, 1265 **kwargs: Any, 1266 ) -> BearerAuthenticator: 1267 if token_provider is not None and model.api_token != "": 1268 raise ValueError( 1269 "If token_provider is set, api_token is ignored and has to be set to empty string." 1270 ) 1271 return BearerAuthenticator( 1272 token_provider=( 1273 token_provider 1274 if token_provider is not None 1275 else InterpolatedStringTokenProvider( 1276 api_token=model.api_token or "", 1277 config=config, 1278 parameters=model.parameters or {}, 1279 ) 1280 ), 1281 config=config, 1282 parameters=model.parameters or {}, 1283 )
1285 @staticmethod 1286 def create_dynamic_stream_check_config( 1287 model: DynamicStreamCheckConfigModel, config: Config, **kwargs: Any 1288 ) -> DynamicStreamCheckConfig: 1289 return DynamicStreamCheckConfig( 1290 dynamic_stream_name=model.dynamic_stream_name, 1291 stream_count=model.stream_count, 1292 )
1294 def create_check_stream( 1295 self, model: CheckStreamModel, config: Config, **kwargs: Any 1296 ) -> CheckStream: 1297 if model.dynamic_streams_check_configs is None and model.stream_names is None: 1298 raise ValueError( 1299 "Expected either stream_names or dynamic_streams_check_configs to be set for CheckStream" 1300 ) 1301 1302 dynamic_streams_check_configs = ( 1303 [ 1304 self._create_component_from_model(model=dynamic_stream_check_config, config=config) 1305 for dynamic_stream_check_config in model.dynamic_streams_check_configs 1306 ] 1307 if model.dynamic_streams_check_configs 1308 else [] 1309 ) 1310 1311 # `model.config_overrides` is deliberately not read here. The source applies it around the whole 1312 # check operation (`ConcurrentDeclarativeSource._config_overridden_for_check`), which is what makes 1313 # it work for every checker type rather than only this one. Do not wire it in a second time. 1314 return CheckStream( 1315 stream_names=model.stream_names or [], 1316 dynamic_streams_check_configs=dynamic_streams_check_configs, 1317 parameters={}, 1318 )
1320 @staticmethod 1321 def create_check_dynamic_stream( 1322 model: CheckDynamicStreamModel, config: Config, **kwargs: Any 1323 ) -> CheckDynamicStream: 1324 assert model.use_check_availability is not None # for mypy 1325 1326 use_check_availability = model.use_check_availability 1327 1328 # See `create_check_stream`: `model.config_overrides` is applied by the source, not here. 1329 return CheckDynamicStream( 1330 stream_count=model.stream_count, 1331 use_check_availability=use_check_availability, 1332 parameters={}, 1333 )
1335 def create_composite_error_handler( 1336 self, model: CompositeErrorHandlerModel, config: Config, **kwargs: Any 1337 ) -> CompositeErrorHandler: 1338 error_handlers = [ 1339 self._create_component_from_model(model=error_handler_model, config=config) 1340 for error_handler_model in model.error_handlers 1341 ] 1342 return CompositeErrorHandler( 1343 error_handlers=error_handlers, parameters=model.parameters or {} 1344 )
1346 @staticmethod 1347 def create_concurrency_level( 1348 model: ConcurrencyLevelModel, config: Config, **kwargs: Any 1349 ) -> ConcurrencyLevel: 1350 return ConcurrencyLevel( 1351 default_concurrency=model.default_concurrency, 1352 max_concurrency=model.max_concurrency, 1353 config=config, 1354 parameters={}, 1355 )
1357 @staticmethod 1358 def apply_stream_state_migrations( 1359 stream_state_migrations: List[Any] | None, stream_state: MutableMapping[str, Any] 1360 ) -> MutableMapping[str, Any]: 1361 if stream_state_migrations: 1362 for state_migration in stream_state_migrations: 1363 if state_migration.should_migrate(stream_state): 1364 # The state variable is expected to be mutable but the migrate method returns an immutable mapping. 1365 stream_state = dict(state_migration.migrate(stream_state)) 1366 return stream_state
1368 def create_concurrent_cursor_from_datetime_based_cursor( 1369 self, 1370 model_type: Type[BaseModel], 1371 component_definition: ComponentDefinition, 1372 stream_name: str, 1373 stream_namespace: Optional[str], 1374 stream_state: MutableMapping[str, Any], 1375 config: Config, 1376 message_repository: Optional[MessageRepository] = None, 1377 runtime_lookback_window: Optional[datetime.timedelta] = None, 1378 **kwargs: Any, 1379 ) -> ConcurrentCursor: 1380 component_type = component_definition.get("type") 1381 if component_definition.get("type") != model_type.__name__: 1382 raise ValueError( 1383 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 1384 ) 1385 1386 # FIXME the interfaces of the concurrent cursor are kind of annoying as they take a `ComponentDefinition` instead of the actual model. This was done because the ConcurrentDeclarativeSource didn't have access to the models [here for example](https://github.com/airbytehq/airbyte-python-cdk/blob/f525803b3fec9329e4cc8478996a92bf884bfde9/airbyte_cdk/sources/declarative/concurrent_declarative_source.py#L354C54-L354C91). So now we have two cases: 1387 # * The ComponentDefinition comes from model.__dict__ in which case we have `parameters` 1388 # * The ComponentDefinition comes from the manifest as a dict in which case we have `$parameters` 1389 # We should change those interfaces to use the model once we clean up the code in CDS at which point the parameter propagation should happen as part of the ModelToComponentFactory. 1390 if "$parameters" not in component_definition and "parameters" in component_definition: 1391 component_definition["$parameters"] = component_definition.get("parameters") # type: ignore # This is a dict 1392 datetime_based_cursor_model = model_type.parse_obj(component_definition) 1393 1394 if not isinstance(datetime_based_cursor_model, DatetimeBasedCursorModel): 1395 raise ValueError( 1396 f"Expected {model_type.__name__} component, but received {datetime_based_cursor_model.__class__.__name__}" 1397 ) 1398 1399 model_parameters = datetime_based_cursor_model.parameters or {} 1400 1401 cursor_field = self._get_catalog_defined_cursor_field( 1402 stream_name=stream_name, 1403 allow_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1404 or False, 1405 ) 1406 1407 if not cursor_field: 1408 interpolated_cursor_field = InterpolatedString.create( 1409 datetime_based_cursor_model.cursor_field, 1410 parameters=model_parameters, 1411 ) 1412 cursor_field = CursorField( 1413 cursor_field_key=interpolated_cursor_field.eval(config=config), 1414 supports_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1415 or False, 1416 ) 1417 1418 interpolated_partition_field_start = InterpolatedString.create( 1419 datetime_based_cursor_model.partition_field_start or "start_time", 1420 parameters=model_parameters, 1421 ) 1422 interpolated_partition_field_end = InterpolatedString.create( 1423 datetime_based_cursor_model.partition_field_end or "end_time", 1424 parameters=model_parameters, 1425 ) 1426 1427 slice_boundary_fields = ( 1428 interpolated_partition_field_start.eval(config=config), 1429 interpolated_partition_field_end.eval(config=config), 1430 ) 1431 1432 datetime_format = datetime_based_cursor_model.datetime_format 1433 1434 cursor_granularity = ( 1435 parse_duration(datetime_based_cursor_model.cursor_granularity) 1436 if datetime_based_cursor_model.cursor_granularity 1437 else None 1438 ) 1439 1440 lookback_window = None 1441 interpolated_lookback_window = ( 1442 InterpolatedString.create( 1443 datetime_based_cursor_model.lookback_window, 1444 parameters=model_parameters, 1445 ) 1446 if datetime_based_cursor_model.lookback_window 1447 else None 1448 ) 1449 if interpolated_lookback_window: 1450 evaluated_lookback_window = interpolated_lookback_window.eval(config=config) 1451 if evaluated_lookback_window: 1452 lookback_window = parse_duration(evaluated_lookback_window) 1453 1454 connector_state_converter: DateTimeStreamStateConverter 1455 connector_state_converter = CustomFormatConcurrentStreamStateConverter( 1456 datetime_format=datetime_format, 1457 input_datetime_formats=datetime_based_cursor_model.cursor_datetime_formats, 1458 is_sequential_state=True, # ConcurrentPerPartitionCursor only works with sequential state 1459 cursor_granularity=cursor_granularity, 1460 ) 1461 1462 # Adjusts the stream state by applying the runtime lookback window. 1463 # This is used to ensure correct state handling in case of failed partitions. 1464 stream_state_value = stream_state.get(cursor_field.cursor_field_key) 1465 if runtime_lookback_window and stream_state_value: 1466 new_stream_state = ( 1467 connector_state_converter.parse_timestamp(stream_state_value) 1468 - runtime_lookback_window 1469 ) 1470 stream_state[cursor_field.cursor_field_key] = connector_state_converter.output_format( 1471 new_stream_state 1472 ) 1473 1474 start_date_runtime_value: Union[InterpolatedString, str, MinMaxDatetime] 1475 if isinstance(datetime_based_cursor_model.start_datetime, MinMaxDatetimeModel): 1476 start_date_runtime_value = self.create_min_max_datetime( 1477 model=datetime_based_cursor_model.start_datetime, config=config 1478 ) 1479 else: 1480 start_date_runtime_value = datetime_based_cursor_model.start_datetime 1481 1482 end_date_runtime_value: Optional[Union[InterpolatedString, str, MinMaxDatetime]] 1483 if isinstance(datetime_based_cursor_model.end_datetime, MinMaxDatetimeModel): 1484 end_date_runtime_value = self.create_min_max_datetime( 1485 model=datetime_based_cursor_model.end_datetime, config=config 1486 ) 1487 else: 1488 end_date_runtime_value = datetime_based_cursor_model.end_datetime 1489 1490 interpolated_start_date = MinMaxDatetime.create( 1491 interpolated_string_or_min_max_datetime=start_date_runtime_value, 1492 parameters=datetime_based_cursor_model.parameters, 1493 ) 1494 interpolated_end_date = ( 1495 None 1496 if not end_date_runtime_value 1497 else MinMaxDatetime.create( 1498 end_date_runtime_value, datetime_based_cursor_model.parameters 1499 ) 1500 ) 1501 1502 # If datetime format is not specified then start/end datetime should inherit it from the stream slicer 1503 if not interpolated_start_date.datetime_format: 1504 interpolated_start_date.datetime_format = datetime_format 1505 if interpolated_end_date and not interpolated_end_date.datetime_format: 1506 interpolated_end_date.datetime_format = datetime_format 1507 1508 start_date = interpolated_start_date.get_datetime(config=config) 1509 end_date_provider = ( 1510 partial(interpolated_end_date.get_datetime, config) 1511 if interpolated_end_date 1512 else connector_state_converter.get_end_provider() 1513 ) 1514 1515 if ( 1516 datetime_based_cursor_model.step and not datetime_based_cursor_model.cursor_granularity 1517 ) or ( 1518 not datetime_based_cursor_model.step and datetime_based_cursor_model.cursor_granularity 1519 ): 1520 raise ValueError( 1521 f"If step is defined, cursor_granularity should be as well and vice-versa. " 1522 f"Right now, step is `{datetime_based_cursor_model.step}` and cursor_granularity is `{datetime_based_cursor_model.cursor_granularity}`" 1523 ) 1524 1525 # When step is not defined, default to a step size from the starting date to the present moment 1526 step_length = datetime.timedelta.max 1527 interpolated_step = ( 1528 InterpolatedString.create( 1529 datetime_based_cursor_model.step, 1530 parameters=model_parameters, 1531 ) 1532 if datetime_based_cursor_model.step 1533 else None 1534 ) 1535 if interpolated_step: 1536 evaluated_step = interpolated_step.eval(config) 1537 if evaluated_step: 1538 step_length = parse_duration(evaluated_step) 1539 1540 clamping_strategy: ClampingStrategy = NoClamping() 1541 if datetime_based_cursor_model.clamping: 1542 # While it is undesirable to interpolate within the model factory (as opposed to at runtime), 1543 # it is still better than shifting interpolation low-code concept into the ConcurrentCursor runtime 1544 # object which we want to keep agnostic of being low-code 1545 target = InterpolatedString( 1546 string=datetime_based_cursor_model.clamping.target, 1547 parameters=model_parameters, 1548 ) 1549 evaluated_target = target.eval(config=config) 1550 match evaluated_target: 1551 case "DAY": 1552 clamping_strategy = DayClampingStrategy() 1553 end_date_provider = ClampingEndProvider( 1554 DayClampingStrategy(is_ceiling=False), 1555 end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1556 granularity=cursor_granularity or datetime.timedelta(seconds=1), 1557 ) 1558 case "WEEK": 1559 if ( 1560 not datetime_based_cursor_model.clamping.target_details 1561 or "weekday" not in datetime_based_cursor_model.clamping.target_details 1562 ): 1563 raise ValueError( 1564 "Given WEEK clamping, weekday needs to be provided as target_details" 1565 ) 1566 weekday = self._assemble_weekday( 1567 datetime_based_cursor_model.clamping.target_details["weekday"] 1568 ) 1569 clamping_strategy = WeekClampingStrategy(weekday) 1570 end_date_provider = ClampingEndProvider( 1571 WeekClampingStrategy(weekday, is_ceiling=False), 1572 end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1573 granularity=cursor_granularity or datetime.timedelta(days=1), 1574 ) 1575 case "MONTH": 1576 clamping_strategy = MonthClampingStrategy() 1577 end_date_provider = ClampingEndProvider( 1578 MonthClampingStrategy(is_ceiling=False), 1579 end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1580 granularity=cursor_granularity or datetime.timedelta(days=1), 1581 ) 1582 case _: 1583 raise ValueError( 1584 f"Invalid clamping target {evaluated_target}, expected DAY, WEEK, MONTH" 1585 ) 1586 1587 return ConcurrentCursor( 1588 stream_name=stream_name, 1589 stream_namespace=stream_namespace, 1590 stream_state=stream_state, 1591 message_repository=message_repository or self._message_repository, 1592 connector_state_manager=self._connector_state_manager, 1593 connector_state_converter=connector_state_converter, 1594 cursor_field=cursor_field, 1595 slice_boundary_fields=slice_boundary_fields, 1596 start=start_date, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1597 end_provider=end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1598 lookback_window=lookback_window, 1599 slice_range=step_length, 1600 cursor_granularity=cursor_granularity, 1601 clamping_strategy=clamping_strategy, 1602 )
1604 def create_concurrent_cursor_from_incrementing_count_cursor( 1605 self, 1606 model_type: Type[BaseModel], 1607 component_definition: ComponentDefinition, 1608 stream_name: str, 1609 stream_namespace: Optional[str], 1610 stream_state: MutableMapping[str, Any], 1611 config: Config, 1612 message_repository: Optional[MessageRepository] = None, 1613 **kwargs: Any, 1614 ) -> ConcurrentCursor: 1615 component_type = component_definition.get("type") 1616 if component_definition.get("type") != model_type.__name__: 1617 raise ValueError( 1618 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 1619 ) 1620 1621 incrementing_count_cursor_model = model_type.parse_obj(component_definition) 1622 1623 if not isinstance(incrementing_count_cursor_model, IncrementingCountCursorModel): 1624 raise ValueError( 1625 f"Expected {model_type.__name__} component, but received {incrementing_count_cursor_model.__class__.__name__}" 1626 ) 1627 1628 start_value: Union[int, str, None] = incrementing_count_cursor_model.start_value 1629 # Pydantic Union type coercion can convert int 0 to string '0' depending on Union order. 1630 # We need to handle both int and str representations of numeric values. 1631 # Evaluate the InterpolatedString and convert to int for the ConcurrentCursor. 1632 if start_value is not None: 1633 interpolated_start_value = InterpolatedString.create( 1634 str(start_value), # Ensure we pass a string to InterpolatedString.create 1635 parameters=incrementing_count_cursor_model.parameters or {}, 1636 ) 1637 evaluated_start_value: int = int(interpolated_start_value.eval(config=config)) 1638 else: 1639 evaluated_start_value = 0 1640 1641 cursor_field = self._get_catalog_defined_cursor_field( 1642 stream_name=stream_name, 1643 allow_catalog_defined_cursor_field=incrementing_count_cursor_model.allow_catalog_defined_cursor_field 1644 or False, 1645 ) 1646 1647 if not cursor_field: 1648 interpolated_cursor_field = InterpolatedString.create( 1649 incrementing_count_cursor_model.cursor_field, 1650 parameters=incrementing_count_cursor_model.parameters or {}, 1651 ) 1652 cursor_field = CursorField( 1653 cursor_field_key=interpolated_cursor_field.eval(config=config), 1654 supports_catalog_defined_cursor_field=incrementing_count_cursor_model.allow_catalog_defined_cursor_field 1655 or False, 1656 ) 1657 1658 connector_state_converter = IncrementingCountStreamStateConverter( 1659 is_sequential_state=True, # ConcurrentPerPartitionCursor only works with sequential state 1660 ) 1661 1662 return ConcurrentCursor( 1663 stream_name=stream_name, 1664 stream_namespace=stream_namespace, 1665 stream_state=stream_state, 1666 message_repository=message_repository or self._message_repository, 1667 connector_state_manager=self._connector_state_manager, 1668 connector_state_converter=connector_state_converter, 1669 cursor_field=cursor_field, 1670 slice_boundary_fields=None, 1671 start=evaluated_start_value, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1672 end_provider=connector_state_converter.get_end_provider(), # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1673 )
1694 def create_concurrent_cursor_from_perpartition_cursor( 1695 self, 1696 state_manager: ConnectorStateManager, 1697 model_type: Type[BaseModel], 1698 component_definition: ComponentDefinition, 1699 stream_name: str, 1700 stream_namespace: Optional[str], 1701 config: Config, 1702 stream_state: MutableMapping[str, Any], 1703 partition_router: PartitionRouter, 1704 attempt_to_create_cursor_if_not_provided: bool = False, 1705 **kwargs: Any, 1706 ) -> ConcurrentPerPartitionCursor: 1707 component_type = component_definition.get("type") 1708 if component_definition.get("type") != model_type.__name__: 1709 raise ValueError( 1710 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 1711 ) 1712 1713 # FIXME the interfaces of the concurrent cursor are kind of annoying as they take a `ComponentDefinition` instead of the actual model. This was done because the ConcurrentDeclarativeSource didn't have access to the models [here for example](https://github.com/airbytehq/airbyte-python-cdk/blob/f525803b3fec9329e4cc8478996a92bf884bfde9/airbyte_cdk/sources/declarative/concurrent_declarative_source.py#L354C54-L354C91). So now we have two cases: 1714 # * The ComponentDefinition comes from model.__dict__ in which case we have `parameters` 1715 # * The ComponentDefinition comes from the manifest as a dict in which case we have `$parameters` 1716 # We should change those interfaces to use the model once we clean up the code in CDS at which point the parameter propagation should happen as part of the ModelToComponentFactory. 1717 if "$parameters" not in component_definition and "parameters" in component_definition: 1718 component_definition["$parameters"] = component_definition.get("parameters") # type: ignore # This is a dict 1719 datetime_based_cursor_model = model_type.parse_obj(component_definition) 1720 1721 if not isinstance(datetime_based_cursor_model, DatetimeBasedCursorModel): 1722 raise ValueError( 1723 f"Expected {model_type.__name__} component, but received {datetime_based_cursor_model.__class__.__name__}" 1724 ) 1725 1726 cursor_field = self._get_catalog_defined_cursor_field( 1727 stream_name=stream_name, 1728 allow_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1729 or False, 1730 ) 1731 1732 if not cursor_field: 1733 interpolated_cursor_field = InterpolatedString.create( 1734 datetime_based_cursor_model.cursor_field, 1735 # FIXME the interfaces of the concurrent cursor are kind of annoying as they take a `ComponentDefinition` instead of the actual model. This was done because the ConcurrentDeclarativeSource didn't have access to the models [here for example](https://github.com/airbytehq/airbyte-python-cdk/blob/f525803b3fec9329e4cc8478996a92bf884bfde9/airbyte_cdk/sources/declarative/concurrent_declarative_source.py#L354C54-L354C91). So now we have two cases: 1736 # * The ComponentDefinition comes from model.__dict__ in which case we have `parameters` 1737 # * The ComponentDefinition comes from the manifest as a dict in which case we have `$parameters` 1738 # We should change those interfaces to use the model once we clean up the code in CDS at which point the parameter propagation should happen as part of the ModelToComponentFactory. 1739 parameters=datetime_based_cursor_model.parameters or {}, 1740 ) 1741 cursor_field = CursorField( 1742 cursor_field_key=interpolated_cursor_field.eval(config=config), 1743 supports_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1744 or False, 1745 ) 1746 1747 datetime_format = datetime_based_cursor_model.datetime_format 1748 1749 cursor_granularity = ( 1750 parse_duration(datetime_based_cursor_model.cursor_granularity) 1751 if datetime_based_cursor_model.cursor_granularity 1752 else None 1753 ) 1754 1755 connector_state_converter: DateTimeStreamStateConverter 1756 connector_state_converter = CustomFormatConcurrentStreamStateConverter( 1757 datetime_format=datetime_format, 1758 input_datetime_formats=datetime_based_cursor_model.cursor_datetime_formats, 1759 is_sequential_state=True, # ConcurrentPerPartitionCursor only works with sequential state 1760 cursor_granularity=cursor_granularity, 1761 ) 1762 1763 # Create the cursor factory 1764 cursor_factory = ConcurrentCursorFactory( 1765 partial( 1766 self.create_concurrent_cursor_from_datetime_based_cursor, 1767 state_manager=state_manager, 1768 model_type=model_type, 1769 component_definition=component_definition, 1770 stream_name=stream_name, 1771 stream_namespace=stream_namespace, 1772 config=config, 1773 message_repository=NoopMessageRepository(), 1774 ) 1775 ) 1776 1777 # Per-partition state doesn't make sense for GroupingPartitionRouter, so force the global state 1778 use_global_cursor = isinstance( 1779 partition_router, GroupingPartitionRouter 1780 ) or component_definition.get("global_substream_cursor", False) 1781 1782 # Return the concurrent cursor and state converter 1783 return ConcurrentPerPartitionCursor( 1784 cursor_factory=cursor_factory, 1785 partition_router=partition_router, 1786 stream_name=stream_name, 1787 stream_namespace=stream_namespace, 1788 stream_state=stream_state, 1789 message_repository=self._message_repository, # type: ignore 1790 connector_state_manager=state_manager, 1791 connector_state_converter=connector_state_converter, 1792 cursor_field=cursor_field, 1793 use_global_cursor=use_global_cursor, 1794 attempt_to_create_cursor_if_not_provided=attempt_to_create_cursor_if_not_provided, 1795 )
1797 @staticmethod 1798 def create_constant_backoff_strategy( 1799 model: ConstantBackoffStrategyModel, config: Config, **kwargs: Any 1800 ) -> ConstantBackoffStrategy: 1801 ModelToComponentFactory._validate_jitter_range(model.jitter_range_in_seconds) 1802 return ConstantBackoffStrategy( 1803 backoff_time_in_seconds=model.backoff_time_in_seconds, 1804 jitter_range_in_seconds=model.jitter_range_in_seconds, 1805 config=config, 1806 parameters=model.parameters or {}, 1807 )
1814 def create_cursor_pagination( 1815 self, model: CursorPaginationModel, config: Config, decoder: Decoder, **kwargs: Any 1816 ) -> CursorPaginationStrategy: 1817 if isinstance(decoder, PaginationDecoderDecorator): 1818 inner_decoder = decoder.decoder 1819 else: 1820 inner_decoder = decoder 1821 decoder = PaginationDecoderDecorator(decoder=decoder) 1822 1823 if self._is_supported_decoder_for_pagination(inner_decoder): 1824 decoder_to_use = decoder 1825 else: 1826 raise ValueError( 1827 self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(inner_decoder)) 1828 ) 1829 1830 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 1831 # If page_size is a string that represents an integer (not an interpolation), convert it back. 1832 page_size = model.page_size 1833 if isinstance(page_size, str) and page_size.isdigit(): 1834 page_size = int(page_size) 1835 1836 return CursorPaginationStrategy( 1837 cursor_value=model.cursor_value, 1838 decoder=decoder_to_use, 1839 page_size=page_size, 1840 stop_condition=model.stop_condition, 1841 config=config, 1842 parameters=model.parameters or {}, 1843 )
1845 def create_custom_component(self, model: Any, config: Config, **kwargs: Any) -> Any: 1846 """ 1847 Generically creates a custom component based on the model type and a class_name reference to the custom Python class being 1848 instantiated. Only the model's additional properties that match the custom class definition are passed to the constructor 1849 :param model: The Pydantic model of the custom component being created 1850 :param config: The custom defined connector config 1851 :return: The declarative component built from the Pydantic model to be used at runtime 1852 """ 1853 # Instantiating a custom component means importing and executing arbitrary code referenced 1854 # by `class_name`. Manifests supplied by a caller, whether through the config or directly to 1855 # the manifest server, are untrusted input and could point `class_name` at any importable 1856 # callable, so they honor the same `AIRBYTE_ENABLE_UNSAFE_CODE` gate as injected 1857 # `components.py` code. Manifests bundled in a published connector image are trusted and may 1858 # always use their bundled custom components. 1859 manifest_is_untrusted = not self._custom_components_trusted or bool( 1860 config.get(INJECTED_MANIFEST) 1861 ) 1862 if manifest_is_untrusted and not custom_code_execution_permitted(): 1863 raise AirbyteCustomCodeNotPermittedError 1864 1865 custom_component_class = self._get_class_from_fully_qualified_class_name(model.class_name) 1866 component_fields = get_type_hints(custom_component_class) 1867 model_args = model.dict() 1868 model_args["config"] = config 1869 1870 # There are cases where a parent component will pass arguments to a child component via kwargs. When there are field collisions 1871 # we defer to these arguments over the component's definition 1872 for key, arg in kwargs.items(): 1873 model_args[key] = arg 1874 1875 # Pydantic is unable to parse a custom component's fields that are subcomponents into models because their fields and types are not 1876 # defined in the schema. The fields and types are defined within the Python class implementation. Pydantic can only parse down to 1877 # the custom component and this code performs a second parse to convert the sub-fields first into models, then declarative components 1878 for model_field, model_value in model_args.items(): 1879 # If a custom component field doesn't have a type set, we try to use the type hints to infer the type 1880 if ( 1881 isinstance(model_value, dict) 1882 and "type" not in model_value 1883 and model_field in component_fields 1884 ): 1885 derived_type = self._derive_component_type_from_type_hints( 1886 component_fields.get(model_field) 1887 ) 1888 if derived_type: 1889 model_value["type"] = derived_type 1890 1891 if self._is_component(model_value): 1892 model_args[model_field] = self._create_nested_component( 1893 model, 1894 model_field, 1895 model_value, 1896 config, 1897 **kwargs, 1898 ) 1899 elif isinstance(model_value, list): 1900 vals = [] 1901 for v in model_value: 1902 if isinstance(v, dict) and "type" not in v and model_field in component_fields: 1903 derived_type = self._derive_component_type_from_type_hints( 1904 component_fields.get(model_field) 1905 ) 1906 if derived_type: 1907 v["type"] = derived_type 1908 if self._is_component(v): 1909 vals.append( 1910 self._create_nested_component( 1911 model, 1912 model_field, 1913 v, 1914 config, 1915 **kwargs, 1916 ) 1917 ) 1918 else: 1919 vals.append(v) 1920 model_args[model_field] = vals 1921 1922 kwargs = { 1923 class_field: model_args[class_field] 1924 for class_field in component_fields.keys() 1925 if class_field in model_args 1926 } 1927 1928 if "api_budget" in component_fields and kwargs.get("api_budget") is None: 1929 kwargs["api_budget"] = self._api_budget 1930 1931 return custom_component_class(**kwargs)
Generically creates a custom component based on the model type and a class_name reference to the custom Python class being instantiated. Only the model's additional properties that match the custom class definition are passed to the constructor
Parameters
- model: The Pydantic model of the custom component being created
- config: The custom defined connector config
Returns
The declarative component built from the Pydantic model to be used at runtime
2066 def create_default_stream( 2067 self, model: DeclarativeStreamModel, config: Config, is_parent: bool = False, **kwargs: Any 2068 ) -> AbstractStream: 2069 primary_key = model.primary_key.__root__ if model.primary_key else None 2070 self._migrate_state(model, config) 2071 self._warn_on_ineffective_incremental_dependency(model) 2072 2073 partition_router = self._build_stream_slicer_from_partition_router( 2074 model.retriever, 2075 config, 2076 stream_name=model.name, 2077 **kwargs, 2078 ) 2079 concurrent_cursor = self._build_concurrent_cursor(model, partition_router, config) 2080 if model.incremental_sync and isinstance(model.incremental_sync, DatetimeBasedCursorModel): 2081 cursor_model: DatetimeBasedCursorModel = model.incremental_sync 2082 2083 end_time_option = ( 2084 self._create_component_from_model( 2085 cursor_model.end_time_option, config, parameters=cursor_model.parameters or {} 2086 ) 2087 if cursor_model.end_time_option 2088 else None 2089 ) 2090 start_time_option = ( 2091 self._create_component_from_model( 2092 cursor_model.start_time_option, config, parameters=cursor_model.parameters or {} 2093 ) 2094 if cursor_model.start_time_option 2095 else None 2096 ) 2097 2098 datetime_request_options_provider = DatetimeBasedRequestOptionsProvider( 2099 start_time_option=start_time_option, 2100 end_time_option=end_time_option, 2101 partition_field_start=cursor_model.partition_field_start, 2102 partition_field_end=cursor_model.partition_field_end, 2103 config=config, 2104 parameters=model.parameters or {}, 2105 ) 2106 request_options_provider = ( 2107 datetime_request_options_provider 2108 if not isinstance(concurrent_cursor, ConcurrentPerPartitionCursor) 2109 else PerPartitionRequestOptionsProvider( 2110 partition_router, datetime_request_options_provider 2111 ) 2112 ) 2113 elif model.incremental_sync and isinstance( 2114 model.incremental_sync, IncrementingCountCursorModel 2115 ): 2116 if isinstance(concurrent_cursor, ConcurrentPerPartitionCursor): 2117 raise ValueError( 2118 "PerPartition does not support per partition states because switching to global state is time based" 2119 ) 2120 2121 cursor_model: IncrementingCountCursorModel = model.incremental_sync # type: ignore 2122 2123 start_time_option = ( 2124 self._create_component_from_model( 2125 cursor_model.start_value_option, # type: ignore # mypy still thinks cursor_model of type DatetimeBasedCursor 2126 config, 2127 parameters=cursor_model.parameters or {}, 2128 ) 2129 if cursor_model.start_value_option # type: ignore # mypy still thinks cursor_model of type DatetimeBasedCursor 2130 else None 2131 ) 2132 2133 # The concurrent engine defaults the start/end fields on the slice to "start" and "end", but 2134 # the default DatetimeBasedRequestOptionsProvider() sets them to start_time/end_time 2135 partition_field_start = "start" 2136 2137 request_options_provider = DatetimeBasedRequestOptionsProvider( 2138 start_time_option=start_time_option, 2139 partition_field_start=partition_field_start, 2140 config=config, 2141 parameters=model.parameters or {}, 2142 ) 2143 else: 2144 request_options_provider = None 2145 2146 transformations = [] 2147 if model.transformations: 2148 for transformation_model in model.transformations: 2149 transformations.append( 2150 self._create_component_from_model(model=transformation_model, config=config) 2151 ) 2152 file_uploader = None 2153 if model.file_uploader: 2154 file_uploader = self._create_component_from_model( 2155 model=model.file_uploader, config=config 2156 ) 2157 2158 stream_slicer: ConcurrentStreamSlicer = ( 2159 partition_router 2160 if isinstance(concurrent_cursor, FinalStateCursor) 2161 else concurrent_cursor 2162 ) 2163 2164 retriever = self._create_component_from_model( 2165 model=model.retriever, 2166 config=config, 2167 name=model.name, 2168 primary_key=primary_key, 2169 request_options_provider=request_options_provider, 2170 stream_slicer=stream_slicer, 2171 partition_router=partition_router, 2172 has_stop_condition_cursor=self._is_stop_condition_on_cursor(model), 2173 is_client_side_incremental_sync=self._is_client_side_filtering_enabled(model), 2174 cursor=concurrent_cursor, 2175 transformations=transformations, 2176 file_uploader=file_uploader, 2177 incremental_sync=model.incremental_sync, 2178 ) 2179 if isinstance(retriever, AsyncRetriever): 2180 stream_slicer = retriever.stream_slicer 2181 2182 schema_loader: SchemaLoader 2183 if model.schema_loader and isinstance(model.schema_loader, list): 2184 nested_schema_loaders = [ 2185 self._create_component_from_model(model=nested_schema_loader, config=config) 2186 for nested_schema_loader in model.schema_loader 2187 ] 2188 schema_loader = CompositeSchemaLoader( 2189 schema_loaders=nested_schema_loaders, parameters={} 2190 ) 2191 elif model.schema_loader: 2192 schema_loader = self._create_component_from_model( 2193 model=model.schema_loader, # type: ignore # If defined, schema_loader is guaranteed not to be a list and will be one of the existing base models 2194 config=config, 2195 ) 2196 else: 2197 options = model.parameters or {} 2198 if "name" not in options: 2199 options["name"] = model.name 2200 schema_loader = DefaultSchemaLoader(config=config, parameters=options) 2201 schema_loader = CachingSchemaLoaderDecorator(schema_loader) 2202 2203 stream_name = model.name or "" 2204 return DefaultStream( 2205 partition_generator=StreamSlicerPartitionGenerator( 2206 DeclarativePartitionFactory( 2207 stream_name, 2208 schema_loader, 2209 retriever, 2210 self._message_repository, 2211 ), 2212 stream_slicer, 2213 slice_limit=self._limit_slices_fetched, 2214 ), 2215 name=stream_name, 2216 json_schema=schema_loader.get_json_schema, 2217 primary_key=get_primary_key_from_stream(primary_key), 2218 cursor_field=( 2219 concurrent_cursor.cursor_field 2220 if hasattr(concurrent_cursor, "cursor_field") 2221 else None 2222 ), 2223 logger=logging.getLogger(f"airbyte.{stream_name}"), 2224 cursor=concurrent_cursor, 2225 supports_file_transfer=hasattr(model, "file_uploader") and bool(model.file_uploader), 2226 )
2398 def create_default_error_handler( 2399 self, model: DefaultErrorHandlerModel, config: Config, **kwargs: Any 2400 ) -> DefaultErrorHandler: 2401 backoff_strategies = [] 2402 if model.backoff_strategies: 2403 for backoff_strategy_model in model.backoff_strategies: 2404 backoff_strategies.append( 2405 self._create_component_from_model(model=backoff_strategy_model, config=config) 2406 ) 2407 2408 response_filters = [] 2409 if model.response_filters: 2410 for response_filter_model in model.response_filters: 2411 response_filters.append( 2412 self._create_component_from_model(model=response_filter_model, config=config) 2413 ) 2414 response_filters.append( 2415 HttpResponseFilter(config=config, parameters=model.parameters or {}) 2416 ) 2417 2418 return DefaultErrorHandler( 2419 backoff_strategies=backoff_strategies, 2420 max_retries=model.max_retries, 2421 response_filters=response_filters, 2422 config=config, 2423 parameters=model.parameters or {}, 2424 )
2426 def create_default_paginator( 2427 self, 2428 model: DefaultPaginatorModel, 2429 config: Config, 2430 *, 2431 url_base: str, 2432 extractor_model: Optional[Union[CustomRecordExtractorModel, DpathExtractorModel]] = None, 2433 decoder: Optional[Decoder] = None, 2434 cursor_used_for_stop_condition: Optional[Cursor] = None, 2435 ) -> Union[DefaultPaginator, PaginatorTestReadDecorator]: 2436 if decoder: 2437 if self._is_supported_decoder_for_pagination(decoder): 2438 decoder_to_use = PaginationDecoderDecorator(decoder=decoder) 2439 else: 2440 raise ValueError(self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(decoder))) 2441 else: 2442 decoder_to_use = PaginationDecoderDecorator(decoder=JsonDecoder(parameters={})) 2443 page_size_option = ( 2444 self._create_component_from_model(model=model.page_size_option, config=config) 2445 if model.page_size_option 2446 else None 2447 ) 2448 page_token_option = ( 2449 self._create_component_from_model(model=model.page_token_option, config=config) 2450 if model.page_token_option 2451 else None 2452 ) 2453 pagination_strategy = self._create_component_from_model( 2454 model=model.pagination_strategy, 2455 config=config, 2456 decoder=decoder_to_use, 2457 extractor_model=extractor_model, 2458 ) 2459 if cursor_used_for_stop_condition: 2460 pagination_strategy = StopConditionPaginationStrategyDecorator( 2461 pagination_strategy, CursorStopCondition(cursor_used_for_stop_condition) 2462 ) 2463 paginator = DefaultPaginator( 2464 decoder=decoder_to_use, 2465 page_size_option=page_size_option, 2466 page_token_option=page_token_option, 2467 pagination_strategy=pagination_strategy, 2468 url_base=url_base, 2469 config=config, 2470 parameters=model.parameters or {}, 2471 ) 2472 if self._limit_pages_fetched_per_slice: 2473 return PaginatorTestReadDecorator(paginator, self._limit_pages_fetched_per_slice) 2474 return paginator
2476 def create_dpath_extractor( 2477 self, 2478 model: DpathExtractorModel, 2479 config: Config, 2480 decoder: Optional[Decoder] = None, 2481 **kwargs: Any, 2482 ) -> DpathExtractor: 2483 if decoder: 2484 decoder_to_use = decoder 2485 else: 2486 decoder_to_use = JsonDecoder(parameters={}) 2487 model_field_path: List[Union[InterpolatedString, str]] = [x for x in model.field_path] 2488 2489 record_expander = None 2490 if model.record_expander: 2491 record_expander = self._create_component_from_model( 2492 model=model.record_expander, 2493 config=config, 2494 ) 2495 2496 return DpathExtractor( 2497 decoder=decoder_to_use, 2498 field_path=model_field_path, 2499 config=config, 2500 parameters=model.parameters or {}, 2501 record_expander=record_expander, 2502 )
2504 def create_record_expander( 2505 self, 2506 model: RecordExpanderModel, 2507 config: Config, 2508 **kwargs: Any, 2509 ) -> RecordExpander: 2510 truncated_list_retriever = None 2511 suppress_incomplete_fetch_warning = False 2512 if model.truncated_list_retriever: 2513 retriever_model = model.truncated_list_retriever 2514 name = "record_expander_truncated_list" 2515 # `CustomRetriever` allows extra fields, so read from the dumped model to cover both types. 2516 retriever_fields = retriever_model.dict() 2517 for unsupported_option in ("partition_router", "pagination_reset"): 2518 if retriever_fields.get(unsupported_option): 2519 raise ValueError( 2520 f"`{unsupported_option}` is not supported on `truncated_list_retriever`." 2521 ) 2522 log_formatter = lambda response: format_http_message( 2523 response, 2524 f"Record expander '{name}' request", 2525 "Request performed in order to fetch the complete nested list of a truncated record.", 2526 name, 2527 is_auxiliary=True, 2528 ) 2529 # `name`/`primary_key`/`transformations` are also what a `CustomRetriever` forwards to its 2530 # nested `requester`/`record_selector`, so they are passed for both retriever types. 2531 truncated_list_retriever = self._create_component_from_model( 2532 model=retriever_model, 2533 config=config, 2534 name=name, 2535 primary_key=None, 2536 transformations=[], 2537 log_formatter=log_formatter, 2538 ) 2539 # Only a capped paginator makes a shortfall expected; `NoPagination` and retrievers 2540 # without a paginator are never capped. 2541 suppress_incomplete_fetch_warning = isinstance( 2542 truncated_list_retriever, SimpleRetriever 2543 ) and isinstance(truncated_list_retriever.paginator, PaginatorTestReadDecorator) 2544 return RecordExpander( 2545 expand_records_from_field=model.expand_records_from_field, 2546 config=config, 2547 parameters=model.parameters or {}, 2548 remain_original_record=model.remain_original_record or False, 2549 on_no_records=OnNoRecords(model.on_no_records.value) 2550 if model.on_no_records 2551 else OnNoRecords.skip, 2552 truncation_indicator_path=model.truncation_indicator_path, 2553 truncated_list_retriever=truncated_list_retriever, 2554 message_repository=self._message_repository, 2555 suppress_incomplete_fetch_warning=suppress_incomplete_fetch_warning, 2556 )
2558 @staticmethod 2559 def create_response_to_file_extractor( 2560 model: ResponseToFileExtractorModel, 2561 **kwargs: Any, 2562 ) -> ResponseToFileExtractor: 2563 return ResponseToFileExtractor( 2564 parameters=model.parameters or {}, 2565 preserve_na_values=model.preserve_na_values or False, 2566 )
2568 @staticmethod 2569 def create_exponential_backoff_strategy( 2570 model: ExponentialBackoffStrategyModel, config: Config 2571 ) -> ExponentialBackoffStrategy: 2572 ModelToComponentFactory._validate_jitter_range(model.jitter_range_in_seconds) 2573 return ExponentialBackoffStrategy( 2574 factor=model.factor or 5, 2575 jitter_range_in_seconds=model.jitter_range_in_seconds, 2576 parameters=model.parameters or {}, 2577 config=config, 2578 )
2584 def create_http_requester( 2585 self, 2586 model: HttpRequesterModel, 2587 config: Config, 2588 decoder: Decoder = JsonDecoder(parameters={}), 2589 query_properties_key: Optional[str] = None, 2590 use_cache: Optional[bool] = None, 2591 *, 2592 name: str, 2593 ) -> HttpRequester: 2594 authenticator = ( 2595 self._create_component_from_model( 2596 model=model.authenticator, 2597 config=config, 2598 url_base=model.url or model.url_base, 2599 name=name, 2600 decoder=decoder, 2601 ) 2602 if model.authenticator 2603 else None 2604 ) 2605 error_handler = ( 2606 self._create_component_from_model(model=model.error_handler, config=config) 2607 if model.error_handler 2608 else DefaultErrorHandler( 2609 backoff_strategies=[], 2610 response_filters=[], 2611 config=config, 2612 parameters=model.parameters or {}, 2613 ) 2614 ) 2615 2616 api_budget = self._api_budget 2617 2618 request_options_provider = InterpolatedRequestOptionsProvider( 2619 request_body=model.request_body, 2620 request_body_data=model.request_body_data, 2621 request_body_json=model.request_body_json, 2622 request_headers=model.request_headers, 2623 request_parameters=model.request_parameters, # type: ignore # QueryProperties have been removed in `create_simple_retriever` 2624 query_properties_key=query_properties_key, 2625 config=config, 2626 parameters=model.parameters or {}, 2627 ) 2628 2629 assert model.use_cache is not None # for mypy 2630 assert model.http_method is not None # for mypy 2631 2632 should_use_cache = (model.use_cache or bool(use_cache)) and not self._disable_cache 2633 2634 return HttpRequester( 2635 name=name, 2636 url=model.url, 2637 url_base=model.url_base, 2638 path=model.path, 2639 authenticator=authenticator, 2640 error_handler=error_handler, 2641 api_budget=api_budget, 2642 http_method=HttpMethod[model.http_method.value], 2643 request_options_provider=request_options_provider, 2644 config=config, 2645 disable_retries=self._disable_retries, 2646 parameters=model.parameters or {}, 2647 message_repository=self._message_repository, 2648 use_cache=should_use_cache, 2649 decoder=decoder, 2650 stream_response=decoder.is_stream_response() if decoder else False, 2651 )
2653 @staticmethod 2654 def create_http_response_filter( 2655 model: HttpResponseFilterModel, config: Config, **kwargs: Any 2656 ) -> HttpResponseFilter: 2657 if model.action: 2658 action = ResponseAction(model.action.value) 2659 else: 2660 action = None 2661 2662 failure_type = FailureType(model.failure_type.value) if model.failure_type else None 2663 2664 http_codes = ( 2665 set(model.http_codes) if model.http_codes else set() 2666 ) # JSON schema notation has no set data type. The schema enforces an array of unique elements 2667 2668 return HttpResponseFilter( 2669 action=action, 2670 failure_type=failure_type, 2671 error_message=model.error_message or "", 2672 error_message_contains=model.error_message_contains or "", 2673 http_codes=http_codes, 2674 predicate=model.predicate or "", 2675 config=config, 2676 parameters=model.parameters or {}, 2677 )
2685 def create_complex_field_type( 2686 self, model: ComplexFieldTypeModel, config: Config, **kwargs: Any 2687 ) -> ComplexFieldType: 2688 items = ( 2689 self._create_component_from_model(model=model.items, config=config) 2690 if isinstance(model.items, ComplexFieldTypeModel) 2691 else model.items 2692 ) 2693 2694 return ComplexFieldType(field_type=model.field_type, items=items)
2696 def create_types_map(self, model: TypesMapModel, config: Config, **kwargs: Any) -> TypesMap: 2697 target_type = ( 2698 self._create_component_from_model(model=model.target_type, config=config) 2699 if isinstance(model.target_type, ComplexFieldTypeModel) 2700 else model.target_type 2701 ) 2702 2703 return TypesMap( 2704 target_type=target_type, 2705 current_type=model.current_type, 2706 condition=model.condition if model.condition is not None else "True", 2707 )
2709 def create_schema_type_identifier( 2710 self, model: SchemaTypeIdentifierModel, config: Config, **kwargs: Any 2711 ) -> SchemaTypeIdentifier: 2712 types_mapping = [] 2713 if model.types_mapping: 2714 types_mapping.extend( 2715 [ 2716 self._create_component_from_model(types_map, config=config) 2717 for types_map in model.types_mapping 2718 ] 2719 ) 2720 model_schema_pointer: List[Union[InterpolatedString, str]] = ( 2721 [x for x in model.schema_pointer] if model.schema_pointer else [] 2722 ) 2723 model_key_pointer: List[Union[InterpolatedString, str]] = [x for x in model.key_pointer] 2724 model_type_pointer: Optional[List[Union[InterpolatedString, str]]] = ( 2725 [x for x in model.type_pointer] if model.type_pointer else None 2726 ) 2727 2728 return SchemaTypeIdentifier( 2729 schema_pointer=model_schema_pointer, 2730 key_pointer=model_key_pointer, 2731 type_pointer=model_type_pointer, 2732 types_mapping=types_mapping, 2733 parameters=model.parameters or {}, 2734 )
2736 def create_dynamic_schema_loader( 2737 self, model: DynamicSchemaLoaderModel, config: Config, **kwargs: Any 2738 ) -> DynamicSchemaLoader: 2739 schema_transformations = [] 2740 if model.schema_transformations: 2741 for transformation_model in model.schema_transformations: 2742 schema_transformations.append( 2743 self._create_component_from_model(model=transformation_model, config=config) 2744 ) 2745 name = "dynamic_properties" 2746 retriever = self._create_component_from_model( 2747 model=model.retriever, 2748 config=config, 2749 name=name, 2750 primary_key=None, 2751 partition_router=self._build_stream_slicer_from_partition_router( 2752 model.retriever, config 2753 ), 2754 transformations=[], 2755 use_cache=True, 2756 log_formatter=( 2757 lambda response: format_http_message( 2758 response, 2759 f"Schema loader '{name}' request", 2760 f"Request performed in order to extract schema.", 2761 name, 2762 is_auxiliary=True, 2763 ) 2764 ), 2765 ) 2766 schema_type_identifier = self._create_component_from_model( 2767 model.schema_type_identifier, config=config, parameters=model.parameters or {} 2768 ) 2769 schema_filter = ( 2770 self._create_component_from_model( 2771 model.schema_filter, config=config, parameters=model.parameters or {} 2772 ) 2773 if model.schema_filter is not None 2774 else None 2775 ) 2776 2777 return DynamicSchemaLoader( 2778 retriever=retriever, 2779 config=config, 2780 schema_transformations=schema_transformations, 2781 schema_filter=schema_filter, 2782 schema_type_identifier=schema_type_identifier, 2783 parameters=model.parameters or {}, 2784 )
2804 def create_json_items_decoder( 2805 self, model: JsonItemsDecoderModel, config: Config, **kwargs: Any 2806 ) -> Decoder: 2807 return CompositeRawDecoder( 2808 parser=ModelToComponentFactory._get_parser(model, config), 2809 stream_response=False if self._emit_connector_builder_messages else True, 2810 )
2812 def create_gzip_decoder( 2813 self, model: GzipDecoderModel, config: Config, **kwargs: Any 2814 ) -> Decoder: 2815 _compressed_response_types = { 2816 "gzip", 2817 "x-gzip", 2818 "gzip, deflate", 2819 "x-gzip, deflate", 2820 "application/zip", 2821 "application/gzip", 2822 "application/x-gzip", 2823 "application/x-zip-compressed", 2824 } 2825 2826 gzip_parser: GzipParser = ModelToComponentFactory._get_parser(model, config) # type: ignore # based on the model, we know this will be a GzipParser 2827 2828 if self._emit_connector_builder_messages: 2829 return CompositeRawDecoder(gzip_parser, False) 2830 2831 transport_gzip_parser = GzipParser(inner_parser=gzip_parser) 2832 return CompositeRawDecoder.by_headers( 2833 [ 2834 ({"Content-Encoding"}, {"gzip"}, transport_gzip_parser), 2835 ({"Content-Type"}, _compressed_response_types, gzip_parser), 2836 ], 2837 stream_response=True, 2838 fallback_parser=gzip_parser, 2839 )
2893 def create_jwt_authenticator( 2894 self, model: JwtAuthenticatorModel, config: Config, **kwargs: Any 2895 ) -> JwtAuthenticator: 2896 jwt_headers = model.jwt_headers or JwtHeadersModel(kid=None, typ="JWT", cty=None) 2897 jwt_payload = model.jwt_payload or JwtPayloadModel(iss=None, sub=None, aud=None) 2898 request_option = ( 2899 self._create_component_from_model(model.request_option, config) 2900 if model.request_option 2901 else None 2902 ) 2903 return JwtAuthenticator( 2904 config=config, 2905 parameters=model.parameters or {}, 2906 algorithm=JwtAlgorithm(model.algorithm.value), 2907 secret_key=model.secret_key, 2908 base64_encode_secret_key=model.base64_encode_secret_key, 2909 token_duration=model.token_duration, 2910 header_prefix=model.header_prefix, 2911 kid=jwt_headers.kid, 2912 typ=jwt_headers.typ, 2913 cty=jwt_headers.cty, 2914 iss=jwt_payload.iss, 2915 sub=jwt_payload.sub, 2916 aud=jwt_payload.aud, 2917 additional_jwt_headers=model.additional_jwt_headers, 2918 additional_jwt_payload=model.additional_jwt_payload, 2919 passphrase=model.passphrase, 2920 request_option=request_option, 2921 )
2923 def create_list_partition_router( 2924 self, model: ListPartitionRouterModel, config: Config, **kwargs: Any 2925 ) -> ListPartitionRouter: 2926 request_option = ( 2927 self._create_component_from_model(model.request_option, config) 2928 if model.request_option 2929 else None 2930 ) 2931 return ListPartitionRouter( 2932 cursor_field=model.cursor_field, 2933 request_option=request_option, 2934 values=model.values, 2935 config=config, 2936 parameters=model.parameters or {}, 2937 )
2939 @staticmethod 2940 def create_min_max_datetime( 2941 model: MinMaxDatetimeModel, config: Config, **kwargs: Any 2942 ) -> MinMaxDatetime: 2943 return MinMaxDatetime( 2944 datetime=model.datetime, 2945 datetime_format=model.datetime_format or "", 2946 max_datetime=model.max_datetime or "", 2947 min_datetime=model.min_datetime or "", 2948 parameters=model.parameters or {}, 2949 )
2961 def create_oauth_authenticator( 2962 self, model: OAuthAuthenticatorModel, config: Config, **kwargs: Any 2963 ) -> DeclarativeOauth2Authenticator: 2964 profile_assertion = ( 2965 self._create_component_from_model(model.profile_assertion, config=config) 2966 if model.profile_assertion 2967 else None 2968 ) 2969 2970 refresh_token_error_status_codes, refresh_token_error_key, refresh_token_error_values = ( 2971 self._get_refresh_token_error_information(model) 2972 ) 2973 if model.refresh_token_updater: 2974 # ignore type error because fixing it would have a lot of dependencies, revisit later 2975 return DeclarativeSingleUseRefreshTokenOauth2Authenticator( # type: ignore 2976 config, 2977 InterpolatedString.create( 2978 model.token_refresh_endpoint, # type: ignore 2979 parameters=model.parameters or {}, 2980 ).eval(config), 2981 access_token_name=InterpolatedString.create( 2982 model.access_token_name or "access_token", parameters=model.parameters or {} 2983 ).eval(config), 2984 refresh_token_name=model.refresh_token_updater.refresh_token_name, 2985 expires_in_name=InterpolatedString.create( 2986 model.expires_in_name or "expires_in", parameters=model.parameters or {} 2987 ).eval(config), 2988 client_id_name=InterpolatedString.create( 2989 model.client_id_name or "client_id", parameters=model.parameters or {} 2990 ).eval(config), 2991 client_id=InterpolatedString.create( 2992 model.client_id, parameters=model.parameters or {} 2993 ).eval(config) 2994 if model.client_id 2995 else model.client_id, 2996 client_secret_name=InterpolatedString.create( 2997 model.client_secret_name or "client_secret", parameters=model.parameters or {} 2998 ).eval(config), 2999 client_secret=InterpolatedString.create( 3000 model.client_secret, parameters=model.parameters or {} 3001 ).eval(config) 3002 if model.client_secret 3003 else model.client_secret, 3004 access_token_config_path=model.refresh_token_updater.access_token_config_path, 3005 refresh_token_config_path=model.refresh_token_updater.refresh_token_config_path, 3006 token_expiry_date_config_path=model.refresh_token_updater.token_expiry_date_config_path, 3007 grant_type_name=InterpolatedString.create( 3008 model.grant_type_name or "grant_type", parameters=model.parameters or {} 3009 ).eval(config), 3010 grant_type=InterpolatedString.create( 3011 model.grant_type or "refresh_token", parameters=model.parameters or {} 3012 ).eval(config), 3013 refresh_request_body=InterpolatedMapping( 3014 model.refresh_request_body or {}, parameters=model.parameters or {} 3015 ).eval(config), 3016 refresh_request_headers=InterpolatedMapping( 3017 model.refresh_request_headers or {}, parameters=model.parameters or {} 3018 ).eval(config), 3019 send_refresh_request_as_query_params=bool( 3020 model.send_refresh_request_as_query_params 3021 ), 3022 scopes=model.scopes, 3023 token_expiry_date_format=model.token_expiry_date_format, 3024 token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), 3025 message_repository=self._message_repository, 3026 refresh_token_error_status_codes=refresh_token_error_status_codes, 3027 refresh_token_error_key=refresh_token_error_key, 3028 refresh_token_error_values=refresh_token_error_values, 3029 ) 3030 # ignore type error because fixing it would have a lot of dependencies, revisit later 3031 return DeclarativeOauth2Authenticator( # type: ignore 3032 access_token_name=model.access_token_name or "access_token", 3033 access_token_value=model.access_token_value, 3034 client_id_name=model.client_id_name or "client_id", 3035 client_id=model.client_id, 3036 client_secret_name=model.client_secret_name or "client_secret", 3037 client_secret=model.client_secret, 3038 expires_in_name=model.expires_in_name or "expires_in", 3039 grant_type_name=model.grant_type_name or "grant_type", 3040 grant_type=model.grant_type or "refresh_token", 3041 refresh_request_body=model.refresh_request_body, 3042 refresh_request_headers=model.refresh_request_headers, 3043 send_refresh_request_as_query_params=bool(model.send_refresh_request_as_query_params), 3044 refresh_token_name=model.refresh_token_name or "refresh_token", 3045 refresh_token=model.refresh_token, 3046 scopes=model.scopes, 3047 token_expiry_date=model.token_expiry_date, 3048 token_expiry_date_format=model.token_expiry_date_format, 3049 token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), 3050 token_refresh_endpoint=model.token_refresh_endpoint, 3051 config=config, 3052 parameters=model.parameters or {}, 3053 message_repository=self._message_repository, 3054 profile_assertion=profile_assertion, 3055 use_profile_assertion=model.use_profile_assertion, 3056 refresh_token_error_status_codes=refresh_token_error_status_codes, 3057 refresh_token_error_key=refresh_token_error_key, 3058 refresh_token_error_values=refresh_token_error_values, 3059 )
3109 def create_offset_increment( 3110 self, 3111 model: OffsetIncrementModel, 3112 config: Config, 3113 decoder: Decoder, 3114 extractor_model: Optional[Union[CustomRecordExtractorModel, DpathExtractorModel]] = None, 3115 **kwargs: Any, 3116 ) -> OffsetIncrement: 3117 if isinstance(decoder, PaginationDecoderDecorator): 3118 inner_decoder = decoder.decoder 3119 else: 3120 inner_decoder = decoder 3121 decoder = PaginationDecoderDecorator(decoder=decoder) 3122 3123 if self._is_supported_decoder_for_pagination(inner_decoder): 3124 decoder_to_use = decoder 3125 else: 3126 raise ValueError( 3127 self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(inner_decoder)) 3128 ) 3129 3130 # Ideally we would instantiate the runtime extractor from highest most level (in this case the SimpleRetriever) 3131 # so that it can be shared by OffSetIncrement and RecordSelector. However, due to how we instantiate the 3132 # decoder with various decorators here, but not in create_record_selector, it is simpler to retain existing 3133 # behavior by having two separate extractors with identical behavior since they use the same extractor model. 3134 # When we have more time to investigate we can look into reusing the same component. 3135 extractor = ( 3136 self._create_component_from_model( 3137 model=extractor_model, config=config, decoder=decoder_to_use 3138 ) 3139 if extractor_model 3140 else None 3141 ) 3142 3143 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 3144 # If page_size is a string that represents an integer (not an interpolation), convert it back. 3145 page_size = model.page_size 3146 if isinstance(page_size, str) and page_size.isdigit(): 3147 page_size = int(page_size) 3148 3149 return OffsetIncrement( 3150 page_size=page_size, 3151 config=config, 3152 decoder=decoder_to_use, 3153 extractor=extractor, 3154 inject_on_first_request=model.inject_on_first_request or False, 3155 parameters=model.parameters or {}, 3156 )
3158 def create_page_increment( 3159 self, 3160 model: PageIncrementModel, 3161 config: Config, 3162 decoder: Optional[Decoder] = None, 3163 extractor_model: Optional[Union[CustomRecordExtractorModel, DpathExtractorModel]] = None, 3164 **kwargs: Any, 3165 ) -> PageIncrement: 3166 # Like OffsetIncrement, we instantiate a separate extractor with identical behavior to the 3167 # RecordSelector's so the strategy can count the raw records in the response. This ensures 3168 # pagination is driven by the API's page size, not the post-filter record count. 3169 extractor = ( 3170 self._create_component_from_model(model=extractor_model, config=config, decoder=decoder) 3171 if extractor_model 3172 else None 3173 ) 3174 3175 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 3176 # If page_size is a string that represents an integer (not an interpolation), convert it back. 3177 page_size = model.page_size 3178 if isinstance(page_size, str) and page_size.isdigit(): 3179 page_size = int(page_size) 3180 3181 return PageIncrement( 3182 page_size=page_size, 3183 config=config, 3184 start_from_page=model.start_from_page or 0, 3185 inject_on_first_request=model.inject_on_first_request or False, 3186 extractor=extractor, 3187 parameters=model.parameters or {}, 3188 )
3190 def create_parent_stream_config( 3191 self, model: ParentStreamConfigModel, config: Config, *, stream_name: str, **kwargs: Any 3192 ) -> ParentStreamConfig: 3193 declarative_stream = self._create_component_from_model( 3194 model.stream, 3195 config=config, 3196 is_parent=True, 3197 **kwargs, 3198 ) 3199 request_option = ( 3200 self._create_component_from_model(model.request_option, config=config) 3201 if model.request_option 3202 else None 3203 ) 3204 3205 if model.lazy_read_pointer and any("*" in pointer for pointer in model.lazy_read_pointer): 3206 raise ValueError( 3207 "The '*' wildcard in 'lazy_read_pointer' is not supported — only direct paths are allowed." 3208 ) 3209 3210 model_lazy_read_pointer: List[Union[InterpolatedString, str]] = ( 3211 [x for x in model.lazy_read_pointer] if model.lazy_read_pointer else [] 3212 ) 3213 3214 return ParentStreamConfig( 3215 parent_key=model.parent_key, 3216 request_option=request_option, 3217 stream=declarative_stream, 3218 partition_field=model.partition_field, 3219 config=config, 3220 incremental_dependency=model.incremental_dependency or False, 3221 parameters=model.parameters or {}, 3222 extra_fields=model.extra_fields, 3223 lazy_read_pointer=model_lazy_read_pointer, 3224 )
3226 def create_properties_from_endpoint( 3227 self, model: PropertiesFromEndpointModel, config: Config, **kwargs: Any 3228 ) -> PropertiesFromEndpoint: 3229 retriever = self._create_component_from_model( 3230 model=model.retriever, 3231 config=config, 3232 name="dynamic_properties", 3233 primary_key=None, 3234 stream_slicer=None, 3235 transformations=[], 3236 use_cache=True, # Enable caching on the HttpRequester/HttpClient because the properties endpoint will be called for every slice being processed, and it is highly unlikely for the response to different 3237 ) 3238 return PropertiesFromEndpoint( 3239 property_field_path=model.property_field_path, 3240 retriever=retriever, 3241 config=config, 3242 parameters=model.parameters or {}, 3243 )
3245 def create_property_chunking( 3246 self, model: PropertyChunkingModel, config: Config, **kwargs: Any 3247 ) -> PropertyChunking: 3248 record_merge_strategy = ( 3249 self._create_component_from_model( 3250 model=model.record_merge_strategy, config=config, **kwargs 3251 ) 3252 if model.record_merge_strategy 3253 else None 3254 ) 3255 3256 property_limit_type: PropertyLimitType 3257 match model.property_limit_type: 3258 case PropertyLimitTypeModel.property_count: 3259 property_limit_type = PropertyLimitType.property_count 3260 case PropertyLimitTypeModel.characters: 3261 property_limit_type = PropertyLimitType.characters 3262 case _: 3263 raise ValueError(f"Invalid PropertyLimitType {property_limit_type}") 3264 3265 return PropertyChunking( 3266 property_limit_type=property_limit_type, 3267 property_limit=model.property_limit, 3268 record_merge_strategy=record_merge_strategy, 3269 config=config, 3270 parameters=model.parameters or {}, 3271 )
3273 def create_query_properties( 3274 self, model: QueryPropertiesModel, config: Config, *, stream_name: str, **kwargs: Any 3275 ) -> QueryProperties: 3276 if isinstance(model.property_list, list): 3277 property_list = model.property_list 3278 else: 3279 property_list = self._create_component_from_model( 3280 model=model.property_list, config=config, **kwargs 3281 ) 3282 3283 property_chunking = ( 3284 self._create_component_from_model( 3285 model=model.property_chunking, config=config, **kwargs 3286 ) 3287 if model.property_chunking 3288 else None 3289 ) 3290 3291 property_selector = ( 3292 self._create_component_from_model( 3293 model=model.property_selector, config=config, stream_name=stream_name, **kwargs 3294 ) 3295 if model.property_selector 3296 else None 3297 ) 3298 3299 return QueryProperties( 3300 property_list=property_list, 3301 always_include_properties=model.always_include_properties, 3302 property_chunking=property_chunking, 3303 property_selector=property_selector, 3304 config=config, 3305 parameters=model.parameters or {}, 3306 )
3308 def create_json_schema_property_selector( 3309 self, 3310 model: JsonSchemaPropertySelectorModel, 3311 config: Config, 3312 *, 3313 stream_name: str, 3314 **kwargs: Any, 3315 ) -> JsonSchemaPropertySelector: 3316 configured_stream = self._stream_name_to_configured_stream.get(stream_name) 3317 3318 transformations = [] 3319 if model.transformations: 3320 for transformation_model in model.transformations: 3321 transformations.append( 3322 self._create_component_from_model(model=transformation_model, config=config) 3323 ) 3324 3325 return JsonSchemaPropertySelector( 3326 configured_stream=configured_stream, 3327 properties_transformations=transformations, 3328 config=config, 3329 parameters=model.parameters or {}, 3330 )
3344 @staticmethod 3345 def create_request_option( 3346 model: RequestOptionModel, config: Config, **kwargs: Any 3347 ) -> RequestOption: 3348 inject_into = RequestOptionType(model.inject_into.value) 3349 field_path: Optional[List[Union[InterpolatedString, str]]] = ( 3350 [ 3351 InterpolatedString.create(segment, parameters=kwargs.get("parameters", {})) 3352 for segment in model.field_path 3353 ] 3354 if model.field_path 3355 else None 3356 ) 3357 field_name = ( 3358 InterpolatedString.create(model.field_name, parameters=kwargs.get("parameters", {})) 3359 if model.field_name 3360 else None 3361 ) 3362 return RequestOption( 3363 field_name=field_name, 3364 field_path=field_path, 3365 inject_into=inject_into, 3366 parameters=kwargs.get("parameters", {}), 3367 )
3369 def create_record_selector( 3370 self, 3371 model: RecordSelectorModel, 3372 config: Config, 3373 *, 3374 name: str, 3375 transformations: List[RecordTransformation] | None = None, 3376 decoder: Decoder | None = None, 3377 client_side_incremental_sync_cursor: Optional[Cursor] = None, 3378 is_client_side_incremental_sync: bool = False, 3379 file_uploader: Optional[DefaultFileUploader] = None, 3380 **kwargs: Any, 3381 ) -> RecordSelector: 3382 extractor = self._create_component_from_model( 3383 model=model.extractor, decoder=decoder, config=config 3384 ) 3385 record_filter = ( 3386 self._create_component_from_model(model.record_filter, config=config) 3387 if model.record_filter 3388 else None 3389 ) 3390 3391 # A client-side incremental stream transforms before filtering by default. That default belongs to the flag, 3392 # not to the component that ends up doing the cursor comparison: a data feed does it in the retriever and 3393 # receives no cursor here, but its `record_filter` condition must keep running after the transformations. 3394 default_transform_before_filtering = bool( 3395 client_side_incremental_sync_cursor or is_client_side_incremental_sync 3396 ) 3397 transform_before_filtering = ( 3398 default_transform_before_filtering 3399 if model.transform_before_filtering is None 3400 else model.transform_before_filtering 3401 ) 3402 if client_side_incremental_sync_cursor: 3403 record_filter = ClientSideIncrementalRecordFilterDecorator( 3404 config=config, 3405 parameters=model.parameters, 3406 condition=model.record_filter.condition 3407 if (model.record_filter and hasattr(model.record_filter, "condition")) 3408 else None, 3409 cursor=client_side_incremental_sync_cursor, 3410 ) 3411 3412 if model.schema_normalization is None: 3413 # default to no schema normalization if not set 3414 model.schema_normalization = SchemaNormalizationModel.None_ 3415 3416 schema_normalization = ( 3417 TypeTransformer(SCHEMA_TRANSFORMER_TYPE_MAPPING[model.schema_normalization]) 3418 if isinstance(model.schema_normalization, SchemaNormalizationModel) 3419 else self._create_component_from_model(model.schema_normalization, config=config) # type: ignore[arg-type] # custom normalization model expected here 3420 ) 3421 3422 return RecordSelector( 3423 extractor=extractor, 3424 name=name, 3425 config=config, 3426 record_filter=record_filter, 3427 transformations=transformations or [], 3428 file_uploader=file_uploader, 3429 schema_normalization=schema_normalization, 3430 parameters=model.parameters or {}, 3431 transform_before_filtering=transform_before_filtering, 3432 )
3442 def create_selective_authenticator( 3443 self, model: SelectiveAuthenticatorModel, config: Config, **kwargs: Any 3444 ) -> DeclarativeAuthenticator: 3445 authenticators = { 3446 name: self._create_component_from_model(model=auth, config=config) 3447 for name, auth in model.authenticators.items() 3448 } 3449 # SelectiveAuthenticator will return instance of DeclarativeAuthenticator or raise ValueError error 3450 return SelectiveAuthenticator( # type: ignore[abstract] 3451 config=config, 3452 authenticators=authenticators, 3453 authenticator_selection_path=model.authenticator_selection_path, 3454 **kwargs, 3455 )
3457 @staticmethod 3458 def create_legacy_session_token_authenticator( 3459 model: LegacySessionTokenAuthenticatorModel, config: Config, *, url_base: str, **kwargs: Any 3460 ) -> LegacySessionTokenAuthenticator: 3461 return LegacySessionTokenAuthenticator( 3462 api_url=url_base, 3463 header=model.header, 3464 login_url=model.login_url, 3465 password=model.password or "", 3466 session_token=model.session_token or "", 3467 session_token_response_key=model.session_token_response_key or "", 3468 username=model.username or "", 3469 validate_session_url=model.validate_session_url, 3470 config=config, 3471 parameters=model.parameters or {}, 3472 )
3474 def create_simple_retriever( 3475 self, 3476 model: SimpleRetrieverModel, 3477 config: Config, 3478 *, 3479 name: str, 3480 primary_key: Optional[Union[str, List[str], List[List[str]]]], 3481 request_options_provider: Optional[RequestOptionsProvider] = None, 3482 cursor: Optional[Cursor] = None, 3483 has_stop_condition_cursor: bool = False, 3484 is_client_side_incremental_sync: bool = False, 3485 transformations: List[RecordTransformation], 3486 file_uploader: Optional[DefaultFileUploader] = None, 3487 incremental_sync: Optional[ 3488 Union[IncrementingCountCursorModel, DatetimeBasedCursorModel] 3489 ] = None, 3490 use_cache: Optional[bool] = None, 3491 log_formatter: Optional[Callable[[Response], Any]] = None, 3492 partition_router: Optional[PartitionRouter] = None, 3493 **kwargs: Any, 3494 ) -> SimpleRetriever: 3495 def _get_url(req: Requester) -> str: 3496 """ 3497 Closure to get the URL from the requester. This is used to get the URL in the case of a lazy retriever. 3498 This is needed because the URL is not set until the requester is created. 3499 """ 3500 3501 _url: str = ( 3502 model.requester.url 3503 if hasattr(model.requester, "url") and model.requester.url is not None 3504 else req.get_url(stream_state=None, stream_slice=None, next_page_token=None) 3505 ) 3506 _url_base: str = ( 3507 model.requester.url_base 3508 if hasattr(model.requester, "url_base") and model.requester.url_base is not None 3509 else req.get_url_base(stream_state=None, stream_slice=None, next_page_token=None) 3510 ) 3511 3512 return _url or _url_base 3513 3514 if cursor is None: 3515 cursor = FinalStateCursor(name, None, self._message_repository) 3516 3517 # A data feed drops the records the cursor considers already synced in the retriever, which sits downstream of 3518 # the paginator. Letting the record selector drop them as well would be redundant and would hide them from the 3519 # pagination stop condition, so a data feed never delegates that filtering to the record selector, whether 3520 # `is_client_side_incremental` is set or not. The `condition` from `record_filter` is intentionally left out of 3521 # the post-pagination filter and stays in the record selector, which preserves the existing behaviour: the 3522 # selector runs inside the page loop, so the records the condition rejects never reach the paginator's 3523 # accounting. Moving it downstream would start counting them. 3524 post_pagination_filter = ( 3525 ClientSideIncrementalRecordFilterDecorator( 3526 config=config, 3527 parameters=model.parameters or {}, 3528 condition=None, 3529 cursor=cursor, 3530 ) 3531 if has_stop_condition_cursor 3532 else None 3533 ) 3534 client_side_incremental_cursor = ( 3535 cursor if is_client_side_incremental_sync and not post_pagination_filter else None 3536 ) 3537 if post_pagination_filter and is_client_side_incremental_sync: 3538 LOGGER.warning( 3539 f"Stream {name}: `is_client_side_incremental` adds no record filtering when `is_data_feed` is set, " 3540 "as a data feed already filters on the cursor value. It still makes the record selector apply the " 3541 "transformations before the `record_filter` condition." 3542 ) 3543 3544 decoder = ( 3545 self._create_component_from_model(model=model.decoder, config=config) 3546 if model.decoder 3547 else JsonDecoder(parameters={}) 3548 ) 3549 record_selector = self._create_component_from_model( 3550 model=model.record_selector, 3551 name=name, 3552 config=config, 3553 decoder=decoder, 3554 transformations=transformations, 3555 client_side_incremental_sync_cursor=client_side_incremental_cursor, 3556 is_client_side_incremental_sync=is_client_side_incremental_sync, 3557 file_uploader=file_uploader, 3558 ) 3559 3560 query_properties: Optional[QueryProperties] = None 3561 query_properties_key: Optional[str] = None 3562 self._ensure_query_properties_to_model(model.requester) 3563 if self._has_query_properties_in_request_parameters(model.requester): 3564 # It is better to be explicit about an error if PropertiesFromEndpoint is defined in multiple 3565 # places instead of default to request_parameters which isn't clearly documented 3566 if ( 3567 hasattr(model.requester, "fetch_properties_from_endpoint") 3568 and model.requester.fetch_properties_from_endpoint 3569 ): 3570 raise ValueError( 3571 f"PropertiesFromEndpoint should only be specified once per stream, but found in {model.requester.type}.fetch_properties_from_endpoint and {model.requester.type}.request_parameters" 3572 ) 3573 3574 query_properties_definitions = [] 3575 for key, request_parameter in model.requester.request_parameters.items(): # type: ignore # request_parameters is already validated to be a Mapping using _has_query_properties_in_request_parameters() 3576 if isinstance(request_parameter, QueryPropertiesModel): 3577 query_properties_key = key 3578 query_properties_definitions.append(request_parameter) 3579 3580 if len(query_properties_definitions) > 1: 3581 raise ValueError( 3582 f"request_parameters only supports defining one QueryProperties field, but found {len(query_properties_definitions)} usages" 3583 ) 3584 3585 if len(query_properties_definitions) == 1: 3586 query_properties = self._create_component_from_model( 3587 model=query_properties_definitions[0], stream_name=name, config=config 3588 ) 3589 3590 # Removes QueryProperties components from the interpolated mappings because it has been designed 3591 # to be used by the SimpleRetriever and will be resolved from the provider from the slice directly 3592 # instead of through jinja interpolation 3593 if hasattr(model.requester, "request_parameters") and isinstance( 3594 model.requester.request_parameters, Mapping 3595 ): 3596 model.requester.request_parameters = self._remove_query_properties( 3597 model.requester.request_parameters 3598 ) 3599 elif ( 3600 hasattr(model.requester, "fetch_properties_from_endpoint") 3601 and model.requester.fetch_properties_from_endpoint 3602 ): 3603 # todo: Deprecate this condition once dependent connectors migrate to query_properties 3604 query_properties_definition = QueryPropertiesModel( 3605 type="QueryProperties", 3606 property_list=model.requester.fetch_properties_from_endpoint, 3607 always_include_properties=None, 3608 property_chunking=None, 3609 ) # type: ignore # $parameters has a default value 3610 3611 query_properties = self.create_query_properties( 3612 model=query_properties_definition, 3613 stream_name=name, 3614 config=config, 3615 ) 3616 elif hasattr(model.requester, "query_properties") and model.requester.query_properties: 3617 query_properties = self.create_query_properties( 3618 model=model.requester.query_properties, 3619 stream_name=name, 3620 config=config, 3621 ) 3622 3623 requester = self._create_component_from_model( 3624 model=model.requester, 3625 decoder=decoder, 3626 name=name, 3627 query_properties_key=query_properties_key, 3628 use_cache=use_cache, 3629 config=config, 3630 ) 3631 3632 if not request_options_provider: 3633 request_options_provider = DefaultRequestOptionsProvider(parameters={}) 3634 if isinstance(request_options_provider, DefaultRequestOptionsProvider) and isinstance( 3635 partition_router, PartitionRouter 3636 ): 3637 request_options_provider = partition_router 3638 3639 paginator = ( 3640 self._create_component_from_model( 3641 model=model.paginator, 3642 config=config, 3643 url_base=_get_url(requester), 3644 extractor_model=model.record_selector.extractor, 3645 decoder=decoder, 3646 cursor_used_for_stop_condition=cursor if has_stop_condition_cursor else None, 3647 ) 3648 if model.paginator 3649 else NoPagination(parameters={}) 3650 ) 3651 3652 ignore_stream_slicer_parameters_on_paginated_requests = ( 3653 model.ignore_stream_slicer_parameters_on_paginated_requests or False 3654 ) 3655 3656 if ( 3657 model.partition_router 3658 and isinstance(model.partition_router, SubstreamPartitionRouterModel) 3659 and not bool(self._connector_state_manager.get_stream_state(name, None)) 3660 and any( 3661 parent_stream_config.lazy_read_pointer 3662 for parent_stream_config in model.partition_router.parent_stream_configs 3663 ) 3664 ): 3665 if incremental_sync: 3666 if incremental_sync.type != "DatetimeBasedCursor": 3667 raise ValueError( 3668 f"LazySimpleRetriever only supports DatetimeBasedCursor. Found: {incremental_sync.type}." 3669 ) 3670 3671 elif incremental_sync.step or incremental_sync.cursor_granularity: 3672 raise ValueError( 3673 f"Found more that one slice per parent. LazySimpleRetriever only supports single slice read for stream - {name}." 3674 ) 3675 3676 if model.decoder and model.decoder.type != "JsonDecoder": 3677 raise ValueError( 3678 f"LazySimpleRetriever only supports JsonDecoder. Found: {model.decoder.type}." 3679 ) 3680 3681 return LazySimpleRetriever( 3682 name=name, 3683 paginator=paginator, 3684 primary_key=primary_key, 3685 requester=requester, 3686 record_selector=record_selector, 3687 stream_slicer=_NO_STREAM_SLICING, 3688 request_option_provider=request_options_provider, 3689 config=config, 3690 ignore_stream_slicer_parameters_on_paginated_requests=ignore_stream_slicer_parameters_on_paginated_requests, 3691 post_pagination_filter=post_pagination_filter, 3692 parameters=model.parameters or {}, 3693 ) 3694 3695 if ( 3696 model.record_selector.record_filter 3697 and model.pagination_reset 3698 and model.pagination_reset.limits 3699 ): 3700 raise ValueError("PaginationResetLimits are not supported while having record filter.") 3701 3702 return SimpleRetriever( 3703 name=name, 3704 paginator=paginator, 3705 primary_key=primary_key, 3706 requester=requester, 3707 record_selector=record_selector, 3708 stream_slicer=_NO_STREAM_SLICING, 3709 request_option_provider=request_options_provider, 3710 config=config, 3711 ignore_stream_slicer_parameters_on_paginated_requests=ignore_stream_slicer_parameters_on_paginated_requests, 3712 additional_query_properties=query_properties, 3713 log_formatter=self._get_log_formatter(log_formatter, name), 3714 pagination_tracker_factory=self._create_pagination_tracker_factory( 3715 model.pagination_reset, cursor 3716 ), 3717 post_pagination_filter=post_pagination_filter, 3718 parameters=model.parameters or {}, 3719 )
3797 def create_state_delegating_stream( 3798 self, 3799 model: StateDelegatingStreamModel, 3800 config: Config, 3801 **kwargs: Any, 3802 ) -> DefaultStream: 3803 if ( 3804 model.full_refresh_stream.name != model.name 3805 or model.name != model.incremental_stream.name 3806 ): 3807 raise ValueError( 3808 f"state_delegating_stream, full_refresh_stream name and incremental_stream must have equal names. Instead has {model.name}, {model.full_refresh_stream.name} and {model.incremental_stream.name}." 3809 ) 3810 3811 # Resolve api_retention_period with config context (supports Jinja2 interpolation) 3812 resolved_retention_period: Optional[str] = None 3813 if model.api_retention_period: 3814 interpolated_retention = InterpolatedString.create( 3815 model.api_retention_period, parameters=model.parameters or {} 3816 ) 3817 resolved_value = interpolated_retention.eval(config=config) 3818 if resolved_value: 3819 resolved_retention_period = str(resolved_value) 3820 3821 if resolved_retention_period: 3822 for stream_model in (model.full_refresh_stream, model.incremental_stream): 3823 if isinstance(stream_model.incremental_sync, IncrementingCountCursorModel): 3824 raise ValueError( 3825 f"Stream '{model.name}' uses IncrementingCountCursor which is not supported " 3826 f"with api_retention_period. IncrementingCountCursor does not use datetime-based " 3827 f"cursors, so cursor age validation cannot be performed." 3828 ) 3829 3830 stream_state = self._connector_state_manager.get_stream_state(model.name, None) 3831 3832 if not stream_state: 3833 return self._create_component_from_model( # type: ignore[no-any-return] 3834 model.full_refresh_stream, config=config, **kwargs 3835 ) 3836 3837 incremental_stream: DefaultStream = self._create_component_from_model( 3838 model.incremental_stream, config=config, **kwargs 3839 ) # type: ignore[assignment] 3840 3841 # Only run cursor age validation for streams that are in the configured 3842 # catalog (or when no catalog was provided, e.g. during discover / connector 3843 # builder). Streams not selected by the user but instantiated as parent-stream 3844 # dependencies must not go through this path because it emits state messages 3845 # that the destination does not know about, causing "Stream not found" crashes. 3846 stream_is_in_catalog = ( 3847 not self._stream_name_to_configured_stream # no catalog → validate by default 3848 or model.name in self._stream_name_to_configured_stream 3849 ) 3850 if resolved_retention_period and stream_is_in_catalog: 3851 full_refresh_stream: DefaultStream = self._create_component_from_model( 3852 model.full_refresh_stream, config=config, **kwargs 3853 ) # type: ignore[assignment] 3854 if self._is_cursor_older_than_retention_period( 3855 stream_state, 3856 full_refresh_stream.cursor, 3857 incremental_stream.cursor, 3858 resolved_retention_period, 3859 model.name, 3860 ): 3861 # Clear state BEFORE constructing the full_refresh_stream so that 3862 # its cursor starts from start_date instead of the stale cursor. 3863 self._connector_state_manager.update_state_for_stream(model.name, None, {}) 3864 state_message = self._connector_state_manager.create_state_message(model.name, None) 3865 self._message_repository.emit_message(state_message) 3866 return self._create_component_from_model( # type: ignore[no-any-return] 3867 model.full_refresh_stream, config=config, **kwargs 3868 ) 3869 3870 return incremental_stream
3969 def create_async_retriever( 3970 self, 3971 model: AsyncRetrieverModel, 3972 config: Config, 3973 *, 3974 name: str, 3975 primary_key: Optional[ 3976 Union[str, List[str], List[List[str]]] 3977 ], # this seems to be needed to match create_simple_retriever 3978 stream_slicer: Optional[StreamSlicer], 3979 client_side_incremental_sync: Optional[Dict[str, Any]] = None, 3980 transformations: List[RecordTransformation], 3981 **kwargs: Any, 3982 ) -> AsyncRetriever: 3983 if model.download_target_requester and not model.download_target_extractor: 3984 raise ValueError( 3985 f"`download_target_extractor` required if using a `download_target_requester`" 3986 ) 3987 3988 def _get_download_retriever( 3989 requester: Requester, extractor: RecordExtractor, _decoder: Decoder 3990 ) -> SimpleRetriever: 3991 # We create a record selector for the download retriever 3992 # with no schema normalization and no transformations, neither record filter 3993 # as all this occurs in the record_selector of the AsyncRetriever 3994 record_selector = RecordSelector( 3995 extractor=extractor, 3996 name=name, 3997 record_filter=None, 3998 transformations=[], 3999 schema_normalization=TypeTransformer(TransformConfig.NoTransform), 4000 config=config, 4001 parameters={}, 4002 ) 4003 paginator = ( 4004 self._create_component_from_model( 4005 model=model.download_paginator, 4006 decoder=_decoder, 4007 config=config, 4008 url_base="", 4009 ) 4010 if model.download_paginator 4011 else NoPagination(parameters={}) 4012 ) 4013 4014 return SimpleRetriever( 4015 requester=requester, 4016 record_selector=record_selector, 4017 primary_key=None, 4018 name=name, 4019 paginator=paginator, 4020 config=config, 4021 parameters={}, 4022 log_formatter=self._get_log_formatter(None, name), 4023 ) 4024 4025 def _get_job_timeout() -> datetime.timedelta: 4026 user_defined_timeout: Optional[int] = ( 4027 int( 4028 InterpolatedString.create( 4029 str(model.polling_job_timeout), 4030 parameters={}, 4031 ).eval(config) 4032 ) 4033 if model.polling_job_timeout 4034 else None 4035 ) 4036 4037 # check for user defined timeout during the test read or 15 minutes 4038 test_read_timeout = datetime.timedelta(minutes=user_defined_timeout or 15) 4039 # default value for non-connector builder is 60 minutes. 4040 default_sync_timeout = datetime.timedelta(minutes=user_defined_timeout or 60) 4041 4042 return ( 4043 test_read_timeout if self._emit_connector_builder_messages else default_sync_timeout 4044 ) 4045 4046 decoder = ( 4047 self._create_component_from_model(model=model.decoder, config=config) 4048 if model.decoder 4049 else JsonDecoder(parameters={}) 4050 ) 4051 record_selector = self._create_component_from_model( 4052 model=model.record_selector, 4053 config=config, 4054 decoder=decoder, 4055 name=name, 4056 transformations=transformations, 4057 client_side_incremental_sync=client_side_incremental_sync, 4058 ) 4059 4060 stream_slicer = stream_slicer or SinglePartitionRouter(parameters={}) 4061 if self._should_limit_slices_fetched(): 4062 stream_slicer = cast( 4063 StreamSlicer, 4064 StreamSlicerTestReadDecorator( 4065 wrapped_slicer=stream_slicer, 4066 maximum_number_of_slices=self._limit_slices_fetched or 5, 4067 ), 4068 ) 4069 4070 creation_requester = self._create_component_from_model( 4071 model=model.creation_requester, 4072 decoder=decoder, 4073 config=config, 4074 name=f"job creation - {name}", 4075 ) 4076 polling_requester = self._create_component_from_model( 4077 model=model.polling_requester, 4078 decoder=decoder, 4079 config=config, 4080 name=f"job polling - {name}", 4081 ) 4082 job_download_components_name = f"job download - {name}" 4083 download_decoder = ( 4084 self._create_component_from_model(model=model.download_decoder, config=config) 4085 if model.download_decoder 4086 else JsonDecoder(parameters={}) 4087 ) 4088 download_extractor = ( 4089 self._create_component_from_model( 4090 model=model.download_extractor, 4091 config=config, 4092 decoder=download_decoder, 4093 parameters=model.parameters, 4094 ) 4095 if model.download_extractor 4096 else DpathExtractor( 4097 [], 4098 config=config, 4099 decoder=download_decoder, 4100 parameters=model.parameters or {}, 4101 ) 4102 ) 4103 download_requester = self._create_component_from_model( 4104 model=model.download_requester, 4105 decoder=download_decoder, 4106 config=config, 4107 name=job_download_components_name, 4108 ) 4109 download_retriever = _get_download_retriever( 4110 download_requester, download_extractor, download_decoder 4111 ) 4112 abort_requester = ( 4113 self._create_component_from_model( 4114 model=model.abort_requester, 4115 decoder=decoder, 4116 config=config, 4117 name=f"job abort - {name}", 4118 ) 4119 if model.abort_requester 4120 else None 4121 ) 4122 delete_requester = ( 4123 self._create_component_from_model( 4124 model=model.delete_requester, 4125 decoder=decoder, 4126 config=config, 4127 name=f"job delete - {name}", 4128 ) 4129 if model.delete_requester 4130 else None 4131 ) 4132 download_target_requester = ( 4133 self._create_component_from_model( 4134 model=model.download_target_requester, 4135 decoder=decoder, 4136 config=config, 4137 name=f"job extract_url - {name}", 4138 ) 4139 if model.download_target_requester 4140 else None 4141 ) 4142 status_extractor = self._create_component_from_model( 4143 model=model.status_extractor, decoder=decoder, config=config, name=name 4144 ) 4145 download_target_extractor = ( 4146 self._create_component_from_model( 4147 model=model.download_target_extractor, 4148 decoder=decoder, 4149 config=config, 4150 name=name, 4151 ) 4152 if model.download_target_extractor 4153 else None 4154 ) 4155 4156 job_repository: AsyncJobRepository = AsyncHttpJobRepository( 4157 creation_requester=creation_requester, 4158 polling_requester=polling_requester, 4159 download_retriever=download_retriever, 4160 download_target_requester=download_target_requester, 4161 abort_requester=abort_requester, 4162 delete_requester=delete_requester, 4163 status_extractor=status_extractor, 4164 status_mapping=self._create_async_job_status_mapping(model.status_mapping, config), 4165 download_target_extractor=download_target_extractor, 4166 job_timeout=_get_job_timeout(), 4167 ) 4168 4169 failed_retry_wait_time_in_seconds: Optional[int] = ( 4170 int( 4171 InterpolatedString.create( 4172 str(model.failed_retry_wait_time_in_seconds), 4173 parameters={}, 4174 ).eval(config) 4175 ) 4176 if model.failed_retry_wait_time_in_seconds 4177 else None 4178 ) 4179 4180 async_job_partition_router = AsyncJobPartitionRouter( 4181 job_orchestrator_factory=lambda stream_slices: AsyncJobOrchestrator( 4182 job_repository, 4183 stream_slices, 4184 self._job_tracker, 4185 self._message_repository, 4186 # FIXME work would need to be done here in order to detect if a stream as a parent stream that is bulk 4187 has_bulk_parent=False, 4188 # set the `job_max_retry` to 1 for the `Connector Builder`` use-case. 4189 # `None` == default retry is set to 3 attempts, under the hood. 4190 job_max_retry=1 if self._emit_connector_builder_messages else None, 4191 failed_retry_wait_time_in_seconds=failed_retry_wait_time_in_seconds, 4192 ), 4193 stream_slicer=stream_slicer, 4194 config=config, 4195 parameters=model.parameters or {}, 4196 ) 4197 4198 return AsyncRetriever( 4199 record_selector=record_selector, 4200 stream_slicer=async_job_partition_router, 4201 config=config, 4202 parameters=model.parameters or {}, 4203 )
4205 def create_spec(self, model: SpecModel, config: Config, **kwargs: Any) -> Spec: 4206 config_migrations = [ 4207 self._create_component_from_model(migration, config) 4208 for migration in ( 4209 model.config_normalization_rules.config_migrations 4210 if ( 4211 model.config_normalization_rules 4212 and model.config_normalization_rules.config_migrations 4213 ) 4214 else [] 4215 ) 4216 ] 4217 config_transformations = [ 4218 self._create_component_from_model(transformation, config) 4219 for transformation in ( 4220 model.config_normalization_rules.transformations 4221 if ( 4222 model.config_normalization_rules 4223 and model.config_normalization_rules.transformations 4224 ) 4225 else [] 4226 ) 4227 ] 4228 config_validations = [ 4229 self._create_component_from_model(validation, config) 4230 for validation in ( 4231 model.config_normalization_rules.validations 4232 if ( 4233 model.config_normalization_rules 4234 and model.config_normalization_rules.validations 4235 ) 4236 else [] 4237 ) 4238 ] 4239 4240 return Spec( 4241 connection_specification=model.connection_specification, 4242 documentation_url=model.documentation_url, 4243 advanced_auth=model.advanced_auth, 4244 parameters={}, 4245 config_migrations=config_migrations, 4246 config_transformations=config_transformations, 4247 config_validations=config_validations, 4248 )
4250 def create_substream_partition_router( 4251 self, 4252 model: SubstreamPartitionRouterModel, 4253 config: Config, 4254 *, 4255 stream_name: str, 4256 **kwargs: Any, 4257 ) -> SubstreamPartitionRouter: 4258 parent_stream_configs = [] 4259 if model.parent_stream_configs: 4260 parent_stream_configs.extend( 4261 [ 4262 self.create_parent_stream_config_with_substream_wrapper( 4263 model=parent_stream_config, config=config, stream_name=stream_name, **kwargs 4264 ) 4265 for parent_stream_config in model.parent_stream_configs 4266 ] 4267 ) 4268 4269 return SubstreamPartitionRouter( 4270 parent_stream_configs=parent_stream_configs, 4271 parameters=model.parameters or {}, 4272 config=config, 4273 )
4275 def create_parent_stream_config_with_substream_wrapper( 4276 self, model: ParentStreamConfigModel, config: Config, *, stream_name: str, **kwargs: Any 4277 ) -> Any: 4278 child_state = self._connector_state_manager.get_stream_state(stream_name, None) 4279 if NO_CURSOR_STATE_KEY in child_state: 4280 # Full refresh streams checkpoint a `{NO_CURSOR_STATE_KEY: true}` sentinel. When such a 4281 # stream is later converted to incremental with an incremental_dependency parent, 4282 # `_instantiate_parent_stream_state_manager` would treat the sentinel's boolean as a legacy 4283 # cursor value and re-key it under the parent's cursor field, crashing cursor initialization. 4284 child_state = { 4285 key: value for key, value in child_state.items() if key != NO_CURSOR_STATE_KEY 4286 } 4287 4288 parent_state: Optional[Mapping[str, Any]] = ( 4289 child_state if model.incremental_dependency and child_state else None 4290 ) 4291 connector_state_manager = self._instantiate_parent_stream_state_manager( 4292 child_state, config, model, parent_state 4293 ) 4294 4295 substream_factory = ModelToComponentFactory( 4296 custom_components_trusted=self._custom_components_trusted, 4297 connector_state_manager=connector_state_manager, 4298 limit_pages_fetched_per_slice=self._limit_pages_fetched_per_slice, 4299 limit_slices_fetched=self._limit_slices_fetched, 4300 emit_connector_builder_messages=self._emit_connector_builder_messages, 4301 disable_retries=self._disable_retries, 4302 disable_cache=self._disable_cache, 4303 message_repository=StateFilteringMessageRepository( 4304 LogAppenderMessageRepositoryDecorator( 4305 { 4306 "airbyte_cdk": {"stream": {"is_substream": True}}, 4307 "http": {"is_auxiliary": True}, 4308 }, 4309 self._message_repository, 4310 self._evaluate_log_level(self._emit_connector_builder_messages), 4311 ), 4312 ), 4313 api_budget=self._api_budget, 4314 # Share the authenticator registry so parent and child streams draw from the 4315 # same token quota counters 4316 rate_limited_authenticators=self._rate_limited_authenticators, 4317 ) 4318 4319 return substream_factory.create_parent_stream_config( 4320 model=model, config=config, stream_name=stream_name, **kwargs 4321 )
4381 @staticmethod 4382 def create_wait_time_from_header( 4383 model: WaitTimeFromHeaderModel, config: Config, **kwargs: Any 4384 ) -> WaitTimeFromHeaderBackoffStrategy: 4385 return WaitTimeFromHeaderBackoffStrategy( 4386 header=model.header, 4387 parameters=model.parameters or {}, 4388 config=config, 4389 regex=model.regex, 4390 max_waiting_time_in_seconds=model.max_waiting_time_in_seconds, 4391 )
4393 @staticmethod 4394 def create_wait_until_time_from_header( 4395 model: WaitUntilTimeFromHeaderModel, config: Config, **kwargs: Any 4396 ) -> WaitUntilTimeFromHeaderBackoffStrategy: 4397 return WaitUntilTimeFromHeaderBackoffStrategy( 4398 header=model.header, 4399 parameters=model.parameters or {}, 4400 config=config, 4401 min_wait=model.min_wait, 4402 regex=model.regex, 4403 max_waiting_time_in_seconds=model.max_waiting_time_in_seconds, 4404 )
4412 @staticmethod 4413 def create_components_mapping_definition( 4414 model: ComponentMappingDefinitionModel, config: Config, **kwargs: Any 4415 ) -> ComponentMappingDefinition: 4416 interpolated_value = InterpolatedString.create( 4417 model.value, parameters=model.parameters or {} 4418 ) 4419 field_path = [ 4420 InterpolatedString.create(path, parameters=model.parameters or {}) 4421 for path in model.field_path 4422 ] 4423 return ComponentMappingDefinition( 4424 field_path=field_path, # type: ignore[arg-type] # field_path can be str and InterpolatedString 4425 value=interpolated_value, 4426 value_type=ModelToComponentFactory._json_schema_type_name_to_type(model.value_type), 4427 create_or_update=model.create_or_update, 4428 condition=model.condition, 4429 parameters=model.parameters or {}, 4430 )
4432 def create_http_components_resolver( 4433 self, model: HttpComponentsResolverModel, config: Config, stream_name: Optional[str] = None 4434 ) -> Any: 4435 retriever = self._create_component_from_model( 4436 model=model.retriever, 4437 config=config, 4438 name=f"{stream_name if stream_name else '__http_components_resolver'}", 4439 primary_key=None, 4440 stream_slicer=self._build_stream_slicer_from_partition_router(model.retriever, config), 4441 transformations=[], 4442 ) 4443 4444 components_mapping = [] 4445 for component_mapping_definition_model in model.components_mapping: 4446 if component_mapping_definition_model.condition: 4447 raise ValueError("`condition` is only supported for `ConfigComponentsResolver`") 4448 components_mapping.append( 4449 self._create_component_from_model( 4450 model=component_mapping_definition_model, 4451 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 4452 component_mapping_definition_model.value_type 4453 ), 4454 config=config, 4455 ) 4456 ) 4457 4458 return HttpComponentsResolver( 4459 retriever=retriever, 4460 stream_slicer=self._build_stream_slicer_from_partition_router(model.retriever, config), 4461 config=config, 4462 components_mapping=components_mapping, 4463 parameters=model.parameters or {}, 4464 )
4466 @staticmethod 4467 def create_stream_config( 4468 model: StreamConfigModel, config: Config, **kwargs: Any 4469 ) -> StreamConfig: 4470 model_configs_pointer: List[Union[InterpolatedString, str]] = ( 4471 [x for x in model.configs_pointer] if model.configs_pointer else [] 4472 ) 4473 4474 return StreamConfig( 4475 configs_pointer=model_configs_pointer, 4476 default_values=model.default_values, 4477 parameters=model.parameters or {}, 4478 )
4480 def create_config_components_resolver( 4481 self, 4482 model: ConfigComponentsResolverModel, 4483 config: Config, 4484 ) -> Any: 4485 model_stream_configs = ( 4486 model.stream_config if isinstance(model.stream_config, list) else [model.stream_config] 4487 ) 4488 4489 stream_configs = [ 4490 self._create_component_from_model( 4491 stream_config, config=config, parameters=model.parameters or {} 4492 ) 4493 for stream_config in model_stream_configs 4494 ] 4495 4496 components_mapping = [ 4497 self._create_component_from_model( 4498 model=components_mapping_definition_model, 4499 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 4500 components_mapping_definition_model.value_type 4501 ), 4502 config=config, 4503 parameters=model.parameters, 4504 ) 4505 for components_mapping_definition_model in model.components_mapping 4506 ] 4507 4508 return ConfigComponentsResolver( 4509 stream_configs=stream_configs, 4510 config=config, 4511 components_mapping=components_mapping, 4512 parameters=model.parameters or {}, 4513 )
4515 def create_parametrized_components_resolver( 4516 self, 4517 model: ParametrizedComponentsResolverModel, 4518 config: Config, 4519 ) -> ParametrizedComponentsResolver: 4520 stream_parameters = StreamParametersDefinition( 4521 list_of_parameters_for_stream=model.stream_parameters.list_of_parameters_for_stream 4522 ) 4523 4524 components_mapping = [] 4525 for components_mapping_definition_model in model.components_mapping: 4526 if components_mapping_definition_model.condition: 4527 raise ValueError("`condition` is only supported for `ConfigComponentsResolver`") 4528 components_mapping.append( 4529 self._create_component_from_model( 4530 model=components_mapping_definition_model, 4531 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 4532 components_mapping_definition_model.value_type 4533 ), 4534 config=config, 4535 ) 4536 ) 4537 return ParametrizedComponentsResolver( 4538 stream_parameters=stream_parameters, 4539 config=config, 4540 components_mapping=components_mapping, 4541 parameters=model.parameters or {}, 4542 )
4566 def create_http_api_budget( 4567 self, model: HTTPAPIBudgetModel, config: Config, **kwargs: Any 4568 ) -> HttpAPIBudget: 4569 policies = [ 4570 self._create_component_from_model(model=policy, config=config) 4571 for policy in model.policies 4572 ] 4573 4574 return HttpAPIBudget( 4575 policies=policies, 4576 ratelimit_reset_header=model.ratelimit_reset_header or "ratelimit-reset", 4577 ratelimit_remaining_header=model.ratelimit_remaining_header or "ratelimit-remaining", 4578 status_codes_for_ratelimit_hit=model.status_codes_for_ratelimit_hit or [429], 4579 )
4581 def create_fixed_window_call_rate_policy( 4582 self, model: FixedWindowCallRatePolicyModel, config: Config, **kwargs: Any 4583 ) -> FixedWindowCallRatePolicy: 4584 matchers = [ 4585 self._create_component_from_model(model=matcher, config=config) 4586 for matcher in model.matchers 4587 ] 4588 4589 # Set the initial reset timestamp to 10 days from now. 4590 # This value will be updated by the first request. 4591 return FixedWindowCallRatePolicy( 4592 next_reset_ts=datetime.datetime.now() + datetime.timedelta(days=10), 4593 period=parse_duration(model.period), 4594 call_limit=model.call_limit, 4595 matchers=matchers, 4596 )
4598 def create_file_uploader( 4599 self, model: FileUploaderModel, config: Config, **kwargs: Any 4600 ) -> FileUploader: 4601 name = "File Uploader" 4602 requester = self._create_component_from_model( 4603 model=model.requester, 4604 config=config, 4605 name=name, 4606 **kwargs, 4607 ) 4608 download_target_extractor = self._create_component_from_model( 4609 model=model.download_target_extractor, 4610 config=config, 4611 name=name, 4612 **kwargs, 4613 ) 4614 emit_connector_builder_messages = self._emit_connector_builder_messages 4615 file_uploader = DefaultFileUploader( 4616 requester=requester, 4617 download_target_extractor=download_target_extractor, 4618 config=config, 4619 file_writer=NoopFileWriter() 4620 if emit_connector_builder_messages 4621 else LocalFileSystemFileWriter(), 4622 parameters=model.parameters or {}, 4623 filename_extractor=model.filename_extractor if model.filename_extractor else None, 4624 ) 4625 4626 return ( 4627 ConnectorBuilderFileUploader(file_uploader) 4628 if emit_connector_builder_messages 4629 else file_uploader 4630 )
4632 def create_moving_window_call_rate_policy( 4633 self, model: MovingWindowCallRatePolicyModel, config: Config, **kwargs: Any 4634 ) -> MovingWindowCallRatePolicy: 4635 rates = [ 4636 self._create_component_from_model(model=rate, config=config) for rate in model.rates 4637 ] 4638 matchers = [ 4639 self._create_component_from_model(model=matcher, config=config) 4640 for matcher in model.matchers 4641 ] 4642 return MovingWindowCallRatePolicy( 4643 rates=rates, 4644 matchers=matchers, 4645 )
4647 def create_unlimited_call_rate_policy( 4648 self, model: UnlimitedCallRatePolicyModel, config: Config, **kwargs: Any 4649 ) -> UnlimitedCallRatePolicy: 4650 matchers = [ 4651 self._create_component_from_model(model=matcher, config=config) 4652 for matcher in model.matchers 4653 ] 4654 4655 return UnlimitedCallRatePolicy( 4656 matchers=matchers, 4657 )
4666 def create_http_request_matcher( 4667 self, model: HttpRequestRegexMatcherModel, config: Config, **kwargs: Any 4668 ) -> HttpRequestRegexMatcher: 4669 weight = model.weight 4670 if weight is not None: 4671 if isinstance(weight, str): 4672 weight = int(InterpolatedString.create(weight, parameters={}).eval(config)) 4673 else: 4674 weight = int(weight) 4675 if weight < 1: 4676 raise ValueError(f"weight must be >= 1, got {weight}") 4677 return HttpRequestRegexMatcher( 4678 method=model.method, 4679 url_base=model.url_base, 4680 url_path_pattern=model.url_path_pattern, 4681 params=model.params, 4682 headers=model.headers, 4683 weight=weight, 4684 )
4686 def create_rate_limited_multiple_token_authenticator( 4687 self, 4688 model: RateLimitedMultipleTokenAuthenticatorModel, 4689 config: Config, 4690 **kwargs: Any, 4691 ) -> RateLimitedMultipleTokenAuthenticator: 4692 if isinstance(model.tokens, str): 4693 tokens_value = InterpolatedString.create(model.tokens, parameters={}).eval(config) 4694 delimiter = model.token_delimiter or "," 4695 tokens = [ 4696 token.strip() for token in str(tokens_value).split(delimiter) if token.strip() 4697 ] 4698 else: 4699 tokens = [ 4700 token_value 4701 for token in model.tokens 4702 if ( 4703 token_value := str( 4704 InterpolatedString.create(token, parameters={}).eval(config) 4705 ).strip() 4706 ) 4707 ] 4708 4709 quota_specs = [ 4710 { 4711 "name": quota_model.name, 4712 "remaining_path": quota_model.remaining_path, 4713 "reset_path": quota_model.reset_path, 4714 "limit_path": quota_model.limit_path, 4715 "remaining_header": quota_model.remaining_header, 4716 "reset_header": quota_model.reset_header, 4717 "limit_header": quota_model.limit_header, 4718 # Normalize the same way as the runtime TokenQuota below, so an omitted field 4719 # and an explicit `[]` key identically and keep sharing one set of counters. 4720 "exhaustion_status_codes": quota_model.exhaustion_status_codes or [], 4721 "matchers": [ 4722 { 4723 "method": matcher_model.method, 4724 "url_base": matcher_model.url_base, 4725 "url_path_pattern": matcher_model.url_path_pattern, 4726 "params": matcher_model.params, 4727 "headers": matcher_model.headers, 4728 "weight": matcher_model.weight, 4729 } 4730 for matcher_model in quota_model.matchers or [] 4731 ], 4732 } 4733 for quota_model in model.quotas 4734 ] 4735 4736 quota_status_url = str( 4737 InterpolatedString.create(model.quota_status_source.url, parameters={}).eval(config) 4738 ) 4739 quota_status_http_method = ( 4740 model.quota_status_source.http_method.value 4741 if model.quota_status_source.http_method 4742 else "GET" 4743 ) 4744 quota_status_headers = { 4745 key: str(InterpolatedString.create(value, parameters={}).eval(config)) 4746 for key, value in (model.quota_status_source.request_headers or {}).items() 4747 } 4748 # Normalize the same way as the quota specs above, so an omitted field and an explicit 4749 # `[]` key identically and keep sharing one set of counters. Deduplicated as well as 4750 # sorted, because the runtime turns this into a set: without it `[404]` and `[404, 404]` 4751 # would key differently and stop sharing counters while behaving identically. 4752 quota_status_unavailable_status_codes = sorted( 4753 set(model.quota_status_source.unavailable_status_codes or []) 4754 ) 4755 auth_method = model.auth_method or "Bearer" 4756 header = model.header or "Authorization" 4757 max_wait_time_str = str( 4758 InterpolatedString.create(model.max_wait_time or "PT2H", parameters={}).eval(config) 4759 ) 4760 max_wait_time = parse_duration(max_wait_time_str) 4761 if not isinstance(max_wait_time, datetime.timedelta): 4762 raise ValueError( 4763 f"max_wait_time must be a fixed-length ISO 8601 duration (e.g. 'PT2H'); " 4764 f"calendar-unit durations like '{max_wait_time_str}' are not supported" 4765 ) 4766 budget_reserve_fraction = ( 4767 model.budget_reserve_fraction if model.budget_reserve_fraction is not None else 0.1 4768 ) 4769 budget_min_reserve = ( 4770 model.budget_min_reserve if model.budget_min_reserve is not None else 50 4771 ) 4772 4773 # Reuse the same instance for identical definitions so that all streams share the 4774 # same token quota counters (similar to how api_budget is shared). The key is built 4775 # from the resolved constructor arguments rather than the raw model so that 4776 # stream-specific `$parameters` propagated onto the model (and its nested components) 4777 # cannot break instance sharing. 4778 cache_key = json.dumps( 4779 { 4780 "tokens": tokens, 4781 "quotas": quota_specs, 4782 "quota_status_url": quota_status_url, 4783 "quota_status_http_method": quota_status_http_method, 4784 "quota_status_headers": quota_status_headers, 4785 "quota_status_unavailable_status_codes": quota_status_unavailable_status_codes, 4786 "auth_method": auth_method, 4787 "header": header, 4788 "max_wait_time": max_wait_time.total_seconds(), 4789 "budget_reserve_fraction": budget_reserve_fraction, 4790 "budget_min_reserve": budget_min_reserve, 4791 }, 4792 sort_keys=True, 4793 ) 4794 if cache_key in self._rate_limited_authenticators: 4795 return self._rate_limited_authenticators[cache_key] 4796 4797 quotas = [ 4798 TokenQuota( 4799 name=quota_model.name, 4800 remaining_path=quota_model.remaining_path, 4801 reset_path=quota_model.reset_path, 4802 limit_path=quota_model.limit_path, 4803 remaining_header=quota_model.remaining_header, 4804 reset_header=quota_model.reset_header, 4805 limit_header=quota_model.limit_header, 4806 exhaustion_status_codes=quota_model.exhaustion_status_codes or [], 4807 matchers=[ 4808 self.create_http_request_matcher(matcher_model, config) 4809 for matcher_model in quota_model.matchers or [] 4810 ], 4811 ) 4812 for quota_model in model.quotas 4813 ] 4814 4815 authenticator = RateLimitedMultipleTokenAuthenticator( 4816 tokens=tokens, 4817 quotas=quotas, 4818 quota_status_url=quota_status_url, 4819 quota_status_http_method=quota_status_http_method, 4820 quota_status_headers=quota_status_headers, 4821 quota_status_unavailable_status_codes=quota_status_unavailable_status_codes, 4822 auth_method=auth_method, 4823 header=header, 4824 max_wait_time=max_wait_time, 4825 budget_reserve_fraction=budget_reserve_fraction, 4826 budget_min_reserve=budget_min_reserve, 4827 ) 4828 self._rate_limited_authenticators[cache_key] = authenticator 4829 return authenticator
4836 def create_grouping_partition_router( 4837 self, 4838 model: GroupingPartitionRouterModel, 4839 config: Config, 4840 *, 4841 stream_name: str, 4842 **kwargs: Any, 4843 ) -> GroupingPartitionRouter: 4844 underlying_router = self._create_component_from_model( 4845 model=model.underlying_partition_router, 4846 config=config, 4847 stream_name=stream_name, 4848 **kwargs, 4849 ) 4850 if model.group_size < 1: 4851 raise ValueError(f"Group size must be greater than 0, got {model.group_size}") 4852 4853 # Request options in underlying partition routers are not supported for GroupingPartitionRouter 4854 # because they are specific to individual partitions and cannot be aggregated or handled 4855 # when grouping, potentially leading to incorrect API calls. Any request customization 4856 # should be managed at the stream level through the requester's configuration. 4857 if isinstance(underlying_router, SubstreamPartitionRouter): 4858 if any( 4859 parent_config.request_option 4860 for parent_config in underlying_router.parent_stream_configs 4861 ): 4862 raise ValueError("Request options are not supported for GroupingPartitionRouter.") 4863 4864 if isinstance(underlying_router, ListPartitionRouter): 4865 if underlying_router.request_option: 4866 raise ValueError("Request options are not supported for GroupingPartitionRouter.") 4867 4868 return GroupingPartitionRouter( 4869 group_size=model.group_size, 4870 underlying_partition_router=underlying_router, 4871 deduplicate=model.deduplicate if model.deduplicate is not None else True, 4872 config=config, 4873 )
4875 def create_union_partition_router( 4876 self, 4877 model: UnionPartitionRouterModel, 4878 config: Config, 4879 *, 4880 stream_name: str, 4881 **kwargs: Any, 4882 ) -> UnionPartitionRouter: 4883 # The schema enforces minItems: 2 for manifests; this guard covers construction paths 4884 # that bypass JSON-schema validation (the generated model carries no min_items constraint). 4885 if len(model.partition_routers) < 2: 4886 raise ValueError( 4887 f"UnionPartitionRouter for stream {stream_name} needs at least 2 child partition routers" 4888 ) 4889 4890 partition_routers = [ 4891 self._create_component_from_model( 4892 model=child, 4893 config=config, 4894 stream_name=stream_name, 4895 **kwargs, 4896 ) 4897 for child in model.partition_routers 4898 ] 4899 4900 # partition_field depends only on config/parameters, so it is evaluated once at build 4901 # time; the runtime component always receives a plain string. 4902 partition_field = InterpolatedString.create( 4903 model.partition_field, parameters=model.parameters or {} 4904 ).eval(config) 4905 4906 # Fail fast at build time when a built-in child router is statically known to emit a 4907 # partition field different from the union's. CustomPartitionRouter children are opaque 4908 # and can only be validated at runtime. 4909 for child_model in model.partition_routers: 4910 child_partition_fields: List[str] = [] 4911 if isinstance(child_model, ListPartitionRouterModel): 4912 child_partition_fields.append( 4913 InterpolatedString.create( 4914 child_model.cursor_field, parameters=child_model.parameters or {} 4915 ).eval(config) 4916 ) 4917 elif isinstance(child_model, SubstreamPartitionRouterModel): 4918 for parent_stream_config in child_model.parent_stream_configs: 4919 child_partition_fields.append( 4920 InterpolatedString.create( 4921 parent_stream_config.partition_field, 4922 parameters=parent_stream_config.parameters 4923 or child_model.parameters 4924 or {}, 4925 ).eval(config) 4926 ) 4927 elif isinstance(child_model, UnionPartitionRouterModel): 4928 child_partition_fields.append( 4929 InterpolatedString.create( 4930 child_model.partition_field, parameters=child_model.parameters or {} 4931 ).eval(config) 4932 ) 4933 for child_partition_field in child_partition_fields: 4934 if child_partition_field != partition_field: 4935 raise ValueError( 4936 f"UnionPartitionRouter expects all child partition routers to emit the " 4937 f"partition field '{partition_field}', but a " 4938 f"{child_model.type} child emits '{child_partition_field}'." 4939 ) 4940 4941 # A union slice comes from exactly one child partition router, so request options 4942 # declared on children cannot be applied consistently to requests built from the 4943 # normalized union slices. Partition values should be consumed via interpolation 4944 # (e.g. stream_partition) instead. Note that this validation only covers built-in 4945 # router types; CustomPartitionRouter children are opaque, so any request options 4946 # they implement internally cannot be detected or rejected here. 4947 for router in partition_routers: 4948 if isinstance(router, SubstreamPartitionRouter): 4949 if any( 4950 parent_config.request_option for parent_config in router.parent_stream_configs 4951 ): 4952 raise ValueError("Request options are not supported for UnionPartitionRouter.") 4953 if isinstance(router, ListPartitionRouter) and router.request_option: 4954 raise ValueError("Request options are not supported for UnionPartitionRouter.") 4955 4956 return UnionPartitionRouter( 4957 partition_routers=partition_routers, 4958 partition_field=partition_field, 4959 parameters=model.parameters or {}, 4960 )