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 return RecordExpander( 2510 expand_records_from_field=model.expand_records_from_field, 2511 config=config, 2512 parameters=model.parameters or {}, 2513 remain_original_record=model.remain_original_record or False, 2514 on_no_records=OnNoRecords(model.on_no_records.value) 2515 if model.on_no_records 2516 else OnNoRecords.skip, 2517 ) 2518 2519 @staticmethod 2520 def create_response_to_file_extractor( 2521 model: ResponseToFileExtractorModel, 2522 **kwargs: Any, 2523 ) -> ResponseToFileExtractor: 2524 return ResponseToFileExtractor( 2525 parameters=model.parameters or {}, 2526 preserve_na_values=model.preserve_na_values or False, 2527 ) 2528 2529 @staticmethod 2530 def create_exponential_backoff_strategy( 2531 model: ExponentialBackoffStrategyModel, config: Config 2532 ) -> ExponentialBackoffStrategy: 2533 ModelToComponentFactory._validate_jitter_range(model.jitter_range_in_seconds) 2534 return ExponentialBackoffStrategy( 2535 factor=model.factor or 5, 2536 jitter_range_in_seconds=model.jitter_range_in_seconds, 2537 parameters=model.parameters or {}, 2538 config=config, 2539 ) 2540 2541 @staticmethod 2542 def create_group_by_key(model: GroupByKeyMergeStrategyModel, config: Config) -> GroupByKey: 2543 return GroupByKey(model.key, config=config, parameters=model.parameters or {}) 2544 2545 def create_http_requester( 2546 self, 2547 model: HttpRequesterModel, 2548 config: Config, 2549 decoder: Decoder = JsonDecoder(parameters={}), 2550 query_properties_key: Optional[str] = None, 2551 use_cache: Optional[bool] = None, 2552 *, 2553 name: str, 2554 ) -> HttpRequester: 2555 authenticator = ( 2556 self._create_component_from_model( 2557 model=model.authenticator, 2558 config=config, 2559 url_base=model.url or model.url_base, 2560 name=name, 2561 decoder=decoder, 2562 ) 2563 if model.authenticator 2564 else None 2565 ) 2566 error_handler = ( 2567 self._create_component_from_model(model=model.error_handler, config=config) 2568 if model.error_handler 2569 else DefaultErrorHandler( 2570 backoff_strategies=[], 2571 response_filters=[], 2572 config=config, 2573 parameters=model.parameters or {}, 2574 ) 2575 ) 2576 2577 api_budget = self._api_budget 2578 2579 request_options_provider = InterpolatedRequestOptionsProvider( 2580 request_body=model.request_body, 2581 request_body_data=model.request_body_data, 2582 request_body_json=model.request_body_json, 2583 request_headers=model.request_headers, 2584 request_parameters=model.request_parameters, # type: ignore # QueryProperties have been removed in `create_simple_retriever` 2585 query_properties_key=query_properties_key, 2586 config=config, 2587 parameters=model.parameters or {}, 2588 ) 2589 2590 assert model.use_cache is not None # for mypy 2591 assert model.http_method is not None # for mypy 2592 2593 should_use_cache = (model.use_cache or bool(use_cache)) and not self._disable_cache 2594 2595 return HttpRequester( 2596 name=name, 2597 url=model.url, 2598 url_base=model.url_base, 2599 path=model.path, 2600 authenticator=authenticator, 2601 error_handler=error_handler, 2602 api_budget=api_budget, 2603 http_method=HttpMethod[model.http_method.value], 2604 request_options_provider=request_options_provider, 2605 config=config, 2606 disable_retries=self._disable_retries, 2607 parameters=model.parameters or {}, 2608 message_repository=self._message_repository, 2609 use_cache=should_use_cache, 2610 decoder=decoder, 2611 stream_response=decoder.is_stream_response() if decoder else False, 2612 ) 2613 2614 @staticmethod 2615 def create_http_response_filter( 2616 model: HttpResponseFilterModel, config: Config, **kwargs: Any 2617 ) -> HttpResponseFilter: 2618 if model.action: 2619 action = ResponseAction(model.action.value) 2620 else: 2621 action = None 2622 2623 failure_type = FailureType(model.failure_type.value) if model.failure_type else None 2624 2625 http_codes = ( 2626 set(model.http_codes) if model.http_codes else set() 2627 ) # JSON schema notation has no set data type. The schema enforces an array of unique elements 2628 2629 return HttpResponseFilter( 2630 action=action, 2631 failure_type=failure_type, 2632 error_message=model.error_message or "", 2633 error_message_contains=model.error_message_contains or "", 2634 http_codes=http_codes, 2635 predicate=model.predicate or "", 2636 config=config, 2637 parameters=model.parameters or {}, 2638 ) 2639 2640 @staticmethod 2641 def create_inline_schema_loader( 2642 model: InlineSchemaLoaderModel, config: Config, **kwargs: Any 2643 ) -> InlineSchemaLoader: 2644 return InlineSchemaLoader(schema=model.schema_ or {}, parameters={}) 2645 2646 def create_complex_field_type( 2647 self, model: ComplexFieldTypeModel, config: Config, **kwargs: Any 2648 ) -> ComplexFieldType: 2649 items = ( 2650 self._create_component_from_model(model=model.items, config=config) 2651 if isinstance(model.items, ComplexFieldTypeModel) 2652 else model.items 2653 ) 2654 2655 return ComplexFieldType(field_type=model.field_type, items=items) 2656 2657 def create_types_map(self, model: TypesMapModel, config: Config, **kwargs: Any) -> TypesMap: 2658 target_type = ( 2659 self._create_component_from_model(model=model.target_type, config=config) 2660 if isinstance(model.target_type, ComplexFieldTypeModel) 2661 else model.target_type 2662 ) 2663 2664 return TypesMap( 2665 target_type=target_type, 2666 current_type=model.current_type, 2667 condition=model.condition if model.condition is not None else "True", 2668 ) 2669 2670 def create_schema_type_identifier( 2671 self, model: SchemaTypeIdentifierModel, config: Config, **kwargs: Any 2672 ) -> SchemaTypeIdentifier: 2673 types_mapping = [] 2674 if model.types_mapping: 2675 types_mapping.extend( 2676 [ 2677 self._create_component_from_model(types_map, config=config) 2678 for types_map in model.types_mapping 2679 ] 2680 ) 2681 model_schema_pointer: List[Union[InterpolatedString, str]] = ( 2682 [x for x in model.schema_pointer] if model.schema_pointer else [] 2683 ) 2684 model_key_pointer: List[Union[InterpolatedString, str]] = [x for x in model.key_pointer] 2685 model_type_pointer: Optional[List[Union[InterpolatedString, str]]] = ( 2686 [x for x in model.type_pointer] if model.type_pointer else None 2687 ) 2688 2689 return SchemaTypeIdentifier( 2690 schema_pointer=model_schema_pointer, 2691 key_pointer=model_key_pointer, 2692 type_pointer=model_type_pointer, 2693 types_mapping=types_mapping, 2694 parameters=model.parameters or {}, 2695 ) 2696 2697 def create_dynamic_schema_loader( 2698 self, model: DynamicSchemaLoaderModel, config: Config, **kwargs: Any 2699 ) -> DynamicSchemaLoader: 2700 schema_transformations = [] 2701 if model.schema_transformations: 2702 for transformation_model in model.schema_transformations: 2703 schema_transformations.append( 2704 self._create_component_from_model(model=transformation_model, config=config) 2705 ) 2706 name = "dynamic_properties" 2707 retriever = self._create_component_from_model( 2708 model=model.retriever, 2709 config=config, 2710 name=name, 2711 primary_key=None, 2712 partition_router=self._build_stream_slicer_from_partition_router( 2713 model.retriever, config 2714 ), 2715 transformations=[], 2716 use_cache=True, 2717 log_formatter=( 2718 lambda response: format_http_message( 2719 response, 2720 f"Schema loader '{name}' request", 2721 f"Request performed in order to extract schema.", 2722 name, 2723 is_auxiliary=True, 2724 ) 2725 ), 2726 ) 2727 schema_type_identifier = self._create_component_from_model( 2728 model.schema_type_identifier, config=config, parameters=model.parameters or {} 2729 ) 2730 schema_filter = ( 2731 self._create_component_from_model( 2732 model.schema_filter, config=config, parameters=model.parameters or {} 2733 ) 2734 if model.schema_filter is not None 2735 else None 2736 ) 2737 2738 return DynamicSchemaLoader( 2739 retriever=retriever, 2740 config=config, 2741 schema_transformations=schema_transformations, 2742 schema_filter=schema_filter, 2743 schema_type_identifier=schema_type_identifier, 2744 parameters=model.parameters or {}, 2745 ) 2746 2747 @staticmethod 2748 def create_json_decoder(model: JsonDecoderModel, config: Config, **kwargs: Any) -> Decoder: 2749 return JsonDecoder(parameters={}) 2750 2751 def create_csv_decoder(self, model: CsvDecoderModel, config: Config, **kwargs: Any) -> Decoder: 2752 return CompositeRawDecoder( 2753 parser=ModelToComponentFactory._get_parser(model, config), 2754 stream_response=False if self._emit_connector_builder_messages else True, 2755 ) 2756 2757 def create_jsonl_decoder( 2758 self, model: JsonlDecoderModel, config: Config, **kwargs: Any 2759 ) -> Decoder: 2760 return CompositeRawDecoder( 2761 parser=ModelToComponentFactory._get_parser(model, config), 2762 stream_response=False if self._emit_connector_builder_messages else True, 2763 ) 2764 2765 def create_json_items_decoder( 2766 self, model: JsonItemsDecoderModel, config: Config, **kwargs: Any 2767 ) -> Decoder: 2768 return CompositeRawDecoder( 2769 parser=ModelToComponentFactory._get_parser(model, config), 2770 stream_response=False if self._emit_connector_builder_messages else True, 2771 ) 2772 2773 def create_gzip_decoder( 2774 self, model: GzipDecoderModel, config: Config, **kwargs: Any 2775 ) -> Decoder: 2776 _compressed_response_types = { 2777 "gzip", 2778 "x-gzip", 2779 "gzip, deflate", 2780 "x-gzip, deflate", 2781 "application/zip", 2782 "application/gzip", 2783 "application/x-gzip", 2784 "application/x-zip-compressed", 2785 } 2786 2787 gzip_parser: GzipParser = ModelToComponentFactory._get_parser(model, config) # type: ignore # based on the model, we know this will be a GzipParser 2788 2789 if self._emit_connector_builder_messages: 2790 return CompositeRawDecoder(gzip_parser, False) 2791 2792 transport_gzip_parser = GzipParser(inner_parser=gzip_parser) 2793 return CompositeRawDecoder.by_headers( 2794 [ 2795 ({"Content-Encoding"}, {"gzip"}, transport_gzip_parser), 2796 ({"Content-Type"}, _compressed_response_types, gzip_parser), 2797 ], 2798 stream_response=True, 2799 fallback_parser=gzip_parser, 2800 ) 2801 2802 @staticmethod 2803 def create_iterable_decoder( 2804 model: IterableDecoderModel, config: Config, **kwargs: Any 2805 ) -> IterableDecoder: 2806 return IterableDecoder(parameters={}) 2807 2808 @staticmethod 2809 def create_xml_decoder(model: XmlDecoderModel, config: Config, **kwargs: Any) -> XmlDecoder: 2810 return XmlDecoder(parameters={}) 2811 2812 def create_zipfile_decoder( 2813 self, model: ZipfileDecoderModel, config: Config, **kwargs: Any 2814 ) -> ZipfileDecoder: 2815 return ZipfileDecoder(parser=ModelToComponentFactory._get_parser(model.decoder, config)) 2816 2817 @staticmethod 2818 def _get_parser(model: BaseModel, config: Config) -> Parser: 2819 if isinstance(model, JsonDecoderModel): 2820 # Note that the logic is a bit different from the JsonDecoder as there is some legacy that is maintained to return {} on error cases 2821 return JsonParser() 2822 elif isinstance(model, JsonItemsDecoderModel): 2823 return JsonItemsParser( 2824 items_path=model.items_path, 2825 encoding=model.encoding, 2826 ) 2827 elif isinstance(model, JsonlDecoderModel): 2828 return JsonLineParser() 2829 elif isinstance(model, CsvDecoderModel): 2830 return CsvParser( 2831 encoding=model.encoding, 2832 delimiter=model.delimiter, 2833 set_values_to_none=model.set_values_to_none, 2834 ) 2835 elif isinstance(model, GzipDecoderModel): 2836 return GzipParser( 2837 inner_parser=ModelToComponentFactory._get_parser(model.decoder, config) 2838 ) 2839 elif isinstance( 2840 model, (CustomDecoderModel, IterableDecoderModel, XmlDecoderModel, ZipfileDecoderModel) 2841 ): 2842 raise ValueError(f"Decoder type {model} does not have parser associated to it") 2843 2844 raise ValueError(f"Unknown decoder type {model}") 2845 2846 @staticmethod 2847 def create_json_file_schema_loader( 2848 model: JsonFileSchemaLoaderModel, config: Config, **kwargs: Any 2849 ) -> JsonFileSchemaLoader: 2850 return JsonFileSchemaLoader( 2851 file_path=model.file_path or "", config=config, parameters=model.parameters or {} 2852 ) 2853 2854 def create_jwt_authenticator( 2855 self, model: JwtAuthenticatorModel, config: Config, **kwargs: Any 2856 ) -> JwtAuthenticator: 2857 jwt_headers = model.jwt_headers or JwtHeadersModel(kid=None, typ="JWT", cty=None) 2858 jwt_payload = model.jwt_payload or JwtPayloadModel(iss=None, sub=None, aud=None) 2859 request_option = ( 2860 self._create_component_from_model(model.request_option, config) 2861 if model.request_option 2862 else None 2863 ) 2864 return JwtAuthenticator( 2865 config=config, 2866 parameters=model.parameters or {}, 2867 algorithm=JwtAlgorithm(model.algorithm.value), 2868 secret_key=model.secret_key, 2869 base64_encode_secret_key=model.base64_encode_secret_key, 2870 token_duration=model.token_duration, 2871 header_prefix=model.header_prefix, 2872 kid=jwt_headers.kid, 2873 typ=jwt_headers.typ, 2874 cty=jwt_headers.cty, 2875 iss=jwt_payload.iss, 2876 sub=jwt_payload.sub, 2877 aud=jwt_payload.aud, 2878 additional_jwt_headers=model.additional_jwt_headers, 2879 additional_jwt_payload=model.additional_jwt_payload, 2880 passphrase=model.passphrase, 2881 request_option=request_option, 2882 ) 2883 2884 def create_list_partition_router( 2885 self, model: ListPartitionRouterModel, config: Config, **kwargs: Any 2886 ) -> ListPartitionRouter: 2887 request_option = ( 2888 self._create_component_from_model(model.request_option, config) 2889 if model.request_option 2890 else None 2891 ) 2892 return ListPartitionRouter( 2893 cursor_field=model.cursor_field, 2894 request_option=request_option, 2895 values=model.values, 2896 config=config, 2897 parameters=model.parameters or {}, 2898 ) 2899 2900 @staticmethod 2901 def create_min_max_datetime( 2902 model: MinMaxDatetimeModel, config: Config, **kwargs: Any 2903 ) -> MinMaxDatetime: 2904 return MinMaxDatetime( 2905 datetime=model.datetime, 2906 datetime_format=model.datetime_format or "", 2907 max_datetime=model.max_datetime or "", 2908 min_datetime=model.min_datetime or "", 2909 parameters=model.parameters or {}, 2910 ) 2911 2912 @staticmethod 2913 def create_no_auth(model: NoAuthModel, config: Config, **kwargs: Any) -> NoAuth: 2914 return NoAuth(parameters=model.parameters or {}) 2915 2916 @staticmethod 2917 def create_no_pagination( 2918 model: NoPaginationModel, config: Config, **kwargs: Any 2919 ) -> NoPagination: 2920 return NoPagination(parameters={}) 2921 2922 def create_oauth_authenticator( 2923 self, model: OAuthAuthenticatorModel, config: Config, **kwargs: Any 2924 ) -> DeclarativeOauth2Authenticator: 2925 profile_assertion = ( 2926 self._create_component_from_model(model.profile_assertion, config=config) 2927 if model.profile_assertion 2928 else None 2929 ) 2930 2931 refresh_token_error_status_codes, refresh_token_error_key, refresh_token_error_values = ( 2932 self._get_refresh_token_error_information(model) 2933 ) 2934 if model.refresh_token_updater: 2935 # ignore type error because fixing it would have a lot of dependencies, revisit later 2936 return DeclarativeSingleUseRefreshTokenOauth2Authenticator( # type: ignore 2937 config, 2938 InterpolatedString.create( 2939 model.token_refresh_endpoint, # type: ignore 2940 parameters=model.parameters or {}, 2941 ).eval(config), 2942 access_token_name=InterpolatedString.create( 2943 model.access_token_name or "access_token", parameters=model.parameters or {} 2944 ).eval(config), 2945 refresh_token_name=model.refresh_token_updater.refresh_token_name, 2946 expires_in_name=InterpolatedString.create( 2947 model.expires_in_name or "expires_in", parameters=model.parameters or {} 2948 ).eval(config), 2949 client_id_name=InterpolatedString.create( 2950 model.client_id_name or "client_id", parameters=model.parameters or {} 2951 ).eval(config), 2952 client_id=InterpolatedString.create( 2953 model.client_id, parameters=model.parameters or {} 2954 ).eval(config) 2955 if model.client_id 2956 else model.client_id, 2957 client_secret_name=InterpolatedString.create( 2958 model.client_secret_name or "client_secret", parameters=model.parameters or {} 2959 ).eval(config), 2960 client_secret=InterpolatedString.create( 2961 model.client_secret, parameters=model.parameters or {} 2962 ).eval(config) 2963 if model.client_secret 2964 else model.client_secret, 2965 access_token_config_path=model.refresh_token_updater.access_token_config_path, 2966 refresh_token_config_path=model.refresh_token_updater.refresh_token_config_path, 2967 token_expiry_date_config_path=model.refresh_token_updater.token_expiry_date_config_path, 2968 grant_type_name=InterpolatedString.create( 2969 model.grant_type_name or "grant_type", parameters=model.parameters or {} 2970 ).eval(config), 2971 grant_type=InterpolatedString.create( 2972 model.grant_type or "refresh_token", parameters=model.parameters or {} 2973 ).eval(config), 2974 refresh_request_body=InterpolatedMapping( 2975 model.refresh_request_body or {}, parameters=model.parameters or {} 2976 ).eval(config), 2977 refresh_request_headers=InterpolatedMapping( 2978 model.refresh_request_headers or {}, parameters=model.parameters or {} 2979 ).eval(config), 2980 send_refresh_request_as_query_params=bool( 2981 model.send_refresh_request_as_query_params 2982 ), 2983 scopes=model.scopes, 2984 token_expiry_date_format=model.token_expiry_date_format, 2985 token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), 2986 message_repository=self._message_repository, 2987 refresh_token_error_status_codes=refresh_token_error_status_codes, 2988 refresh_token_error_key=refresh_token_error_key, 2989 refresh_token_error_values=refresh_token_error_values, 2990 ) 2991 # ignore type error because fixing it would have a lot of dependencies, revisit later 2992 return DeclarativeOauth2Authenticator( # type: ignore 2993 access_token_name=model.access_token_name or "access_token", 2994 access_token_value=model.access_token_value, 2995 client_id_name=model.client_id_name or "client_id", 2996 client_id=model.client_id, 2997 client_secret_name=model.client_secret_name or "client_secret", 2998 client_secret=model.client_secret, 2999 expires_in_name=model.expires_in_name or "expires_in", 3000 grant_type_name=model.grant_type_name or "grant_type", 3001 grant_type=model.grant_type or "refresh_token", 3002 refresh_request_body=model.refresh_request_body, 3003 refresh_request_headers=model.refresh_request_headers, 3004 send_refresh_request_as_query_params=bool(model.send_refresh_request_as_query_params), 3005 refresh_token_name=model.refresh_token_name or "refresh_token", 3006 refresh_token=model.refresh_token, 3007 scopes=model.scopes, 3008 token_expiry_date=model.token_expiry_date, 3009 token_expiry_date_format=model.token_expiry_date_format, 3010 token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), 3011 token_refresh_endpoint=model.token_refresh_endpoint, 3012 config=config, 3013 parameters=model.parameters or {}, 3014 message_repository=self._message_repository, 3015 profile_assertion=profile_assertion, 3016 use_profile_assertion=model.use_profile_assertion, 3017 refresh_token_error_status_codes=refresh_token_error_status_codes, 3018 refresh_token_error_key=refresh_token_error_key, 3019 refresh_token_error_values=refresh_token_error_values, 3020 ) 3021 3022 @staticmethod 3023 def _get_refresh_token_error_information( 3024 model: OAuthAuthenticatorModel, 3025 ) -> Tuple[Tuple[int, ...], str, Tuple[str, ...]]: 3026 """ 3027 In a previous version of the CDK, the auth error as config_error was only done if a refresh token updater was 3028 defined. As a transition, we added those fields on the OAuthAuthenticatorModel. This method ensures that the 3029 information is defined only once and return the right fields. 3030 """ 3031 refresh_token_updater = model.refresh_token_updater 3032 is_defined_on_refresh_token_updated = refresh_token_updater and ( 3033 refresh_token_updater.refresh_token_error_status_codes 3034 or refresh_token_updater.refresh_token_error_key 3035 or refresh_token_updater.refresh_token_error_values 3036 ) 3037 is_defined_on_oauth_authenticator = ( 3038 model.refresh_token_error_status_codes 3039 or model.refresh_token_error_key 3040 or model.refresh_token_error_values 3041 ) 3042 if is_defined_on_refresh_token_updated and is_defined_on_oauth_authenticator: 3043 raise ValueError( 3044 "refresh_token_error should either be defined on the OAuthAuthenticatorModel or the RefreshTokenUpdaterModel, not both" 3045 ) 3046 3047 if is_defined_on_refresh_token_updated: 3048 not_optional_refresh_token_updater: RefreshTokenUpdaterModel = refresh_token_updater # type: ignore # we know from the condition that this is not None 3049 return ( 3050 tuple(not_optional_refresh_token_updater.refresh_token_error_status_codes) 3051 if not_optional_refresh_token_updater.refresh_token_error_status_codes 3052 else (), 3053 not_optional_refresh_token_updater.refresh_token_error_key or "", 3054 tuple(not_optional_refresh_token_updater.refresh_token_error_values) 3055 if not_optional_refresh_token_updater.refresh_token_error_values 3056 else (), 3057 ) 3058 elif is_defined_on_oauth_authenticator: 3059 return ( 3060 tuple(model.refresh_token_error_status_codes) 3061 if model.refresh_token_error_status_codes 3062 else (), 3063 model.refresh_token_error_key or "", 3064 tuple(model.refresh_token_error_values) if model.refresh_token_error_values else (), 3065 ) 3066 3067 # returning default values we think cover most cases 3068 return (400,), "error", ("invalid_grant", "invalid_permissions") 3069 3070 def create_offset_increment( 3071 self, 3072 model: OffsetIncrementModel, 3073 config: Config, 3074 decoder: Decoder, 3075 extractor_model: Optional[Union[CustomRecordExtractorModel, DpathExtractorModel]] = None, 3076 **kwargs: Any, 3077 ) -> OffsetIncrement: 3078 if isinstance(decoder, PaginationDecoderDecorator): 3079 inner_decoder = decoder.decoder 3080 else: 3081 inner_decoder = decoder 3082 decoder = PaginationDecoderDecorator(decoder=decoder) 3083 3084 if self._is_supported_decoder_for_pagination(inner_decoder): 3085 decoder_to_use = decoder 3086 else: 3087 raise ValueError( 3088 self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(inner_decoder)) 3089 ) 3090 3091 # Ideally we would instantiate the runtime extractor from highest most level (in this case the SimpleRetriever) 3092 # so that it can be shared by OffSetIncrement and RecordSelector. However, due to how we instantiate the 3093 # decoder with various decorators here, but not in create_record_selector, it is simpler to retain existing 3094 # behavior by having two separate extractors with identical behavior since they use the same extractor model. 3095 # When we have more time to investigate we can look into reusing the same component. 3096 extractor = ( 3097 self._create_component_from_model( 3098 model=extractor_model, config=config, decoder=decoder_to_use 3099 ) 3100 if extractor_model 3101 else None 3102 ) 3103 3104 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 3105 # If page_size is a string that represents an integer (not an interpolation), convert it back. 3106 page_size = model.page_size 3107 if isinstance(page_size, str) and page_size.isdigit(): 3108 page_size = int(page_size) 3109 3110 return OffsetIncrement( 3111 page_size=page_size, 3112 config=config, 3113 decoder=decoder_to_use, 3114 extractor=extractor, 3115 inject_on_first_request=model.inject_on_first_request or False, 3116 parameters=model.parameters or {}, 3117 ) 3118 3119 def create_page_increment( 3120 self, 3121 model: PageIncrementModel, 3122 config: Config, 3123 decoder: Optional[Decoder] = None, 3124 extractor_model: Optional[Union[CustomRecordExtractorModel, DpathExtractorModel]] = None, 3125 **kwargs: Any, 3126 ) -> PageIncrement: 3127 # Like OffsetIncrement, we instantiate a separate extractor with identical behavior to the 3128 # RecordSelector's so the strategy can count the raw records in the response. This ensures 3129 # pagination is driven by the API's page size, not the post-filter record count. 3130 extractor = ( 3131 self._create_component_from_model(model=extractor_model, config=config, decoder=decoder) 3132 if extractor_model 3133 else None 3134 ) 3135 3136 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 3137 # If page_size is a string that represents an integer (not an interpolation), convert it back. 3138 page_size = model.page_size 3139 if isinstance(page_size, str) and page_size.isdigit(): 3140 page_size = int(page_size) 3141 3142 return PageIncrement( 3143 page_size=page_size, 3144 config=config, 3145 start_from_page=model.start_from_page or 0, 3146 inject_on_first_request=model.inject_on_first_request or False, 3147 extractor=extractor, 3148 parameters=model.parameters or {}, 3149 ) 3150 3151 def create_parent_stream_config( 3152 self, model: ParentStreamConfigModel, config: Config, *, stream_name: str, **kwargs: Any 3153 ) -> ParentStreamConfig: 3154 declarative_stream = self._create_component_from_model( 3155 model.stream, 3156 config=config, 3157 is_parent=True, 3158 **kwargs, 3159 ) 3160 request_option = ( 3161 self._create_component_from_model(model.request_option, config=config) 3162 if model.request_option 3163 else None 3164 ) 3165 3166 if model.lazy_read_pointer and any("*" in pointer for pointer in model.lazy_read_pointer): 3167 raise ValueError( 3168 "The '*' wildcard in 'lazy_read_pointer' is not supported — only direct paths are allowed." 3169 ) 3170 3171 model_lazy_read_pointer: List[Union[InterpolatedString, str]] = ( 3172 [x for x in model.lazy_read_pointer] if model.lazy_read_pointer else [] 3173 ) 3174 3175 return ParentStreamConfig( 3176 parent_key=model.parent_key, 3177 request_option=request_option, 3178 stream=declarative_stream, 3179 partition_field=model.partition_field, 3180 config=config, 3181 incremental_dependency=model.incremental_dependency or False, 3182 parameters=model.parameters or {}, 3183 extra_fields=model.extra_fields, 3184 lazy_read_pointer=model_lazy_read_pointer, 3185 ) 3186 3187 def create_properties_from_endpoint( 3188 self, model: PropertiesFromEndpointModel, config: Config, **kwargs: Any 3189 ) -> PropertiesFromEndpoint: 3190 retriever = self._create_component_from_model( 3191 model=model.retriever, 3192 config=config, 3193 name="dynamic_properties", 3194 primary_key=None, 3195 stream_slicer=None, 3196 transformations=[], 3197 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 3198 ) 3199 return PropertiesFromEndpoint( 3200 property_field_path=model.property_field_path, 3201 retriever=retriever, 3202 config=config, 3203 parameters=model.parameters or {}, 3204 ) 3205 3206 def create_property_chunking( 3207 self, model: PropertyChunkingModel, config: Config, **kwargs: Any 3208 ) -> PropertyChunking: 3209 record_merge_strategy = ( 3210 self._create_component_from_model( 3211 model=model.record_merge_strategy, config=config, **kwargs 3212 ) 3213 if model.record_merge_strategy 3214 else None 3215 ) 3216 3217 property_limit_type: PropertyLimitType 3218 match model.property_limit_type: 3219 case PropertyLimitTypeModel.property_count: 3220 property_limit_type = PropertyLimitType.property_count 3221 case PropertyLimitTypeModel.characters: 3222 property_limit_type = PropertyLimitType.characters 3223 case _: 3224 raise ValueError(f"Invalid PropertyLimitType {property_limit_type}") 3225 3226 return PropertyChunking( 3227 property_limit_type=property_limit_type, 3228 property_limit=model.property_limit, 3229 record_merge_strategy=record_merge_strategy, 3230 config=config, 3231 parameters=model.parameters or {}, 3232 ) 3233 3234 def create_query_properties( 3235 self, model: QueryPropertiesModel, config: Config, *, stream_name: str, **kwargs: Any 3236 ) -> QueryProperties: 3237 if isinstance(model.property_list, list): 3238 property_list = model.property_list 3239 else: 3240 property_list = self._create_component_from_model( 3241 model=model.property_list, config=config, **kwargs 3242 ) 3243 3244 property_chunking = ( 3245 self._create_component_from_model( 3246 model=model.property_chunking, config=config, **kwargs 3247 ) 3248 if model.property_chunking 3249 else None 3250 ) 3251 3252 property_selector = ( 3253 self._create_component_from_model( 3254 model=model.property_selector, config=config, stream_name=stream_name, **kwargs 3255 ) 3256 if model.property_selector 3257 else None 3258 ) 3259 3260 return QueryProperties( 3261 property_list=property_list, 3262 always_include_properties=model.always_include_properties, 3263 property_chunking=property_chunking, 3264 property_selector=property_selector, 3265 config=config, 3266 parameters=model.parameters or {}, 3267 ) 3268 3269 def create_json_schema_property_selector( 3270 self, 3271 model: JsonSchemaPropertySelectorModel, 3272 config: Config, 3273 *, 3274 stream_name: str, 3275 **kwargs: Any, 3276 ) -> JsonSchemaPropertySelector: 3277 configured_stream = self._stream_name_to_configured_stream.get(stream_name) 3278 3279 transformations = [] 3280 if model.transformations: 3281 for transformation_model in model.transformations: 3282 transformations.append( 3283 self._create_component_from_model(model=transformation_model, config=config) 3284 ) 3285 3286 return JsonSchemaPropertySelector( 3287 configured_stream=configured_stream, 3288 properties_transformations=transformations, 3289 config=config, 3290 parameters=model.parameters or {}, 3291 ) 3292 3293 @staticmethod 3294 def create_record_filter( 3295 model: RecordFilterModel, config: Config, **kwargs: Any 3296 ) -> RecordFilter: 3297 return RecordFilter( 3298 condition=model.condition or "", config=config, parameters=model.parameters or {} 3299 ) 3300 3301 @staticmethod 3302 def create_request_path(model: RequestPathModel, config: Config, **kwargs: Any) -> RequestPath: 3303 return RequestPath(parameters={}) 3304 3305 @staticmethod 3306 def create_request_option( 3307 model: RequestOptionModel, config: Config, **kwargs: Any 3308 ) -> RequestOption: 3309 inject_into = RequestOptionType(model.inject_into.value) 3310 field_path: Optional[List[Union[InterpolatedString, str]]] = ( 3311 [ 3312 InterpolatedString.create(segment, parameters=kwargs.get("parameters", {})) 3313 for segment in model.field_path 3314 ] 3315 if model.field_path 3316 else None 3317 ) 3318 field_name = ( 3319 InterpolatedString.create(model.field_name, parameters=kwargs.get("parameters", {})) 3320 if model.field_name 3321 else None 3322 ) 3323 return RequestOption( 3324 field_name=field_name, 3325 field_path=field_path, 3326 inject_into=inject_into, 3327 parameters=kwargs.get("parameters", {}), 3328 ) 3329 3330 def create_record_selector( 3331 self, 3332 model: RecordSelectorModel, 3333 config: Config, 3334 *, 3335 name: str, 3336 transformations: List[RecordTransformation] | None = None, 3337 decoder: Decoder | None = None, 3338 client_side_incremental_sync_cursor: Optional[Cursor] = None, 3339 is_client_side_incremental_sync: bool = False, 3340 file_uploader: Optional[DefaultFileUploader] = None, 3341 **kwargs: Any, 3342 ) -> RecordSelector: 3343 extractor = self._create_component_from_model( 3344 model=model.extractor, decoder=decoder, config=config 3345 ) 3346 record_filter = ( 3347 self._create_component_from_model(model.record_filter, config=config) 3348 if model.record_filter 3349 else None 3350 ) 3351 3352 # A client-side incremental stream transforms before filtering by default. That default belongs to the flag, 3353 # not to the component that ends up doing the cursor comparison: a data feed does it in the retriever and 3354 # receives no cursor here, but its `record_filter` condition must keep running after the transformations. 3355 default_transform_before_filtering = bool( 3356 client_side_incremental_sync_cursor or is_client_side_incremental_sync 3357 ) 3358 transform_before_filtering = ( 3359 default_transform_before_filtering 3360 if model.transform_before_filtering is None 3361 else model.transform_before_filtering 3362 ) 3363 if client_side_incremental_sync_cursor: 3364 record_filter = ClientSideIncrementalRecordFilterDecorator( 3365 config=config, 3366 parameters=model.parameters, 3367 condition=model.record_filter.condition 3368 if (model.record_filter and hasattr(model.record_filter, "condition")) 3369 else None, 3370 cursor=client_side_incremental_sync_cursor, 3371 ) 3372 3373 if model.schema_normalization is None: 3374 # default to no schema normalization if not set 3375 model.schema_normalization = SchemaNormalizationModel.None_ 3376 3377 schema_normalization = ( 3378 TypeTransformer(SCHEMA_TRANSFORMER_TYPE_MAPPING[model.schema_normalization]) 3379 if isinstance(model.schema_normalization, SchemaNormalizationModel) 3380 else self._create_component_from_model(model.schema_normalization, config=config) # type: ignore[arg-type] # custom normalization model expected here 3381 ) 3382 3383 return RecordSelector( 3384 extractor=extractor, 3385 name=name, 3386 config=config, 3387 record_filter=record_filter, 3388 transformations=transformations or [], 3389 file_uploader=file_uploader, 3390 schema_normalization=schema_normalization, 3391 parameters=model.parameters or {}, 3392 transform_before_filtering=transform_before_filtering, 3393 ) 3394 3395 @staticmethod 3396 def create_remove_fields( 3397 model: RemoveFieldsModel, config: Config, **kwargs: Any 3398 ) -> RemoveFields: 3399 return RemoveFields( 3400 field_pointers=model.field_pointers, condition=model.condition or "", parameters={} 3401 ) 3402 3403 def create_selective_authenticator( 3404 self, model: SelectiveAuthenticatorModel, config: Config, **kwargs: Any 3405 ) -> DeclarativeAuthenticator: 3406 authenticators = { 3407 name: self._create_component_from_model(model=auth, config=config) 3408 for name, auth in model.authenticators.items() 3409 } 3410 # SelectiveAuthenticator will return instance of DeclarativeAuthenticator or raise ValueError error 3411 return SelectiveAuthenticator( # type: ignore[abstract] 3412 config=config, 3413 authenticators=authenticators, 3414 authenticator_selection_path=model.authenticator_selection_path, 3415 **kwargs, 3416 ) 3417 3418 @staticmethod 3419 def create_legacy_session_token_authenticator( 3420 model: LegacySessionTokenAuthenticatorModel, config: Config, *, url_base: str, **kwargs: Any 3421 ) -> LegacySessionTokenAuthenticator: 3422 return LegacySessionTokenAuthenticator( 3423 api_url=url_base, 3424 header=model.header, 3425 login_url=model.login_url, 3426 password=model.password or "", 3427 session_token=model.session_token or "", 3428 session_token_response_key=model.session_token_response_key or "", 3429 username=model.username or "", 3430 validate_session_url=model.validate_session_url, 3431 config=config, 3432 parameters=model.parameters or {}, 3433 ) 3434 3435 def create_simple_retriever( 3436 self, 3437 model: SimpleRetrieverModel, 3438 config: Config, 3439 *, 3440 name: str, 3441 primary_key: Optional[Union[str, List[str], List[List[str]]]], 3442 request_options_provider: Optional[RequestOptionsProvider] = None, 3443 cursor: Optional[Cursor] = None, 3444 has_stop_condition_cursor: bool = False, 3445 is_client_side_incremental_sync: bool = False, 3446 transformations: List[RecordTransformation], 3447 file_uploader: Optional[DefaultFileUploader] = None, 3448 incremental_sync: Optional[ 3449 Union[IncrementingCountCursorModel, DatetimeBasedCursorModel] 3450 ] = None, 3451 use_cache: Optional[bool] = None, 3452 log_formatter: Optional[Callable[[Response], Any]] = None, 3453 partition_router: Optional[PartitionRouter] = None, 3454 **kwargs: Any, 3455 ) -> SimpleRetriever: 3456 def _get_url(req: Requester) -> str: 3457 """ 3458 Closure to get the URL from the requester. This is used to get the URL in the case of a lazy retriever. 3459 This is needed because the URL is not set until the requester is created. 3460 """ 3461 3462 _url: str = ( 3463 model.requester.url 3464 if hasattr(model.requester, "url") and model.requester.url is not None 3465 else req.get_url(stream_state=None, stream_slice=None, next_page_token=None) 3466 ) 3467 _url_base: str = ( 3468 model.requester.url_base 3469 if hasattr(model.requester, "url_base") and model.requester.url_base is not None 3470 else req.get_url_base(stream_state=None, stream_slice=None, next_page_token=None) 3471 ) 3472 3473 return _url or _url_base 3474 3475 if cursor is None: 3476 cursor = FinalStateCursor(name, None, self._message_repository) 3477 3478 # A data feed drops the records the cursor considers already synced in the retriever, which sits downstream of 3479 # the paginator. Letting the record selector drop them as well would be redundant and would hide them from the 3480 # pagination stop condition, so a data feed never delegates that filtering to the record selector, whether 3481 # `is_client_side_incremental` is set or not. The `condition` from `record_filter` is intentionally left out of 3482 # the post-pagination filter and stays in the record selector, which preserves the existing behaviour: the 3483 # selector runs inside the page loop, so the records the condition rejects never reach the paginator's 3484 # accounting. Moving it downstream would start counting them. 3485 post_pagination_filter = ( 3486 ClientSideIncrementalRecordFilterDecorator( 3487 config=config, 3488 parameters=model.parameters or {}, 3489 condition=None, 3490 cursor=cursor, 3491 ) 3492 if has_stop_condition_cursor 3493 else None 3494 ) 3495 client_side_incremental_cursor = ( 3496 cursor if is_client_side_incremental_sync and not post_pagination_filter else None 3497 ) 3498 if post_pagination_filter and is_client_side_incremental_sync: 3499 LOGGER.warning( 3500 f"Stream {name}: `is_client_side_incremental` adds no record filtering when `is_data_feed` is set, " 3501 "as a data feed already filters on the cursor value. It still makes the record selector apply the " 3502 "transformations before the `record_filter` condition." 3503 ) 3504 3505 decoder = ( 3506 self._create_component_from_model(model=model.decoder, config=config) 3507 if model.decoder 3508 else JsonDecoder(parameters={}) 3509 ) 3510 record_selector = self._create_component_from_model( 3511 model=model.record_selector, 3512 name=name, 3513 config=config, 3514 decoder=decoder, 3515 transformations=transformations, 3516 client_side_incremental_sync_cursor=client_side_incremental_cursor, 3517 is_client_side_incremental_sync=is_client_side_incremental_sync, 3518 file_uploader=file_uploader, 3519 ) 3520 3521 query_properties: Optional[QueryProperties] = None 3522 query_properties_key: Optional[str] = None 3523 self._ensure_query_properties_to_model(model.requester) 3524 if self._has_query_properties_in_request_parameters(model.requester): 3525 # It is better to be explicit about an error if PropertiesFromEndpoint is defined in multiple 3526 # places instead of default to request_parameters which isn't clearly documented 3527 if ( 3528 hasattr(model.requester, "fetch_properties_from_endpoint") 3529 and model.requester.fetch_properties_from_endpoint 3530 ): 3531 raise ValueError( 3532 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" 3533 ) 3534 3535 query_properties_definitions = [] 3536 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() 3537 if isinstance(request_parameter, QueryPropertiesModel): 3538 query_properties_key = key 3539 query_properties_definitions.append(request_parameter) 3540 3541 if len(query_properties_definitions) > 1: 3542 raise ValueError( 3543 f"request_parameters only supports defining one QueryProperties field, but found {len(query_properties_definitions)} usages" 3544 ) 3545 3546 if len(query_properties_definitions) == 1: 3547 query_properties = self._create_component_from_model( 3548 model=query_properties_definitions[0], stream_name=name, config=config 3549 ) 3550 3551 # Removes QueryProperties components from the interpolated mappings because it has been designed 3552 # to be used by the SimpleRetriever and will be resolved from the provider from the slice directly 3553 # instead of through jinja interpolation 3554 if hasattr(model.requester, "request_parameters") and isinstance( 3555 model.requester.request_parameters, Mapping 3556 ): 3557 model.requester.request_parameters = self._remove_query_properties( 3558 model.requester.request_parameters 3559 ) 3560 elif ( 3561 hasattr(model.requester, "fetch_properties_from_endpoint") 3562 and model.requester.fetch_properties_from_endpoint 3563 ): 3564 # todo: Deprecate this condition once dependent connectors migrate to query_properties 3565 query_properties_definition = QueryPropertiesModel( 3566 type="QueryProperties", 3567 property_list=model.requester.fetch_properties_from_endpoint, 3568 always_include_properties=None, 3569 property_chunking=None, 3570 ) # type: ignore # $parameters has a default value 3571 3572 query_properties = self.create_query_properties( 3573 model=query_properties_definition, 3574 stream_name=name, 3575 config=config, 3576 ) 3577 elif hasattr(model.requester, "query_properties") and model.requester.query_properties: 3578 query_properties = self.create_query_properties( 3579 model=model.requester.query_properties, 3580 stream_name=name, 3581 config=config, 3582 ) 3583 3584 requester = self._create_component_from_model( 3585 model=model.requester, 3586 decoder=decoder, 3587 name=name, 3588 query_properties_key=query_properties_key, 3589 use_cache=use_cache, 3590 config=config, 3591 ) 3592 3593 if not request_options_provider: 3594 request_options_provider = DefaultRequestOptionsProvider(parameters={}) 3595 if isinstance(request_options_provider, DefaultRequestOptionsProvider) and isinstance( 3596 partition_router, PartitionRouter 3597 ): 3598 request_options_provider = partition_router 3599 3600 paginator = ( 3601 self._create_component_from_model( 3602 model=model.paginator, 3603 config=config, 3604 url_base=_get_url(requester), 3605 extractor_model=model.record_selector.extractor, 3606 decoder=decoder, 3607 cursor_used_for_stop_condition=cursor if has_stop_condition_cursor else None, 3608 ) 3609 if model.paginator 3610 else NoPagination(parameters={}) 3611 ) 3612 3613 ignore_stream_slicer_parameters_on_paginated_requests = ( 3614 model.ignore_stream_slicer_parameters_on_paginated_requests or False 3615 ) 3616 3617 if ( 3618 model.partition_router 3619 and isinstance(model.partition_router, SubstreamPartitionRouterModel) 3620 and not bool(self._connector_state_manager.get_stream_state(name, None)) 3621 and any( 3622 parent_stream_config.lazy_read_pointer 3623 for parent_stream_config in model.partition_router.parent_stream_configs 3624 ) 3625 ): 3626 if incremental_sync: 3627 if incremental_sync.type != "DatetimeBasedCursor": 3628 raise ValueError( 3629 f"LazySimpleRetriever only supports DatetimeBasedCursor. Found: {incremental_sync.type}." 3630 ) 3631 3632 elif incremental_sync.step or incremental_sync.cursor_granularity: 3633 raise ValueError( 3634 f"Found more that one slice per parent. LazySimpleRetriever only supports single slice read for stream - {name}." 3635 ) 3636 3637 if model.decoder and model.decoder.type != "JsonDecoder": 3638 raise ValueError( 3639 f"LazySimpleRetriever only supports JsonDecoder. Found: {model.decoder.type}." 3640 ) 3641 3642 return LazySimpleRetriever( 3643 name=name, 3644 paginator=paginator, 3645 primary_key=primary_key, 3646 requester=requester, 3647 record_selector=record_selector, 3648 stream_slicer=_NO_STREAM_SLICING, 3649 request_option_provider=request_options_provider, 3650 config=config, 3651 ignore_stream_slicer_parameters_on_paginated_requests=ignore_stream_slicer_parameters_on_paginated_requests, 3652 post_pagination_filter=post_pagination_filter, 3653 parameters=model.parameters or {}, 3654 ) 3655 3656 if ( 3657 model.record_selector.record_filter 3658 and model.pagination_reset 3659 and model.pagination_reset.limits 3660 ): 3661 raise ValueError("PaginationResetLimits are not supported while having record filter.") 3662 3663 return SimpleRetriever( 3664 name=name, 3665 paginator=paginator, 3666 primary_key=primary_key, 3667 requester=requester, 3668 record_selector=record_selector, 3669 stream_slicer=_NO_STREAM_SLICING, 3670 request_option_provider=request_options_provider, 3671 config=config, 3672 ignore_stream_slicer_parameters_on_paginated_requests=ignore_stream_slicer_parameters_on_paginated_requests, 3673 additional_query_properties=query_properties, 3674 log_formatter=self._get_log_formatter(log_formatter, name), 3675 pagination_tracker_factory=self._create_pagination_tracker_factory( 3676 model.pagination_reset, cursor 3677 ), 3678 post_pagination_filter=post_pagination_filter, 3679 parameters=model.parameters or {}, 3680 ) 3681 3682 def _create_pagination_tracker_factory( 3683 self, model: Optional[PaginationResetModel], cursor: Cursor 3684 ) -> Callable[[], PaginationTracker]: 3685 if model is None: 3686 return lambda: PaginationTracker() 3687 3688 # Until we figure out a way to use any cursor for PaginationTracker, we will have to have this cursor selector logic 3689 cursor_factory: Callable[[], Optional[ConcurrentCursor]] = lambda: None 3690 if model.action == PaginationResetActionModel.RESET: 3691 # in that case, we will let cursor_factory to return None even if the stream has a cursor 3692 pass 3693 elif model.action == PaginationResetActionModel.SPLIT_USING_CURSOR: 3694 if isinstance(cursor, ConcurrentCursor): 3695 cursor_factory = lambda: cursor.copy_without_state() # type: ignore # the if condition validates that it is a ConcurrentCursor 3696 elif isinstance(cursor, ConcurrentPerPartitionCursor): 3697 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 3698 {}, datetime.timedelta(0) 3699 ) 3700 elif not isinstance(cursor, FinalStateCursor): 3701 LOGGER.warning( 3702 "Unknown cursor for PaginationTracker. Pagination resets might not work properly" 3703 ) 3704 else: 3705 raise ValueError(f"Unknown PaginationReset action: {model.action}") 3706 3707 limit = model.limits.number_of_records if model and model.limits else None 3708 return lambda: PaginationTracker(cursor_factory(), limit) 3709 3710 def _get_log_formatter( 3711 self, log_formatter: Callable[[Response], Any] | None, name: str 3712 ) -> Callable[[Response], Any] | None: 3713 if self._should_limit_slices_fetched(): 3714 return ( 3715 ( 3716 lambda response: format_http_message( 3717 response, 3718 f"Stream '{name}' request", 3719 f"Request performed in order to extract records for stream '{name}'", 3720 name, 3721 ) 3722 ) 3723 if not log_formatter 3724 else log_formatter 3725 ) 3726 return None 3727 3728 def _should_limit_slices_fetched(self) -> bool: 3729 """ 3730 Returns True if the number of slices fetched should be limited, False otherwise. 3731 This is used to limit the number of slices fetched during tests. 3732 """ 3733 return bool(self._limit_slices_fetched or self._emit_connector_builder_messages) 3734 3735 @staticmethod 3736 def _has_query_properties_in_request_parameters( 3737 requester: Union[HttpRequesterModel, CustomRequesterModel], 3738 ) -> bool: 3739 if not hasattr(requester, "request_parameters"): 3740 return False 3741 request_parameters = requester.request_parameters 3742 if request_parameters and isinstance(request_parameters, Mapping): 3743 for request_parameter in request_parameters.values(): 3744 if isinstance(request_parameter, QueryPropertiesModel): 3745 return True 3746 return False 3747 3748 @staticmethod 3749 def _remove_query_properties( 3750 request_parameters: Mapping[str, Union[str, QueryPropertiesModel]], 3751 ) -> Mapping[str, str]: 3752 return { 3753 parameter_field: request_parameter 3754 for parameter_field, request_parameter in request_parameters.items() 3755 if not isinstance(request_parameter, QueryPropertiesModel) 3756 } 3757 3758 def create_state_delegating_stream( 3759 self, 3760 model: StateDelegatingStreamModel, 3761 config: Config, 3762 **kwargs: Any, 3763 ) -> DefaultStream: 3764 if ( 3765 model.full_refresh_stream.name != model.name 3766 or model.name != model.incremental_stream.name 3767 ): 3768 raise ValueError( 3769 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}." 3770 ) 3771 3772 # Resolve api_retention_period with config context (supports Jinja2 interpolation) 3773 resolved_retention_period: Optional[str] = None 3774 if model.api_retention_period: 3775 interpolated_retention = InterpolatedString.create( 3776 model.api_retention_period, parameters=model.parameters or {} 3777 ) 3778 resolved_value = interpolated_retention.eval(config=config) 3779 if resolved_value: 3780 resolved_retention_period = str(resolved_value) 3781 3782 if resolved_retention_period: 3783 for stream_model in (model.full_refresh_stream, model.incremental_stream): 3784 if isinstance(stream_model.incremental_sync, IncrementingCountCursorModel): 3785 raise ValueError( 3786 f"Stream '{model.name}' uses IncrementingCountCursor which is not supported " 3787 f"with api_retention_period. IncrementingCountCursor does not use datetime-based " 3788 f"cursors, so cursor age validation cannot be performed." 3789 ) 3790 3791 stream_state = self._connector_state_manager.get_stream_state(model.name, None) 3792 3793 if not stream_state: 3794 return self._create_component_from_model( # type: ignore[no-any-return] 3795 model.full_refresh_stream, config=config, **kwargs 3796 ) 3797 3798 incremental_stream: DefaultStream = self._create_component_from_model( 3799 model.incremental_stream, config=config, **kwargs 3800 ) # type: ignore[assignment] 3801 3802 # Only run cursor age validation for streams that are in the configured 3803 # catalog (or when no catalog was provided, e.g. during discover / connector 3804 # builder). Streams not selected by the user but instantiated as parent-stream 3805 # dependencies must not go through this path because it emits state messages 3806 # that the destination does not know about, causing "Stream not found" crashes. 3807 stream_is_in_catalog = ( 3808 not self._stream_name_to_configured_stream # no catalog → validate by default 3809 or model.name in self._stream_name_to_configured_stream 3810 ) 3811 if resolved_retention_period and stream_is_in_catalog: 3812 full_refresh_stream: DefaultStream = self._create_component_from_model( 3813 model.full_refresh_stream, config=config, **kwargs 3814 ) # type: ignore[assignment] 3815 if self._is_cursor_older_than_retention_period( 3816 stream_state, 3817 full_refresh_stream.cursor, 3818 incremental_stream.cursor, 3819 resolved_retention_period, 3820 model.name, 3821 ): 3822 # Clear state BEFORE constructing the full_refresh_stream so that 3823 # its cursor starts from start_date instead of the stale cursor. 3824 self._connector_state_manager.update_state_for_stream(model.name, None, {}) 3825 state_message = self._connector_state_manager.create_state_message(model.name, None) 3826 self._message_repository.emit_message(state_message) 3827 return self._create_component_from_model( # type: ignore[no-any-return] 3828 model.full_refresh_stream, config=config, **kwargs 3829 ) 3830 3831 return incremental_stream 3832 3833 @staticmethod 3834 def _is_cursor_older_than_retention_period( 3835 stream_state: Mapping[str, Any], 3836 full_refresh_cursor: Cursor, 3837 incremental_cursor: Cursor, 3838 api_retention_period: str, 3839 stream_name: str, 3840 ) -> bool: 3841 """Check if the cursor value in the state is older than the API's retention period. 3842 3843 Checks cursors in sequence: full refresh cursor first, then incremental cursor. 3844 FinalStateCursor returns now() for completed full refresh state (NO_CURSOR_STATE_KEY), 3845 which is always within retention, so we use incremental. For other states, it returns 3846 None and we fall back to checking the incremental cursor. 3847 3848 Returns True if the cursor is older than the retention period (should use full refresh). 3849 Returns False if the cursor is within the retention period (safe to use incremental). 3850 """ 3851 retention_duration = parse_duration(api_retention_period) 3852 retention_cutoff = datetime.datetime.now(datetime.timezone.utc) - retention_duration 3853 3854 # Check full refresh cursor first 3855 cursor_datetime = full_refresh_cursor.get_cursor_datetime_from_state(stream_state) 3856 3857 # If full refresh cursor returns None, check incremental cursor 3858 if cursor_datetime is None: 3859 cursor_datetime = incremental_cursor.get_cursor_datetime_from_state(stream_state) 3860 3861 if cursor_datetime is None: 3862 # Neither cursor could parse the state - fall back to full refresh to be safe 3863 return True 3864 3865 if cursor_datetime < retention_cutoff: 3866 logging.warning( 3867 f"Stream '{stream_name}' has a cursor value older than " 3868 f"the API's retention period of {api_retention_period} " 3869 f"(cutoff: {retention_cutoff.isoformat()}). " 3870 f"Falling back to full refresh to avoid data loss." 3871 ) 3872 return True 3873 3874 return False 3875 3876 def _get_state_delegating_stream_model( 3877 self, 3878 model: StateDelegatingStreamModel, 3879 parent_state: Optional[Mapping[str, Any]] = None, 3880 ) -> DeclarativeStreamModel: 3881 """Return the appropriate underlying stream model based on state.""" 3882 return ( 3883 model.incremental_stream 3884 if self._connector_state_manager.get_stream_state(model.name, None) or parent_state 3885 else model.full_refresh_stream 3886 ) 3887 3888 _OPTIONAL_ASYNC_STATUS_FIELDS = {"skipped"} 3889 3890 def _create_async_job_status_mapping( 3891 self, model: AsyncJobStatusMapModel, config: Config, **kwargs: Any 3892 ) -> Mapping[str, AsyncJobStatus]: 3893 api_status_to_cdk_status = {} 3894 for cdk_status, api_statuses in model.dict().items(): 3895 if cdk_status == "type": 3896 # This is an element of the dict because of the typing of the CDK but it is not a CDK status 3897 continue 3898 3899 if api_statuses is None: 3900 if cdk_status in self._OPTIONAL_ASYNC_STATUS_FIELDS: 3901 continue 3902 raise ValueError( 3903 f"Required CDK status '{cdk_status}' has no API statuses mapped. " 3904 f"Please provide at least an empty list for required status fields." 3905 ) 3906 3907 for status in api_statuses: 3908 if status in api_status_to_cdk_status: 3909 raise ValueError( 3910 f"API status {status} is already set for CDK status {cdk_status}. Please ensure API statuses are only provided once" 3911 ) 3912 api_status_to_cdk_status[status] = self._get_async_job_status(cdk_status) 3913 return api_status_to_cdk_status 3914 3915 def _get_async_job_status(self, status: str) -> AsyncJobStatus: 3916 match status: 3917 case "running": 3918 return AsyncJobStatus.RUNNING 3919 case "completed": 3920 return AsyncJobStatus.COMPLETED 3921 case "failed": 3922 return AsyncJobStatus.FAILED 3923 case "timeout": 3924 return AsyncJobStatus.TIMED_OUT 3925 case "skipped": 3926 return AsyncJobStatus.SKIPPED 3927 case _: 3928 raise ValueError(f"Unsupported CDK status {status}") 3929 3930 def create_async_retriever( 3931 self, 3932 model: AsyncRetrieverModel, 3933 config: Config, 3934 *, 3935 name: str, 3936 primary_key: Optional[ 3937 Union[str, List[str], List[List[str]]] 3938 ], # this seems to be needed to match create_simple_retriever 3939 stream_slicer: Optional[StreamSlicer], 3940 client_side_incremental_sync: Optional[Dict[str, Any]] = None, 3941 transformations: List[RecordTransformation], 3942 **kwargs: Any, 3943 ) -> AsyncRetriever: 3944 if model.download_target_requester and not model.download_target_extractor: 3945 raise ValueError( 3946 f"`download_target_extractor` required if using a `download_target_requester`" 3947 ) 3948 3949 def _get_download_retriever( 3950 requester: Requester, extractor: RecordExtractor, _decoder: Decoder 3951 ) -> SimpleRetriever: 3952 # We create a record selector for the download retriever 3953 # with no schema normalization and no transformations, neither record filter 3954 # as all this occurs in the record_selector of the AsyncRetriever 3955 record_selector = RecordSelector( 3956 extractor=extractor, 3957 name=name, 3958 record_filter=None, 3959 transformations=[], 3960 schema_normalization=TypeTransformer(TransformConfig.NoTransform), 3961 config=config, 3962 parameters={}, 3963 ) 3964 paginator = ( 3965 self._create_component_from_model( 3966 model=model.download_paginator, 3967 decoder=_decoder, 3968 config=config, 3969 url_base="", 3970 ) 3971 if model.download_paginator 3972 else NoPagination(parameters={}) 3973 ) 3974 3975 return SimpleRetriever( 3976 requester=requester, 3977 record_selector=record_selector, 3978 primary_key=None, 3979 name=name, 3980 paginator=paginator, 3981 config=config, 3982 parameters={}, 3983 log_formatter=self._get_log_formatter(None, name), 3984 ) 3985 3986 def _get_job_timeout() -> datetime.timedelta: 3987 user_defined_timeout: Optional[int] = ( 3988 int( 3989 InterpolatedString.create( 3990 str(model.polling_job_timeout), 3991 parameters={}, 3992 ).eval(config) 3993 ) 3994 if model.polling_job_timeout 3995 else None 3996 ) 3997 3998 # check for user defined timeout during the test read or 15 minutes 3999 test_read_timeout = datetime.timedelta(minutes=user_defined_timeout or 15) 4000 # default value for non-connector builder is 60 minutes. 4001 default_sync_timeout = datetime.timedelta(minutes=user_defined_timeout or 60) 4002 4003 return ( 4004 test_read_timeout if self._emit_connector_builder_messages else default_sync_timeout 4005 ) 4006 4007 decoder = ( 4008 self._create_component_from_model(model=model.decoder, config=config) 4009 if model.decoder 4010 else JsonDecoder(parameters={}) 4011 ) 4012 record_selector = self._create_component_from_model( 4013 model=model.record_selector, 4014 config=config, 4015 decoder=decoder, 4016 name=name, 4017 transformations=transformations, 4018 client_side_incremental_sync=client_side_incremental_sync, 4019 ) 4020 4021 stream_slicer = stream_slicer or SinglePartitionRouter(parameters={}) 4022 if self._should_limit_slices_fetched(): 4023 stream_slicer = cast( 4024 StreamSlicer, 4025 StreamSlicerTestReadDecorator( 4026 wrapped_slicer=stream_slicer, 4027 maximum_number_of_slices=self._limit_slices_fetched or 5, 4028 ), 4029 ) 4030 4031 creation_requester = self._create_component_from_model( 4032 model=model.creation_requester, 4033 decoder=decoder, 4034 config=config, 4035 name=f"job creation - {name}", 4036 ) 4037 polling_requester = self._create_component_from_model( 4038 model=model.polling_requester, 4039 decoder=decoder, 4040 config=config, 4041 name=f"job polling - {name}", 4042 ) 4043 job_download_components_name = f"job download - {name}" 4044 download_decoder = ( 4045 self._create_component_from_model(model=model.download_decoder, config=config) 4046 if model.download_decoder 4047 else JsonDecoder(parameters={}) 4048 ) 4049 download_extractor = ( 4050 self._create_component_from_model( 4051 model=model.download_extractor, 4052 config=config, 4053 decoder=download_decoder, 4054 parameters=model.parameters, 4055 ) 4056 if model.download_extractor 4057 else DpathExtractor( 4058 [], 4059 config=config, 4060 decoder=download_decoder, 4061 parameters=model.parameters or {}, 4062 ) 4063 ) 4064 download_requester = self._create_component_from_model( 4065 model=model.download_requester, 4066 decoder=download_decoder, 4067 config=config, 4068 name=job_download_components_name, 4069 ) 4070 download_retriever = _get_download_retriever( 4071 download_requester, download_extractor, download_decoder 4072 ) 4073 abort_requester = ( 4074 self._create_component_from_model( 4075 model=model.abort_requester, 4076 decoder=decoder, 4077 config=config, 4078 name=f"job abort - {name}", 4079 ) 4080 if model.abort_requester 4081 else None 4082 ) 4083 delete_requester = ( 4084 self._create_component_from_model( 4085 model=model.delete_requester, 4086 decoder=decoder, 4087 config=config, 4088 name=f"job delete - {name}", 4089 ) 4090 if model.delete_requester 4091 else None 4092 ) 4093 download_target_requester = ( 4094 self._create_component_from_model( 4095 model=model.download_target_requester, 4096 decoder=decoder, 4097 config=config, 4098 name=f"job extract_url - {name}", 4099 ) 4100 if model.download_target_requester 4101 else None 4102 ) 4103 status_extractor = self._create_component_from_model( 4104 model=model.status_extractor, decoder=decoder, config=config, name=name 4105 ) 4106 download_target_extractor = ( 4107 self._create_component_from_model( 4108 model=model.download_target_extractor, 4109 decoder=decoder, 4110 config=config, 4111 name=name, 4112 ) 4113 if model.download_target_extractor 4114 else None 4115 ) 4116 4117 job_repository: AsyncJobRepository = AsyncHttpJobRepository( 4118 creation_requester=creation_requester, 4119 polling_requester=polling_requester, 4120 download_retriever=download_retriever, 4121 download_target_requester=download_target_requester, 4122 abort_requester=abort_requester, 4123 delete_requester=delete_requester, 4124 status_extractor=status_extractor, 4125 status_mapping=self._create_async_job_status_mapping(model.status_mapping, config), 4126 download_target_extractor=download_target_extractor, 4127 job_timeout=_get_job_timeout(), 4128 ) 4129 4130 failed_retry_wait_time_in_seconds: Optional[int] = ( 4131 int( 4132 InterpolatedString.create( 4133 str(model.failed_retry_wait_time_in_seconds), 4134 parameters={}, 4135 ).eval(config) 4136 ) 4137 if model.failed_retry_wait_time_in_seconds 4138 else None 4139 ) 4140 4141 async_job_partition_router = AsyncJobPartitionRouter( 4142 job_orchestrator_factory=lambda stream_slices: AsyncJobOrchestrator( 4143 job_repository, 4144 stream_slices, 4145 self._job_tracker, 4146 self._message_repository, 4147 # FIXME work would need to be done here in order to detect if a stream as a parent stream that is bulk 4148 has_bulk_parent=False, 4149 # set the `job_max_retry` to 1 for the `Connector Builder`` use-case. 4150 # `None` == default retry is set to 3 attempts, under the hood. 4151 job_max_retry=1 if self._emit_connector_builder_messages else None, 4152 failed_retry_wait_time_in_seconds=failed_retry_wait_time_in_seconds, 4153 ), 4154 stream_slicer=stream_slicer, 4155 config=config, 4156 parameters=model.parameters or {}, 4157 ) 4158 4159 return AsyncRetriever( 4160 record_selector=record_selector, 4161 stream_slicer=async_job_partition_router, 4162 config=config, 4163 parameters=model.parameters or {}, 4164 ) 4165 4166 def create_spec(self, model: SpecModel, config: Config, **kwargs: Any) -> Spec: 4167 config_migrations = [ 4168 self._create_component_from_model(migration, config) 4169 for migration in ( 4170 model.config_normalization_rules.config_migrations 4171 if ( 4172 model.config_normalization_rules 4173 and model.config_normalization_rules.config_migrations 4174 ) 4175 else [] 4176 ) 4177 ] 4178 config_transformations = [ 4179 self._create_component_from_model(transformation, config) 4180 for transformation in ( 4181 model.config_normalization_rules.transformations 4182 if ( 4183 model.config_normalization_rules 4184 and model.config_normalization_rules.transformations 4185 ) 4186 else [] 4187 ) 4188 ] 4189 config_validations = [ 4190 self._create_component_from_model(validation, config) 4191 for validation in ( 4192 model.config_normalization_rules.validations 4193 if ( 4194 model.config_normalization_rules 4195 and model.config_normalization_rules.validations 4196 ) 4197 else [] 4198 ) 4199 ] 4200 4201 return Spec( 4202 connection_specification=model.connection_specification, 4203 documentation_url=model.documentation_url, 4204 advanced_auth=model.advanced_auth, 4205 parameters={}, 4206 config_migrations=config_migrations, 4207 config_transformations=config_transformations, 4208 config_validations=config_validations, 4209 ) 4210 4211 def create_substream_partition_router( 4212 self, 4213 model: SubstreamPartitionRouterModel, 4214 config: Config, 4215 *, 4216 stream_name: str, 4217 **kwargs: Any, 4218 ) -> SubstreamPartitionRouter: 4219 parent_stream_configs = [] 4220 if model.parent_stream_configs: 4221 parent_stream_configs.extend( 4222 [ 4223 self.create_parent_stream_config_with_substream_wrapper( 4224 model=parent_stream_config, config=config, stream_name=stream_name, **kwargs 4225 ) 4226 for parent_stream_config in model.parent_stream_configs 4227 ] 4228 ) 4229 4230 return SubstreamPartitionRouter( 4231 parent_stream_configs=parent_stream_configs, 4232 parameters=model.parameters or {}, 4233 config=config, 4234 ) 4235 4236 def create_parent_stream_config_with_substream_wrapper( 4237 self, model: ParentStreamConfigModel, config: Config, *, stream_name: str, **kwargs: Any 4238 ) -> Any: 4239 child_state = self._connector_state_manager.get_stream_state(stream_name, None) 4240 if NO_CURSOR_STATE_KEY in child_state: 4241 # Full refresh streams checkpoint a `{NO_CURSOR_STATE_KEY: true}` sentinel. When such a 4242 # stream is later converted to incremental with an incremental_dependency parent, 4243 # `_instantiate_parent_stream_state_manager` would treat the sentinel's boolean as a legacy 4244 # cursor value and re-key it under the parent's cursor field, crashing cursor initialization. 4245 child_state = { 4246 key: value for key, value in child_state.items() if key != NO_CURSOR_STATE_KEY 4247 } 4248 4249 parent_state: Optional[Mapping[str, Any]] = ( 4250 child_state if model.incremental_dependency and child_state else None 4251 ) 4252 connector_state_manager = self._instantiate_parent_stream_state_manager( 4253 child_state, config, model, parent_state 4254 ) 4255 4256 substream_factory = ModelToComponentFactory( 4257 custom_components_trusted=self._custom_components_trusted, 4258 connector_state_manager=connector_state_manager, 4259 limit_pages_fetched_per_slice=self._limit_pages_fetched_per_slice, 4260 limit_slices_fetched=self._limit_slices_fetched, 4261 emit_connector_builder_messages=self._emit_connector_builder_messages, 4262 disable_retries=self._disable_retries, 4263 disable_cache=self._disable_cache, 4264 message_repository=StateFilteringMessageRepository( 4265 LogAppenderMessageRepositoryDecorator( 4266 { 4267 "airbyte_cdk": {"stream": {"is_substream": True}}, 4268 "http": {"is_auxiliary": True}, 4269 }, 4270 self._message_repository, 4271 self._evaluate_log_level(self._emit_connector_builder_messages), 4272 ), 4273 ), 4274 api_budget=self._api_budget, 4275 # Share the authenticator registry so parent and child streams draw from the 4276 # same token quota counters 4277 rate_limited_authenticators=self._rate_limited_authenticators, 4278 ) 4279 4280 return substream_factory.create_parent_stream_config( 4281 model=model, config=config, stream_name=stream_name, **kwargs 4282 ) 4283 4284 def _instantiate_parent_stream_state_manager( 4285 self, 4286 child_state: MutableMapping[str, Any], 4287 config: Config, 4288 model: ParentStreamConfigModel, 4289 parent_state: Optional[Mapping[str, Any]] = None, 4290 ) -> ConnectorStateManager: 4291 """ 4292 With DefaultStream, the state needs to be provided during __init__ of the cursor as opposed to the 4293 `set_initial_state` flow that existed for the declarative cursors. This state is taken from 4294 self._connector_state_manager.get_stream_state (`self` being a newly created ModelToComponentFactory to account 4295 for the MessageRepository being different). So we need to pass a ConnectorStateManager to the 4296 ModelToComponentFactory that has the parent states. This method populates this if there is a child state and if 4297 incremental_dependency is set. 4298 """ 4299 if model.incremental_dependency and child_state: 4300 parent_stream_name = model.stream.name or "" 4301 extracted_parent_state = ConcurrentPerPartitionCursor.get_parent_state( 4302 child_state, parent_stream_name 4303 ) 4304 4305 if not extracted_parent_state: 4306 extracted_parent_state = ConcurrentPerPartitionCursor.get_global_state( 4307 child_state, parent_stream_name 4308 ) 4309 4310 if not extracted_parent_state and not isinstance(extracted_parent_state, dict): 4311 cursor_values = child_state.values() 4312 if cursor_values and len(cursor_values) == 1: 4313 incremental_sync_model: Union[ 4314 DatetimeBasedCursorModel, 4315 IncrementingCountCursorModel, 4316 ] = ( 4317 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 4318 if isinstance(model.stream, DeclarativeStreamModel) 4319 else self._get_state_delegating_stream_model( 4320 model.stream, parent_state=parent_state 4321 ).incremental_sync 4322 ) 4323 cursor_field = InterpolatedString.create( 4324 incremental_sync_model.cursor_field, 4325 parameters=incremental_sync_model.parameters or {}, 4326 ).eval(config) 4327 extracted_parent_state = AirbyteStateMessage( 4328 type=AirbyteStateType.STREAM, 4329 stream=AirbyteStreamState( 4330 stream_descriptor=StreamDescriptor( 4331 name=parent_stream_name, namespace=None 4332 ), 4333 stream_state=AirbyteStateBlob( 4334 {cursor_field: list(cursor_values)[0]} 4335 ), 4336 ), 4337 ) 4338 return ConnectorStateManager([extracted_parent_state] if extracted_parent_state else []) 4339 4340 return ConnectorStateManager([]) 4341 4342 @staticmethod 4343 def create_wait_time_from_header( 4344 model: WaitTimeFromHeaderModel, config: Config, **kwargs: Any 4345 ) -> WaitTimeFromHeaderBackoffStrategy: 4346 return WaitTimeFromHeaderBackoffStrategy( 4347 header=model.header, 4348 parameters=model.parameters or {}, 4349 config=config, 4350 regex=model.regex, 4351 max_waiting_time_in_seconds=model.max_waiting_time_in_seconds, 4352 ) 4353 4354 @staticmethod 4355 def create_wait_until_time_from_header( 4356 model: WaitUntilTimeFromHeaderModel, config: Config, **kwargs: Any 4357 ) -> WaitUntilTimeFromHeaderBackoffStrategy: 4358 return WaitUntilTimeFromHeaderBackoffStrategy( 4359 header=model.header, 4360 parameters=model.parameters or {}, 4361 config=config, 4362 min_wait=model.min_wait, 4363 regex=model.regex, 4364 max_waiting_time_in_seconds=model.max_waiting_time_in_seconds, 4365 ) 4366 4367 def get_message_repository(self) -> MessageRepository: 4368 return self._message_repository 4369 4370 def _evaluate_log_level(self, emit_connector_builder_messages: bool) -> Level: 4371 return Level.DEBUG if emit_connector_builder_messages else Level.INFO 4372 4373 @staticmethod 4374 def create_components_mapping_definition( 4375 model: ComponentMappingDefinitionModel, config: Config, **kwargs: Any 4376 ) -> ComponentMappingDefinition: 4377 interpolated_value = InterpolatedString.create( 4378 model.value, parameters=model.parameters or {} 4379 ) 4380 field_path = [ 4381 InterpolatedString.create(path, parameters=model.parameters or {}) 4382 for path in model.field_path 4383 ] 4384 return ComponentMappingDefinition( 4385 field_path=field_path, # type: ignore[arg-type] # field_path can be str and InterpolatedString 4386 value=interpolated_value, 4387 value_type=ModelToComponentFactory._json_schema_type_name_to_type(model.value_type), 4388 create_or_update=model.create_or_update, 4389 condition=model.condition, 4390 parameters=model.parameters or {}, 4391 ) 4392 4393 def create_http_components_resolver( 4394 self, model: HttpComponentsResolverModel, config: Config, stream_name: Optional[str] = None 4395 ) -> Any: 4396 retriever = self._create_component_from_model( 4397 model=model.retriever, 4398 config=config, 4399 name=f"{stream_name if stream_name else '__http_components_resolver'}", 4400 primary_key=None, 4401 stream_slicer=self._build_stream_slicer_from_partition_router(model.retriever, config), 4402 transformations=[], 4403 ) 4404 4405 components_mapping = [] 4406 for component_mapping_definition_model in model.components_mapping: 4407 if component_mapping_definition_model.condition: 4408 raise ValueError("`condition` is only supported for `ConfigComponentsResolver`") 4409 components_mapping.append( 4410 self._create_component_from_model( 4411 model=component_mapping_definition_model, 4412 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 4413 component_mapping_definition_model.value_type 4414 ), 4415 config=config, 4416 ) 4417 ) 4418 4419 return HttpComponentsResolver( 4420 retriever=retriever, 4421 stream_slicer=self._build_stream_slicer_from_partition_router(model.retriever, config), 4422 config=config, 4423 components_mapping=components_mapping, 4424 parameters=model.parameters or {}, 4425 ) 4426 4427 @staticmethod 4428 def create_stream_config( 4429 model: StreamConfigModel, config: Config, **kwargs: Any 4430 ) -> StreamConfig: 4431 model_configs_pointer: List[Union[InterpolatedString, str]] = ( 4432 [x for x in model.configs_pointer] if model.configs_pointer else [] 4433 ) 4434 4435 return StreamConfig( 4436 configs_pointer=model_configs_pointer, 4437 default_values=model.default_values, 4438 parameters=model.parameters or {}, 4439 ) 4440 4441 def create_config_components_resolver( 4442 self, 4443 model: ConfigComponentsResolverModel, 4444 config: Config, 4445 ) -> Any: 4446 model_stream_configs = ( 4447 model.stream_config if isinstance(model.stream_config, list) else [model.stream_config] 4448 ) 4449 4450 stream_configs = [ 4451 self._create_component_from_model( 4452 stream_config, config=config, parameters=model.parameters or {} 4453 ) 4454 for stream_config in model_stream_configs 4455 ] 4456 4457 components_mapping = [ 4458 self._create_component_from_model( 4459 model=components_mapping_definition_model, 4460 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 4461 components_mapping_definition_model.value_type 4462 ), 4463 config=config, 4464 parameters=model.parameters, 4465 ) 4466 for components_mapping_definition_model in model.components_mapping 4467 ] 4468 4469 return ConfigComponentsResolver( 4470 stream_configs=stream_configs, 4471 config=config, 4472 components_mapping=components_mapping, 4473 parameters=model.parameters or {}, 4474 ) 4475 4476 def create_parametrized_components_resolver( 4477 self, 4478 model: ParametrizedComponentsResolverModel, 4479 config: Config, 4480 ) -> ParametrizedComponentsResolver: 4481 stream_parameters = StreamParametersDefinition( 4482 list_of_parameters_for_stream=model.stream_parameters.list_of_parameters_for_stream 4483 ) 4484 4485 components_mapping = [] 4486 for components_mapping_definition_model in model.components_mapping: 4487 if components_mapping_definition_model.condition: 4488 raise ValueError("`condition` is only supported for `ConfigComponentsResolver`") 4489 components_mapping.append( 4490 self._create_component_from_model( 4491 model=components_mapping_definition_model, 4492 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 4493 components_mapping_definition_model.value_type 4494 ), 4495 config=config, 4496 ) 4497 ) 4498 return ParametrizedComponentsResolver( 4499 stream_parameters=stream_parameters, 4500 config=config, 4501 components_mapping=components_mapping, 4502 parameters=model.parameters or {}, 4503 ) 4504 4505 _UNSUPPORTED_DECODER_ERROR = ( 4506 "Specified decoder of {decoder_type} is not supported for pagination." 4507 "Please set as `JsonDecoder`, `XmlDecoder`, or a `CompositeRawDecoder` with an inner_parser of `JsonParser` or `GzipParser` instead." 4508 "If using `GzipParser`, please ensure that the lowest level inner_parser is a `JsonParser`." 4509 ) 4510 4511 def _is_supported_decoder_for_pagination(self, decoder: Decoder) -> bool: 4512 if isinstance(decoder, (JsonDecoder, XmlDecoder)): 4513 return True 4514 elif isinstance(decoder, CompositeRawDecoder): 4515 return self._is_supported_parser_for_pagination(decoder.parser) 4516 else: 4517 return False 4518 4519 def _is_supported_parser_for_pagination(self, parser: Parser) -> bool: 4520 if isinstance(parser, JsonParser): 4521 return True 4522 elif isinstance(parser, GzipParser): 4523 return isinstance(parser.inner_parser, JsonParser) 4524 else: 4525 return False 4526 4527 def create_http_api_budget( 4528 self, model: HTTPAPIBudgetModel, config: Config, **kwargs: Any 4529 ) -> HttpAPIBudget: 4530 policies = [ 4531 self._create_component_from_model(model=policy, config=config) 4532 for policy in model.policies 4533 ] 4534 4535 return HttpAPIBudget( 4536 policies=policies, 4537 ratelimit_reset_header=model.ratelimit_reset_header or "ratelimit-reset", 4538 ratelimit_remaining_header=model.ratelimit_remaining_header or "ratelimit-remaining", 4539 status_codes_for_ratelimit_hit=model.status_codes_for_ratelimit_hit or [429], 4540 ) 4541 4542 def create_fixed_window_call_rate_policy( 4543 self, model: FixedWindowCallRatePolicyModel, config: Config, **kwargs: Any 4544 ) -> FixedWindowCallRatePolicy: 4545 matchers = [ 4546 self._create_component_from_model(model=matcher, config=config) 4547 for matcher in model.matchers 4548 ] 4549 4550 # Set the initial reset timestamp to 10 days from now. 4551 # This value will be updated by the first request. 4552 return FixedWindowCallRatePolicy( 4553 next_reset_ts=datetime.datetime.now() + datetime.timedelta(days=10), 4554 period=parse_duration(model.period), 4555 call_limit=model.call_limit, 4556 matchers=matchers, 4557 ) 4558 4559 def create_file_uploader( 4560 self, model: FileUploaderModel, config: Config, **kwargs: Any 4561 ) -> FileUploader: 4562 name = "File Uploader" 4563 requester = self._create_component_from_model( 4564 model=model.requester, 4565 config=config, 4566 name=name, 4567 **kwargs, 4568 ) 4569 download_target_extractor = self._create_component_from_model( 4570 model=model.download_target_extractor, 4571 config=config, 4572 name=name, 4573 **kwargs, 4574 ) 4575 emit_connector_builder_messages = self._emit_connector_builder_messages 4576 file_uploader = DefaultFileUploader( 4577 requester=requester, 4578 download_target_extractor=download_target_extractor, 4579 config=config, 4580 file_writer=NoopFileWriter() 4581 if emit_connector_builder_messages 4582 else LocalFileSystemFileWriter(), 4583 parameters=model.parameters or {}, 4584 filename_extractor=model.filename_extractor if model.filename_extractor else None, 4585 ) 4586 4587 return ( 4588 ConnectorBuilderFileUploader(file_uploader) 4589 if emit_connector_builder_messages 4590 else file_uploader 4591 ) 4592 4593 def create_moving_window_call_rate_policy( 4594 self, model: MovingWindowCallRatePolicyModel, config: Config, **kwargs: Any 4595 ) -> MovingWindowCallRatePolicy: 4596 rates = [ 4597 self._create_component_from_model(model=rate, config=config) for rate in model.rates 4598 ] 4599 matchers = [ 4600 self._create_component_from_model(model=matcher, config=config) 4601 for matcher in model.matchers 4602 ] 4603 return MovingWindowCallRatePolicy( 4604 rates=rates, 4605 matchers=matchers, 4606 ) 4607 4608 def create_unlimited_call_rate_policy( 4609 self, model: UnlimitedCallRatePolicyModel, config: Config, **kwargs: Any 4610 ) -> UnlimitedCallRatePolicy: 4611 matchers = [ 4612 self._create_component_from_model(model=matcher, config=config) 4613 for matcher in model.matchers 4614 ] 4615 4616 return UnlimitedCallRatePolicy( 4617 matchers=matchers, 4618 ) 4619 4620 def create_rate(self, model: RateModel, config: Config, **kwargs: Any) -> Rate: 4621 interpolated_limit = InterpolatedString.create(str(model.limit), parameters={}) 4622 return Rate( 4623 limit=int(interpolated_limit.eval(config=config)), 4624 interval=parse_duration(model.interval), 4625 ) 4626 4627 def create_http_request_matcher( 4628 self, model: HttpRequestRegexMatcherModel, config: Config, **kwargs: Any 4629 ) -> HttpRequestRegexMatcher: 4630 weight = model.weight 4631 if weight is not None: 4632 if isinstance(weight, str): 4633 weight = int(InterpolatedString.create(weight, parameters={}).eval(config)) 4634 else: 4635 weight = int(weight) 4636 if weight < 1: 4637 raise ValueError(f"weight must be >= 1, got {weight}") 4638 return HttpRequestRegexMatcher( 4639 method=model.method, 4640 url_base=model.url_base, 4641 url_path_pattern=model.url_path_pattern, 4642 params=model.params, 4643 headers=model.headers, 4644 weight=weight, 4645 ) 4646 4647 def create_rate_limited_multiple_token_authenticator( 4648 self, 4649 model: RateLimitedMultipleTokenAuthenticatorModel, 4650 config: Config, 4651 **kwargs: Any, 4652 ) -> RateLimitedMultipleTokenAuthenticator: 4653 if isinstance(model.tokens, str): 4654 tokens_value = InterpolatedString.create(model.tokens, parameters={}).eval(config) 4655 delimiter = model.token_delimiter or "," 4656 tokens = [ 4657 token.strip() for token in str(tokens_value).split(delimiter) if token.strip() 4658 ] 4659 else: 4660 tokens = [ 4661 token_value 4662 for token in model.tokens 4663 if ( 4664 token_value := str( 4665 InterpolatedString.create(token, parameters={}).eval(config) 4666 ).strip() 4667 ) 4668 ] 4669 4670 quota_specs = [ 4671 { 4672 "name": quota_model.name, 4673 "remaining_path": quota_model.remaining_path, 4674 "reset_path": quota_model.reset_path, 4675 "limit_path": quota_model.limit_path, 4676 "remaining_header": quota_model.remaining_header, 4677 "reset_header": quota_model.reset_header, 4678 "limit_header": quota_model.limit_header, 4679 # Normalize the same way as the runtime TokenQuota below, so an omitted field 4680 # and an explicit `[]` key identically and keep sharing one set of counters. 4681 "exhaustion_status_codes": quota_model.exhaustion_status_codes or [], 4682 "matchers": [ 4683 { 4684 "method": matcher_model.method, 4685 "url_base": matcher_model.url_base, 4686 "url_path_pattern": matcher_model.url_path_pattern, 4687 "params": matcher_model.params, 4688 "headers": matcher_model.headers, 4689 "weight": matcher_model.weight, 4690 } 4691 for matcher_model in quota_model.matchers or [] 4692 ], 4693 } 4694 for quota_model in model.quotas 4695 ] 4696 4697 quota_status_url = str( 4698 InterpolatedString.create(model.quota_status_source.url, parameters={}).eval(config) 4699 ) 4700 quota_status_http_method = ( 4701 model.quota_status_source.http_method.value 4702 if model.quota_status_source.http_method 4703 else "GET" 4704 ) 4705 quota_status_headers = { 4706 key: str(InterpolatedString.create(value, parameters={}).eval(config)) 4707 for key, value in (model.quota_status_source.request_headers or {}).items() 4708 } 4709 # Normalize the same way as the quota specs above, so an omitted field and an explicit 4710 # `[]` key identically and keep sharing one set of counters. Deduplicated as well as 4711 # sorted, because the runtime turns this into a set: without it `[404]` and `[404, 404]` 4712 # would key differently and stop sharing counters while behaving identically. 4713 quota_status_unavailable_status_codes = sorted( 4714 set(model.quota_status_source.unavailable_status_codes or []) 4715 ) 4716 auth_method = model.auth_method or "Bearer" 4717 header = model.header or "Authorization" 4718 max_wait_time_str = str( 4719 InterpolatedString.create(model.max_wait_time or "PT2H", parameters={}).eval(config) 4720 ) 4721 max_wait_time = parse_duration(max_wait_time_str) 4722 if not isinstance(max_wait_time, datetime.timedelta): 4723 raise ValueError( 4724 f"max_wait_time must be a fixed-length ISO 8601 duration (e.g. 'PT2H'); " 4725 f"calendar-unit durations like '{max_wait_time_str}' are not supported" 4726 ) 4727 budget_reserve_fraction = ( 4728 model.budget_reserve_fraction if model.budget_reserve_fraction is not None else 0.1 4729 ) 4730 budget_min_reserve = ( 4731 model.budget_min_reserve if model.budget_min_reserve is not None else 50 4732 ) 4733 4734 # Reuse the same instance for identical definitions so that all streams share the 4735 # same token quota counters (similar to how api_budget is shared). The key is built 4736 # from the resolved constructor arguments rather than the raw model so that 4737 # stream-specific `$parameters` propagated onto the model (and its nested components) 4738 # cannot break instance sharing. 4739 cache_key = json.dumps( 4740 { 4741 "tokens": tokens, 4742 "quotas": quota_specs, 4743 "quota_status_url": quota_status_url, 4744 "quota_status_http_method": quota_status_http_method, 4745 "quota_status_headers": quota_status_headers, 4746 "quota_status_unavailable_status_codes": quota_status_unavailable_status_codes, 4747 "auth_method": auth_method, 4748 "header": header, 4749 "max_wait_time": max_wait_time.total_seconds(), 4750 "budget_reserve_fraction": budget_reserve_fraction, 4751 "budget_min_reserve": budget_min_reserve, 4752 }, 4753 sort_keys=True, 4754 ) 4755 if cache_key in self._rate_limited_authenticators: 4756 return self._rate_limited_authenticators[cache_key] 4757 4758 quotas = [ 4759 TokenQuota( 4760 name=quota_model.name, 4761 remaining_path=quota_model.remaining_path, 4762 reset_path=quota_model.reset_path, 4763 limit_path=quota_model.limit_path, 4764 remaining_header=quota_model.remaining_header, 4765 reset_header=quota_model.reset_header, 4766 limit_header=quota_model.limit_header, 4767 exhaustion_status_codes=quota_model.exhaustion_status_codes or [], 4768 matchers=[ 4769 self.create_http_request_matcher(matcher_model, config) 4770 for matcher_model in quota_model.matchers or [] 4771 ], 4772 ) 4773 for quota_model in model.quotas 4774 ] 4775 4776 authenticator = RateLimitedMultipleTokenAuthenticator( 4777 tokens=tokens, 4778 quotas=quotas, 4779 quota_status_url=quota_status_url, 4780 quota_status_http_method=quota_status_http_method, 4781 quota_status_headers=quota_status_headers, 4782 quota_status_unavailable_status_codes=quota_status_unavailable_status_codes, 4783 auth_method=auth_method, 4784 header=header, 4785 max_wait_time=max_wait_time, 4786 budget_reserve_fraction=budget_reserve_fraction, 4787 budget_min_reserve=budget_min_reserve, 4788 ) 4789 self._rate_limited_authenticators[cache_key] = authenticator 4790 return authenticator 4791 4792 def set_api_budget(self, component_definition: ComponentDefinition, config: Config) -> None: 4793 self._api_budget = self.create_component( 4794 model_type=HTTPAPIBudgetModel, component_definition=component_definition, config=config 4795 ) 4796 4797 def create_grouping_partition_router( 4798 self, 4799 model: GroupingPartitionRouterModel, 4800 config: Config, 4801 *, 4802 stream_name: str, 4803 **kwargs: Any, 4804 ) -> GroupingPartitionRouter: 4805 underlying_router = self._create_component_from_model( 4806 model=model.underlying_partition_router, 4807 config=config, 4808 stream_name=stream_name, 4809 **kwargs, 4810 ) 4811 if model.group_size < 1: 4812 raise ValueError(f"Group size must be greater than 0, got {model.group_size}") 4813 4814 # Request options in underlying partition routers are not supported for GroupingPartitionRouter 4815 # because they are specific to individual partitions and cannot be aggregated or handled 4816 # when grouping, potentially leading to incorrect API calls. Any request customization 4817 # should be managed at the stream level through the requester's configuration. 4818 if isinstance(underlying_router, SubstreamPartitionRouter): 4819 if any( 4820 parent_config.request_option 4821 for parent_config in underlying_router.parent_stream_configs 4822 ): 4823 raise ValueError("Request options are not supported for GroupingPartitionRouter.") 4824 4825 if isinstance(underlying_router, ListPartitionRouter): 4826 if underlying_router.request_option: 4827 raise ValueError("Request options are not supported for GroupingPartitionRouter.") 4828 4829 return GroupingPartitionRouter( 4830 group_size=model.group_size, 4831 underlying_partition_router=underlying_router, 4832 deduplicate=model.deduplicate if model.deduplicate is not None else True, 4833 config=config, 4834 ) 4835 4836 def create_union_partition_router( 4837 self, 4838 model: UnionPartitionRouterModel, 4839 config: Config, 4840 *, 4841 stream_name: str, 4842 **kwargs: Any, 4843 ) -> UnionPartitionRouter: 4844 # The schema enforces minItems: 2 for manifests; this guard covers construction paths 4845 # that bypass JSON-schema validation (the generated model carries no min_items constraint). 4846 if len(model.partition_routers) < 2: 4847 raise ValueError( 4848 f"UnionPartitionRouter for stream {stream_name} needs at least 2 child partition routers" 4849 ) 4850 4851 partition_routers = [ 4852 self._create_component_from_model( 4853 model=child, 4854 config=config, 4855 stream_name=stream_name, 4856 **kwargs, 4857 ) 4858 for child in model.partition_routers 4859 ] 4860 4861 # partition_field depends only on config/parameters, so it is evaluated once at build 4862 # time; the runtime component always receives a plain string. 4863 partition_field = InterpolatedString.create( 4864 model.partition_field, parameters=model.parameters or {} 4865 ).eval(config) 4866 4867 # Fail fast at build time when a built-in child router is statically known to emit a 4868 # partition field different from the union's. CustomPartitionRouter children are opaque 4869 # and can only be validated at runtime. 4870 for child_model in model.partition_routers: 4871 child_partition_fields: List[str] = [] 4872 if isinstance(child_model, ListPartitionRouterModel): 4873 child_partition_fields.append( 4874 InterpolatedString.create( 4875 child_model.cursor_field, parameters=child_model.parameters or {} 4876 ).eval(config) 4877 ) 4878 elif isinstance(child_model, SubstreamPartitionRouterModel): 4879 for parent_stream_config in child_model.parent_stream_configs: 4880 child_partition_fields.append( 4881 InterpolatedString.create( 4882 parent_stream_config.partition_field, 4883 parameters=parent_stream_config.parameters 4884 or child_model.parameters 4885 or {}, 4886 ).eval(config) 4887 ) 4888 elif isinstance(child_model, UnionPartitionRouterModel): 4889 child_partition_fields.append( 4890 InterpolatedString.create( 4891 child_model.partition_field, parameters=child_model.parameters or {} 4892 ).eval(config) 4893 ) 4894 for child_partition_field in child_partition_fields: 4895 if child_partition_field != partition_field: 4896 raise ValueError( 4897 f"UnionPartitionRouter expects all child partition routers to emit the " 4898 f"partition field '{partition_field}', but a " 4899 f"{child_model.type} child emits '{child_partition_field}'." 4900 ) 4901 4902 # A union slice comes from exactly one child partition router, so request options 4903 # declared on children cannot be applied consistently to requests built from the 4904 # normalized union slices. Partition values should be consumed via interpolation 4905 # (e.g. stream_partition) instead. Note that this validation only covers built-in 4906 # router types; CustomPartitionRouter children are opaque, so any request options 4907 # they implement internally cannot be detected or rejected here. 4908 for router in partition_routers: 4909 if isinstance(router, SubstreamPartitionRouter): 4910 if any( 4911 parent_config.request_option for parent_config in router.parent_stream_configs 4912 ): 4913 raise ValueError("Request options are not supported for UnionPartitionRouter.") 4914 if isinstance(router, ListPartitionRouter) and router.request_option: 4915 raise ValueError("Request options are not supported for UnionPartitionRouter.") 4916 4917 return UnionPartitionRouter( 4918 partition_routers=partition_routers, 4919 partition_field=partition_field, 4920 parameters=model.parameters or {}, 4921 ) 4922 4923 def _ensure_query_properties_to_model( 4924 self, requester: Union[HttpRequesterModel, CustomRequesterModel] 4925 ) -> None: 4926 """ 4927 For some reason, it seems like CustomRequesterModel request_parameters stays as dictionaries which means that 4928 the other conditions relying on it being QueryPropertiesModel instead of a dict fail. Here, we migrate them to 4929 proper model. 4930 """ 4931 if not hasattr(requester, "request_parameters"): 4932 return 4933 4934 request_parameters = requester.request_parameters 4935 if request_parameters and isinstance(request_parameters, Dict): 4936 for request_parameter_key in request_parameters.keys(): 4937 request_parameter = request_parameters[request_parameter_key] 4938 if ( 4939 isinstance(request_parameter, Dict) 4940 and request_parameter.get("type") == "QueryProperties" 4941 ): 4942 request_parameters[request_parameter_key] = QueryPropertiesModel.parse_obj( 4943 request_parameter 4944 ) 4945 4946 def _get_catalog_defined_cursor_field( 4947 self, stream_name: str, allow_catalog_defined_cursor_field: bool 4948 ) -> Optional[CursorField]: 4949 if not allow_catalog_defined_cursor_field: 4950 return None 4951 4952 configured_stream = self._stream_name_to_configured_stream.get(stream_name) 4953 4954 # Depending on the operation is being performed, there may not be a configured stream yet. In this 4955 # case we return None which will then use the default cursor field defined on the cursor model. 4956 # We also treat cursor_field: [""] (list with empty string) as no cursor field, since this can 4957 # occur when the platform serializes "no cursor configured" streams incorrectly. 4958 if ( 4959 not configured_stream 4960 or not configured_stream.cursor_field 4961 or not configured_stream.cursor_field[0] 4962 ): 4963 return None 4964 elif len(configured_stream.cursor_field) > 1: 4965 raise ValueError( 4966 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." 4967 ) 4968 else: 4969 return CursorField( 4970 cursor_field_key=configured_stream.cursor_field[0], 4971 supports_catalog_defined_cursor_field=allow_catalog_defined_cursor_field, 4972 )
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 return RecordExpander( 2511 expand_records_from_field=model.expand_records_from_field, 2512 config=config, 2513 parameters=model.parameters or {}, 2514 remain_original_record=model.remain_original_record or False, 2515 on_no_records=OnNoRecords(model.on_no_records.value) 2516 if model.on_no_records 2517 else OnNoRecords.skip, 2518 ) 2519 2520 @staticmethod 2521 def create_response_to_file_extractor( 2522 model: ResponseToFileExtractorModel, 2523 **kwargs: Any, 2524 ) -> ResponseToFileExtractor: 2525 return ResponseToFileExtractor( 2526 parameters=model.parameters or {}, 2527 preserve_na_values=model.preserve_na_values or False, 2528 ) 2529 2530 @staticmethod 2531 def create_exponential_backoff_strategy( 2532 model: ExponentialBackoffStrategyModel, config: Config 2533 ) -> ExponentialBackoffStrategy: 2534 ModelToComponentFactory._validate_jitter_range(model.jitter_range_in_seconds) 2535 return ExponentialBackoffStrategy( 2536 factor=model.factor or 5, 2537 jitter_range_in_seconds=model.jitter_range_in_seconds, 2538 parameters=model.parameters or {}, 2539 config=config, 2540 ) 2541 2542 @staticmethod 2543 def create_group_by_key(model: GroupByKeyMergeStrategyModel, config: Config) -> GroupByKey: 2544 return GroupByKey(model.key, config=config, parameters=model.parameters or {}) 2545 2546 def create_http_requester( 2547 self, 2548 model: HttpRequesterModel, 2549 config: Config, 2550 decoder: Decoder = JsonDecoder(parameters={}), 2551 query_properties_key: Optional[str] = None, 2552 use_cache: Optional[bool] = None, 2553 *, 2554 name: str, 2555 ) -> HttpRequester: 2556 authenticator = ( 2557 self._create_component_from_model( 2558 model=model.authenticator, 2559 config=config, 2560 url_base=model.url or model.url_base, 2561 name=name, 2562 decoder=decoder, 2563 ) 2564 if model.authenticator 2565 else None 2566 ) 2567 error_handler = ( 2568 self._create_component_from_model(model=model.error_handler, config=config) 2569 if model.error_handler 2570 else DefaultErrorHandler( 2571 backoff_strategies=[], 2572 response_filters=[], 2573 config=config, 2574 parameters=model.parameters or {}, 2575 ) 2576 ) 2577 2578 api_budget = self._api_budget 2579 2580 request_options_provider = InterpolatedRequestOptionsProvider( 2581 request_body=model.request_body, 2582 request_body_data=model.request_body_data, 2583 request_body_json=model.request_body_json, 2584 request_headers=model.request_headers, 2585 request_parameters=model.request_parameters, # type: ignore # QueryProperties have been removed in `create_simple_retriever` 2586 query_properties_key=query_properties_key, 2587 config=config, 2588 parameters=model.parameters or {}, 2589 ) 2590 2591 assert model.use_cache is not None # for mypy 2592 assert model.http_method is not None # for mypy 2593 2594 should_use_cache = (model.use_cache or bool(use_cache)) and not self._disable_cache 2595 2596 return HttpRequester( 2597 name=name, 2598 url=model.url, 2599 url_base=model.url_base, 2600 path=model.path, 2601 authenticator=authenticator, 2602 error_handler=error_handler, 2603 api_budget=api_budget, 2604 http_method=HttpMethod[model.http_method.value], 2605 request_options_provider=request_options_provider, 2606 config=config, 2607 disable_retries=self._disable_retries, 2608 parameters=model.parameters or {}, 2609 message_repository=self._message_repository, 2610 use_cache=should_use_cache, 2611 decoder=decoder, 2612 stream_response=decoder.is_stream_response() if decoder else False, 2613 ) 2614 2615 @staticmethod 2616 def create_http_response_filter( 2617 model: HttpResponseFilterModel, config: Config, **kwargs: Any 2618 ) -> HttpResponseFilter: 2619 if model.action: 2620 action = ResponseAction(model.action.value) 2621 else: 2622 action = None 2623 2624 failure_type = FailureType(model.failure_type.value) if model.failure_type else None 2625 2626 http_codes = ( 2627 set(model.http_codes) if model.http_codes else set() 2628 ) # JSON schema notation has no set data type. The schema enforces an array of unique elements 2629 2630 return HttpResponseFilter( 2631 action=action, 2632 failure_type=failure_type, 2633 error_message=model.error_message or "", 2634 error_message_contains=model.error_message_contains or "", 2635 http_codes=http_codes, 2636 predicate=model.predicate or "", 2637 config=config, 2638 parameters=model.parameters or {}, 2639 ) 2640 2641 @staticmethod 2642 def create_inline_schema_loader( 2643 model: InlineSchemaLoaderModel, config: Config, **kwargs: Any 2644 ) -> InlineSchemaLoader: 2645 return InlineSchemaLoader(schema=model.schema_ or {}, parameters={}) 2646 2647 def create_complex_field_type( 2648 self, model: ComplexFieldTypeModel, config: Config, **kwargs: Any 2649 ) -> ComplexFieldType: 2650 items = ( 2651 self._create_component_from_model(model=model.items, config=config) 2652 if isinstance(model.items, ComplexFieldTypeModel) 2653 else model.items 2654 ) 2655 2656 return ComplexFieldType(field_type=model.field_type, items=items) 2657 2658 def create_types_map(self, model: TypesMapModel, config: Config, **kwargs: Any) -> TypesMap: 2659 target_type = ( 2660 self._create_component_from_model(model=model.target_type, config=config) 2661 if isinstance(model.target_type, ComplexFieldTypeModel) 2662 else model.target_type 2663 ) 2664 2665 return TypesMap( 2666 target_type=target_type, 2667 current_type=model.current_type, 2668 condition=model.condition if model.condition is not None else "True", 2669 ) 2670 2671 def create_schema_type_identifier( 2672 self, model: SchemaTypeIdentifierModel, config: Config, **kwargs: Any 2673 ) -> SchemaTypeIdentifier: 2674 types_mapping = [] 2675 if model.types_mapping: 2676 types_mapping.extend( 2677 [ 2678 self._create_component_from_model(types_map, config=config) 2679 for types_map in model.types_mapping 2680 ] 2681 ) 2682 model_schema_pointer: List[Union[InterpolatedString, str]] = ( 2683 [x for x in model.schema_pointer] if model.schema_pointer else [] 2684 ) 2685 model_key_pointer: List[Union[InterpolatedString, str]] = [x for x in model.key_pointer] 2686 model_type_pointer: Optional[List[Union[InterpolatedString, str]]] = ( 2687 [x for x in model.type_pointer] if model.type_pointer else None 2688 ) 2689 2690 return SchemaTypeIdentifier( 2691 schema_pointer=model_schema_pointer, 2692 key_pointer=model_key_pointer, 2693 type_pointer=model_type_pointer, 2694 types_mapping=types_mapping, 2695 parameters=model.parameters or {}, 2696 ) 2697 2698 def create_dynamic_schema_loader( 2699 self, model: DynamicSchemaLoaderModel, config: Config, **kwargs: Any 2700 ) -> DynamicSchemaLoader: 2701 schema_transformations = [] 2702 if model.schema_transformations: 2703 for transformation_model in model.schema_transformations: 2704 schema_transformations.append( 2705 self._create_component_from_model(model=transformation_model, config=config) 2706 ) 2707 name = "dynamic_properties" 2708 retriever = self._create_component_from_model( 2709 model=model.retriever, 2710 config=config, 2711 name=name, 2712 primary_key=None, 2713 partition_router=self._build_stream_slicer_from_partition_router( 2714 model.retriever, config 2715 ), 2716 transformations=[], 2717 use_cache=True, 2718 log_formatter=( 2719 lambda response: format_http_message( 2720 response, 2721 f"Schema loader '{name}' request", 2722 f"Request performed in order to extract schema.", 2723 name, 2724 is_auxiliary=True, 2725 ) 2726 ), 2727 ) 2728 schema_type_identifier = self._create_component_from_model( 2729 model.schema_type_identifier, config=config, parameters=model.parameters or {} 2730 ) 2731 schema_filter = ( 2732 self._create_component_from_model( 2733 model.schema_filter, config=config, parameters=model.parameters or {} 2734 ) 2735 if model.schema_filter is not None 2736 else None 2737 ) 2738 2739 return DynamicSchemaLoader( 2740 retriever=retriever, 2741 config=config, 2742 schema_transformations=schema_transformations, 2743 schema_filter=schema_filter, 2744 schema_type_identifier=schema_type_identifier, 2745 parameters=model.parameters or {}, 2746 ) 2747 2748 @staticmethod 2749 def create_json_decoder(model: JsonDecoderModel, config: Config, **kwargs: Any) -> Decoder: 2750 return JsonDecoder(parameters={}) 2751 2752 def create_csv_decoder(self, model: CsvDecoderModel, config: Config, **kwargs: Any) -> Decoder: 2753 return CompositeRawDecoder( 2754 parser=ModelToComponentFactory._get_parser(model, config), 2755 stream_response=False if self._emit_connector_builder_messages else True, 2756 ) 2757 2758 def create_jsonl_decoder( 2759 self, model: JsonlDecoderModel, config: Config, **kwargs: Any 2760 ) -> Decoder: 2761 return CompositeRawDecoder( 2762 parser=ModelToComponentFactory._get_parser(model, config), 2763 stream_response=False if self._emit_connector_builder_messages else True, 2764 ) 2765 2766 def create_json_items_decoder( 2767 self, model: JsonItemsDecoderModel, config: Config, **kwargs: Any 2768 ) -> Decoder: 2769 return CompositeRawDecoder( 2770 parser=ModelToComponentFactory._get_parser(model, config), 2771 stream_response=False if self._emit_connector_builder_messages else True, 2772 ) 2773 2774 def create_gzip_decoder( 2775 self, model: GzipDecoderModel, config: Config, **kwargs: Any 2776 ) -> Decoder: 2777 _compressed_response_types = { 2778 "gzip", 2779 "x-gzip", 2780 "gzip, deflate", 2781 "x-gzip, deflate", 2782 "application/zip", 2783 "application/gzip", 2784 "application/x-gzip", 2785 "application/x-zip-compressed", 2786 } 2787 2788 gzip_parser: GzipParser = ModelToComponentFactory._get_parser(model, config) # type: ignore # based on the model, we know this will be a GzipParser 2789 2790 if self._emit_connector_builder_messages: 2791 return CompositeRawDecoder(gzip_parser, False) 2792 2793 transport_gzip_parser = GzipParser(inner_parser=gzip_parser) 2794 return CompositeRawDecoder.by_headers( 2795 [ 2796 ({"Content-Encoding"}, {"gzip"}, transport_gzip_parser), 2797 ({"Content-Type"}, _compressed_response_types, gzip_parser), 2798 ], 2799 stream_response=True, 2800 fallback_parser=gzip_parser, 2801 ) 2802 2803 @staticmethod 2804 def create_iterable_decoder( 2805 model: IterableDecoderModel, config: Config, **kwargs: Any 2806 ) -> IterableDecoder: 2807 return IterableDecoder(parameters={}) 2808 2809 @staticmethod 2810 def create_xml_decoder(model: XmlDecoderModel, config: Config, **kwargs: Any) -> XmlDecoder: 2811 return XmlDecoder(parameters={}) 2812 2813 def create_zipfile_decoder( 2814 self, model: ZipfileDecoderModel, config: Config, **kwargs: Any 2815 ) -> ZipfileDecoder: 2816 return ZipfileDecoder(parser=ModelToComponentFactory._get_parser(model.decoder, config)) 2817 2818 @staticmethod 2819 def _get_parser(model: BaseModel, config: Config) -> Parser: 2820 if isinstance(model, JsonDecoderModel): 2821 # Note that the logic is a bit different from the JsonDecoder as there is some legacy that is maintained to return {} on error cases 2822 return JsonParser() 2823 elif isinstance(model, JsonItemsDecoderModel): 2824 return JsonItemsParser( 2825 items_path=model.items_path, 2826 encoding=model.encoding, 2827 ) 2828 elif isinstance(model, JsonlDecoderModel): 2829 return JsonLineParser() 2830 elif isinstance(model, CsvDecoderModel): 2831 return CsvParser( 2832 encoding=model.encoding, 2833 delimiter=model.delimiter, 2834 set_values_to_none=model.set_values_to_none, 2835 ) 2836 elif isinstance(model, GzipDecoderModel): 2837 return GzipParser( 2838 inner_parser=ModelToComponentFactory._get_parser(model.decoder, config) 2839 ) 2840 elif isinstance( 2841 model, (CustomDecoderModel, IterableDecoderModel, XmlDecoderModel, ZipfileDecoderModel) 2842 ): 2843 raise ValueError(f"Decoder type {model} does not have parser associated to it") 2844 2845 raise ValueError(f"Unknown decoder type {model}") 2846 2847 @staticmethod 2848 def create_json_file_schema_loader( 2849 model: JsonFileSchemaLoaderModel, config: Config, **kwargs: Any 2850 ) -> JsonFileSchemaLoader: 2851 return JsonFileSchemaLoader( 2852 file_path=model.file_path or "", config=config, parameters=model.parameters or {} 2853 ) 2854 2855 def create_jwt_authenticator( 2856 self, model: JwtAuthenticatorModel, config: Config, **kwargs: Any 2857 ) -> JwtAuthenticator: 2858 jwt_headers = model.jwt_headers or JwtHeadersModel(kid=None, typ="JWT", cty=None) 2859 jwt_payload = model.jwt_payload or JwtPayloadModel(iss=None, sub=None, aud=None) 2860 request_option = ( 2861 self._create_component_from_model(model.request_option, config) 2862 if model.request_option 2863 else None 2864 ) 2865 return JwtAuthenticator( 2866 config=config, 2867 parameters=model.parameters or {}, 2868 algorithm=JwtAlgorithm(model.algorithm.value), 2869 secret_key=model.secret_key, 2870 base64_encode_secret_key=model.base64_encode_secret_key, 2871 token_duration=model.token_duration, 2872 header_prefix=model.header_prefix, 2873 kid=jwt_headers.kid, 2874 typ=jwt_headers.typ, 2875 cty=jwt_headers.cty, 2876 iss=jwt_payload.iss, 2877 sub=jwt_payload.sub, 2878 aud=jwt_payload.aud, 2879 additional_jwt_headers=model.additional_jwt_headers, 2880 additional_jwt_payload=model.additional_jwt_payload, 2881 passphrase=model.passphrase, 2882 request_option=request_option, 2883 ) 2884 2885 def create_list_partition_router( 2886 self, model: ListPartitionRouterModel, config: Config, **kwargs: Any 2887 ) -> ListPartitionRouter: 2888 request_option = ( 2889 self._create_component_from_model(model.request_option, config) 2890 if model.request_option 2891 else None 2892 ) 2893 return ListPartitionRouter( 2894 cursor_field=model.cursor_field, 2895 request_option=request_option, 2896 values=model.values, 2897 config=config, 2898 parameters=model.parameters or {}, 2899 ) 2900 2901 @staticmethod 2902 def create_min_max_datetime( 2903 model: MinMaxDatetimeModel, config: Config, **kwargs: Any 2904 ) -> MinMaxDatetime: 2905 return MinMaxDatetime( 2906 datetime=model.datetime, 2907 datetime_format=model.datetime_format or "", 2908 max_datetime=model.max_datetime or "", 2909 min_datetime=model.min_datetime or "", 2910 parameters=model.parameters or {}, 2911 ) 2912 2913 @staticmethod 2914 def create_no_auth(model: NoAuthModel, config: Config, **kwargs: Any) -> NoAuth: 2915 return NoAuth(parameters=model.parameters or {}) 2916 2917 @staticmethod 2918 def create_no_pagination( 2919 model: NoPaginationModel, config: Config, **kwargs: Any 2920 ) -> NoPagination: 2921 return NoPagination(parameters={}) 2922 2923 def create_oauth_authenticator( 2924 self, model: OAuthAuthenticatorModel, config: Config, **kwargs: Any 2925 ) -> DeclarativeOauth2Authenticator: 2926 profile_assertion = ( 2927 self._create_component_from_model(model.profile_assertion, config=config) 2928 if model.profile_assertion 2929 else None 2930 ) 2931 2932 refresh_token_error_status_codes, refresh_token_error_key, refresh_token_error_values = ( 2933 self._get_refresh_token_error_information(model) 2934 ) 2935 if model.refresh_token_updater: 2936 # ignore type error because fixing it would have a lot of dependencies, revisit later 2937 return DeclarativeSingleUseRefreshTokenOauth2Authenticator( # type: ignore 2938 config, 2939 InterpolatedString.create( 2940 model.token_refresh_endpoint, # type: ignore 2941 parameters=model.parameters or {}, 2942 ).eval(config), 2943 access_token_name=InterpolatedString.create( 2944 model.access_token_name or "access_token", parameters=model.parameters or {} 2945 ).eval(config), 2946 refresh_token_name=model.refresh_token_updater.refresh_token_name, 2947 expires_in_name=InterpolatedString.create( 2948 model.expires_in_name or "expires_in", parameters=model.parameters or {} 2949 ).eval(config), 2950 client_id_name=InterpolatedString.create( 2951 model.client_id_name or "client_id", parameters=model.parameters or {} 2952 ).eval(config), 2953 client_id=InterpolatedString.create( 2954 model.client_id, parameters=model.parameters or {} 2955 ).eval(config) 2956 if model.client_id 2957 else model.client_id, 2958 client_secret_name=InterpolatedString.create( 2959 model.client_secret_name or "client_secret", parameters=model.parameters or {} 2960 ).eval(config), 2961 client_secret=InterpolatedString.create( 2962 model.client_secret, parameters=model.parameters or {} 2963 ).eval(config) 2964 if model.client_secret 2965 else model.client_secret, 2966 access_token_config_path=model.refresh_token_updater.access_token_config_path, 2967 refresh_token_config_path=model.refresh_token_updater.refresh_token_config_path, 2968 token_expiry_date_config_path=model.refresh_token_updater.token_expiry_date_config_path, 2969 grant_type_name=InterpolatedString.create( 2970 model.grant_type_name or "grant_type", parameters=model.parameters or {} 2971 ).eval(config), 2972 grant_type=InterpolatedString.create( 2973 model.grant_type or "refresh_token", parameters=model.parameters or {} 2974 ).eval(config), 2975 refresh_request_body=InterpolatedMapping( 2976 model.refresh_request_body or {}, parameters=model.parameters or {} 2977 ).eval(config), 2978 refresh_request_headers=InterpolatedMapping( 2979 model.refresh_request_headers or {}, parameters=model.parameters or {} 2980 ).eval(config), 2981 send_refresh_request_as_query_params=bool( 2982 model.send_refresh_request_as_query_params 2983 ), 2984 scopes=model.scopes, 2985 token_expiry_date_format=model.token_expiry_date_format, 2986 token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), 2987 message_repository=self._message_repository, 2988 refresh_token_error_status_codes=refresh_token_error_status_codes, 2989 refresh_token_error_key=refresh_token_error_key, 2990 refresh_token_error_values=refresh_token_error_values, 2991 ) 2992 # ignore type error because fixing it would have a lot of dependencies, revisit later 2993 return DeclarativeOauth2Authenticator( # type: ignore 2994 access_token_name=model.access_token_name or "access_token", 2995 access_token_value=model.access_token_value, 2996 client_id_name=model.client_id_name or "client_id", 2997 client_id=model.client_id, 2998 client_secret_name=model.client_secret_name or "client_secret", 2999 client_secret=model.client_secret, 3000 expires_in_name=model.expires_in_name or "expires_in", 3001 grant_type_name=model.grant_type_name or "grant_type", 3002 grant_type=model.grant_type or "refresh_token", 3003 refresh_request_body=model.refresh_request_body, 3004 refresh_request_headers=model.refresh_request_headers, 3005 send_refresh_request_as_query_params=bool(model.send_refresh_request_as_query_params), 3006 refresh_token_name=model.refresh_token_name or "refresh_token", 3007 refresh_token=model.refresh_token, 3008 scopes=model.scopes, 3009 token_expiry_date=model.token_expiry_date, 3010 token_expiry_date_format=model.token_expiry_date_format, 3011 token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), 3012 token_refresh_endpoint=model.token_refresh_endpoint, 3013 config=config, 3014 parameters=model.parameters or {}, 3015 message_repository=self._message_repository, 3016 profile_assertion=profile_assertion, 3017 use_profile_assertion=model.use_profile_assertion, 3018 refresh_token_error_status_codes=refresh_token_error_status_codes, 3019 refresh_token_error_key=refresh_token_error_key, 3020 refresh_token_error_values=refresh_token_error_values, 3021 ) 3022 3023 @staticmethod 3024 def _get_refresh_token_error_information( 3025 model: OAuthAuthenticatorModel, 3026 ) -> Tuple[Tuple[int, ...], str, Tuple[str, ...]]: 3027 """ 3028 In a previous version of the CDK, the auth error as config_error was only done if a refresh token updater was 3029 defined. As a transition, we added those fields on the OAuthAuthenticatorModel. This method ensures that the 3030 information is defined only once and return the right fields. 3031 """ 3032 refresh_token_updater = model.refresh_token_updater 3033 is_defined_on_refresh_token_updated = refresh_token_updater and ( 3034 refresh_token_updater.refresh_token_error_status_codes 3035 or refresh_token_updater.refresh_token_error_key 3036 or refresh_token_updater.refresh_token_error_values 3037 ) 3038 is_defined_on_oauth_authenticator = ( 3039 model.refresh_token_error_status_codes 3040 or model.refresh_token_error_key 3041 or model.refresh_token_error_values 3042 ) 3043 if is_defined_on_refresh_token_updated and is_defined_on_oauth_authenticator: 3044 raise ValueError( 3045 "refresh_token_error should either be defined on the OAuthAuthenticatorModel or the RefreshTokenUpdaterModel, not both" 3046 ) 3047 3048 if is_defined_on_refresh_token_updated: 3049 not_optional_refresh_token_updater: RefreshTokenUpdaterModel = refresh_token_updater # type: ignore # we know from the condition that this is not None 3050 return ( 3051 tuple(not_optional_refresh_token_updater.refresh_token_error_status_codes) 3052 if not_optional_refresh_token_updater.refresh_token_error_status_codes 3053 else (), 3054 not_optional_refresh_token_updater.refresh_token_error_key or "", 3055 tuple(not_optional_refresh_token_updater.refresh_token_error_values) 3056 if not_optional_refresh_token_updater.refresh_token_error_values 3057 else (), 3058 ) 3059 elif is_defined_on_oauth_authenticator: 3060 return ( 3061 tuple(model.refresh_token_error_status_codes) 3062 if model.refresh_token_error_status_codes 3063 else (), 3064 model.refresh_token_error_key or "", 3065 tuple(model.refresh_token_error_values) if model.refresh_token_error_values else (), 3066 ) 3067 3068 # returning default values we think cover most cases 3069 return (400,), "error", ("invalid_grant", "invalid_permissions") 3070 3071 def create_offset_increment( 3072 self, 3073 model: OffsetIncrementModel, 3074 config: Config, 3075 decoder: Decoder, 3076 extractor_model: Optional[Union[CustomRecordExtractorModel, DpathExtractorModel]] = None, 3077 **kwargs: Any, 3078 ) -> OffsetIncrement: 3079 if isinstance(decoder, PaginationDecoderDecorator): 3080 inner_decoder = decoder.decoder 3081 else: 3082 inner_decoder = decoder 3083 decoder = PaginationDecoderDecorator(decoder=decoder) 3084 3085 if self._is_supported_decoder_for_pagination(inner_decoder): 3086 decoder_to_use = decoder 3087 else: 3088 raise ValueError( 3089 self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(inner_decoder)) 3090 ) 3091 3092 # Ideally we would instantiate the runtime extractor from highest most level (in this case the SimpleRetriever) 3093 # so that it can be shared by OffSetIncrement and RecordSelector. However, due to how we instantiate the 3094 # decoder with various decorators here, but not in create_record_selector, it is simpler to retain existing 3095 # behavior by having two separate extractors with identical behavior since they use the same extractor model. 3096 # When we have more time to investigate we can look into reusing the same component. 3097 extractor = ( 3098 self._create_component_from_model( 3099 model=extractor_model, config=config, decoder=decoder_to_use 3100 ) 3101 if extractor_model 3102 else None 3103 ) 3104 3105 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 3106 # If page_size is a string that represents an integer (not an interpolation), convert it back. 3107 page_size = model.page_size 3108 if isinstance(page_size, str) and page_size.isdigit(): 3109 page_size = int(page_size) 3110 3111 return OffsetIncrement( 3112 page_size=page_size, 3113 config=config, 3114 decoder=decoder_to_use, 3115 extractor=extractor, 3116 inject_on_first_request=model.inject_on_first_request or False, 3117 parameters=model.parameters or {}, 3118 ) 3119 3120 def create_page_increment( 3121 self, 3122 model: PageIncrementModel, 3123 config: Config, 3124 decoder: Optional[Decoder] = None, 3125 extractor_model: Optional[Union[CustomRecordExtractorModel, DpathExtractorModel]] = None, 3126 **kwargs: Any, 3127 ) -> PageIncrement: 3128 # Like OffsetIncrement, we instantiate a separate extractor with identical behavior to the 3129 # RecordSelector's so the strategy can count the raw records in the response. This ensures 3130 # pagination is driven by the API's page size, not the post-filter record count. 3131 extractor = ( 3132 self._create_component_from_model(model=extractor_model, config=config, decoder=decoder) 3133 if extractor_model 3134 else None 3135 ) 3136 3137 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 3138 # If page_size is a string that represents an integer (not an interpolation), convert it back. 3139 page_size = model.page_size 3140 if isinstance(page_size, str) and page_size.isdigit(): 3141 page_size = int(page_size) 3142 3143 return PageIncrement( 3144 page_size=page_size, 3145 config=config, 3146 start_from_page=model.start_from_page or 0, 3147 inject_on_first_request=model.inject_on_first_request or False, 3148 extractor=extractor, 3149 parameters=model.parameters or {}, 3150 ) 3151 3152 def create_parent_stream_config( 3153 self, model: ParentStreamConfigModel, config: Config, *, stream_name: str, **kwargs: Any 3154 ) -> ParentStreamConfig: 3155 declarative_stream = self._create_component_from_model( 3156 model.stream, 3157 config=config, 3158 is_parent=True, 3159 **kwargs, 3160 ) 3161 request_option = ( 3162 self._create_component_from_model(model.request_option, config=config) 3163 if model.request_option 3164 else None 3165 ) 3166 3167 if model.lazy_read_pointer and any("*" in pointer for pointer in model.lazy_read_pointer): 3168 raise ValueError( 3169 "The '*' wildcard in 'lazy_read_pointer' is not supported — only direct paths are allowed." 3170 ) 3171 3172 model_lazy_read_pointer: List[Union[InterpolatedString, str]] = ( 3173 [x for x in model.lazy_read_pointer] if model.lazy_read_pointer else [] 3174 ) 3175 3176 return ParentStreamConfig( 3177 parent_key=model.parent_key, 3178 request_option=request_option, 3179 stream=declarative_stream, 3180 partition_field=model.partition_field, 3181 config=config, 3182 incremental_dependency=model.incremental_dependency or False, 3183 parameters=model.parameters or {}, 3184 extra_fields=model.extra_fields, 3185 lazy_read_pointer=model_lazy_read_pointer, 3186 ) 3187 3188 def create_properties_from_endpoint( 3189 self, model: PropertiesFromEndpointModel, config: Config, **kwargs: Any 3190 ) -> PropertiesFromEndpoint: 3191 retriever = self._create_component_from_model( 3192 model=model.retriever, 3193 config=config, 3194 name="dynamic_properties", 3195 primary_key=None, 3196 stream_slicer=None, 3197 transformations=[], 3198 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 3199 ) 3200 return PropertiesFromEndpoint( 3201 property_field_path=model.property_field_path, 3202 retriever=retriever, 3203 config=config, 3204 parameters=model.parameters or {}, 3205 ) 3206 3207 def create_property_chunking( 3208 self, model: PropertyChunkingModel, config: Config, **kwargs: Any 3209 ) -> PropertyChunking: 3210 record_merge_strategy = ( 3211 self._create_component_from_model( 3212 model=model.record_merge_strategy, config=config, **kwargs 3213 ) 3214 if model.record_merge_strategy 3215 else None 3216 ) 3217 3218 property_limit_type: PropertyLimitType 3219 match model.property_limit_type: 3220 case PropertyLimitTypeModel.property_count: 3221 property_limit_type = PropertyLimitType.property_count 3222 case PropertyLimitTypeModel.characters: 3223 property_limit_type = PropertyLimitType.characters 3224 case _: 3225 raise ValueError(f"Invalid PropertyLimitType {property_limit_type}") 3226 3227 return PropertyChunking( 3228 property_limit_type=property_limit_type, 3229 property_limit=model.property_limit, 3230 record_merge_strategy=record_merge_strategy, 3231 config=config, 3232 parameters=model.parameters or {}, 3233 ) 3234 3235 def create_query_properties( 3236 self, model: QueryPropertiesModel, config: Config, *, stream_name: str, **kwargs: Any 3237 ) -> QueryProperties: 3238 if isinstance(model.property_list, list): 3239 property_list = model.property_list 3240 else: 3241 property_list = self._create_component_from_model( 3242 model=model.property_list, config=config, **kwargs 3243 ) 3244 3245 property_chunking = ( 3246 self._create_component_from_model( 3247 model=model.property_chunking, config=config, **kwargs 3248 ) 3249 if model.property_chunking 3250 else None 3251 ) 3252 3253 property_selector = ( 3254 self._create_component_from_model( 3255 model=model.property_selector, config=config, stream_name=stream_name, **kwargs 3256 ) 3257 if model.property_selector 3258 else None 3259 ) 3260 3261 return QueryProperties( 3262 property_list=property_list, 3263 always_include_properties=model.always_include_properties, 3264 property_chunking=property_chunking, 3265 property_selector=property_selector, 3266 config=config, 3267 parameters=model.parameters or {}, 3268 ) 3269 3270 def create_json_schema_property_selector( 3271 self, 3272 model: JsonSchemaPropertySelectorModel, 3273 config: Config, 3274 *, 3275 stream_name: str, 3276 **kwargs: Any, 3277 ) -> JsonSchemaPropertySelector: 3278 configured_stream = self._stream_name_to_configured_stream.get(stream_name) 3279 3280 transformations = [] 3281 if model.transformations: 3282 for transformation_model in model.transformations: 3283 transformations.append( 3284 self._create_component_from_model(model=transformation_model, config=config) 3285 ) 3286 3287 return JsonSchemaPropertySelector( 3288 configured_stream=configured_stream, 3289 properties_transformations=transformations, 3290 config=config, 3291 parameters=model.parameters or {}, 3292 ) 3293 3294 @staticmethod 3295 def create_record_filter( 3296 model: RecordFilterModel, config: Config, **kwargs: Any 3297 ) -> RecordFilter: 3298 return RecordFilter( 3299 condition=model.condition or "", config=config, parameters=model.parameters or {} 3300 ) 3301 3302 @staticmethod 3303 def create_request_path(model: RequestPathModel, config: Config, **kwargs: Any) -> RequestPath: 3304 return RequestPath(parameters={}) 3305 3306 @staticmethod 3307 def create_request_option( 3308 model: RequestOptionModel, config: Config, **kwargs: Any 3309 ) -> RequestOption: 3310 inject_into = RequestOptionType(model.inject_into.value) 3311 field_path: Optional[List[Union[InterpolatedString, str]]] = ( 3312 [ 3313 InterpolatedString.create(segment, parameters=kwargs.get("parameters", {})) 3314 for segment in model.field_path 3315 ] 3316 if model.field_path 3317 else None 3318 ) 3319 field_name = ( 3320 InterpolatedString.create(model.field_name, parameters=kwargs.get("parameters", {})) 3321 if model.field_name 3322 else None 3323 ) 3324 return RequestOption( 3325 field_name=field_name, 3326 field_path=field_path, 3327 inject_into=inject_into, 3328 parameters=kwargs.get("parameters", {}), 3329 ) 3330 3331 def create_record_selector( 3332 self, 3333 model: RecordSelectorModel, 3334 config: Config, 3335 *, 3336 name: str, 3337 transformations: List[RecordTransformation] | None = None, 3338 decoder: Decoder | None = None, 3339 client_side_incremental_sync_cursor: Optional[Cursor] = None, 3340 is_client_side_incremental_sync: bool = False, 3341 file_uploader: Optional[DefaultFileUploader] = None, 3342 **kwargs: Any, 3343 ) -> RecordSelector: 3344 extractor = self._create_component_from_model( 3345 model=model.extractor, decoder=decoder, config=config 3346 ) 3347 record_filter = ( 3348 self._create_component_from_model(model.record_filter, config=config) 3349 if model.record_filter 3350 else None 3351 ) 3352 3353 # A client-side incremental stream transforms before filtering by default. That default belongs to the flag, 3354 # not to the component that ends up doing the cursor comparison: a data feed does it in the retriever and 3355 # receives no cursor here, but its `record_filter` condition must keep running after the transformations. 3356 default_transform_before_filtering = bool( 3357 client_side_incremental_sync_cursor or is_client_side_incremental_sync 3358 ) 3359 transform_before_filtering = ( 3360 default_transform_before_filtering 3361 if model.transform_before_filtering is None 3362 else model.transform_before_filtering 3363 ) 3364 if client_side_incremental_sync_cursor: 3365 record_filter = ClientSideIncrementalRecordFilterDecorator( 3366 config=config, 3367 parameters=model.parameters, 3368 condition=model.record_filter.condition 3369 if (model.record_filter and hasattr(model.record_filter, "condition")) 3370 else None, 3371 cursor=client_side_incremental_sync_cursor, 3372 ) 3373 3374 if model.schema_normalization is None: 3375 # default to no schema normalization if not set 3376 model.schema_normalization = SchemaNormalizationModel.None_ 3377 3378 schema_normalization = ( 3379 TypeTransformer(SCHEMA_TRANSFORMER_TYPE_MAPPING[model.schema_normalization]) 3380 if isinstance(model.schema_normalization, SchemaNormalizationModel) 3381 else self._create_component_from_model(model.schema_normalization, config=config) # type: ignore[arg-type] # custom normalization model expected here 3382 ) 3383 3384 return RecordSelector( 3385 extractor=extractor, 3386 name=name, 3387 config=config, 3388 record_filter=record_filter, 3389 transformations=transformations or [], 3390 file_uploader=file_uploader, 3391 schema_normalization=schema_normalization, 3392 parameters=model.parameters or {}, 3393 transform_before_filtering=transform_before_filtering, 3394 ) 3395 3396 @staticmethod 3397 def create_remove_fields( 3398 model: RemoveFieldsModel, config: Config, **kwargs: Any 3399 ) -> RemoveFields: 3400 return RemoveFields( 3401 field_pointers=model.field_pointers, condition=model.condition or "", parameters={} 3402 ) 3403 3404 def create_selective_authenticator( 3405 self, model: SelectiveAuthenticatorModel, config: Config, **kwargs: Any 3406 ) -> DeclarativeAuthenticator: 3407 authenticators = { 3408 name: self._create_component_from_model(model=auth, config=config) 3409 for name, auth in model.authenticators.items() 3410 } 3411 # SelectiveAuthenticator will return instance of DeclarativeAuthenticator or raise ValueError error 3412 return SelectiveAuthenticator( # type: ignore[abstract] 3413 config=config, 3414 authenticators=authenticators, 3415 authenticator_selection_path=model.authenticator_selection_path, 3416 **kwargs, 3417 ) 3418 3419 @staticmethod 3420 def create_legacy_session_token_authenticator( 3421 model: LegacySessionTokenAuthenticatorModel, config: Config, *, url_base: str, **kwargs: Any 3422 ) -> LegacySessionTokenAuthenticator: 3423 return LegacySessionTokenAuthenticator( 3424 api_url=url_base, 3425 header=model.header, 3426 login_url=model.login_url, 3427 password=model.password or "", 3428 session_token=model.session_token or "", 3429 session_token_response_key=model.session_token_response_key or "", 3430 username=model.username or "", 3431 validate_session_url=model.validate_session_url, 3432 config=config, 3433 parameters=model.parameters or {}, 3434 ) 3435 3436 def create_simple_retriever( 3437 self, 3438 model: SimpleRetrieverModel, 3439 config: Config, 3440 *, 3441 name: str, 3442 primary_key: Optional[Union[str, List[str], List[List[str]]]], 3443 request_options_provider: Optional[RequestOptionsProvider] = None, 3444 cursor: Optional[Cursor] = None, 3445 has_stop_condition_cursor: bool = False, 3446 is_client_side_incremental_sync: bool = False, 3447 transformations: List[RecordTransformation], 3448 file_uploader: Optional[DefaultFileUploader] = None, 3449 incremental_sync: Optional[ 3450 Union[IncrementingCountCursorModel, DatetimeBasedCursorModel] 3451 ] = None, 3452 use_cache: Optional[bool] = None, 3453 log_formatter: Optional[Callable[[Response], Any]] = None, 3454 partition_router: Optional[PartitionRouter] = None, 3455 **kwargs: Any, 3456 ) -> SimpleRetriever: 3457 def _get_url(req: Requester) -> str: 3458 """ 3459 Closure to get the URL from the requester. This is used to get the URL in the case of a lazy retriever. 3460 This is needed because the URL is not set until the requester is created. 3461 """ 3462 3463 _url: str = ( 3464 model.requester.url 3465 if hasattr(model.requester, "url") and model.requester.url is not None 3466 else req.get_url(stream_state=None, stream_slice=None, next_page_token=None) 3467 ) 3468 _url_base: str = ( 3469 model.requester.url_base 3470 if hasattr(model.requester, "url_base") and model.requester.url_base is not None 3471 else req.get_url_base(stream_state=None, stream_slice=None, next_page_token=None) 3472 ) 3473 3474 return _url or _url_base 3475 3476 if cursor is None: 3477 cursor = FinalStateCursor(name, None, self._message_repository) 3478 3479 # A data feed drops the records the cursor considers already synced in the retriever, which sits downstream of 3480 # the paginator. Letting the record selector drop them as well would be redundant and would hide them from the 3481 # pagination stop condition, so a data feed never delegates that filtering to the record selector, whether 3482 # `is_client_side_incremental` is set or not. The `condition` from `record_filter` is intentionally left out of 3483 # the post-pagination filter and stays in the record selector, which preserves the existing behaviour: the 3484 # selector runs inside the page loop, so the records the condition rejects never reach the paginator's 3485 # accounting. Moving it downstream would start counting them. 3486 post_pagination_filter = ( 3487 ClientSideIncrementalRecordFilterDecorator( 3488 config=config, 3489 parameters=model.parameters or {}, 3490 condition=None, 3491 cursor=cursor, 3492 ) 3493 if has_stop_condition_cursor 3494 else None 3495 ) 3496 client_side_incremental_cursor = ( 3497 cursor if is_client_side_incremental_sync and not post_pagination_filter else None 3498 ) 3499 if post_pagination_filter and is_client_side_incremental_sync: 3500 LOGGER.warning( 3501 f"Stream {name}: `is_client_side_incremental` adds no record filtering when `is_data_feed` is set, " 3502 "as a data feed already filters on the cursor value. It still makes the record selector apply the " 3503 "transformations before the `record_filter` condition." 3504 ) 3505 3506 decoder = ( 3507 self._create_component_from_model(model=model.decoder, config=config) 3508 if model.decoder 3509 else JsonDecoder(parameters={}) 3510 ) 3511 record_selector = self._create_component_from_model( 3512 model=model.record_selector, 3513 name=name, 3514 config=config, 3515 decoder=decoder, 3516 transformations=transformations, 3517 client_side_incremental_sync_cursor=client_side_incremental_cursor, 3518 is_client_side_incremental_sync=is_client_side_incremental_sync, 3519 file_uploader=file_uploader, 3520 ) 3521 3522 query_properties: Optional[QueryProperties] = None 3523 query_properties_key: Optional[str] = None 3524 self._ensure_query_properties_to_model(model.requester) 3525 if self._has_query_properties_in_request_parameters(model.requester): 3526 # It is better to be explicit about an error if PropertiesFromEndpoint is defined in multiple 3527 # places instead of default to request_parameters which isn't clearly documented 3528 if ( 3529 hasattr(model.requester, "fetch_properties_from_endpoint") 3530 and model.requester.fetch_properties_from_endpoint 3531 ): 3532 raise ValueError( 3533 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" 3534 ) 3535 3536 query_properties_definitions = [] 3537 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() 3538 if isinstance(request_parameter, QueryPropertiesModel): 3539 query_properties_key = key 3540 query_properties_definitions.append(request_parameter) 3541 3542 if len(query_properties_definitions) > 1: 3543 raise ValueError( 3544 f"request_parameters only supports defining one QueryProperties field, but found {len(query_properties_definitions)} usages" 3545 ) 3546 3547 if len(query_properties_definitions) == 1: 3548 query_properties = self._create_component_from_model( 3549 model=query_properties_definitions[0], stream_name=name, config=config 3550 ) 3551 3552 # Removes QueryProperties components from the interpolated mappings because it has been designed 3553 # to be used by the SimpleRetriever and will be resolved from the provider from the slice directly 3554 # instead of through jinja interpolation 3555 if hasattr(model.requester, "request_parameters") and isinstance( 3556 model.requester.request_parameters, Mapping 3557 ): 3558 model.requester.request_parameters = self._remove_query_properties( 3559 model.requester.request_parameters 3560 ) 3561 elif ( 3562 hasattr(model.requester, "fetch_properties_from_endpoint") 3563 and model.requester.fetch_properties_from_endpoint 3564 ): 3565 # todo: Deprecate this condition once dependent connectors migrate to query_properties 3566 query_properties_definition = QueryPropertiesModel( 3567 type="QueryProperties", 3568 property_list=model.requester.fetch_properties_from_endpoint, 3569 always_include_properties=None, 3570 property_chunking=None, 3571 ) # type: ignore # $parameters has a default value 3572 3573 query_properties = self.create_query_properties( 3574 model=query_properties_definition, 3575 stream_name=name, 3576 config=config, 3577 ) 3578 elif hasattr(model.requester, "query_properties") and model.requester.query_properties: 3579 query_properties = self.create_query_properties( 3580 model=model.requester.query_properties, 3581 stream_name=name, 3582 config=config, 3583 ) 3584 3585 requester = self._create_component_from_model( 3586 model=model.requester, 3587 decoder=decoder, 3588 name=name, 3589 query_properties_key=query_properties_key, 3590 use_cache=use_cache, 3591 config=config, 3592 ) 3593 3594 if not request_options_provider: 3595 request_options_provider = DefaultRequestOptionsProvider(parameters={}) 3596 if isinstance(request_options_provider, DefaultRequestOptionsProvider) and isinstance( 3597 partition_router, PartitionRouter 3598 ): 3599 request_options_provider = partition_router 3600 3601 paginator = ( 3602 self._create_component_from_model( 3603 model=model.paginator, 3604 config=config, 3605 url_base=_get_url(requester), 3606 extractor_model=model.record_selector.extractor, 3607 decoder=decoder, 3608 cursor_used_for_stop_condition=cursor if has_stop_condition_cursor else None, 3609 ) 3610 if model.paginator 3611 else NoPagination(parameters={}) 3612 ) 3613 3614 ignore_stream_slicer_parameters_on_paginated_requests = ( 3615 model.ignore_stream_slicer_parameters_on_paginated_requests or False 3616 ) 3617 3618 if ( 3619 model.partition_router 3620 and isinstance(model.partition_router, SubstreamPartitionRouterModel) 3621 and not bool(self._connector_state_manager.get_stream_state(name, None)) 3622 and any( 3623 parent_stream_config.lazy_read_pointer 3624 for parent_stream_config in model.partition_router.parent_stream_configs 3625 ) 3626 ): 3627 if incremental_sync: 3628 if incremental_sync.type != "DatetimeBasedCursor": 3629 raise ValueError( 3630 f"LazySimpleRetriever only supports DatetimeBasedCursor. Found: {incremental_sync.type}." 3631 ) 3632 3633 elif incremental_sync.step or incremental_sync.cursor_granularity: 3634 raise ValueError( 3635 f"Found more that one slice per parent. LazySimpleRetriever only supports single slice read for stream - {name}." 3636 ) 3637 3638 if model.decoder and model.decoder.type != "JsonDecoder": 3639 raise ValueError( 3640 f"LazySimpleRetriever only supports JsonDecoder. Found: {model.decoder.type}." 3641 ) 3642 3643 return LazySimpleRetriever( 3644 name=name, 3645 paginator=paginator, 3646 primary_key=primary_key, 3647 requester=requester, 3648 record_selector=record_selector, 3649 stream_slicer=_NO_STREAM_SLICING, 3650 request_option_provider=request_options_provider, 3651 config=config, 3652 ignore_stream_slicer_parameters_on_paginated_requests=ignore_stream_slicer_parameters_on_paginated_requests, 3653 post_pagination_filter=post_pagination_filter, 3654 parameters=model.parameters or {}, 3655 ) 3656 3657 if ( 3658 model.record_selector.record_filter 3659 and model.pagination_reset 3660 and model.pagination_reset.limits 3661 ): 3662 raise ValueError("PaginationResetLimits are not supported while having record filter.") 3663 3664 return SimpleRetriever( 3665 name=name, 3666 paginator=paginator, 3667 primary_key=primary_key, 3668 requester=requester, 3669 record_selector=record_selector, 3670 stream_slicer=_NO_STREAM_SLICING, 3671 request_option_provider=request_options_provider, 3672 config=config, 3673 ignore_stream_slicer_parameters_on_paginated_requests=ignore_stream_slicer_parameters_on_paginated_requests, 3674 additional_query_properties=query_properties, 3675 log_formatter=self._get_log_formatter(log_formatter, name), 3676 pagination_tracker_factory=self._create_pagination_tracker_factory( 3677 model.pagination_reset, cursor 3678 ), 3679 post_pagination_filter=post_pagination_filter, 3680 parameters=model.parameters or {}, 3681 ) 3682 3683 def _create_pagination_tracker_factory( 3684 self, model: Optional[PaginationResetModel], cursor: Cursor 3685 ) -> Callable[[], PaginationTracker]: 3686 if model is None: 3687 return lambda: PaginationTracker() 3688 3689 # Until we figure out a way to use any cursor for PaginationTracker, we will have to have this cursor selector logic 3690 cursor_factory: Callable[[], Optional[ConcurrentCursor]] = lambda: None 3691 if model.action == PaginationResetActionModel.RESET: 3692 # in that case, we will let cursor_factory to return None even if the stream has a cursor 3693 pass 3694 elif model.action == PaginationResetActionModel.SPLIT_USING_CURSOR: 3695 if isinstance(cursor, ConcurrentCursor): 3696 cursor_factory = lambda: cursor.copy_without_state() # type: ignore # the if condition validates that it is a ConcurrentCursor 3697 elif isinstance(cursor, ConcurrentPerPartitionCursor): 3698 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 3699 {}, datetime.timedelta(0) 3700 ) 3701 elif not isinstance(cursor, FinalStateCursor): 3702 LOGGER.warning( 3703 "Unknown cursor for PaginationTracker. Pagination resets might not work properly" 3704 ) 3705 else: 3706 raise ValueError(f"Unknown PaginationReset action: {model.action}") 3707 3708 limit = model.limits.number_of_records if model and model.limits else None 3709 return lambda: PaginationTracker(cursor_factory(), limit) 3710 3711 def _get_log_formatter( 3712 self, log_formatter: Callable[[Response], Any] | None, name: str 3713 ) -> Callable[[Response], Any] | None: 3714 if self._should_limit_slices_fetched(): 3715 return ( 3716 ( 3717 lambda response: format_http_message( 3718 response, 3719 f"Stream '{name}' request", 3720 f"Request performed in order to extract records for stream '{name}'", 3721 name, 3722 ) 3723 ) 3724 if not log_formatter 3725 else log_formatter 3726 ) 3727 return None 3728 3729 def _should_limit_slices_fetched(self) -> bool: 3730 """ 3731 Returns True if the number of slices fetched should be limited, False otherwise. 3732 This is used to limit the number of slices fetched during tests. 3733 """ 3734 return bool(self._limit_slices_fetched or self._emit_connector_builder_messages) 3735 3736 @staticmethod 3737 def _has_query_properties_in_request_parameters( 3738 requester: Union[HttpRequesterModel, CustomRequesterModel], 3739 ) -> bool: 3740 if not hasattr(requester, "request_parameters"): 3741 return False 3742 request_parameters = requester.request_parameters 3743 if request_parameters and isinstance(request_parameters, Mapping): 3744 for request_parameter in request_parameters.values(): 3745 if isinstance(request_parameter, QueryPropertiesModel): 3746 return True 3747 return False 3748 3749 @staticmethod 3750 def _remove_query_properties( 3751 request_parameters: Mapping[str, Union[str, QueryPropertiesModel]], 3752 ) -> Mapping[str, str]: 3753 return { 3754 parameter_field: request_parameter 3755 for parameter_field, request_parameter in request_parameters.items() 3756 if not isinstance(request_parameter, QueryPropertiesModel) 3757 } 3758 3759 def create_state_delegating_stream( 3760 self, 3761 model: StateDelegatingStreamModel, 3762 config: Config, 3763 **kwargs: Any, 3764 ) -> DefaultStream: 3765 if ( 3766 model.full_refresh_stream.name != model.name 3767 or model.name != model.incremental_stream.name 3768 ): 3769 raise ValueError( 3770 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}." 3771 ) 3772 3773 # Resolve api_retention_period with config context (supports Jinja2 interpolation) 3774 resolved_retention_period: Optional[str] = None 3775 if model.api_retention_period: 3776 interpolated_retention = InterpolatedString.create( 3777 model.api_retention_period, parameters=model.parameters or {} 3778 ) 3779 resolved_value = interpolated_retention.eval(config=config) 3780 if resolved_value: 3781 resolved_retention_period = str(resolved_value) 3782 3783 if resolved_retention_period: 3784 for stream_model in (model.full_refresh_stream, model.incremental_stream): 3785 if isinstance(stream_model.incremental_sync, IncrementingCountCursorModel): 3786 raise ValueError( 3787 f"Stream '{model.name}' uses IncrementingCountCursor which is not supported " 3788 f"with api_retention_period. IncrementingCountCursor does not use datetime-based " 3789 f"cursors, so cursor age validation cannot be performed." 3790 ) 3791 3792 stream_state = self._connector_state_manager.get_stream_state(model.name, None) 3793 3794 if not stream_state: 3795 return self._create_component_from_model( # type: ignore[no-any-return] 3796 model.full_refresh_stream, config=config, **kwargs 3797 ) 3798 3799 incremental_stream: DefaultStream = self._create_component_from_model( 3800 model.incremental_stream, config=config, **kwargs 3801 ) # type: ignore[assignment] 3802 3803 # Only run cursor age validation for streams that are in the configured 3804 # catalog (or when no catalog was provided, e.g. during discover / connector 3805 # builder). Streams not selected by the user but instantiated as parent-stream 3806 # dependencies must not go through this path because it emits state messages 3807 # that the destination does not know about, causing "Stream not found" crashes. 3808 stream_is_in_catalog = ( 3809 not self._stream_name_to_configured_stream # no catalog → validate by default 3810 or model.name in self._stream_name_to_configured_stream 3811 ) 3812 if resolved_retention_period and stream_is_in_catalog: 3813 full_refresh_stream: DefaultStream = self._create_component_from_model( 3814 model.full_refresh_stream, config=config, **kwargs 3815 ) # type: ignore[assignment] 3816 if self._is_cursor_older_than_retention_period( 3817 stream_state, 3818 full_refresh_stream.cursor, 3819 incremental_stream.cursor, 3820 resolved_retention_period, 3821 model.name, 3822 ): 3823 # Clear state BEFORE constructing the full_refresh_stream so that 3824 # its cursor starts from start_date instead of the stale cursor. 3825 self._connector_state_manager.update_state_for_stream(model.name, None, {}) 3826 state_message = self._connector_state_manager.create_state_message(model.name, None) 3827 self._message_repository.emit_message(state_message) 3828 return self._create_component_from_model( # type: ignore[no-any-return] 3829 model.full_refresh_stream, config=config, **kwargs 3830 ) 3831 3832 return incremental_stream 3833 3834 @staticmethod 3835 def _is_cursor_older_than_retention_period( 3836 stream_state: Mapping[str, Any], 3837 full_refresh_cursor: Cursor, 3838 incremental_cursor: Cursor, 3839 api_retention_period: str, 3840 stream_name: str, 3841 ) -> bool: 3842 """Check if the cursor value in the state is older than the API's retention period. 3843 3844 Checks cursors in sequence: full refresh cursor first, then incremental cursor. 3845 FinalStateCursor returns now() for completed full refresh state (NO_CURSOR_STATE_KEY), 3846 which is always within retention, so we use incremental. For other states, it returns 3847 None and we fall back to checking the incremental cursor. 3848 3849 Returns True if the cursor is older than the retention period (should use full refresh). 3850 Returns False if the cursor is within the retention period (safe to use incremental). 3851 """ 3852 retention_duration = parse_duration(api_retention_period) 3853 retention_cutoff = datetime.datetime.now(datetime.timezone.utc) - retention_duration 3854 3855 # Check full refresh cursor first 3856 cursor_datetime = full_refresh_cursor.get_cursor_datetime_from_state(stream_state) 3857 3858 # If full refresh cursor returns None, check incremental cursor 3859 if cursor_datetime is None: 3860 cursor_datetime = incremental_cursor.get_cursor_datetime_from_state(stream_state) 3861 3862 if cursor_datetime is None: 3863 # Neither cursor could parse the state - fall back to full refresh to be safe 3864 return True 3865 3866 if cursor_datetime < retention_cutoff: 3867 logging.warning( 3868 f"Stream '{stream_name}' has a cursor value older than " 3869 f"the API's retention period of {api_retention_period} " 3870 f"(cutoff: {retention_cutoff.isoformat()}). " 3871 f"Falling back to full refresh to avoid data loss." 3872 ) 3873 return True 3874 3875 return False 3876 3877 def _get_state_delegating_stream_model( 3878 self, 3879 model: StateDelegatingStreamModel, 3880 parent_state: Optional[Mapping[str, Any]] = None, 3881 ) -> DeclarativeStreamModel: 3882 """Return the appropriate underlying stream model based on state.""" 3883 return ( 3884 model.incremental_stream 3885 if self._connector_state_manager.get_stream_state(model.name, None) or parent_state 3886 else model.full_refresh_stream 3887 ) 3888 3889 _OPTIONAL_ASYNC_STATUS_FIELDS = {"skipped"} 3890 3891 def _create_async_job_status_mapping( 3892 self, model: AsyncJobStatusMapModel, config: Config, **kwargs: Any 3893 ) -> Mapping[str, AsyncJobStatus]: 3894 api_status_to_cdk_status = {} 3895 for cdk_status, api_statuses in model.dict().items(): 3896 if cdk_status == "type": 3897 # This is an element of the dict because of the typing of the CDK but it is not a CDK status 3898 continue 3899 3900 if api_statuses is None: 3901 if cdk_status in self._OPTIONAL_ASYNC_STATUS_FIELDS: 3902 continue 3903 raise ValueError( 3904 f"Required CDK status '{cdk_status}' has no API statuses mapped. " 3905 f"Please provide at least an empty list for required status fields." 3906 ) 3907 3908 for status in api_statuses: 3909 if status in api_status_to_cdk_status: 3910 raise ValueError( 3911 f"API status {status} is already set for CDK status {cdk_status}. Please ensure API statuses are only provided once" 3912 ) 3913 api_status_to_cdk_status[status] = self._get_async_job_status(cdk_status) 3914 return api_status_to_cdk_status 3915 3916 def _get_async_job_status(self, status: str) -> AsyncJobStatus: 3917 match status: 3918 case "running": 3919 return AsyncJobStatus.RUNNING 3920 case "completed": 3921 return AsyncJobStatus.COMPLETED 3922 case "failed": 3923 return AsyncJobStatus.FAILED 3924 case "timeout": 3925 return AsyncJobStatus.TIMED_OUT 3926 case "skipped": 3927 return AsyncJobStatus.SKIPPED 3928 case _: 3929 raise ValueError(f"Unsupported CDK status {status}") 3930 3931 def create_async_retriever( 3932 self, 3933 model: AsyncRetrieverModel, 3934 config: Config, 3935 *, 3936 name: str, 3937 primary_key: Optional[ 3938 Union[str, List[str], List[List[str]]] 3939 ], # this seems to be needed to match create_simple_retriever 3940 stream_slicer: Optional[StreamSlicer], 3941 client_side_incremental_sync: Optional[Dict[str, Any]] = None, 3942 transformations: List[RecordTransformation], 3943 **kwargs: Any, 3944 ) -> AsyncRetriever: 3945 if model.download_target_requester and not model.download_target_extractor: 3946 raise ValueError( 3947 f"`download_target_extractor` required if using a `download_target_requester`" 3948 ) 3949 3950 def _get_download_retriever( 3951 requester: Requester, extractor: RecordExtractor, _decoder: Decoder 3952 ) -> SimpleRetriever: 3953 # We create a record selector for the download retriever 3954 # with no schema normalization and no transformations, neither record filter 3955 # as all this occurs in the record_selector of the AsyncRetriever 3956 record_selector = RecordSelector( 3957 extractor=extractor, 3958 name=name, 3959 record_filter=None, 3960 transformations=[], 3961 schema_normalization=TypeTransformer(TransformConfig.NoTransform), 3962 config=config, 3963 parameters={}, 3964 ) 3965 paginator = ( 3966 self._create_component_from_model( 3967 model=model.download_paginator, 3968 decoder=_decoder, 3969 config=config, 3970 url_base="", 3971 ) 3972 if model.download_paginator 3973 else NoPagination(parameters={}) 3974 ) 3975 3976 return SimpleRetriever( 3977 requester=requester, 3978 record_selector=record_selector, 3979 primary_key=None, 3980 name=name, 3981 paginator=paginator, 3982 config=config, 3983 parameters={}, 3984 log_formatter=self._get_log_formatter(None, name), 3985 ) 3986 3987 def _get_job_timeout() -> datetime.timedelta: 3988 user_defined_timeout: Optional[int] = ( 3989 int( 3990 InterpolatedString.create( 3991 str(model.polling_job_timeout), 3992 parameters={}, 3993 ).eval(config) 3994 ) 3995 if model.polling_job_timeout 3996 else None 3997 ) 3998 3999 # check for user defined timeout during the test read or 15 minutes 4000 test_read_timeout = datetime.timedelta(minutes=user_defined_timeout or 15) 4001 # default value for non-connector builder is 60 minutes. 4002 default_sync_timeout = datetime.timedelta(minutes=user_defined_timeout or 60) 4003 4004 return ( 4005 test_read_timeout if self._emit_connector_builder_messages else default_sync_timeout 4006 ) 4007 4008 decoder = ( 4009 self._create_component_from_model(model=model.decoder, config=config) 4010 if model.decoder 4011 else JsonDecoder(parameters={}) 4012 ) 4013 record_selector = self._create_component_from_model( 4014 model=model.record_selector, 4015 config=config, 4016 decoder=decoder, 4017 name=name, 4018 transformations=transformations, 4019 client_side_incremental_sync=client_side_incremental_sync, 4020 ) 4021 4022 stream_slicer = stream_slicer or SinglePartitionRouter(parameters={}) 4023 if self._should_limit_slices_fetched(): 4024 stream_slicer = cast( 4025 StreamSlicer, 4026 StreamSlicerTestReadDecorator( 4027 wrapped_slicer=stream_slicer, 4028 maximum_number_of_slices=self._limit_slices_fetched or 5, 4029 ), 4030 ) 4031 4032 creation_requester = self._create_component_from_model( 4033 model=model.creation_requester, 4034 decoder=decoder, 4035 config=config, 4036 name=f"job creation - {name}", 4037 ) 4038 polling_requester = self._create_component_from_model( 4039 model=model.polling_requester, 4040 decoder=decoder, 4041 config=config, 4042 name=f"job polling - {name}", 4043 ) 4044 job_download_components_name = f"job download - {name}" 4045 download_decoder = ( 4046 self._create_component_from_model(model=model.download_decoder, config=config) 4047 if model.download_decoder 4048 else JsonDecoder(parameters={}) 4049 ) 4050 download_extractor = ( 4051 self._create_component_from_model( 4052 model=model.download_extractor, 4053 config=config, 4054 decoder=download_decoder, 4055 parameters=model.parameters, 4056 ) 4057 if model.download_extractor 4058 else DpathExtractor( 4059 [], 4060 config=config, 4061 decoder=download_decoder, 4062 parameters=model.parameters or {}, 4063 ) 4064 ) 4065 download_requester = self._create_component_from_model( 4066 model=model.download_requester, 4067 decoder=download_decoder, 4068 config=config, 4069 name=job_download_components_name, 4070 ) 4071 download_retriever = _get_download_retriever( 4072 download_requester, download_extractor, download_decoder 4073 ) 4074 abort_requester = ( 4075 self._create_component_from_model( 4076 model=model.abort_requester, 4077 decoder=decoder, 4078 config=config, 4079 name=f"job abort - {name}", 4080 ) 4081 if model.abort_requester 4082 else None 4083 ) 4084 delete_requester = ( 4085 self._create_component_from_model( 4086 model=model.delete_requester, 4087 decoder=decoder, 4088 config=config, 4089 name=f"job delete - {name}", 4090 ) 4091 if model.delete_requester 4092 else None 4093 ) 4094 download_target_requester = ( 4095 self._create_component_from_model( 4096 model=model.download_target_requester, 4097 decoder=decoder, 4098 config=config, 4099 name=f"job extract_url - {name}", 4100 ) 4101 if model.download_target_requester 4102 else None 4103 ) 4104 status_extractor = self._create_component_from_model( 4105 model=model.status_extractor, decoder=decoder, config=config, name=name 4106 ) 4107 download_target_extractor = ( 4108 self._create_component_from_model( 4109 model=model.download_target_extractor, 4110 decoder=decoder, 4111 config=config, 4112 name=name, 4113 ) 4114 if model.download_target_extractor 4115 else None 4116 ) 4117 4118 job_repository: AsyncJobRepository = AsyncHttpJobRepository( 4119 creation_requester=creation_requester, 4120 polling_requester=polling_requester, 4121 download_retriever=download_retriever, 4122 download_target_requester=download_target_requester, 4123 abort_requester=abort_requester, 4124 delete_requester=delete_requester, 4125 status_extractor=status_extractor, 4126 status_mapping=self._create_async_job_status_mapping(model.status_mapping, config), 4127 download_target_extractor=download_target_extractor, 4128 job_timeout=_get_job_timeout(), 4129 ) 4130 4131 failed_retry_wait_time_in_seconds: Optional[int] = ( 4132 int( 4133 InterpolatedString.create( 4134 str(model.failed_retry_wait_time_in_seconds), 4135 parameters={}, 4136 ).eval(config) 4137 ) 4138 if model.failed_retry_wait_time_in_seconds 4139 else None 4140 ) 4141 4142 async_job_partition_router = AsyncJobPartitionRouter( 4143 job_orchestrator_factory=lambda stream_slices: AsyncJobOrchestrator( 4144 job_repository, 4145 stream_slices, 4146 self._job_tracker, 4147 self._message_repository, 4148 # FIXME work would need to be done here in order to detect if a stream as a parent stream that is bulk 4149 has_bulk_parent=False, 4150 # set the `job_max_retry` to 1 for the `Connector Builder`` use-case. 4151 # `None` == default retry is set to 3 attempts, under the hood. 4152 job_max_retry=1 if self._emit_connector_builder_messages else None, 4153 failed_retry_wait_time_in_seconds=failed_retry_wait_time_in_seconds, 4154 ), 4155 stream_slicer=stream_slicer, 4156 config=config, 4157 parameters=model.parameters or {}, 4158 ) 4159 4160 return AsyncRetriever( 4161 record_selector=record_selector, 4162 stream_slicer=async_job_partition_router, 4163 config=config, 4164 parameters=model.parameters or {}, 4165 ) 4166 4167 def create_spec(self, model: SpecModel, config: Config, **kwargs: Any) -> Spec: 4168 config_migrations = [ 4169 self._create_component_from_model(migration, config) 4170 for migration in ( 4171 model.config_normalization_rules.config_migrations 4172 if ( 4173 model.config_normalization_rules 4174 and model.config_normalization_rules.config_migrations 4175 ) 4176 else [] 4177 ) 4178 ] 4179 config_transformations = [ 4180 self._create_component_from_model(transformation, config) 4181 for transformation in ( 4182 model.config_normalization_rules.transformations 4183 if ( 4184 model.config_normalization_rules 4185 and model.config_normalization_rules.transformations 4186 ) 4187 else [] 4188 ) 4189 ] 4190 config_validations = [ 4191 self._create_component_from_model(validation, config) 4192 for validation in ( 4193 model.config_normalization_rules.validations 4194 if ( 4195 model.config_normalization_rules 4196 and model.config_normalization_rules.validations 4197 ) 4198 else [] 4199 ) 4200 ] 4201 4202 return Spec( 4203 connection_specification=model.connection_specification, 4204 documentation_url=model.documentation_url, 4205 advanced_auth=model.advanced_auth, 4206 parameters={}, 4207 config_migrations=config_migrations, 4208 config_transformations=config_transformations, 4209 config_validations=config_validations, 4210 ) 4211 4212 def create_substream_partition_router( 4213 self, 4214 model: SubstreamPartitionRouterModel, 4215 config: Config, 4216 *, 4217 stream_name: str, 4218 **kwargs: Any, 4219 ) -> SubstreamPartitionRouter: 4220 parent_stream_configs = [] 4221 if model.parent_stream_configs: 4222 parent_stream_configs.extend( 4223 [ 4224 self.create_parent_stream_config_with_substream_wrapper( 4225 model=parent_stream_config, config=config, stream_name=stream_name, **kwargs 4226 ) 4227 for parent_stream_config in model.parent_stream_configs 4228 ] 4229 ) 4230 4231 return SubstreamPartitionRouter( 4232 parent_stream_configs=parent_stream_configs, 4233 parameters=model.parameters or {}, 4234 config=config, 4235 ) 4236 4237 def create_parent_stream_config_with_substream_wrapper( 4238 self, model: ParentStreamConfigModel, config: Config, *, stream_name: str, **kwargs: Any 4239 ) -> Any: 4240 child_state = self._connector_state_manager.get_stream_state(stream_name, None) 4241 if NO_CURSOR_STATE_KEY in child_state: 4242 # Full refresh streams checkpoint a `{NO_CURSOR_STATE_KEY: true}` sentinel. When such a 4243 # stream is later converted to incremental with an incremental_dependency parent, 4244 # `_instantiate_parent_stream_state_manager` would treat the sentinel's boolean as a legacy 4245 # cursor value and re-key it under the parent's cursor field, crashing cursor initialization. 4246 child_state = { 4247 key: value for key, value in child_state.items() if key != NO_CURSOR_STATE_KEY 4248 } 4249 4250 parent_state: Optional[Mapping[str, Any]] = ( 4251 child_state if model.incremental_dependency and child_state else None 4252 ) 4253 connector_state_manager = self._instantiate_parent_stream_state_manager( 4254 child_state, config, model, parent_state 4255 ) 4256 4257 substream_factory = ModelToComponentFactory( 4258 custom_components_trusted=self._custom_components_trusted, 4259 connector_state_manager=connector_state_manager, 4260 limit_pages_fetched_per_slice=self._limit_pages_fetched_per_slice, 4261 limit_slices_fetched=self._limit_slices_fetched, 4262 emit_connector_builder_messages=self._emit_connector_builder_messages, 4263 disable_retries=self._disable_retries, 4264 disable_cache=self._disable_cache, 4265 message_repository=StateFilteringMessageRepository( 4266 LogAppenderMessageRepositoryDecorator( 4267 { 4268 "airbyte_cdk": {"stream": {"is_substream": True}}, 4269 "http": {"is_auxiliary": True}, 4270 }, 4271 self._message_repository, 4272 self._evaluate_log_level(self._emit_connector_builder_messages), 4273 ), 4274 ), 4275 api_budget=self._api_budget, 4276 # Share the authenticator registry so parent and child streams draw from the 4277 # same token quota counters 4278 rate_limited_authenticators=self._rate_limited_authenticators, 4279 ) 4280 4281 return substream_factory.create_parent_stream_config( 4282 model=model, config=config, stream_name=stream_name, **kwargs 4283 ) 4284 4285 def _instantiate_parent_stream_state_manager( 4286 self, 4287 child_state: MutableMapping[str, Any], 4288 config: Config, 4289 model: ParentStreamConfigModel, 4290 parent_state: Optional[Mapping[str, Any]] = None, 4291 ) -> ConnectorStateManager: 4292 """ 4293 With DefaultStream, the state needs to be provided during __init__ of the cursor as opposed to the 4294 `set_initial_state` flow that existed for the declarative cursors. This state is taken from 4295 self._connector_state_manager.get_stream_state (`self` being a newly created ModelToComponentFactory to account 4296 for the MessageRepository being different). So we need to pass a ConnectorStateManager to the 4297 ModelToComponentFactory that has the parent states. This method populates this if there is a child state and if 4298 incremental_dependency is set. 4299 """ 4300 if model.incremental_dependency and child_state: 4301 parent_stream_name = model.stream.name or "" 4302 extracted_parent_state = ConcurrentPerPartitionCursor.get_parent_state( 4303 child_state, parent_stream_name 4304 ) 4305 4306 if not extracted_parent_state: 4307 extracted_parent_state = ConcurrentPerPartitionCursor.get_global_state( 4308 child_state, parent_stream_name 4309 ) 4310 4311 if not extracted_parent_state and not isinstance(extracted_parent_state, dict): 4312 cursor_values = child_state.values() 4313 if cursor_values and len(cursor_values) == 1: 4314 incremental_sync_model: Union[ 4315 DatetimeBasedCursorModel, 4316 IncrementingCountCursorModel, 4317 ] = ( 4318 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 4319 if isinstance(model.stream, DeclarativeStreamModel) 4320 else self._get_state_delegating_stream_model( 4321 model.stream, parent_state=parent_state 4322 ).incremental_sync 4323 ) 4324 cursor_field = InterpolatedString.create( 4325 incremental_sync_model.cursor_field, 4326 parameters=incremental_sync_model.parameters or {}, 4327 ).eval(config) 4328 extracted_parent_state = AirbyteStateMessage( 4329 type=AirbyteStateType.STREAM, 4330 stream=AirbyteStreamState( 4331 stream_descriptor=StreamDescriptor( 4332 name=parent_stream_name, namespace=None 4333 ), 4334 stream_state=AirbyteStateBlob( 4335 {cursor_field: list(cursor_values)[0]} 4336 ), 4337 ), 4338 ) 4339 return ConnectorStateManager([extracted_parent_state] if extracted_parent_state else []) 4340 4341 return ConnectorStateManager([]) 4342 4343 @staticmethod 4344 def create_wait_time_from_header( 4345 model: WaitTimeFromHeaderModel, config: Config, **kwargs: Any 4346 ) -> WaitTimeFromHeaderBackoffStrategy: 4347 return WaitTimeFromHeaderBackoffStrategy( 4348 header=model.header, 4349 parameters=model.parameters or {}, 4350 config=config, 4351 regex=model.regex, 4352 max_waiting_time_in_seconds=model.max_waiting_time_in_seconds, 4353 ) 4354 4355 @staticmethod 4356 def create_wait_until_time_from_header( 4357 model: WaitUntilTimeFromHeaderModel, config: Config, **kwargs: Any 4358 ) -> WaitUntilTimeFromHeaderBackoffStrategy: 4359 return WaitUntilTimeFromHeaderBackoffStrategy( 4360 header=model.header, 4361 parameters=model.parameters or {}, 4362 config=config, 4363 min_wait=model.min_wait, 4364 regex=model.regex, 4365 max_waiting_time_in_seconds=model.max_waiting_time_in_seconds, 4366 ) 4367 4368 def get_message_repository(self) -> MessageRepository: 4369 return self._message_repository 4370 4371 def _evaluate_log_level(self, emit_connector_builder_messages: bool) -> Level: 4372 return Level.DEBUG if emit_connector_builder_messages else Level.INFO 4373 4374 @staticmethod 4375 def create_components_mapping_definition( 4376 model: ComponentMappingDefinitionModel, config: Config, **kwargs: Any 4377 ) -> ComponentMappingDefinition: 4378 interpolated_value = InterpolatedString.create( 4379 model.value, parameters=model.parameters or {} 4380 ) 4381 field_path = [ 4382 InterpolatedString.create(path, parameters=model.parameters or {}) 4383 for path in model.field_path 4384 ] 4385 return ComponentMappingDefinition( 4386 field_path=field_path, # type: ignore[arg-type] # field_path can be str and InterpolatedString 4387 value=interpolated_value, 4388 value_type=ModelToComponentFactory._json_schema_type_name_to_type(model.value_type), 4389 create_or_update=model.create_or_update, 4390 condition=model.condition, 4391 parameters=model.parameters or {}, 4392 ) 4393 4394 def create_http_components_resolver( 4395 self, model: HttpComponentsResolverModel, config: Config, stream_name: Optional[str] = None 4396 ) -> Any: 4397 retriever = self._create_component_from_model( 4398 model=model.retriever, 4399 config=config, 4400 name=f"{stream_name if stream_name else '__http_components_resolver'}", 4401 primary_key=None, 4402 stream_slicer=self._build_stream_slicer_from_partition_router(model.retriever, config), 4403 transformations=[], 4404 ) 4405 4406 components_mapping = [] 4407 for component_mapping_definition_model in model.components_mapping: 4408 if component_mapping_definition_model.condition: 4409 raise ValueError("`condition` is only supported for `ConfigComponentsResolver`") 4410 components_mapping.append( 4411 self._create_component_from_model( 4412 model=component_mapping_definition_model, 4413 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 4414 component_mapping_definition_model.value_type 4415 ), 4416 config=config, 4417 ) 4418 ) 4419 4420 return HttpComponentsResolver( 4421 retriever=retriever, 4422 stream_slicer=self._build_stream_slicer_from_partition_router(model.retriever, config), 4423 config=config, 4424 components_mapping=components_mapping, 4425 parameters=model.parameters or {}, 4426 ) 4427 4428 @staticmethod 4429 def create_stream_config( 4430 model: StreamConfigModel, config: Config, **kwargs: Any 4431 ) -> StreamConfig: 4432 model_configs_pointer: List[Union[InterpolatedString, str]] = ( 4433 [x for x in model.configs_pointer] if model.configs_pointer else [] 4434 ) 4435 4436 return StreamConfig( 4437 configs_pointer=model_configs_pointer, 4438 default_values=model.default_values, 4439 parameters=model.parameters or {}, 4440 ) 4441 4442 def create_config_components_resolver( 4443 self, 4444 model: ConfigComponentsResolverModel, 4445 config: Config, 4446 ) -> Any: 4447 model_stream_configs = ( 4448 model.stream_config if isinstance(model.stream_config, list) else [model.stream_config] 4449 ) 4450 4451 stream_configs = [ 4452 self._create_component_from_model( 4453 stream_config, config=config, parameters=model.parameters or {} 4454 ) 4455 for stream_config in model_stream_configs 4456 ] 4457 4458 components_mapping = [ 4459 self._create_component_from_model( 4460 model=components_mapping_definition_model, 4461 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 4462 components_mapping_definition_model.value_type 4463 ), 4464 config=config, 4465 parameters=model.parameters, 4466 ) 4467 for components_mapping_definition_model in model.components_mapping 4468 ] 4469 4470 return ConfigComponentsResolver( 4471 stream_configs=stream_configs, 4472 config=config, 4473 components_mapping=components_mapping, 4474 parameters=model.parameters or {}, 4475 ) 4476 4477 def create_parametrized_components_resolver( 4478 self, 4479 model: ParametrizedComponentsResolverModel, 4480 config: Config, 4481 ) -> ParametrizedComponentsResolver: 4482 stream_parameters = StreamParametersDefinition( 4483 list_of_parameters_for_stream=model.stream_parameters.list_of_parameters_for_stream 4484 ) 4485 4486 components_mapping = [] 4487 for components_mapping_definition_model in model.components_mapping: 4488 if components_mapping_definition_model.condition: 4489 raise ValueError("`condition` is only supported for `ConfigComponentsResolver`") 4490 components_mapping.append( 4491 self._create_component_from_model( 4492 model=components_mapping_definition_model, 4493 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 4494 components_mapping_definition_model.value_type 4495 ), 4496 config=config, 4497 ) 4498 ) 4499 return ParametrizedComponentsResolver( 4500 stream_parameters=stream_parameters, 4501 config=config, 4502 components_mapping=components_mapping, 4503 parameters=model.parameters or {}, 4504 ) 4505 4506 _UNSUPPORTED_DECODER_ERROR = ( 4507 "Specified decoder of {decoder_type} is not supported for pagination." 4508 "Please set as `JsonDecoder`, `XmlDecoder`, or a `CompositeRawDecoder` with an inner_parser of `JsonParser` or `GzipParser` instead." 4509 "If using `GzipParser`, please ensure that the lowest level inner_parser is a `JsonParser`." 4510 ) 4511 4512 def _is_supported_decoder_for_pagination(self, decoder: Decoder) -> bool: 4513 if isinstance(decoder, (JsonDecoder, XmlDecoder)): 4514 return True 4515 elif isinstance(decoder, CompositeRawDecoder): 4516 return self._is_supported_parser_for_pagination(decoder.parser) 4517 else: 4518 return False 4519 4520 def _is_supported_parser_for_pagination(self, parser: Parser) -> bool: 4521 if isinstance(parser, JsonParser): 4522 return True 4523 elif isinstance(parser, GzipParser): 4524 return isinstance(parser.inner_parser, JsonParser) 4525 else: 4526 return False 4527 4528 def create_http_api_budget( 4529 self, model: HTTPAPIBudgetModel, config: Config, **kwargs: Any 4530 ) -> HttpAPIBudget: 4531 policies = [ 4532 self._create_component_from_model(model=policy, config=config) 4533 for policy in model.policies 4534 ] 4535 4536 return HttpAPIBudget( 4537 policies=policies, 4538 ratelimit_reset_header=model.ratelimit_reset_header or "ratelimit-reset", 4539 ratelimit_remaining_header=model.ratelimit_remaining_header or "ratelimit-remaining", 4540 status_codes_for_ratelimit_hit=model.status_codes_for_ratelimit_hit or [429], 4541 ) 4542 4543 def create_fixed_window_call_rate_policy( 4544 self, model: FixedWindowCallRatePolicyModel, config: Config, **kwargs: Any 4545 ) -> FixedWindowCallRatePolicy: 4546 matchers = [ 4547 self._create_component_from_model(model=matcher, config=config) 4548 for matcher in model.matchers 4549 ] 4550 4551 # Set the initial reset timestamp to 10 days from now. 4552 # This value will be updated by the first request. 4553 return FixedWindowCallRatePolicy( 4554 next_reset_ts=datetime.datetime.now() + datetime.timedelta(days=10), 4555 period=parse_duration(model.period), 4556 call_limit=model.call_limit, 4557 matchers=matchers, 4558 ) 4559 4560 def create_file_uploader( 4561 self, model: FileUploaderModel, config: Config, **kwargs: Any 4562 ) -> FileUploader: 4563 name = "File Uploader" 4564 requester = self._create_component_from_model( 4565 model=model.requester, 4566 config=config, 4567 name=name, 4568 **kwargs, 4569 ) 4570 download_target_extractor = self._create_component_from_model( 4571 model=model.download_target_extractor, 4572 config=config, 4573 name=name, 4574 **kwargs, 4575 ) 4576 emit_connector_builder_messages = self._emit_connector_builder_messages 4577 file_uploader = DefaultFileUploader( 4578 requester=requester, 4579 download_target_extractor=download_target_extractor, 4580 config=config, 4581 file_writer=NoopFileWriter() 4582 if emit_connector_builder_messages 4583 else LocalFileSystemFileWriter(), 4584 parameters=model.parameters or {}, 4585 filename_extractor=model.filename_extractor if model.filename_extractor else None, 4586 ) 4587 4588 return ( 4589 ConnectorBuilderFileUploader(file_uploader) 4590 if emit_connector_builder_messages 4591 else file_uploader 4592 ) 4593 4594 def create_moving_window_call_rate_policy( 4595 self, model: MovingWindowCallRatePolicyModel, config: Config, **kwargs: Any 4596 ) -> MovingWindowCallRatePolicy: 4597 rates = [ 4598 self._create_component_from_model(model=rate, config=config) for rate in model.rates 4599 ] 4600 matchers = [ 4601 self._create_component_from_model(model=matcher, config=config) 4602 for matcher in model.matchers 4603 ] 4604 return MovingWindowCallRatePolicy( 4605 rates=rates, 4606 matchers=matchers, 4607 ) 4608 4609 def create_unlimited_call_rate_policy( 4610 self, model: UnlimitedCallRatePolicyModel, config: Config, **kwargs: Any 4611 ) -> UnlimitedCallRatePolicy: 4612 matchers = [ 4613 self._create_component_from_model(model=matcher, config=config) 4614 for matcher in model.matchers 4615 ] 4616 4617 return UnlimitedCallRatePolicy( 4618 matchers=matchers, 4619 ) 4620 4621 def create_rate(self, model: RateModel, config: Config, **kwargs: Any) -> Rate: 4622 interpolated_limit = InterpolatedString.create(str(model.limit), parameters={}) 4623 return Rate( 4624 limit=int(interpolated_limit.eval(config=config)), 4625 interval=parse_duration(model.interval), 4626 ) 4627 4628 def create_http_request_matcher( 4629 self, model: HttpRequestRegexMatcherModel, config: Config, **kwargs: Any 4630 ) -> HttpRequestRegexMatcher: 4631 weight = model.weight 4632 if weight is not None: 4633 if isinstance(weight, str): 4634 weight = int(InterpolatedString.create(weight, parameters={}).eval(config)) 4635 else: 4636 weight = int(weight) 4637 if weight < 1: 4638 raise ValueError(f"weight must be >= 1, got {weight}") 4639 return HttpRequestRegexMatcher( 4640 method=model.method, 4641 url_base=model.url_base, 4642 url_path_pattern=model.url_path_pattern, 4643 params=model.params, 4644 headers=model.headers, 4645 weight=weight, 4646 ) 4647 4648 def create_rate_limited_multiple_token_authenticator( 4649 self, 4650 model: RateLimitedMultipleTokenAuthenticatorModel, 4651 config: Config, 4652 **kwargs: Any, 4653 ) -> RateLimitedMultipleTokenAuthenticator: 4654 if isinstance(model.tokens, str): 4655 tokens_value = InterpolatedString.create(model.tokens, parameters={}).eval(config) 4656 delimiter = model.token_delimiter or "," 4657 tokens = [ 4658 token.strip() for token in str(tokens_value).split(delimiter) if token.strip() 4659 ] 4660 else: 4661 tokens = [ 4662 token_value 4663 for token in model.tokens 4664 if ( 4665 token_value := str( 4666 InterpolatedString.create(token, parameters={}).eval(config) 4667 ).strip() 4668 ) 4669 ] 4670 4671 quota_specs = [ 4672 { 4673 "name": quota_model.name, 4674 "remaining_path": quota_model.remaining_path, 4675 "reset_path": quota_model.reset_path, 4676 "limit_path": quota_model.limit_path, 4677 "remaining_header": quota_model.remaining_header, 4678 "reset_header": quota_model.reset_header, 4679 "limit_header": quota_model.limit_header, 4680 # Normalize the same way as the runtime TokenQuota below, so an omitted field 4681 # and an explicit `[]` key identically and keep sharing one set of counters. 4682 "exhaustion_status_codes": quota_model.exhaustion_status_codes or [], 4683 "matchers": [ 4684 { 4685 "method": matcher_model.method, 4686 "url_base": matcher_model.url_base, 4687 "url_path_pattern": matcher_model.url_path_pattern, 4688 "params": matcher_model.params, 4689 "headers": matcher_model.headers, 4690 "weight": matcher_model.weight, 4691 } 4692 for matcher_model in quota_model.matchers or [] 4693 ], 4694 } 4695 for quota_model in model.quotas 4696 ] 4697 4698 quota_status_url = str( 4699 InterpolatedString.create(model.quota_status_source.url, parameters={}).eval(config) 4700 ) 4701 quota_status_http_method = ( 4702 model.quota_status_source.http_method.value 4703 if model.quota_status_source.http_method 4704 else "GET" 4705 ) 4706 quota_status_headers = { 4707 key: str(InterpolatedString.create(value, parameters={}).eval(config)) 4708 for key, value in (model.quota_status_source.request_headers or {}).items() 4709 } 4710 # Normalize the same way as the quota specs above, so an omitted field and an explicit 4711 # `[]` key identically and keep sharing one set of counters. Deduplicated as well as 4712 # sorted, because the runtime turns this into a set: without it `[404]` and `[404, 404]` 4713 # would key differently and stop sharing counters while behaving identically. 4714 quota_status_unavailable_status_codes = sorted( 4715 set(model.quota_status_source.unavailable_status_codes or []) 4716 ) 4717 auth_method = model.auth_method or "Bearer" 4718 header = model.header or "Authorization" 4719 max_wait_time_str = str( 4720 InterpolatedString.create(model.max_wait_time or "PT2H", parameters={}).eval(config) 4721 ) 4722 max_wait_time = parse_duration(max_wait_time_str) 4723 if not isinstance(max_wait_time, datetime.timedelta): 4724 raise ValueError( 4725 f"max_wait_time must be a fixed-length ISO 8601 duration (e.g. 'PT2H'); " 4726 f"calendar-unit durations like '{max_wait_time_str}' are not supported" 4727 ) 4728 budget_reserve_fraction = ( 4729 model.budget_reserve_fraction if model.budget_reserve_fraction is not None else 0.1 4730 ) 4731 budget_min_reserve = ( 4732 model.budget_min_reserve if model.budget_min_reserve is not None else 50 4733 ) 4734 4735 # Reuse the same instance for identical definitions so that all streams share the 4736 # same token quota counters (similar to how api_budget is shared). The key is built 4737 # from the resolved constructor arguments rather than the raw model so that 4738 # stream-specific `$parameters` propagated onto the model (and its nested components) 4739 # cannot break instance sharing. 4740 cache_key = json.dumps( 4741 { 4742 "tokens": tokens, 4743 "quotas": quota_specs, 4744 "quota_status_url": quota_status_url, 4745 "quota_status_http_method": quota_status_http_method, 4746 "quota_status_headers": quota_status_headers, 4747 "quota_status_unavailable_status_codes": quota_status_unavailable_status_codes, 4748 "auth_method": auth_method, 4749 "header": header, 4750 "max_wait_time": max_wait_time.total_seconds(), 4751 "budget_reserve_fraction": budget_reserve_fraction, 4752 "budget_min_reserve": budget_min_reserve, 4753 }, 4754 sort_keys=True, 4755 ) 4756 if cache_key in self._rate_limited_authenticators: 4757 return self._rate_limited_authenticators[cache_key] 4758 4759 quotas = [ 4760 TokenQuota( 4761 name=quota_model.name, 4762 remaining_path=quota_model.remaining_path, 4763 reset_path=quota_model.reset_path, 4764 limit_path=quota_model.limit_path, 4765 remaining_header=quota_model.remaining_header, 4766 reset_header=quota_model.reset_header, 4767 limit_header=quota_model.limit_header, 4768 exhaustion_status_codes=quota_model.exhaustion_status_codes or [], 4769 matchers=[ 4770 self.create_http_request_matcher(matcher_model, config) 4771 for matcher_model in quota_model.matchers or [] 4772 ], 4773 ) 4774 for quota_model in model.quotas 4775 ] 4776 4777 authenticator = RateLimitedMultipleTokenAuthenticator( 4778 tokens=tokens, 4779 quotas=quotas, 4780 quota_status_url=quota_status_url, 4781 quota_status_http_method=quota_status_http_method, 4782 quota_status_headers=quota_status_headers, 4783 quota_status_unavailable_status_codes=quota_status_unavailable_status_codes, 4784 auth_method=auth_method, 4785 header=header, 4786 max_wait_time=max_wait_time, 4787 budget_reserve_fraction=budget_reserve_fraction, 4788 budget_min_reserve=budget_min_reserve, 4789 ) 4790 self._rate_limited_authenticators[cache_key] = authenticator 4791 return authenticator 4792 4793 def set_api_budget(self, component_definition: ComponentDefinition, config: Config) -> None: 4794 self._api_budget = self.create_component( 4795 model_type=HTTPAPIBudgetModel, component_definition=component_definition, config=config 4796 ) 4797 4798 def create_grouping_partition_router( 4799 self, 4800 model: GroupingPartitionRouterModel, 4801 config: Config, 4802 *, 4803 stream_name: str, 4804 **kwargs: Any, 4805 ) -> GroupingPartitionRouter: 4806 underlying_router = self._create_component_from_model( 4807 model=model.underlying_partition_router, 4808 config=config, 4809 stream_name=stream_name, 4810 **kwargs, 4811 ) 4812 if model.group_size < 1: 4813 raise ValueError(f"Group size must be greater than 0, got {model.group_size}") 4814 4815 # Request options in underlying partition routers are not supported for GroupingPartitionRouter 4816 # because they are specific to individual partitions and cannot be aggregated or handled 4817 # when grouping, potentially leading to incorrect API calls. Any request customization 4818 # should be managed at the stream level through the requester's configuration. 4819 if isinstance(underlying_router, SubstreamPartitionRouter): 4820 if any( 4821 parent_config.request_option 4822 for parent_config in underlying_router.parent_stream_configs 4823 ): 4824 raise ValueError("Request options are not supported for GroupingPartitionRouter.") 4825 4826 if isinstance(underlying_router, ListPartitionRouter): 4827 if underlying_router.request_option: 4828 raise ValueError("Request options are not supported for GroupingPartitionRouter.") 4829 4830 return GroupingPartitionRouter( 4831 group_size=model.group_size, 4832 underlying_partition_router=underlying_router, 4833 deduplicate=model.deduplicate if model.deduplicate is not None else True, 4834 config=config, 4835 ) 4836 4837 def create_union_partition_router( 4838 self, 4839 model: UnionPartitionRouterModel, 4840 config: Config, 4841 *, 4842 stream_name: str, 4843 **kwargs: Any, 4844 ) -> UnionPartitionRouter: 4845 # The schema enforces minItems: 2 for manifests; this guard covers construction paths 4846 # that bypass JSON-schema validation (the generated model carries no min_items constraint). 4847 if len(model.partition_routers) < 2: 4848 raise ValueError( 4849 f"UnionPartitionRouter for stream {stream_name} needs at least 2 child partition routers" 4850 ) 4851 4852 partition_routers = [ 4853 self._create_component_from_model( 4854 model=child, 4855 config=config, 4856 stream_name=stream_name, 4857 **kwargs, 4858 ) 4859 for child in model.partition_routers 4860 ] 4861 4862 # partition_field depends only on config/parameters, so it is evaluated once at build 4863 # time; the runtime component always receives a plain string. 4864 partition_field = InterpolatedString.create( 4865 model.partition_field, parameters=model.parameters or {} 4866 ).eval(config) 4867 4868 # Fail fast at build time when a built-in child router is statically known to emit a 4869 # partition field different from the union's. CustomPartitionRouter children are opaque 4870 # and can only be validated at runtime. 4871 for child_model in model.partition_routers: 4872 child_partition_fields: List[str] = [] 4873 if isinstance(child_model, ListPartitionRouterModel): 4874 child_partition_fields.append( 4875 InterpolatedString.create( 4876 child_model.cursor_field, parameters=child_model.parameters or {} 4877 ).eval(config) 4878 ) 4879 elif isinstance(child_model, SubstreamPartitionRouterModel): 4880 for parent_stream_config in child_model.parent_stream_configs: 4881 child_partition_fields.append( 4882 InterpolatedString.create( 4883 parent_stream_config.partition_field, 4884 parameters=parent_stream_config.parameters 4885 or child_model.parameters 4886 or {}, 4887 ).eval(config) 4888 ) 4889 elif isinstance(child_model, UnionPartitionRouterModel): 4890 child_partition_fields.append( 4891 InterpolatedString.create( 4892 child_model.partition_field, parameters=child_model.parameters or {} 4893 ).eval(config) 4894 ) 4895 for child_partition_field in child_partition_fields: 4896 if child_partition_field != partition_field: 4897 raise ValueError( 4898 f"UnionPartitionRouter expects all child partition routers to emit the " 4899 f"partition field '{partition_field}', but a " 4900 f"{child_model.type} child emits '{child_partition_field}'." 4901 ) 4902 4903 # A union slice comes from exactly one child partition router, so request options 4904 # declared on children cannot be applied consistently to requests built from the 4905 # normalized union slices. Partition values should be consumed via interpolation 4906 # (e.g. stream_partition) instead. Note that this validation only covers built-in 4907 # router types; CustomPartitionRouter children are opaque, so any request options 4908 # they implement internally cannot be detected or rejected here. 4909 for router in partition_routers: 4910 if isinstance(router, SubstreamPartitionRouter): 4911 if any( 4912 parent_config.request_option for parent_config in router.parent_stream_configs 4913 ): 4914 raise ValueError("Request options are not supported for UnionPartitionRouter.") 4915 if isinstance(router, ListPartitionRouter) and router.request_option: 4916 raise ValueError("Request options are not supported for UnionPartitionRouter.") 4917 4918 return UnionPartitionRouter( 4919 partition_routers=partition_routers, 4920 partition_field=partition_field, 4921 parameters=model.parameters or {}, 4922 ) 4923 4924 def _ensure_query_properties_to_model( 4925 self, requester: Union[HttpRequesterModel, CustomRequesterModel] 4926 ) -> None: 4927 """ 4928 For some reason, it seems like CustomRequesterModel request_parameters stays as dictionaries which means that 4929 the other conditions relying on it being QueryPropertiesModel instead of a dict fail. Here, we migrate them to 4930 proper model. 4931 """ 4932 if not hasattr(requester, "request_parameters"): 4933 return 4934 4935 request_parameters = requester.request_parameters 4936 if request_parameters and isinstance(request_parameters, Dict): 4937 for request_parameter_key in request_parameters.keys(): 4938 request_parameter = request_parameters[request_parameter_key] 4939 if ( 4940 isinstance(request_parameter, Dict) 4941 and request_parameter.get("type") == "QueryProperties" 4942 ): 4943 request_parameters[request_parameter_key] = QueryPropertiesModel.parse_obj( 4944 request_parameter 4945 ) 4946 4947 def _get_catalog_defined_cursor_field( 4948 self, stream_name: str, allow_catalog_defined_cursor_field: bool 4949 ) -> Optional[CursorField]: 4950 if not allow_catalog_defined_cursor_field: 4951 return None 4952 4953 configured_stream = self._stream_name_to_configured_stream.get(stream_name) 4954 4955 # Depending on the operation is being performed, there may not be a configured stream yet. In this 4956 # case we return None which will then use the default cursor field defined on the cursor model. 4957 # We also treat cursor_field: [""] (list with empty string) as no cursor field, since this can 4958 # occur when the platform serializes "no cursor configured" streams incorrectly. 4959 if ( 4960 not configured_stream 4961 or not configured_stream.cursor_field 4962 or not configured_stream.cursor_field[0] 4963 ): 4964 return None 4965 elif len(configured_stream.cursor_field) > 1: 4966 raise ValueError( 4967 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." 4968 ) 4969 else: 4970 return CursorField( 4971 cursor_field_key=configured_stream.cursor_field[0], 4972 supports_catalog_defined_cursor_field=allow_catalog_defined_cursor_field, 4973 )
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 return RecordExpander( 2511 expand_records_from_field=model.expand_records_from_field, 2512 config=config, 2513 parameters=model.parameters or {}, 2514 remain_original_record=model.remain_original_record or False, 2515 on_no_records=OnNoRecords(model.on_no_records.value) 2516 if model.on_no_records 2517 else OnNoRecords.skip, 2518 )
2520 @staticmethod 2521 def create_response_to_file_extractor( 2522 model: ResponseToFileExtractorModel, 2523 **kwargs: Any, 2524 ) -> ResponseToFileExtractor: 2525 return ResponseToFileExtractor( 2526 parameters=model.parameters or {}, 2527 preserve_na_values=model.preserve_na_values or False, 2528 )
2530 @staticmethod 2531 def create_exponential_backoff_strategy( 2532 model: ExponentialBackoffStrategyModel, config: Config 2533 ) -> ExponentialBackoffStrategy: 2534 ModelToComponentFactory._validate_jitter_range(model.jitter_range_in_seconds) 2535 return ExponentialBackoffStrategy( 2536 factor=model.factor or 5, 2537 jitter_range_in_seconds=model.jitter_range_in_seconds, 2538 parameters=model.parameters or {}, 2539 config=config, 2540 )
2546 def create_http_requester( 2547 self, 2548 model: HttpRequesterModel, 2549 config: Config, 2550 decoder: Decoder = JsonDecoder(parameters={}), 2551 query_properties_key: Optional[str] = None, 2552 use_cache: Optional[bool] = None, 2553 *, 2554 name: str, 2555 ) -> HttpRequester: 2556 authenticator = ( 2557 self._create_component_from_model( 2558 model=model.authenticator, 2559 config=config, 2560 url_base=model.url or model.url_base, 2561 name=name, 2562 decoder=decoder, 2563 ) 2564 if model.authenticator 2565 else None 2566 ) 2567 error_handler = ( 2568 self._create_component_from_model(model=model.error_handler, config=config) 2569 if model.error_handler 2570 else DefaultErrorHandler( 2571 backoff_strategies=[], 2572 response_filters=[], 2573 config=config, 2574 parameters=model.parameters or {}, 2575 ) 2576 ) 2577 2578 api_budget = self._api_budget 2579 2580 request_options_provider = InterpolatedRequestOptionsProvider( 2581 request_body=model.request_body, 2582 request_body_data=model.request_body_data, 2583 request_body_json=model.request_body_json, 2584 request_headers=model.request_headers, 2585 request_parameters=model.request_parameters, # type: ignore # QueryProperties have been removed in `create_simple_retriever` 2586 query_properties_key=query_properties_key, 2587 config=config, 2588 parameters=model.parameters or {}, 2589 ) 2590 2591 assert model.use_cache is not None # for mypy 2592 assert model.http_method is not None # for mypy 2593 2594 should_use_cache = (model.use_cache or bool(use_cache)) and not self._disable_cache 2595 2596 return HttpRequester( 2597 name=name, 2598 url=model.url, 2599 url_base=model.url_base, 2600 path=model.path, 2601 authenticator=authenticator, 2602 error_handler=error_handler, 2603 api_budget=api_budget, 2604 http_method=HttpMethod[model.http_method.value], 2605 request_options_provider=request_options_provider, 2606 config=config, 2607 disable_retries=self._disable_retries, 2608 parameters=model.parameters or {}, 2609 message_repository=self._message_repository, 2610 use_cache=should_use_cache, 2611 decoder=decoder, 2612 stream_response=decoder.is_stream_response() if decoder else False, 2613 )
2615 @staticmethod 2616 def create_http_response_filter( 2617 model: HttpResponseFilterModel, config: Config, **kwargs: Any 2618 ) -> HttpResponseFilter: 2619 if model.action: 2620 action = ResponseAction(model.action.value) 2621 else: 2622 action = None 2623 2624 failure_type = FailureType(model.failure_type.value) if model.failure_type else None 2625 2626 http_codes = ( 2627 set(model.http_codes) if model.http_codes else set() 2628 ) # JSON schema notation has no set data type. The schema enforces an array of unique elements 2629 2630 return HttpResponseFilter( 2631 action=action, 2632 failure_type=failure_type, 2633 error_message=model.error_message or "", 2634 error_message_contains=model.error_message_contains or "", 2635 http_codes=http_codes, 2636 predicate=model.predicate or "", 2637 config=config, 2638 parameters=model.parameters or {}, 2639 )
2647 def create_complex_field_type( 2648 self, model: ComplexFieldTypeModel, config: Config, **kwargs: Any 2649 ) -> ComplexFieldType: 2650 items = ( 2651 self._create_component_from_model(model=model.items, config=config) 2652 if isinstance(model.items, ComplexFieldTypeModel) 2653 else model.items 2654 ) 2655 2656 return ComplexFieldType(field_type=model.field_type, items=items)
2658 def create_types_map(self, model: TypesMapModel, config: Config, **kwargs: Any) -> TypesMap: 2659 target_type = ( 2660 self._create_component_from_model(model=model.target_type, config=config) 2661 if isinstance(model.target_type, ComplexFieldTypeModel) 2662 else model.target_type 2663 ) 2664 2665 return TypesMap( 2666 target_type=target_type, 2667 current_type=model.current_type, 2668 condition=model.condition if model.condition is not None else "True", 2669 )
2671 def create_schema_type_identifier( 2672 self, model: SchemaTypeIdentifierModel, config: Config, **kwargs: Any 2673 ) -> SchemaTypeIdentifier: 2674 types_mapping = [] 2675 if model.types_mapping: 2676 types_mapping.extend( 2677 [ 2678 self._create_component_from_model(types_map, config=config) 2679 for types_map in model.types_mapping 2680 ] 2681 ) 2682 model_schema_pointer: List[Union[InterpolatedString, str]] = ( 2683 [x for x in model.schema_pointer] if model.schema_pointer else [] 2684 ) 2685 model_key_pointer: List[Union[InterpolatedString, str]] = [x for x in model.key_pointer] 2686 model_type_pointer: Optional[List[Union[InterpolatedString, str]]] = ( 2687 [x for x in model.type_pointer] if model.type_pointer else None 2688 ) 2689 2690 return SchemaTypeIdentifier( 2691 schema_pointer=model_schema_pointer, 2692 key_pointer=model_key_pointer, 2693 type_pointer=model_type_pointer, 2694 types_mapping=types_mapping, 2695 parameters=model.parameters or {}, 2696 )
2698 def create_dynamic_schema_loader( 2699 self, model: DynamicSchemaLoaderModel, config: Config, **kwargs: Any 2700 ) -> DynamicSchemaLoader: 2701 schema_transformations = [] 2702 if model.schema_transformations: 2703 for transformation_model in model.schema_transformations: 2704 schema_transformations.append( 2705 self._create_component_from_model(model=transformation_model, config=config) 2706 ) 2707 name = "dynamic_properties" 2708 retriever = self._create_component_from_model( 2709 model=model.retriever, 2710 config=config, 2711 name=name, 2712 primary_key=None, 2713 partition_router=self._build_stream_slicer_from_partition_router( 2714 model.retriever, config 2715 ), 2716 transformations=[], 2717 use_cache=True, 2718 log_formatter=( 2719 lambda response: format_http_message( 2720 response, 2721 f"Schema loader '{name}' request", 2722 f"Request performed in order to extract schema.", 2723 name, 2724 is_auxiliary=True, 2725 ) 2726 ), 2727 ) 2728 schema_type_identifier = self._create_component_from_model( 2729 model.schema_type_identifier, config=config, parameters=model.parameters or {} 2730 ) 2731 schema_filter = ( 2732 self._create_component_from_model( 2733 model.schema_filter, config=config, parameters=model.parameters or {} 2734 ) 2735 if model.schema_filter is not None 2736 else None 2737 ) 2738 2739 return DynamicSchemaLoader( 2740 retriever=retriever, 2741 config=config, 2742 schema_transformations=schema_transformations, 2743 schema_filter=schema_filter, 2744 schema_type_identifier=schema_type_identifier, 2745 parameters=model.parameters or {}, 2746 )
2766 def create_json_items_decoder( 2767 self, model: JsonItemsDecoderModel, config: Config, **kwargs: Any 2768 ) -> Decoder: 2769 return CompositeRawDecoder( 2770 parser=ModelToComponentFactory._get_parser(model, config), 2771 stream_response=False if self._emit_connector_builder_messages else True, 2772 )
2774 def create_gzip_decoder( 2775 self, model: GzipDecoderModel, config: Config, **kwargs: Any 2776 ) -> Decoder: 2777 _compressed_response_types = { 2778 "gzip", 2779 "x-gzip", 2780 "gzip, deflate", 2781 "x-gzip, deflate", 2782 "application/zip", 2783 "application/gzip", 2784 "application/x-gzip", 2785 "application/x-zip-compressed", 2786 } 2787 2788 gzip_parser: GzipParser = ModelToComponentFactory._get_parser(model, config) # type: ignore # based on the model, we know this will be a GzipParser 2789 2790 if self._emit_connector_builder_messages: 2791 return CompositeRawDecoder(gzip_parser, False) 2792 2793 transport_gzip_parser = GzipParser(inner_parser=gzip_parser) 2794 return CompositeRawDecoder.by_headers( 2795 [ 2796 ({"Content-Encoding"}, {"gzip"}, transport_gzip_parser), 2797 ({"Content-Type"}, _compressed_response_types, gzip_parser), 2798 ], 2799 stream_response=True, 2800 fallback_parser=gzip_parser, 2801 )
2855 def create_jwt_authenticator( 2856 self, model: JwtAuthenticatorModel, config: Config, **kwargs: Any 2857 ) -> JwtAuthenticator: 2858 jwt_headers = model.jwt_headers or JwtHeadersModel(kid=None, typ="JWT", cty=None) 2859 jwt_payload = model.jwt_payload or JwtPayloadModel(iss=None, sub=None, aud=None) 2860 request_option = ( 2861 self._create_component_from_model(model.request_option, config) 2862 if model.request_option 2863 else None 2864 ) 2865 return JwtAuthenticator( 2866 config=config, 2867 parameters=model.parameters or {}, 2868 algorithm=JwtAlgorithm(model.algorithm.value), 2869 secret_key=model.secret_key, 2870 base64_encode_secret_key=model.base64_encode_secret_key, 2871 token_duration=model.token_duration, 2872 header_prefix=model.header_prefix, 2873 kid=jwt_headers.kid, 2874 typ=jwt_headers.typ, 2875 cty=jwt_headers.cty, 2876 iss=jwt_payload.iss, 2877 sub=jwt_payload.sub, 2878 aud=jwt_payload.aud, 2879 additional_jwt_headers=model.additional_jwt_headers, 2880 additional_jwt_payload=model.additional_jwt_payload, 2881 passphrase=model.passphrase, 2882 request_option=request_option, 2883 )
2885 def create_list_partition_router( 2886 self, model: ListPartitionRouterModel, config: Config, **kwargs: Any 2887 ) -> ListPartitionRouter: 2888 request_option = ( 2889 self._create_component_from_model(model.request_option, config) 2890 if model.request_option 2891 else None 2892 ) 2893 return ListPartitionRouter( 2894 cursor_field=model.cursor_field, 2895 request_option=request_option, 2896 values=model.values, 2897 config=config, 2898 parameters=model.parameters or {}, 2899 )
2901 @staticmethod 2902 def create_min_max_datetime( 2903 model: MinMaxDatetimeModel, config: Config, **kwargs: Any 2904 ) -> MinMaxDatetime: 2905 return MinMaxDatetime( 2906 datetime=model.datetime, 2907 datetime_format=model.datetime_format or "", 2908 max_datetime=model.max_datetime or "", 2909 min_datetime=model.min_datetime or "", 2910 parameters=model.parameters or {}, 2911 )
2923 def create_oauth_authenticator( 2924 self, model: OAuthAuthenticatorModel, config: Config, **kwargs: Any 2925 ) -> DeclarativeOauth2Authenticator: 2926 profile_assertion = ( 2927 self._create_component_from_model(model.profile_assertion, config=config) 2928 if model.profile_assertion 2929 else None 2930 ) 2931 2932 refresh_token_error_status_codes, refresh_token_error_key, refresh_token_error_values = ( 2933 self._get_refresh_token_error_information(model) 2934 ) 2935 if model.refresh_token_updater: 2936 # ignore type error because fixing it would have a lot of dependencies, revisit later 2937 return DeclarativeSingleUseRefreshTokenOauth2Authenticator( # type: ignore 2938 config, 2939 InterpolatedString.create( 2940 model.token_refresh_endpoint, # type: ignore 2941 parameters=model.parameters or {}, 2942 ).eval(config), 2943 access_token_name=InterpolatedString.create( 2944 model.access_token_name or "access_token", parameters=model.parameters or {} 2945 ).eval(config), 2946 refresh_token_name=model.refresh_token_updater.refresh_token_name, 2947 expires_in_name=InterpolatedString.create( 2948 model.expires_in_name or "expires_in", parameters=model.parameters or {} 2949 ).eval(config), 2950 client_id_name=InterpolatedString.create( 2951 model.client_id_name or "client_id", parameters=model.parameters or {} 2952 ).eval(config), 2953 client_id=InterpolatedString.create( 2954 model.client_id, parameters=model.parameters or {} 2955 ).eval(config) 2956 if model.client_id 2957 else model.client_id, 2958 client_secret_name=InterpolatedString.create( 2959 model.client_secret_name or "client_secret", parameters=model.parameters or {} 2960 ).eval(config), 2961 client_secret=InterpolatedString.create( 2962 model.client_secret, parameters=model.parameters or {} 2963 ).eval(config) 2964 if model.client_secret 2965 else model.client_secret, 2966 access_token_config_path=model.refresh_token_updater.access_token_config_path, 2967 refresh_token_config_path=model.refresh_token_updater.refresh_token_config_path, 2968 token_expiry_date_config_path=model.refresh_token_updater.token_expiry_date_config_path, 2969 grant_type_name=InterpolatedString.create( 2970 model.grant_type_name or "grant_type", parameters=model.parameters or {} 2971 ).eval(config), 2972 grant_type=InterpolatedString.create( 2973 model.grant_type or "refresh_token", parameters=model.parameters or {} 2974 ).eval(config), 2975 refresh_request_body=InterpolatedMapping( 2976 model.refresh_request_body or {}, parameters=model.parameters or {} 2977 ).eval(config), 2978 refresh_request_headers=InterpolatedMapping( 2979 model.refresh_request_headers or {}, parameters=model.parameters or {} 2980 ).eval(config), 2981 send_refresh_request_as_query_params=bool( 2982 model.send_refresh_request_as_query_params 2983 ), 2984 scopes=model.scopes, 2985 token_expiry_date_format=model.token_expiry_date_format, 2986 token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), 2987 message_repository=self._message_repository, 2988 refresh_token_error_status_codes=refresh_token_error_status_codes, 2989 refresh_token_error_key=refresh_token_error_key, 2990 refresh_token_error_values=refresh_token_error_values, 2991 ) 2992 # ignore type error because fixing it would have a lot of dependencies, revisit later 2993 return DeclarativeOauth2Authenticator( # type: ignore 2994 access_token_name=model.access_token_name or "access_token", 2995 access_token_value=model.access_token_value, 2996 client_id_name=model.client_id_name or "client_id", 2997 client_id=model.client_id, 2998 client_secret_name=model.client_secret_name or "client_secret", 2999 client_secret=model.client_secret, 3000 expires_in_name=model.expires_in_name or "expires_in", 3001 grant_type_name=model.grant_type_name or "grant_type", 3002 grant_type=model.grant_type or "refresh_token", 3003 refresh_request_body=model.refresh_request_body, 3004 refresh_request_headers=model.refresh_request_headers, 3005 send_refresh_request_as_query_params=bool(model.send_refresh_request_as_query_params), 3006 refresh_token_name=model.refresh_token_name or "refresh_token", 3007 refresh_token=model.refresh_token, 3008 scopes=model.scopes, 3009 token_expiry_date=model.token_expiry_date, 3010 token_expiry_date_format=model.token_expiry_date_format, 3011 token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), 3012 token_refresh_endpoint=model.token_refresh_endpoint, 3013 config=config, 3014 parameters=model.parameters or {}, 3015 message_repository=self._message_repository, 3016 profile_assertion=profile_assertion, 3017 use_profile_assertion=model.use_profile_assertion, 3018 refresh_token_error_status_codes=refresh_token_error_status_codes, 3019 refresh_token_error_key=refresh_token_error_key, 3020 refresh_token_error_values=refresh_token_error_values, 3021 )
3071 def create_offset_increment( 3072 self, 3073 model: OffsetIncrementModel, 3074 config: Config, 3075 decoder: Decoder, 3076 extractor_model: Optional[Union[CustomRecordExtractorModel, DpathExtractorModel]] = None, 3077 **kwargs: Any, 3078 ) -> OffsetIncrement: 3079 if isinstance(decoder, PaginationDecoderDecorator): 3080 inner_decoder = decoder.decoder 3081 else: 3082 inner_decoder = decoder 3083 decoder = PaginationDecoderDecorator(decoder=decoder) 3084 3085 if self._is_supported_decoder_for_pagination(inner_decoder): 3086 decoder_to_use = decoder 3087 else: 3088 raise ValueError( 3089 self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(inner_decoder)) 3090 ) 3091 3092 # Ideally we would instantiate the runtime extractor from highest most level (in this case the SimpleRetriever) 3093 # so that it can be shared by OffSetIncrement and RecordSelector. However, due to how we instantiate the 3094 # decoder with various decorators here, but not in create_record_selector, it is simpler to retain existing 3095 # behavior by having two separate extractors with identical behavior since they use the same extractor model. 3096 # When we have more time to investigate we can look into reusing the same component. 3097 extractor = ( 3098 self._create_component_from_model( 3099 model=extractor_model, config=config, decoder=decoder_to_use 3100 ) 3101 if extractor_model 3102 else None 3103 ) 3104 3105 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 3106 # If page_size is a string that represents an integer (not an interpolation), convert it back. 3107 page_size = model.page_size 3108 if isinstance(page_size, str) and page_size.isdigit(): 3109 page_size = int(page_size) 3110 3111 return OffsetIncrement( 3112 page_size=page_size, 3113 config=config, 3114 decoder=decoder_to_use, 3115 extractor=extractor, 3116 inject_on_first_request=model.inject_on_first_request or False, 3117 parameters=model.parameters or {}, 3118 )
3120 def create_page_increment( 3121 self, 3122 model: PageIncrementModel, 3123 config: Config, 3124 decoder: Optional[Decoder] = None, 3125 extractor_model: Optional[Union[CustomRecordExtractorModel, DpathExtractorModel]] = None, 3126 **kwargs: Any, 3127 ) -> PageIncrement: 3128 # Like OffsetIncrement, we instantiate a separate extractor with identical behavior to the 3129 # RecordSelector's so the strategy can count the raw records in the response. This ensures 3130 # pagination is driven by the API's page size, not the post-filter record count. 3131 extractor = ( 3132 self._create_component_from_model(model=extractor_model, config=config, decoder=decoder) 3133 if extractor_model 3134 else None 3135 ) 3136 3137 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 3138 # If page_size is a string that represents an integer (not an interpolation), convert it back. 3139 page_size = model.page_size 3140 if isinstance(page_size, str) and page_size.isdigit(): 3141 page_size = int(page_size) 3142 3143 return PageIncrement( 3144 page_size=page_size, 3145 config=config, 3146 start_from_page=model.start_from_page or 0, 3147 inject_on_first_request=model.inject_on_first_request or False, 3148 extractor=extractor, 3149 parameters=model.parameters or {}, 3150 )
3152 def create_parent_stream_config( 3153 self, model: ParentStreamConfigModel, config: Config, *, stream_name: str, **kwargs: Any 3154 ) -> ParentStreamConfig: 3155 declarative_stream = self._create_component_from_model( 3156 model.stream, 3157 config=config, 3158 is_parent=True, 3159 **kwargs, 3160 ) 3161 request_option = ( 3162 self._create_component_from_model(model.request_option, config=config) 3163 if model.request_option 3164 else None 3165 ) 3166 3167 if model.lazy_read_pointer and any("*" in pointer for pointer in model.lazy_read_pointer): 3168 raise ValueError( 3169 "The '*' wildcard in 'lazy_read_pointer' is not supported — only direct paths are allowed." 3170 ) 3171 3172 model_lazy_read_pointer: List[Union[InterpolatedString, str]] = ( 3173 [x for x in model.lazy_read_pointer] if model.lazy_read_pointer else [] 3174 ) 3175 3176 return ParentStreamConfig( 3177 parent_key=model.parent_key, 3178 request_option=request_option, 3179 stream=declarative_stream, 3180 partition_field=model.partition_field, 3181 config=config, 3182 incremental_dependency=model.incremental_dependency or False, 3183 parameters=model.parameters or {}, 3184 extra_fields=model.extra_fields, 3185 lazy_read_pointer=model_lazy_read_pointer, 3186 )
3188 def create_properties_from_endpoint( 3189 self, model: PropertiesFromEndpointModel, config: Config, **kwargs: Any 3190 ) -> PropertiesFromEndpoint: 3191 retriever = self._create_component_from_model( 3192 model=model.retriever, 3193 config=config, 3194 name="dynamic_properties", 3195 primary_key=None, 3196 stream_slicer=None, 3197 transformations=[], 3198 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 3199 ) 3200 return PropertiesFromEndpoint( 3201 property_field_path=model.property_field_path, 3202 retriever=retriever, 3203 config=config, 3204 parameters=model.parameters or {}, 3205 )
3207 def create_property_chunking( 3208 self, model: PropertyChunkingModel, config: Config, **kwargs: Any 3209 ) -> PropertyChunking: 3210 record_merge_strategy = ( 3211 self._create_component_from_model( 3212 model=model.record_merge_strategy, config=config, **kwargs 3213 ) 3214 if model.record_merge_strategy 3215 else None 3216 ) 3217 3218 property_limit_type: PropertyLimitType 3219 match model.property_limit_type: 3220 case PropertyLimitTypeModel.property_count: 3221 property_limit_type = PropertyLimitType.property_count 3222 case PropertyLimitTypeModel.characters: 3223 property_limit_type = PropertyLimitType.characters 3224 case _: 3225 raise ValueError(f"Invalid PropertyLimitType {property_limit_type}") 3226 3227 return PropertyChunking( 3228 property_limit_type=property_limit_type, 3229 property_limit=model.property_limit, 3230 record_merge_strategy=record_merge_strategy, 3231 config=config, 3232 parameters=model.parameters or {}, 3233 )
3235 def create_query_properties( 3236 self, model: QueryPropertiesModel, config: Config, *, stream_name: str, **kwargs: Any 3237 ) -> QueryProperties: 3238 if isinstance(model.property_list, list): 3239 property_list = model.property_list 3240 else: 3241 property_list = self._create_component_from_model( 3242 model=model.property_list, config=config, **kwargs 3243 ) 3244 3245 property_chunking = ( 3246 self._create_component_from_model( 3247 model=model.property_chunking, config=config, **kwargs 3248 ) 3249 if model.property_chunking 3250 else None 3251 ) 3252 3253 property_selector = ( 3254 self._create_component_from_model( 3255 model=model.property_selector, config=config, stream_name=stream_name, **kwargs 3256 ) 3257 if model.property_selector 3258 else None 3259 ) 3260 3261 return QueryProperties( 3262 property_list=property_list, 3263 always_include_properties=model.always_include_properties, 3264 property_chunking=property_chunking, 3265 property_selector=property_selector, 3266 config=config, 3267 parameters=model.parameters or {}, 3268 )
3270 def create_json_schema_property_selector( 3271 self, 3272 model: JsonSchemaPropertySelectorModel, 3273 config: Config, 3274 *, 3275 stream_name: str, 3276 **kwargs: Any, 3277 ) -> JsonSchemaPropertySelector: 3278 configured_stream = self._stream_name_to_configured_stream.get(stream_name) 3279 3280 transformations = [] 3281 if model.transformations: 3282 for transformation_model in model.transformations: 3283 transformations.append( 3284 self._create_component_from_model(model=transformation_model, config=config) 3285 ) 3286 3287 return JsonSchemaPropertySelector( 3288 configured_stream=configured_stream, 3289 properties_transformations=transformations, 3290 config=config, 3291 parameters=model.parameters or {}, 3292 )
3306 @staticmethod 3307 def create_request_option( 3308 model: RequestOptionModel, config: Config, **kwargs: Any 3309 ) -> RequestOption: 3310 inject_into = RequestOptionType(model.inject_into.value) 3311 field_path: Optional[List[Union[InterpolatedString, str]]] = ( 3312 [ 3313 InterpolatedString.create(segment, parameters=kwargs.get("parameters", {})) 3314 for segment in model.field_path 3315 ] 3316 if model.field_path 3317 else None 3318 ) 3319 field_name = ( 3320 InterpolatedString.create(model.field_name, parameters=kwargs.get("parameters", {})) 3321 if model.field_name 3322 else None 3323 ) 3324 return RequestOption( 3325 field_name=field_name, 3326 field_path=field_path, 3327 inject_into=inject_into, 3328 parameters=kwargs.get("parameters", {}), 3329 )
3331 def create_record_selector( 3332 self, 3333 model: RecordSelectorModel, 3334 config: Config, 3335 *, 3336 name: str, 3337 transformations: List[RecordTransformation] | None = None, 3338 decoder: Decoder | None = None, 3339 client_side_incremental_sync_cursor: Optional[Cursor] = None, 3340 is_client_side_incremental_sync: bool = False, 3341 file_uploader: Optional[DefaultFileUploader] = None, 3342 **kwargs: Any, 3343 ) -> RecordSelector: 3344 extractor = self._create_component_from_model( 3345 model=model.extractor, decoder=decoder, config=config 3346 ) 3347 record_filter = ( 3348 self._create_component_from_model(model.record_filter, config=config) 3349 if model.record_filter 3350 else None 3351 ) 3352 3353 # A client-side incremental stream transforms before filtering by default. That default belongs to the flag, 3354 # not to the component that ends up doing the cursor comparison: a data feed does it in the retriever and 3355 # receives no cursor here, but its `record_filter` condition must keep running after the transformations. 3356 default_transform_before_filtering = bool( 3357 client_side_incremental_sync_cursor or is_client_side_incremental_sync 3358 ) 3359 transform_before_filtering = ( 3360 default_transform_before_filtering 3361 if model.transform_before_filtering is None 3362 else model.transform_before_filtering 3363 ) 3364 if client_side_incremental_sync_cursor: 3365 record_filter = ClientSideIncrementalRecordFilterDecorator( 3366 config=config, 3367 parameters=model.parameters, 3368 condition=model.record_filter.condition 3369 if (model.record_filter and hasattr(model.record_filter, "condition")) 3370 else None, 3371 cursor=client_side_incremental_sync_cursor, 3372 ) 3373 3374 if model.schema_normalization is None: 3375 # default to no schema normalization if not set 3376 model.schema_normalization = SchemaNormalizationModel.None_ 3377 3378 schema_normalization = ( 3379 TypeTransformer(SCHEMA_TRANSFORMER_TYPE_MAPPING[model.schema_normalization]) 3380 if isinstance(model.schema_normalization, SchemaNormalizationModel) 3381 else self._create_component_from_model(model.schema_normalization, config=config) # type: ignore[arg-type] # custom normalization model expected here 3382 ) 3383 3384 return RecordSelector( 3385 extractor=extractor, 3386 name=name, 3387 config=config, 3388 record_filter=record_filter, 3389 transformations=transformations or [], 3390 file_uploader=file_uploader, 3391 schema_normalization=schema_normalization, 3392 parameters=model.parameters or {}, 3393 transform_before_filtering=transform_before_filtering, 3394 )
3404 def create_selective_authenticator( 3405 self, model: SelectiveAuthenticatorModel, config: Config, **kwargs: Any 3406 ) -> DeclarativeAuthenticator: 3407 authenticators = { 3408 name: self._create_component_from_model(model=auth, config=config) 3409 for name, auth in model.authenticators.items() 3410 } 3411 # SelectiveAuthenticator will return instance of DeclarativeAuthenticator or raise ValueError error 3412 return SelectiveAuthenticator( # type: ignore[abstract] 3413 config=config, 3414 authenticators=authenticators, 3415 authenticator_selection_path=model.authenticator_selection_path, 3416 **kwargs, 3417 )
3419 @staticmethod 3420 def create_legacy_session_token_authenticator( 3421 model: LegacySessionTokenAuthenticatorModel, config: Config, *, url_base: str, **kwargs: Any 3422 ) -> LegacySessionTokenAuthenticator: 3423 return LegacySessionTokenAuthenticator( 3424 api_url=url_base, 3425 header=model.header, 3426 login_url=model.login_url, 3427 password=model.password or "", 3428 session_token=model.session_token or "", 3429 session_token_response_key=model.session_token_response_key or "", 3430 username=model.username or "", 3431 validate_session_url=model.validate_session_url, 3432 config=config, 3433 parameters=model.parameters or {}, 3434 )
3436 def create_simple_retriever( 3437 self, 3438 model: SimpleRetrieverModel, 3439 config: Config, 3440 *, 3441 name: str, 3442 primary_key: Optional[Union[str, List[str], List[List[str]]]], 3443 request_options_provider: Optional[RequestOptionsProvider] = None, 3444 cursor: Optional[Cursor] = None, 3445 has_stop_condition_cursor: bool = False, 3446 is_client_side_incremental_sync: bool = False, 3447 transformations: List[RecordTransformation], 3448 file_uploader: Optional[DefaultFileUploader] = None, 3449 incremental_sync: Optional[ 3450 Union[IncrementingCountCursorModel, DatetimeBasedCursorModel] 3451 ] = None, 3452 use_cache: Optional[bool] = None, 3453 log_formatter: Optional[Callable[[Response], Any]] = None, 3454 partition_router: Optional[PartitionRouter] = None, 3455 **kwargs: Any, 3456 ) -> SimpleRetriever: 3457 def _get_url(req: Requester) -> str: 3458 """ 3459 Closure to get the URL from the requester. This is used to get the URL in the case of a lazy retriever. 3460 This is needed because the URL is not set until the requester is created. 3461 """ 3462 3463 _url: str = ( 3464 model.requester.url 3465 if hasattr(model.requester, "url") and model.requester.url is not None 3466 else req.get_url(stream_state=None, stream_slice=None, next_page_token=None) 3467 ) 3468 _url_base: str = ( 3469 model.requester.url_base 3470 if hasattr(model.requester, "url_base") and model.requester.url_base is not None 3471 else req.get_url_base(stream_state=None, stream_slice=None, next_page_token=None) 3472 ) 3473 3474 return _url or _url_base 3475 3476 if cursor is None: 3477 cursor = FinalStateCursor(name, None, self._message_repository) 3478 3479 # A data feed drops the records the cursor considers already synced in the retriever, which sits downstream of 3480 # the paginator. Letting the record selector drop them as well would be redundant and would hide them from the 3481 # pagination stop condition, so a data feed never delegates that filtering to the record selector, whether 3482 # `is_client_side_incremental` is set or not. The `condition` from `record_filter` is intentionally left out of 3483 # the post-pagination filter and stays in the record selector, which preserves the existing behaviour: the 3484 # selector runs inside the page loop, so the records the condition rejects never reach the paginator's 3485 # accounting. Moving it downstream would start counting them. 3486 post_pagination_filter = ( 3487 ClientSideIncrementalRecordFilterDecorator( 3488 config=config, 3489 parameters=model.parameters or {}, 3490 condition=None, 3491 cursor=cursor, 3492 ) 3493 if has_stop_condition_cursor 3494 else None 3495 ) 3496 client_side_incremental_cursor = ( 3497 cursor if is_client_side_incremental_sync and not post_pagination_filter else None 3498 ) 3499 if post_pagination_filter and is_client_side_incremental_sync: 3500 LOGGER.warning( 3501 f"Stream {name}: `is_client_side_incremental` adds no record filtering when `is_data_feed` is set, " 3502 "as a data feed already filters on the cursor value. It still makes the record selector apply the " 3503 "transformations before the `record_filter` condition." 3504 ) 3505 3506 decoder = ( 3507 self._create_component_from_model(model=model.decoder, config=config) 3508 if model.decoder 3509 else JsonDecoder(parameters={}) 3510 ) 3511 record_selector = self._create_component_from_model( 3512 model=model.record_selector, 3513 name=name, 3514 config=config, 3515 decoder=decoder, 3516 transformations=transformations, 3517 client_side_incremental_sync_cursor=client_side_incremental_cursor, 3518 is_client_side_incremental_sync=is_client_side_incremental_sync, 3519 file_uploader=file_uploader, 3520 ) 3521 3522 query_properties: Optional[QueryProperties] = None 3523 query_properties_key: Optional[str] = None 3524 self._ensure_query_properties_to_model(model.requester) 3525 if self._has_query_properties_in_request_parameters(model.requester): 3526 # It is better to be explicit about an error if PropertiesFromEndpoint is defined in multiple 3527 # places instead of default to request_parameters which isn't clearly documented 3528 if ( 3529 hasattr(model.requester, "fetch_properties_from_endpoint") 3530 and model.requester.fetch_properties_from_endpoint 3531 ): 3532 raise ValueError( 3533 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" 3534 ) 3535 3536 query_properties_definitions = [] 3537 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() 3538 if isinstance(request_parameter, QueryPropertiesModel): 3539 query_properties_key = key 3540 query_properties_definitions.append(request_parameter) 3541 3542 if len(query_properties_definitions) > 1: 3543 raise ValueError( 3544 f"request_parameters only supports defining one QueryProperties field, but found {len(query_properties_definitions)} usages" 3545 ) 3546 3547 if len(query_properties_definitions) == 1: 3548 query_properties = self._create_component_from_model( 3549 model=query_properties_definitions[0], stream_name=name, config=config 3550 ) 3551 3552 # Removes QueryProperties components from the interpolated mappings because it has been designed 3553 # to be used by the SimpleRetriever and will be resolved from the provider from the slice directly 3554 # instead of through jinja interpolation 3555 if hasattr(model.requester, "request_parameters") and isinstance( 3556 model.requester.request_parameters, Mapping 3557 ): 3558 model.requester.request_parameters = self._remove_query_properties( 3559 model.requester.request_parameters 3560 ) 3561 elif ( 3562 hasattr(model.requester, "fetch_properties_from_endpoint") 3563 and model.requester.fetch_properties_from_endpoint 3564 ): 3565 # todo: Deprecate this condition once dependent connectors migrate to query_properties 3566 query_properties_definition = QueryPropertiesModel( 3567 type="QueryProperties", 3568 property_list=model.requester.fetch_properties_from_endpoint, 3569 always_include_properties=None, 3570 property_chunking=None, 3571 ) # type: ignore # $parameters has a default value 3572 3573 query_properties = self.create_query_properties( 3574 model=query_properties_definition, 3575 stream_name=name, 3576 config=config, 3577 ) 3578 elif hasattr(model.requester, "query_properties") and model.requester.query_properties: 3579 query_properties = self.create_query_properties( 3580 model=model.requester.query_properties, 3581 stream_name=name, 3582 config=config, 3583 ) 3584 3585 requester = self._create_component_from_model( 3586 model=model.requester, 3587 decoder=decoder, 3588 name=name, 3589 query_properties_key=query_properties_key, 3590 use_cache=use_cache, 3591 config=config, 3592 ) 3593 3594 if not request_options_provider: 3595 request_options_provider = DefaultRequestOptionsProvider(parameters={}) 3596 if isinstance(request_options_provider, DefaultRequestOptionsProvider) and isinstance( 3597 partition_router, PartitionRouter 3598 ): 3599 request_options_provider = partition_router 3600 3601 paginator = ( 3602 self._create_component_from_model( 3603 model=model.paginator, 3604 config=config, 3605 url_base=_get_url(requester), 3606 extractor_model=model.record_selector.extractor, 3607 decoder=decoder, 3608 cursor_used_for_stop_condition=cursor if has_stop_condition_cursor else None, 3609 ) 3610 if model.paginator 3611 else NoPagination(parameters={}) 3612 ) 3613 3614 ignore_stream_slicer_parameters_on_paginated_requests = ( 3615 model.ignore_stream_slicer_parameters_on_paginated_requests or False 3616 ) 3617 3618 if ( 3619 model.partition_router 3620 and isinstance(model.partition_router, SubstreamPartitionRouterModel) 3621 and not bool(self._connector_state_manager.get_stream_state(name, None)) 3622 and any( 3623 parent_stream_config.lazy_read_pointer 3624 for parent_stream_config in model.partition_router.parent_stream_configs 3625 ) 3626 ): 3627 if incremental_sync: 3628 if incremental_sync.type != "DatetimeBasedCursor": 3629 raise ValueError( 3630 f"LazySimpleRetriever only supports DatetimeBasedCursor. Found: {incremental_sync.type}." 3631 ) 3632 3633 elif incremental_sync.step or incremental_sync.cursor_granularity: 3634 raise ValueError( 3635 f"Found more that one slice per parent. LazySimpleRetriever only supports single slice read for stream - {name}." 3636 ) 3637 3638 if model.decoder and model.decoder.type != "JsonDecoder": 3639 raise ValueError( 3640 f"LazySimpleRetriever only supports JsonDecoder. Found: {model.decoder.type}." 3641 ) 3642 3643 return LazySimpleRetriever( 3644 name=name, 3645 paginator=paginator, 3646 primary_key=primary_key, 3647 requester=requester, 3648 record_selector=record_selector, 3649 stream_slicer=_NO_STREAM_SLICING, 3650 request_option_provider=request_options_provider, 3651 config=config, 3652 ignore_stream_slicer_parameters_on_paginated_requests=ignore_stream_slicer_parameters_on_paginated_requests, 3653 post_pagination_filter=post_pagination_filter, 3654 parameters=model.parameters or {}, 3655 ) 3656 3657 if ( 3658 model.record_selector.record_filter 3659 and model.pagination_reset 3660 and model.pagination_reset.limits 3661 ): 3662 raise ValueError("PaginationResetLimits are not supported while having record filter.") 3663 3664 return SimpleRetriever( 3665 name=name, 3666 paginator=paginator, 3667 primary_key=primary_key, 3668 requester=requester, 3669 record_selector=record_selector, 3670 stream_slicer=_NO_STREAM_SLICING, 3671 request_option_provider=request_options_provider, 3672 config=config, 3673 ignore_stream_slicer_parameters_on_paginated_requests=ignore_stream_slicer_parameters_on_paginated_requests, 3674 additional_query_properties=query_properties, 3675 log_formatter=self._get_log_formatter(log_formatter, name), 3676 pagination_tracker_factory=self._create_pagination_tracker_factory( 3677 model.pagination_reset, cursor 3678 ), 3679 post_pagination_filter=post_pagination_filter, 3680 parameters=model.parameters or {}, 3681 )
3759 def create_state_delegating_stream( 3760 self, 3761 model: StateDelegatingStreamModel, 3762 config: Config, 3763 **kwargs: Any, 3764 ) -> DefaultStream: 3765 if ( 3766 model.full_refresh_stream.name != model.name 3767 or model.name != model.incremental_stream.name 3768 ): 3769 raise ValueError( 3770 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}." 3771 ) 3772 3773 # Resolve api_retention_period with config context (supports Jinja2 interpolation) 3774 resolved_retention_period: Optional[str] = None 3775 if model.api_retention_period: 3776 interpolated_retention = InterpolatedString.create( 3777 model.api_retention_period, parameters=model.parameters or {} 3778 ) 3779 resolved_value = interpolated_retention.eval(config=config) 3780 if resolved_value: 3781 resolved_retention_period = str(resolved_value) 3782 3783 if resolved_retention_period: 3784 for stream_model in (model.full_refresh_stream, model.incremental_stream): 3785 if isinstance(stream_model.incremental_sync, IncrementingCountCursorModel): 3786 raise ValueError( 3787 f"Stream '{model.name}' uses IncrementingCountCursor which is not supported " 3788 f"with api_retention_period. IncrementingCountCursor does not use datetime-based " 3789 f"cursors, so cursor age validation cannot be performed." 3790 ) 3791 3792 stream_state = self._connector_state_manager.get_stream_state(model.name, None) 3793 3794 if not stream_state: 3795 return self._create_component_from_model( # type: ignore[no-any-return] 3796 model.full_refresh_stream, config=config, **kwargs 3797 ) 3798 3799 incremental_stream: DefaultStream = self._create_component_from_model( 3800 model.incremental_stream, config=config, **kwargs 3801 ) # type: ignore[assignment] 3802 3803 # Only run cursor age validation for streams that are in the configured 3804 # catalog (or when no catalog was provided, e.g. during discover / connector 3805 # builder). Streams not selected by the user but instantiated as parent-stream 3806 # dependencies must not go through this path because it emits state messages 3807 # that the destination does not know about, causing "Stream not found" crashes. 3808 stream_is_in_catalog = ( 3809 not self._stream_name_to_configured_stream # no catalog → validate by default 3810 or model.name in self._stream_name_to_configured_stream 3811 ) 3812 if resolved_retention_period and stream_is_in_catalog: 3813 full_refresh_stream: DefaultStream = self._create_component_from_model( 3814 model.full_refresh_stream, config=config, **kwargs 3815 ) # type: ignore[assignment] 3816 if self._is_cursor_older_than_retention_period( 3817 stream_state, 3818 full_refresh_stream.cursor, 3819 incremental_stream.cursor, 3820 resolved_retention_period, 3821 model.name, 3822 ): 3823 # Clear state BEFORE constructing the full_refresh_stream so that 3824 # its cursor starts from start_date instead of the stale cursor. 3825 self._connector_state_manager.update_state_for_stream(model.name, None, {}) 3826 state_message = self._connector_state_manager.create_state_message(model.name, None) 3827 self._message_repository.emit_message(state_message) 3828 return self._create_component_from_model( # type: ignore[no-any-return] 3829 model.full_refresh_stream, config=config, **kwargs 3830 ) 3831 3832 return incremental_stream
3931 def create_async_retriever( 3932 self, 3933 model: AsyncRetrieverModel, 3934 config: Config, 3935 *, 3936 name: str, 3937 primary_key: Optional[ 3938 Union[str, List[str], List[List[str]]] 3939 ], # this seems to be needed to match create_simple_retriever 3940 stream_slicer: Optional[StreamSlicer], 3941 client_side_incremental_sync: Optional[Dict[str, Any]] = None, 3942 transformations: List[RecordTransformation], 3943 **kwargs: Any, 3944 ) -> AsyncRetriever: 3945 if model.download_target_requester and not model.download_target_extractor: 3946 raise ValueError( 3947 f"`download_target_extractor` required if using a `download_target_requester`" 3948 ) 3949 3950 def _get_download_retriever( 3951 requester: Requester, extractor: RecordExtractor, _decoder: Decoder 3952 ) -> SimpleRetriever: 3953 # We create a record selector for the download retriever 3954 # with no schema normalization and no transformations, neither record filter 3955 # as all this occurs in the record_selector of the AsyncRetriever 3956 record_selector = RecordSelector( 3957 extractor=extractor, 3958 name=name, 3959 record_filter=None, 3960 transformations=[], 3961 schema_normalization=TypeTransformer(TransformConfig.NoTransform), 3962 config=config, 3963 parameters={}, 3964 ) 3965 paginator = ( 3966 self._create_component_from_model( 3967 model=model.download_paginator, 3968 decoder=_decoder, 3969 config=config, 3970 url_base="", 3971 ) 3972 if model.download_paginator 3973 else NoPagination(parameters={}) 3974 ) 3975 3976 return SimpleRetriever( 3977 requester=requester, 3978 record_selector=record_selector, 3979 primary_key=None, 3980 name=name, 3981 paginator=paginator, 3982 config=config, 3983 parameters={}, 3984 log_formatter=self._get_log_formatter(None, name), 3985 ) 3986 3987 def _get_job_timeout() -> datetime.timedelta: 3988 user_defined_timeout: Optional[int] = ( 3989 int( 3990 InterpolatedString.create( 3991 str(model.polling_job_timeout), 3992 parameters={}, 3993 ).eval(config) 3994 ) 3995 if model.polling_job_timeout 3996 else None 3997 ) 3998 3999 # check for user defined timeout during the test read or 15 minutes 4000 test_read_timeout = datetime.timedelta(minutes=user_defined_timeout or 15) 4001 # default value for non-connector builder is 60 minutes. 4002 default_sync_timeout = datetime.timedelta(minutes=user_defined_timeout or 60) 4003 4004 return ( 4005 test_read_timeout if self._emit_connector_builder_messages else default_sync_timeout 4006 ) 4007 4008 decoder = ( 4009 self._create_component_from_model(model=model.decoder, config=config) 4010 if model.decoder 4011 else JsonDecoder(parameters={}) 4012 ) 4013 record_selector = self._create_component_from_model( 4014 model=model.record_selector, 4015 config=config, 4016 decoder=decoder, 4017 name=name, 4018 transformations=transformations, 4019 client_side_incremental_sync=client_side_incremental_sync, 4020 ) 4021 4022 stream_slicer = stream_slicer or SinglePartitionRouter(parameters={}) 4023 if self._should_limit_slices_fetched(): 4024 stream_slicer = cast( 4025 StreamSlicer, 4026 StreamSlicerTestReadDecorator( 4027 wrapped_slicer=stream_slicer, 4028 maximum_number_of_slices=self._limit_slices_fetched or 5, 4029 ), 4030 ) 4031 4032 creation_requester = self._create_component_from_model( 4033 model=model.creation_requester, 4034 decoder=decoder, 4035 config=config, 4036 name=f"job creation - {name}", 4037 ) 4038 polling_requester = self._create_component_from_model( 4039 model=model.polling_requester, 4040 decoder=decoder, 4041 config=config, 4042 name=f"job polling - {name}", 4043 ) 4044 job_download_components_name = f"job download - {name}" 4045 download_decoder = ( 4046 self._create_component_from_model(model=model.download_decoder, config=config) 4047 if model.download_decoder 4048 else JsonDecoder(parameters={}) 4049 ) 4050 download_extractor = ( 4051 self._create_component_from_model( 4052 model=model.download_extractor, 4053 config=config, 4054 decoder=download_decoder, 4055 parameters=model.parameters, 4056 ) 4057 if model.download_extractor 4058 else DpathExtractor( 4059 [], 4060 config=config, 4061 decoder=download_decoder, 4062 parameters=model.parameters or {}, 4063 ) 4064 ) 4065 download_requester = self._create_component_from_model( 4066 model=model.download_requester, 4067 decoder=download_decoder, 4068 config=config, 4069 name=job_download_components_name, 4070 ) 4071 download_retriever = _get_download_retriever( 4072 download_requester, download_extractor, download_decoder 4073 ) 4074 abort_requester = ( 4075 self._create_component_from_model( 4076 model=model.abort_requester, 4077 decoder=decoder, 4078 config=config, 4079 name=f"job abort - {name}", 4080 ) 4081 if model.abort_requester 4082 else None 4083 ) 4084 delete_requester = ( 4085 self._create_component_from_model( 4086 model=model.delete_requester, 4087 decoder=decoder, 4088 config=config, 4089 name=f"job delete - {name}", 4090 ) 4091 if model.delete_requester 4092 else None 4093 ) 4094 download_target_requester = ( 4095 self._create_component_from_model( 4096 model=model.download_target_requester, 4097 decoder=decoder, 4098 config=config, 4099 name=f"job extract_url - {name}", 4100 ) 4101 if model.download_target_requester 4102 else None 4103 ) 4104 status_extractor = self._create_component_from_model( 4105 model=model.status_extractor, decoder=decoder, config=config, name=name 4106 ) 4107 download_target_extractor = ( 4108 self._create_component_from_model( 4109 model=model.download_target_extractor, 4110 decoder=decoder, 4111 config=config, 4112 name=name, 4113 ) 4114 if model.download_target_extractor 4115 else None 4116 ) 4117 4118 job_repository: AsyncJobRepository = AsyncHttpJobRepository( 4119 creation_requester=creation_requester, 4120 polling_requester=polling_requester, 4121 download_retriever=download_retriever, 4122 download_target_requester=download_target_requester, 4123 abort_requester=abort_requester, 4124 delete_requester=delete_requester, 4125 status_extractor=status_extractor, 4126 status_mapping=self._create_async_job_status_mapping(model.status_mapping, config), 4127 download_target_extractor=download_target_extractor, 4128 job_timeout=_get_job_timeout(), 4129 ) 4130 4131 failed_retry_wait_time_in_seconds: Optional[int] = ( 4132 int( 4133 InterpolatedString.create( 4134 str(model.failed_retry_wait_time_in_seconds), 4135 parameters={}, 4136 ).eval(config) 4137 ) 4138 if model.failed_retry_wait_time_in_seconds 4139 else None 4140 ) 4141 4142 async_job_partition_router = AsyncJobPartitionRouter( 4143 job_orchestrator_factory=lambda stream_slices: AsyncJobOrchestrator( 4144 job_repository, 4145 stream_slices, 4146 self._job_tracker, 4147 self._message_repository, 4148 # FIXME work would need to be done here in order to detect if a stream as a parent stream that is bulk 4149 has_bulk_parent=False, 4150 # set the `job_max_retry` to 1 for the `Connector Builder`` use-case. 4151 # `None` == default retry is set to 3 attempts, under the hood. 4152 job_max_retry=1 if self._emit_connector_builder_messages else None, 4153 failed_retry_wait_time_in_seconds=failed_retry_wait_time_in_seconds, 4154 ), 4155 stream_slicer=stream_slicer, 4156 config=config, 4157 parameters=model.parameters or {}, 4158 ) 4159 4160 return AsyncRetriever( 4161 record_selector=record_selector, 4162 stream_slicer=async_job_partition_router, 4163 config=config, 4164 parameters=model.parameters or {}, 4165 )
4167 def create_spec(self, model: SpecModel, config: Config, **kwargs: Any) -> Spec: 4168 config_migrations = [ 4169 self._create_component_from_model(migration, config) 4170 for migration in ( 4171 model.config_normalization_rules.config_migrations 4172 if ( 4173 model.config_normalization_rules 4174 and model.config_normalization_rules.config_migrations 4175 ) 4176 else [] 4177 ) 4178 ] 4179 config_transformations = [ 4180 self._create_component_from_model(transformation, config) 4181 for transformation in ( 4182 model.config_normalization_rules.transformations 4183 if ( 4184 model.config_normalization_rules 4185 and model.config_normalization_rules.transformations 4186 ) 4187 else [] 4188 ) 4189 ] 4190 config_validations = [ 4191 self._create_component_from_model(validation, config) 4192 for validation in ( 4193 model.config_normalization_rules.validations 4194 if ( 4195 model.config_normalization_rules 4196 and model.config_normalization_rules.validations 4197 ) 4198 else [] 4199 ) 4200 ] 4201 4202 return Spec( 4203 connection_specification=model.connection_specification, 4204 documentation_url=model.documentation_url, 4205 advanced_auth=model.advanced_auth, 4206 parameters={}, 4207 config_migrations=config_migrations, 4208 config_transformations=config_transformations, 4209 config_validations=config_validations, 4210 )
4212 def create_substream_partition_router( 4213 self, 4214 model: SubstreamPartitionRouterModel, 4215 config: Config, 4216 *, 4217 stream_name: str, 4218 **kwargs: Any, 4219 ) -> SubstreamPartitionRouter: 4220 parent_stream_configs = [] 4221 if model.parent_stream_configs: 4222 parent_stream_configs.extend( 4223 [ 4224 self.create_parent_stream_config_with_substream_wrapper( 4225 model=parent_stream_config, config=config, stream_name=stream_name, **kwargs 4226 ) 4227 for parent_stream_config in model.parent_stream_configs 4228 ] 4229 ) 4230 4231 return SubstreamPartitionRouter( 4232 parent_stream_configs=parent_stream_configs, 4233 parameters=model.parameters or {}, 4234 config=config, 4235 )
4237 def create_parent_stream_config_with_substream_wrapper( 4238 self, model: ParentStreamConfigModel, config: Config, *, stream_name: str, **kwargs: Any 4239 ) -> Any: 4240 child_state = self._connector_state_manager.get_stream_state(stream_name, None) 4241 if NO_CURSOR_STATE_KEY in child_state: 4242 # Full refresh streams checkpoint a `{NO_CURSOR_STATE_KEY: true}` sentinel. When such a 4243 # stream is later converted to incremental with an incremental_dependency parent, 4244 # `_instantiate_parent_stream_state_manager` would treat the sentinel's boolean as a legacy 4245 # cursor value and re-key it under the parent's cursor field, crashing cursor initialization. 4246 child_state = { 4247 key: value for key, value in child_state.items() if key != NO_CURSOR_STATE_KEY 4248 } 4249 4250 parent_state: Optional[Mapping[str, Any]] = ( 4251 child_state if model.incremental_dependency and child_state else None 4252 ) 4253 connector_state_manager = self._instantiate_parent_stream_state_manager( 4254 child_state, config, model, parent_state 4255 ) 4256 4257 substream_factory = ModelToComponentFactory( 4258 custom_components_trusted=self._custom_components_trusted, 4259 connector_state_manager=connector_state_manager, 4260 limit_pages_fetched_per_slice=self._limit_pages_fetched_per_slice, 4261 limit_slices_fetched=self._limit_slices_fetched, 4262 emit_connector_builder_messages=self._emit_connector_builder_messages, 4263 disable_retries=self._disable_retries, 4264 disable_cache=self._disable_cache, 4265 message_repository=StateFilteringMessageRepository( 4266 LogAppenderMessageRepositoryDecorator( 4267 { 4268 "airbyte_cdk": {"stream": {"is_substream": True}}, 4269 "http": {"is_auxiliary": True}, 4270 }, 4271 self._message_repository, 4272 self._evaluate_log_level(self._emit_connector_builder_messages), 4273 ), 4274 ), 4275 api_budget=self._api_budget, 4276 # Share the authenticator registry so parent and child streams draw from the 4277 # same token quota counters 4278 rate_limited_authenticators=self._rate_limited_authenticators, 4279 ) 4280 4281 return substream_factory.create_parent_stream_config( 4282 model=model, config=config, stream_name=stream_name, **kwargs 4283 )
4343 @staticmethod 4344 def create_wait_time_from_header( 4345 model: WaitTimeFromHeaderModel, config: Config, **kwargs: Any 4346 ) -> WaitTimeFromHeaderBackoffStrategy: 4347 return WaitTimeFromHeaderBackoffStrategy( 4348 header=model.header, 4349 parameters=model.parameters or {}, 4350 config=config, 4351 regex=model.regex, 4352 max_waiting_time_in_seconds=model.max_waiting_time_in_seconds, 4353 )
4355 @staticmethod 4356 def create_wait_until_time_from_header( 4357 model: WaitUntilTimeFromHeaderModel, config: Config, **kwargs: Any 4358 ) -> WaitUntilTimeFromHeaderBackoffStrategy: 4359 return WaitUntilTimeFromHeaderBackoffStrategy( 4360 header=model.header, 4361 parameters=model.parameters or {}, 4362 config=config, 4363 min_wait=model.min_wait, 4364 regex=model.regex, 4365 max_waiting_time_in_seconds=model.max_waiting_time_in_seconds, 4366 )
4374 @staticmethod 4375 def create_components_mapping_definition( 4376 model: ComponentMappingDefinitionModel, config: Config, **kwargs: Any 4377 ) -> ComponentMappingDefinition: 4378 interpolated_value = InterpolatedString.create( 4379 model.value, parameters=model.parameters or {} 4380 ) 4381 field_path = [ 4382 InterpolatedString.create(path, parameters=model.parameters or {}) 4383 for path in model.field_path 4384 ] 4385 return ComponentMappingDefinition( 4386 field_path=field_path, # type: ignore[arg-type] # field_path can be str and InterpolatedString 4387 value=interpolated_value, 4388 value_type=ModelToComponentFactory._json_schema_type_name_to_type(model.value_type), 4389 create_or_update=model.create_or_update, 4390 condition=model.condition, 4391 parameters=model.parameters or {}, 4392 )
4394 def create_http_components_resolver( 4395 self, model: HttpComponentsResolverModel, config: Config, stream_name: Optional[str] = None 4396 ) -> Any: 4397 retriever = self._create_component_from_model( 4398 model=model.retriever, 4399 config=config, 4400 name=f"{stream_name if stream_name else '__http_components_resolver'}", 4401 primary_key=None, 4402 stream_slicer=self._build_stream_slicer_from_partition_router(model.retriever, config), 4403 transformations=[], 4404 ) 4405 4406 components_mapping = [] 4407 for component_mapping_definition_model in model.components_mapping: 4408 if component_mapping_definition_model.condition: 4409 raise ValueError("`condition` is only supported for `ConfigComponentsResolver`") 4410 components_mapping.append( 4411 self._create_component_from_model( 4412 model=component_mapping_definition_model, 4413 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 4414 component_mapping_definition_model.value_type 4415 ), 4416 config=config, 4417 ) 4418 ) 4419 4420 return HttpComponentsResolver( 4421 retriever=retriever, 4422 stream_slicer=self._build_stream_slicer_from_partition_router(model.retriever, config), 4423 config=config, 4424 components_mapping=components_mapping, 4425 parameters=model.parameters or {}, 4426 )
4428 @staticmethod 4429 def create_stream_config( 4430 model: StreamConfigModel, config: Config, **kwargs: Any 4431 ) -> StreamConfig: 4432 model_configs_pointer: List[Union[InterpolatedString, str]] = ( 4433 [x for x in model.configs_pointer] if model.configs_pointer else [] 4434 ) 4435 4436 return StreamConfig( 4437 configs_pointer=model_configs_pointer, 4438 default_values=model.default_values, 4439 parameters=model.parameters or {}, 4440 )
4442 def create_config_components_resolver( 4443 self, 4444 model: ConfigComponentsResolverModel, 4445 config: Config, 4446 ) -> Any: 4447 model_stream_configs = ( 4448 model.stream_config if isinstance(model.stream_config, list) else [model.stream_config] 4449 ) 4450 4451 stream_configs = [ 4452 self._create_component_from_model( 4453 stream_config, config=config, parameters=model.parameters or {} 4454 ) 4455 for stream_config in model_stream_configs 4456 ] 4457 4458 components_mapping = [ 4459 self._create_component_from_model( 4460 model=components_mapping_definition_model, 4461 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 4462 components_mapping_definition_model.value_type 4463 ), 4464 config=config, 4465 parameters=model.parameters, 4466 ) 4467 for components_mapping_definition_model in model.components_mapping 4468 ] 4469 4470 return ConfigComponentsResolver( 4471 stream_configs=stream_configs, 4472 config=config, 4473 components_mapping=components_mapping, 4474 parameters=model.parameters or {}, 4475 )
4477 def create_parametrized_components_resolver( 4478 self, 4479 model: ParametrizedComponentsResolverModel, 4480 config: Config, 4481 ) -> ParametrizedComponentsResolver: 4482 stream_parameters = StreamParametersDefinition( 4483 list_of_parameters_for_stream=model.stream_parameters.list_of_parameters_for_stream 4484 ) 4485 4486 components_mapping = [] 4487 for components_mapping_definition_model in model.components_mapping: 4488 if components_mapping_definition_model.condition: 4489 raise ValueError("`condition` is only supported for `ConfigComponentsResolver`") 4490 components_mapping.append( 4491 self._create_component_from_model( 4492 model=components_mapping_definition_model, 4493 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 4494 components_mapping_definition_model.value_type 4495 ), 4496 config=config, 4497 ) 4498 ) 4499 return ParametrizedComponentsResolver( 4500 stream_parameters=stream_parameters, 4501 config=config, 4502 components_mapping=components_mapping, 4503 parameters=model.parameters or {}, 4504 )
4528 def create_http_api_budget( 4529 self, model: HTTPAPIBudgetModel, config: Config, **kwargs: Any 4530 ) -> HttpAPIBudget: 4531 policies = [ 4532 self._create_component_from_model(model=policy, config=config) 4533 for policy in model.policies 4534 ] 4535 4536 return HttpAPIBudget( 4537 policies=policies, 4538 ratelimit_reset_header=model.ratelimit_reset_header or "ratelimit-reset", 4539 ratelimit_remaining_header=model.ratelimit_remaining_header or "ratelimit-remaining", 4540 status_codes_for_ratelimit_hit=model.status_codes_for_ratelimit_hit or [429], 4541 )
4543 def create_fixed_window_call_rate_policy( 4544 self, model: FixedWindowCallRatePolicyModel, config: Config, **kwargs: Any 4545 ) -> FixedWindowCallRatePolicy: 4546 matchers = [ 4547 self._create_component_from_model(model=matcher, config=config) 4548 for matcher in model.matchers 4549 ] 4550 4551 # Set the initial reset timestamp to 10 days from now. 4552 # This value will be updated by the first request. 4553 return FixedWindowCallRatePolicy( 4554 next_reset_ts=datetime.datetime.now() + datetime.timedelta(days=10), 4555 period=parse_duration(model.period), 4556 call_limit=model.call_limit, 4557 matchers=matchers, 4558 )
4560 def create_file_uploader( 4561 self, model: FileUploaderModel, config: Config, **kwargs: Any 4562 ) -> FileUploader: 4563 name = "File Uploader" 4564 requester = self._create_component_from_model( 4565 model=model.requester, 4566 config=config, 4567 name=name, 4568 **kwargs, 4569 ) 4570 download_target_extractor = self._create_component_from_model( 4571 model=model.download_target_extractor, 4572 config=config, 4573 name=name, 4574 **kwargs, 4575 ) 4576 emit_connector_builder_messages = self._emit_connector_builder_messages 4577 file_uploader = DefaultFileUploader( 4578 requester=requester, 4579 download_target_extractor=download_target_extractor, 4580 config=config, 4581 file_writer=NoopFileWriter() 4582 if emit_connector_builder_messages 4583 else LocalFileSystemFileWriter(), 4584 parameters=model.parameters or {}, 4585 filename_extractor=model.filename_extractor if model.filename_extractor else None, 4586 ) 4587 4588 return ( 4589 ConnectorBuilderFileUploader(file_uploader) 4590 if emit_connector_builder_messages 4591 else file_uploader 4592 )
4594 def create_moving_window_call_rate_policy( 4595 self, model: MovingWindowCallRatePolicyModel, config: Config, **kwargs: Any 4596 ) -> MovingWindowCallRatePolicy: 4597 rates = [ 4598 self._create_component_from_model(model=rate, config=config) for rate in model.rates 4599 ] 4600 matchers = [ 4601 self._create_component_from_model(model=matcher, config=config) 4602 for matcher in model.matchers 4603 ] 4604 return MovingWindowCallRatePolicy( 4605 rates=rates, 4606 matchers=matchers, 4607 )
4609 def create_unlimited_call_rate_policy( 4610 self, model: UnlimitedCallRatePolicyModel, config: Config, **kwargs: Any 4611 ) -> UnlimitedCallRatePolicy: 4612 matchers = [ 4613 self._create_component_from_model(model=matcher, config=config) 4614 for matcher in model.matchers 4615 ] 4616 4617 return UnlimitedCallRatePolicy( 4618 matchers=matchers, 4619 )
4628 def create_http_request_matcher( 4629 self, model: HttpRequestRegexMatcherModel, config: Config, **kwargs: Any 4630 ) -> HttpRequestRegexMatcher: 4631 weight = model.weight 4632 if weight is not None: 4633 if isinstance(weight, str): 4634 weight = int(InterpolatedString.create(weight, parameters={}).eval(config)) 4635 else: 4636 weight = int(weight) 4637 if weight < 1: 4638 raise ValueError(f"weight must be >= 1, got {weight}") 4639 return HttpRequestRegexMatcher( 4640 method=model.method, 4641 url_base=model.url_base, 4642 url_path_pattern=model.url_path_pattern, 4643 params=model.params, 4644 headers=model.headers, 4645 weight=weight, 4646 )
4648 def create_rate_limited_multiple_token_authenticator( 4649 self, 4650 model: RateLimitedMultipleTokenAuthenticatorModel, 4651 config: Config, 4652 **kwargs: Any, 4653 ) -> RateLimitedMultipleTokenAuthenticator: 4654 if isinstance(model.tokens, str): 4655 tokens_value = InterpolatedString.create(model.tokens, parameters={}).eval(config) 4656 delimiter = model.token_delimiter or "," 4657 tokens = [ 4658 token.strip() for token in str(tokens_value).split(delimiter) if token.strip() 4659 ] 4660 else: 4661 tokens = [ 4662 token_value 4663 for token in model.tokens 4664 if ( 4665 token_value := str( 4666 InterpolatedString.create(token, parameters={}).eval(config) 4667 ).strip() 4668 ) 4669 ] 4670 4671 quota_specs = [ 4672 { 4673 "name": quota_model.name, 4674 "remaining_path": quota_model.remaining_path, 4675 "reset_path": quota_model.reset_path, 4676 "limit_path": quota_model.limit_path, 4677 "remaining_header": quota_model.remaining_header, 4678 "reset_header": quota_model.reset_header, 4679 "limit_header": quota_model.limit_header, 4680 # Normalize the same way as the runtime TokenQuota below, so an omitted field 4681 # and an explicit `[]` key identically and keep sharing one set of counters. 4682 "exhaustion_status_codes": quota_model.exhaustion_status_codes or [], 4683 "matchers": [ 4684 { 4685 "method": matcher_model.method, 4686 "url_base": matcher_model.url_base, 4687 "url_path_pattern": matcher_model.url_path_pattern, 4688 "params": matcher_model.params, 4689 "headers": matcher_model.headers, 4690 "weight": matcher_model.weight, 4691 } 4692 for matcher_model in quota_model.matchers or [] 4693 ], 4694 } 4695 for quota_model in model.quotas 4696 ] 4697 4698 quota_status_url = str( 4699 InterpolatedString.create(model.quota_status_source.url, parameters={}).eval(config) 4700 ) 4701 quota_status_http_method = ( 4702 model.quota_status_source.http_method.value 4703 if model.quota_status_source.http_method 4704 else "GET" 4705 ) 4706 quota_status_headers = { 4707 key: str(InterpolatedString.create(value, parameters={}).eval(config)) 4708 for key, value in (model.quota_status_source.request_headers or {}).items() 4709 } 4710 # Normalize the same way as the quota specs above, so an omitted field and an explicit 4711 # `[]` key identically and keep sharing one set of counters. Deduplicated as well as 4712 # sorted, because the runtime turns this into a set: without it `[404]` and `[404, 404]` 4713 # would key differently and stop sharing counters while behaving identically. 4714 quota_status_unavailable_status_codes = sorted( 4715 set(model.quota_status_source.unavailable_status_codes or []) 4716 ) 4717 auth_method = model.auth_method or "Bearer" 4718 header = model.header or "Authorization" 4719 max_wait_time_str = str( 4720 InterpolatedString.create(model.max_wait_time or "PT2H", parameters={}).eval(config) 4721 ) 4722 max_wait_time = parse_duration(max_wait_time_str) 4723 if not isinstance(max_wait_time, datetime.timedelta): 4724 raise ValueError( 4725 f"max_wait_time must be a fixed-length ISO 8601 duration (e.g. 'PT2H'); " 4726 f"calendar-unit durations like '{max_wait_time_str}' are not supported" 4727 ) 4728 budget_reserve_fraction = ( 4729 model.budget_reserve_fraction if model.budget_reserve_fraction is not None else 0.1 4730 ) 4731 budget_min_reserve = ( 4732 model.budget_min_reserve if model.budget_min_reserve is not None else 50 4733 ) 4734 4735 # Reuse the same instance for identical definitions so that all streams share the 4736 # same token quota counters (similar to how api_budget is shared). The key is built 4737 # from the resolved constructor arguments rather than the raw model so that 4738 # stream-specific `$parameters` propagated onto the model (and its nested components) 4739 # cannot break instance sharing. 4740 cache_key = json.dumps( 4741 { 4742 "tokens": tokens, 4743 "quotas": quota_specs, 4744 "quota_status_url": quota_status_url, 4745 "quota_status_http_method": quota_status_http_method, 4746 "quota_status_headers": quota_status_headers, 4747 "quota_status_unavailable_status_codes": quota_status_unavailable_status_codes, 4748 "auth_method": auth_method, 4749 "header": header, 4750 "max_wait_time": max_wait_time.total_seconds(), 4751 "budget_reserve_fraction": budget_reserve_fraction, 4752 "budget_min_reserve": budget_min_reserve, 4753 }, 4754 sort_keys=True, 4755 ) 4756 if cache_key in self._rate_limited_authenticators: 4757 return self._rate_limited_authenticators[cache_key] 4758 4759 quotas = [ 4760 TokenQuota( 4761 name=quota_model.name, 4762 remaining_path=quota_model.remaining_path, 4763 reset_path=quota_model.reset_path, 4764 limit_path=quota_model.limit_path, 4765 remaining_header=quota_model.remaining_header, 4766 reset_header=quota_model.reset_header, 4767 limit_header=quota_model.limit_header, 4768 exhaustion_status_codes=quota_model.exhaustion_status_codes or [], 4769 matchers=[ 4770 self.create_http_request_matcher(matcher_model, config) 4771 for matcher_model in quota_model.matchers or [] 4772 ], 4773 ) 4774 for quota_model in model.quotas 4775 ] 4776 4777 authenticator = RateLimitedMultipleTokenAuthenticator( 4778 tokens=tokens, 4779 quotas=quotas, 4780 quota_status_url=quota_status_url, 4781 quota_status_http_method=quota_status_http_method, 4782 quota_status_headers=quota_status_headers, 4783 quota_status_unavailable_status_codes=quota_status_unavailable_status_codes, 4784 auth_method=auth_method, 4785 header=header, 4786 max_wait_time=max_wait_time, 4787 budget_reserve_fraction=budget_reserve_fraction, 4788 budget_min_reserve=budget_min_reserve, 4789 ) 4790 self._rate_limited_authenticators[cache_key] = authenticator 4791 return authenticator
4798 def create_grouping_partition_router( 4799 self, 4800 model: GroupingPartitionRouterModel, 4801 config: Config, 4802 *, 4803 stream_name: str, 4804 **kwargs: Any, 4805 ) -> GroupingPartitionRouter: 4806 underlying_router = self._create_component_from_model( 4807 model=model.underlying_partition_router, 4808 config=config, 4809 stream_name=stream_name, 4810 **kwargs, 4811 ) 4812 if model.group_size < 1: 4813 raise ValueError(f"Group size must be greater than 0, got {model.group_size}") 4814 4815 # Request options in underlying partition routers are not supported for GroupingPartitionRouter 4816 # because they are specific to individual partitions and cannot be aggregated or handled 4817 # when grouping, potentially leading to incorrect API calls. Any request customization 4818 # should be managed at the stream level through the requester's configuration. 4819 if isinstance(underlying_router, SubstreamPartitionRouter): 4820 if any( 4821 parent_config.request_option 4822 for parent_config in underlying_router.parent_stream_configs 4823 ): 4824 raise ValueError("Request options are not supported for GroupingPartitionRouter.") 4825 4826 if isinstance(underlying_router, ListPartitionRouter): 4827 if underlying_router.request_option: 4828 raise ValueError("Request options are not supported for GroupingPartitionRouter.") 4829 4830 return GroupingPartitionRouter( 4831 group_size=model.group_size, 4832 underlying_partition_router=underlying_router, 4833 deduplicate=model.deduplicate if model.deduplicate is not None else True, 4834 config=config, 4835 )
4837 def create_union_partition_router( 4838 self, 4839 model: UnionPartitionRouterModel, 4840 config: Config, 4841 *, 4842 stream_name: str, 4843 **kwargs: Any, 4844 ) -> UnionPartitionRouter: 4845 # The schema enforces minItems: 2 for manifests; this guard covers construction paths 4846 # that bypass JSON-schema validation (the generated model carries no min_items constraint). 4847 if len(model.partition_routers) < 2: 4848 raise ValueError( 4849 f"UnionPartitionRouter for stream {stream_name} needs at least 2 child partition routers" 4850 ) 4851 4852 partition_routers = [ 4853 self._create_component_from_model( 4854 model=child, 4855 config=config, 4856 stream_name=stream_name, 4857 **kwargs, 4858 ) 4859 for child in model.partition_routers 4860 ] 4861 4862 # partition_field depends only on config/parameters, so it is evaluated once at build 4863 # time; the runtime component always receives a plain string. 4864 partition_field = InterpolatedString.create( 4865 model.partition_field, parameters=model.parameters or {} 4866 ).eval(config) 4867 4868 # Fail fast at build time when a built-in child router is statically known to emit a 4869 # partition field different from the union's. CustomPartitionRouter children are opaque 4870 # and can only be validated at runtime. 4871 for child_model in model.partition_routers: 4872 child_partition_fields: List[str] = [] 4873 if isinstance(child_model, ListPartitionRouterModel): 4874 child_partition_fields.append( 4875 InterpolatedString.create( 4876 child_model.cursor_field, parameters=child_model.parameters or {} 4877 ).eval(config) 4878 ) 4879 elif isinstance(child_model, SubstreamPartitionRouterModel): 4880 for parent_stream_config in child_model.parent_stream_configs: 4881 child_partition_fields.append( 4882 InterpolatedString.create( 4883 parent_stream_config.partition_field, 4884 parameters=parent_stream_config.parameters 4885 or child_model.parameters 4886 or {}, 4887 ).eval(config) 4888 ) 4889 elif isinstance(child_model, UnionPartitionRouterModel): 4890 child_partition_fields.append( 4891 InterpolatedString.create( 4892 child_model.partition_field, parameters=child_model.parameters or {} 4893 ).eval(config) 4894 ) 4895 for child_partition_field in child_partition_fields: 4896 if child_partition_field != partition_field: 4897 raise ValueError( 4898 f"UnionPartitionRouter expects all child partition routers to emit the " 4899 f"partition field '{partition_field}', but a " 4900 f"{child_model.type} child emits '{child_partition_field}'." 4901 ) 4902 4903 # A union slice comes from exactly one child partition router, so request options 4904 # declared on children cannot be applied consistently to requests built from the 4905 # normalized union slices. Partition values should be consumed via interpolation 4906 # (e.g. stream_partition) instead. Note that this validation only covers built-in 4907 # router types; CustomPartitionRouter children are opaque, so any request options 4908 # they implement internally cannot be detected or rejected here. 4909 for router in partition_routers: 4910 if isinstance(router, SubstreamPartitionRouter): 4911 if any( 4912 parent_config.request_option for parent_config in router.parent_stream_configs 4913 ): 4914 raise ValueError("Request options are not supported for UnionPartitionRouter.") 4915 if isinstance(router, ListPartitionRouter) and router.request_option: 4916 raise ValueError("Request options are not supported for UnionPartitionRouter.") 4917 4918 return UnionPartitionRouter( 4919 partition_routers=partition_routers, 4920 partition_field=partition_field, 4921 parameters=model.parameters or {}, 4922 )