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 math 13import re 14from functools import partial 15from typing import ( 16 TYPE_CHECKING, 17 Any, 18 Callable, 19 Dict, 20 List, 21 Mapping, 22 MutableMapping, 23 Optional, 24 Tuple, 25 Type, 26 Union, 27 cast, 28 get_args, 29 get_origin, 30 get_type_hints, 31) 32 33if TYPE_CHECKING: 34 from airbyte_cdk.legacy.sources.declarative.incremental.datetime_based_cursor import ( 35 DatetimeBasedCursor, 36 ) 37 38from airbyte_protocol_dataclasses.models import ConfiguredAirbyteStream 39from isodate import parse_duration 40from pydantic.v1 import BaseModel 41from requests import Response 42 43from airbyte_cdk.connector_builder.models import ( 44 LogMessage as ConnectorBuilderLogMessage, 45) 46from airbyte_cdk.models import ( 47 AirbyteStateBlob, 48 AirbyteStateMessage, 49 AirbyteStateType, 50 AirbyteStreamState, 51 ConfiguredAirbyteCatalog, 52 FailureType, 53 Level, 54 StreamDescriptor, 55) 56from airbyte_cdk.sources.connector_state_manager import ConnectorStateManager 57from airbyte_cdk.sources.declarative.async_job.job_orchestrator import AsyncJobOrchestrator 58from airbyte_cdk.sources.declarative.async_job.job_tracker import JobTracker 59from airbyte_cdk.sources.declarative.async_job.repository import AsyncJobRepository 60from airbyte_cdk.sources.declarative.async_job.status import AsyncJobStatus 61from airbyte_cdk.sources.declarative.auth import DeclarativeOauth2Authenticator, JwtAuthenticator 62from airbyte_cdk.sources.declarative.auth.declarative_authenticator import ( 63 DeclarativeAuthenticator, 64 NoAuth, 65) 66from airbyte_cdk.sources.declarative.auth.jwt import JwtAlgorithm 67from airbyte_cdk.sources.declarative.auth.oauth import ( 68 DeclarativeSingleUseRefreshTokenOauth2Authenticator, 69) 70from airbyte_cdk.sources.declarative.auth.rate_limited_multiple_token import ( 71 RateLimitedMultipleTokenAuthenticator, 72 TokenQuota, 73) 74from airbyte_cdk.sources.declarative.auth.selective_authenticator import SelectiveAuthenticator 75from airbyte_cdk.sources.declarative.auth.token import ( 76 ApiKeyAuthenticator, 77 BasicHttpAuthenticator, 78 BearerAuthenticator, 79 LegacySessionTokenAuthenticator, 80) 81from airbyte_cdk.sources.declarative.auth.token_provider import ( 82 InterpolatedSessionTokenProvider, 83 InterpolatedStringTokenProvider, 84 SessionTokenProvider, 85 TokenProvider, 86) 87from airbyte_cdk.sources.declarative.checks import ( 88 CheckDynamicStream, 89 CheckStream, 90 DynamicStreamCheckConfig, 91) 92from airbyte_cdk.sources.declarative.concurrency_level import ConcurrencyLevel 93from airbyte_cdk.sources.declarative.datetime.min_max_datetime import MinMaxDatetime 94from airbyte_cdk.sources.declarative.decoders import ( 95 Decoder, 96 IterableDecoder, 97 JsonDecoder, 98 PaginationDecoderDecorator, 99 XmlDecoder, 100 ZipfileDecoder, 101) 102from airbyte_cdk.sources.declarative.decoders.composite_raw_decoder import ( 103 CompositeRawDecoder, 104 CsvParser, 105 GzipParser, 106 JsonItemsParser, 107 JsonLineParser, 108 JsonParser, 109 Parser, 110) 111from airbyte_cdk.sources.declarative.expanders.record_expander import ( 112 OnNoRecords, 113 ParentFieldPath, 114 RecordExpander, 115) 116from airbyte_cdk.sources.declarative.extractors import ( 117 CombinedExtractor, 118 CombineMode, 119 DpathExtractor, 120 RecordFilter, 121 RecordSelector, 122 ResponseToFileExtractor, 123) 124from airbyte_cdk.sources.declarative.extractors.record_extractor import RecordExtractor 125from airbyte_cdk.sources.declarative.extractors.record_filter import ( 126 ClientSideIncrementalRecordFilterDecorator, 127) 128from airbyte_cdk.sources.declarative.incremental import ( 129 ConcurrentCursorFactory, 130 ConcurrentPerPartitionCursor, 131) 132from airbyte_cdk.sources.declarative.interpolation import InterpolatedString 133from airbyte_cdk.sources.declarative.interpolation.interpolated_mapping import InterpolatedMapping 134from airbyte_cdk.sources.declarative.migrations.legacy_to_per_partition_state_migration import ( 135 LegacyToPerPartitionStateMigration, 136) 137from airbyte_cdk.sources.declarative.models import ( 138 CustomStateMigration, 139 PaginationResetLimits, 140) 141from airbyte_cdk.sources.declarative.models.base_model_with_deprecations import ( 142 DEPRECATION_LOGS_TAG, 143 BaseModelWithDeprecations, 144) 145from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 146 Action as HttpResponseFilterActionModel, 147) 148from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 149 Action1 as PaginationResetActionModel, 150) 151from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 152 AddedFieldDefinition as AddedFieldDefinitionModel, 153) 154from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 155 AddFields as AddFieldsModel, 156) 157from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 158 ApiKeyAuthenticator as ApiKeyAuthenticatorModel, 159) 160from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 161 AsyncJobStatusMap as AsyncJobStatusMapModel, 162) 163from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 164 AsyncRetriever as AsyncRetrieverModel, 165) 166from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 167 BasicHttpAuthenticator as BasicHttpAuthenticatorModel, 168) 169from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 170 BearerAuthenticator as BearerAuthenticatorModel, 171) 172from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 173 CheckDynamicStream as CheckDynamicStreamModel, 174) 175from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 176 CheckStream as CheckStreamModel, 177) 178from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 179 CombinedExtractor as CombinedExtractorModel, 180) 181from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 182 ComplexFieldType as ComplexFieldTypeModel, 183) 184from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 185 ComponentMappingDefinition as ComponentMappingDefinitionModel, 186) 187from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 188 CompositeErrorHandler as CompositeErrorHandlerModel, 189) 190from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 191 ConcurrencyLevel as ConcurrencyLevelModel, 192) 193from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 194 ConfigAddFields as ConfigAddFieldsModel, 195) 196from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 197 ConfigComponentsResolver as ConfigComponentsResolverModel, 198) 199from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 200 ConfigMigration as ConfigMigrationModel, 201) 202from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 203 ConfigRemapField as ConfigRemapFieldModel, 204) 205from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 206 ConfigRemoveFields as ConfigRemoveFieldsModel, 207) 208from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 209 ConstantBackoffStrategy as ConstantBackoffStrategyModel, 210) 211from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 212 CsvDecoder as CsvDecoderModel, 213) 214from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 215 CursorPagination as CursorPaginationModel, 216) 217from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 218 CustomAuthenticator as CustomAuthenticatorModel, 219) 220from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 221 CustomBackoffStrategy as CustomBackoffStrategyModel, 222) 223from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 224 CustomConfigTransformation as CustomConfigTransformationModel, 225) 226from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 227 CustomDecoder as CustomDecoderModel, 228) 229from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 230 CustomErrorHandler as CustomErrorHandlerModel, 231) 232from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 233 CustomPaginationStrategy as CustomPaginationStrategyModel, 234) 235from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 236 CustomPartitionRouter as CustomPartitionRouterModel, 237) 238from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 239 CustomRecordExtractor as CustomRecordExtractorModel, 240) 241from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 242 CustomRecordFilter as CustomRecordFilterModel, 243) 244from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 245 CustomRequester as CustomRequesterModel, 246) 247from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 248 CustomRetriever as CustomRetrieverModel, 249) 250from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 251 CustomSchemaLoader as CustomSchemaLoader, 252) 253from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 254 CustomSchemaNormalization as CustomSchemaNormalizationModel, 255) 256from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 257 CustomTransformation as CustomTransformationModel, 258) 259from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 260 CustomValidationStrategy as CustomValidationStrategyModel, 261) 262from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 263 DatetimeBasedCursor as DatetimeBasedCursorModel, 264) 265from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 266 DeclarativeStream as DeclarativeStreamModel, 267) 268from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 269 DefaultErrorHandler as DefaultErrorHandlerModel, 270) 271from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 272 DefaultPaginator as DefaultPaginatorModel, 273) 274from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 275 DpathExtractor as DpathExtractorModel, 276) 277from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 278 DpathFlattenFields as DpathFlattenFieldsModel, 279) 280from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 281 DpathValidator as DpathValidatorModel, 282) 283from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 284 DynamicSchemaLoader as DynamicSchemaLoaderModel, 285) 286from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 287 DynamicStreamCheckConfig as DynamicStreamCheckConfigModel, 288) 289from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 290 ExponentialBackoffStrategy as ExponentialBackoffStrategyModel, 291) 292from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 293 FileUploader as FileUploaderModel, 294) 295from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 296 FixedWindowCallRatePolicy as FixedWindowCallRatePolicyModel, 297) 298from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 299 FlattenFields as FlattenFieldsModel, 300) 301from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 302 GroupByKeyMergeStrategy as GroupByKeyMergeStrategyModel, 303) 304from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 305 GroupingPartitionRouter as GroupingPartitionRouterModel, 306) 307from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 308 GzipDecoder as GzipDecoderModel, 309) 310from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 311 HTTPAPIBudget as HTTPAPIBudgetModel, 312) 313from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 314 HttpComponentsResolver as HttpComponentsResolverModel, 315) 316from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 317 HttpRequester as HttpRequesterModel, 318) 319from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 320 HttpRequestRegexMatcher as HttpRequestRegexMatcherModel, 321) 322from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 323 HttpResponseFilter as HttpResponseFilterModel, 324) 325from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 326 IncrementingCountCursor as IncrementingCountCursorModel, 327) 328from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 329 InlineSchemaLoader as InlineSchemaLoaderModel, 330) 331from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 332 IterableDecoder as IterableDecoderModel, 333) 334from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 335 JsonDecoder as JsonDecoderModel, 336) 337from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 338 JsonFileSchemaLoader as JsonFileSchemaLoaderModel, 339) 340from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 341 JsonItemsDecoder as JsonItemsDecoderModel, 342) 343from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 344 JsonlDecoder as JsonlDecoderModel, 345) 346from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 347 JsonSchemaPropertySelector as JsonSchemaPropertySelectorModel, 348) 349from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 350 JwtAuthenticator as JwtAuthenticatorModel, 351) 352from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 353 JwtHeaders as JwtHeadersModel, 354) 355from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 356 JwtPayload as JwtPayloadModel, 357) 358from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 359 KeysReplace as KeysReplaceModel, 360) 361from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 362 KeysToLower as KeysToLowerModel, 363) 364from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 365 KeysToSnakeCase as KeysToSnakeCaseModel, 366) 367from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 368 LegacySessionTokenAuthenticator as LegacySessionTokenAuthenticatorModel, 369) 370from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 371 LegacyToPerPartitionStateMigration as LegacyToPerPartitionStateMigrationModel, 372) 373from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 374 ListPartitionRouter as ListPartitionRouterModel, 375) 376from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 377 MinMaxDatetime as MinMaxDatetimeModel, 378) 379from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 380 MovingWindowCallRatePolicy as MovingWindowCallRatePolicyModel, 381) 382from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 383 NoAuth as NoAuthModel, 384) 385from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 386 NoPagination as NoPaginationModel, 387) 388from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 389 OAuthAuthenticator as OAuthAuthenticatorModel, 390) 391from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 392 OffsetIncrement as OffsetIncrementModel, 393) 394from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 395 PageIncrement as PageIncrementModel, 396) 397from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 398 PageSizeReduction as PageSizeReductionModel, 399) 400from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 401 PaginationReset as PaginationResetModel, 402) 403from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 404 ParametrizedComponentsResolver as ParametrizedComponentsResolverModel, 405) 406from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 407 ParentFieldPath as ParentFieldPathModel, 408) 409from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 410 ParentStreamConfig as ParentStreamConfigModel, 411) 412from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 413 PredicateValidator as PredicateValidatorModel, 414) 415from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 416 PropertiesFromEndpoint as PropertiesFromEndpointModel, 417) 418from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 419 PropertyChunking as PropertyChunkingModel, 420) 421from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 422 PropertyLimitType as PropertyLimitTypeModel, 423) 424from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 425 QueryProperties as QueryPropertiesModel, 426) 427from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 428 Rate as RateModel, 429) 430from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 431 RateLimitedMultipleTokenAuthenticator as RateLimitedMultipleTokenAuthenticatorModel, 432) 433from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 434 RecordExpander as RecordExpanderModel, 435) 436from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 437 RecordFilter as RecordFilterModel, 438) 439from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 440 RecordSelector as RecordSelectorModel, 441) 442from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 443 RefreshTokenUpdater as RefreshTokenUpdaterModel, 444) 445from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 446 RemoveFields as RemoveFieldsModel, 447) 448from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 449 RequestOption as RequestOptionModel, 450) 451from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 452 RequestPath as RequestPathModel, 453) 454from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 455 ResponseToFileExtractor as ResponseToFileExtractorModel, 456) 457from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 458 SchemaNormalization as SchemaNormalizationModel, 459) 460from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 461 SchemaTypeIdentifier as SchemaTypeIdentifierModel, 462) 463from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 464 SelectiveAuthenticator as SelectiveAuthenticatorModel, 465) 466from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 467 SessionTokenAuthenticator as SessionTokenAuthenticatorModel, 468) 469from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 470 SimpleRetriever as SimpleRetrieverModel, 471) 472from airbyte_cdk.sources.declarative.models.declarative_component_schema import Spec as SpecModel 473from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 474 StateDelegatingStream as StateDelegatingStreamModel, 475) 476from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 477 StreamConfig as StreamConfigModel, 478) 479from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 480 SubstreamPartitionRouter as SubstreamPartitionRouterModel, 481) 482from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 483 TypesMap as TypesMapModel, 484) 485from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 486 UnionPartitionRouter as UnionPartitionRouterModel, 487) 488from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 489 UnlimitedCallRatePolicy as UnlimitedCallRatePolicyModel, 490) 491from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 492 ValidateAdheresToSchema as ValidateAdheresToSchemaModel, 493) 494from airbyte_cdk.sources.declarative.models.declarative_component_schema import ValueType 495from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 496 WaitTimeFromHeader as WaitTimeFromHeaderModel, 497) 498from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 499 WaitUntilTimeFromHeader as WaitUntilTimeFromHeaderModel, 500) 501from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 502 XmlDecoder as XmlDecoderModel, 503) 504from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( 505 ZipfileDecoder as ZipfileDecoderModel, 506) 507from airbyte_cdk.sources.declarative.parsers.custom_code_compiler import ( 508 INJECTED_MANIFEST, 509 AirbyteCustomCodeNotPermittedError, 510 custom_code_execution_permitted, 511) 512from airbyte_cdk.sources.declarative.parsers.stop_condition_safety import ( 513 StopConditionSafety, 514 classify_stop_condition, 515) 516from airbyte_cdk.sources.declarative.partition_routers import ( 517 CartesianProductStreamSlicer, 518 GroupingPartitionRouter, 519 ListPartitionRouter, 520 PartitionRouter, 521 SinglePartitionRouter, 522 SubstreamPartitionRouter, 523 UnionPartitionRouter, 524) 525from airbyte_cdk.sources.declarative.partition_routers.async_job_partition_router import ( 526 AsyncJobPartitionRouter, 527) 528from airbyte_cdk.sources.declarative.partition_routers.substream_partition_router import ( 529 ParentStreamConfig, 530) 531from airbyte_cdk.sources.declarative.requesters import HttpRequester, RequestOption 532from airbyte_cdk.sources.declarative.requesters.error_handlers import ( 533 CompositeErrorHandler, 534 DefaultErrorHandler, 535 HttpResponseFilter, 536) 537from airbyte_cdk.sources.declarative.requesters.error_handlers.backoff_strategies import ( 538 ConstantBackoffStrategy, 539 ExponentialBackoffStrategy, 540 WaitTimeFromHeaderBackoffStrategy, 541 WaitUntilTimeFromHeaderBackoffStrategy, 542) 543from airbyte_cdk.sources.declarative.requesters.http_job_repository import AsyncHttpJobRepository 544from airbyte_cdk.sources.declarative.requesters.paginators import ( 545 DefaultPaginator, 546 NoPagination, 547 PaginatorTestReadDecorator, 548) 549from airbyte_cdk.sources.declarative.requesters.paginators.strategies import ( 550 CursorPaginationStrategy, 551 CursorStopCondition, 552 OffsetIncrement, 553 PageIncrement, 554 StopConditionPaginationStrategyDecorator, 555) 556from airbyte_cdk.sources.declarative.requesters.query_properties import ( 557 PropertiesFromEndpoint, 558 PropertyChunking, 559 QueryProperties, 560) 561from airbyte_cdk.sources.declarative.requesters.query_properties.property_chunking import ( 562 PropertyLimitType, 563) 564from airbyte_cdk.sources.declarative.requesters.query_properties.property_selector import ( 565 JsonSchemaPropertySelector, 566) 567from airbyte_cdk.sources.declarative.requesters.query_properties.strategies import ( 568 GroupByKey, 569) 570from airbyte_cdk.sources.declarative.requesters.request_option import RequestOptionType 571from airbyte_cdk.sources.declarative.requesters.request_options import ( 572 DatetimeBasedRequestOptionsProvider, 573 DefaultRequestOptionsProvider, 574 InterpolatedRequestOptionsProvider, 575 RequestOptionsProvider, 576) 577from airbyte_cdk.sources.declarative.requesters.request_options.per_partition_request_option_provider import ( 578 PerPartitionRequestOptionsProvider, 579) 580from airbyte_cdk.sources.declarative.requesters.request_path import RequestPath 581from airbyte_cdk.sources.declarative.requesters.requester import HttpMethod, Requester 582from airbyte_cdk.sources.declarative.resolvers import ( 583 ComponentMappingDefinition, 584 ConfigComponentsResolver, 585 HttpComponentsResolver, 586 ParametrizedComponentsResolver, 587 StreamConfig, 588 StreamParametersDefinition, 589) 590from airbyte_cdk.sources.declarative.retrievers import ( 591 AsyncRetriever, 592 LazySimpleRetriever, 593 SimpleRetriever, 594) 595from airbyte_cdk.sources.declarative.retrievers.file_uploader import ( 596 ConnectorBuilderFileUploader, 597 DefaultFileUploader, 598 FileUploader, 599 LocalFileSystemFileWriter, 600 NoopFileWriter, 601) 602from airbyte_cdk.sources.declarative.retrievers.page_size_reducer import ( 603 PageSizeReduction, 604 PageSizeResetPolicy, 605) 606from airbyte_cdk.sources.declarative.retrievers.pagination_tracker import PaginationTracker 607from airbyte_cdk.sources.declarative.retrievers.request_window_splitting import ( 608 RequestWindowSplitting, 609) 610from airbyte_cdk.sources.declarative.schema import ( 611 ComplexFieldType, 612 DefaultSchemaLoader, 613 DynamicSchemaLoader, 614 InlineSchemaLoader, 615 JsonFileSchemaLoader, 616 SchemaLoader, 617 SchemaTypeIdentifier, 618 TypesMap, 619) 620from airbyte_cdk.sources.declarative.schema.caching_schema_loader_decorator import ( 621 CachingSchemaLoaderDecorator, 622) 623from airbyte_cdk.sources.declarative.schema.composite_schema_loader import CompositeSchemaLoader 624from airbyte_cdk.sources.declarative.spec import ConfigMigration, Spec 625from airbyte_cdk.sources.declarative.stream_slicers import ( 626 StreamSlicer, 627 StreamSlicerTestReadDecorator, 628) 629from airbyte_cdk.sources.declarative.stream_slicers.declarative_partition_generator import ( 630 DeclarativePartitionFactory, 631 StreamSlicerPartitionGenerator, 632) 633from airbyte_cdk.sources.declarative.transformations import ( 634 AddFields, 635 RecordTransformation, 636 RemoveFields, 637) 638from airbyte_cdk.sources.declarative.transformations.add_fields import AddedFieldDefinition 639from airbyte_cdk.sources.declarative.transformations.config_transformations import ( 640 ConfigAddFields, 641 ConfigRemapField, 642 ConfigRemoveFields, 643) 644from airbyte_cdk.sources.declarative.transformations.config_transformations.config_transformation import ( 645 ConfigTransformation, 646) 647from airbyte_cdk.sources.declarative.transformations.dpath_flatten_fields import ( 648 DpathFlattenFields, 649 KeyTransformation, 650) 651from airbyte_cdk.sources.declarative.transformations.flatten_fields import ( 652 FlattenFields, 653) 654from airbyte_cdk.sources.declarative.transformations.keys_replace_transformation import ( 655 KeysReplaceTransformation, 656) 657from airbyte_cdk.sources.declarative.transformations.keys_to_lower_transformation import ( 658 KeysToLowerTransformation, 659) 660from airbyte_cdk.sources.declarative.transformations.keys_to_snake_transformation import ( 661 KeysToSnakeCaseTransformation, 662) 663from airbyte_cdk.sources.declarative.validators import ( 664 DpathValidator, 665 PredicateValidator, 666 ValidateAdheresToSchema, 667) 668from airbyte_cdk.sources.http_logger import format_http_message 669from airbyte_cdk.sources.message import ( 670 InMemoryMessageRepository, 671 LogAppenderMessageRepositoryDecorator, 672 MessageRepository, 673 NoopMessageRepository, 674) 675from airbyte_cdk.sources.message.repository import StateFilteringMessageRepository 676from airbyte_cdk.sources.streams import NO_CURSOR_STATE_KEY 677from airbyte_cdk.sources.streams.call_rate import ( 678 APIBudget, 679 FixedWindowCallRatePolicy, 680 HttpAPIBudget, 681 HttpRequestRegexMatcher, 682 MovingWindowCallRatePolicy, 683 Rate, 684 UnlimitedCallRatePolicy, 685) 686from airbyte_cdk.sources.streams.concurrent.abstract_stream import AbstractStream 687from airbyte_cdk.sources.streams.concurrent.clamping import ( 688 ClampingEndProvider, 689 ClampingStrategy, 690 DayClampingStrategy, 691 MonthClampingStrategy, 692 NoClamping, 693 WeekClampingStrategy, 694 Weekday, 695) 696from airbyte_cdk.sources.streams.concurrent.cursor import ( 697 ConcurrentCursor, 698 Cursor, 699 CursorField, 700 FinalStateCursor, 701) 702from airbyte_cdk.sources.streams.concurrent.default_stream import DefaultStream 703from airbyte_cdk.sources.streams.concurrent.helpers import get_primary_key_from_stream 704from airbyte_cdk.sources.streams.concurrent.partitions.stream_slicer import ( 705 StreamSlicer as ConcurrentStreamSlicer, 706) 707from airbyte_cdk.sources.streams.concurrent.state_converters.datetime_stream_state_converter import ( 708 CustomFormatConcurrentStreamStateConverter, 709 DateTimeStreamStateConverter, 710) 711from airbyte_cdk.sources.streams.concurrent.state_converters.incrementing_count_stream_state_converter import ( 712 IncrementingCountStreamStateConverter, 713) 714from airbyte_cdk.sources.streams.http.error_handlers.response_models import ResponseAction 715from airbyte_cdk.sources.types import Config 716from airbyte_cdk.sources.utils.transform import TransformConfig, TypeTransformer 717from airbyte_cdk.utils.traced_exception import AirbyteTracedException 718 719ComponentDefinition = Mapping[str, Any] 720 721SCHEMA_TRANSFORMER_TYPE_MAPPING = { 722 SchemaNormalizationModel.None_: TransformConfig.NoTransform, 723 SchemaNormalizationModel.Default: TransformConfig.DefaultSchemaNormalization, 724} 725_NO_STREAM_SLICING = SinglePartitionRouter(parameters={}) 726 727# Ideally this should use the value defined in ConcurrentDeclarativeSource, but 728# this would be a circular import 729MAX_SLICES = 5 730 731# Smallest duration each `DatetimeParser`/`strftime` directive can distinguish, used by 732# `_smallest_datetime_format_unit` to check `datetime_format` can represent `cursor_granularity`. A format with 733# no entry here (e.g. a bare `%Y`/`%m`) is treated as "cannot determine" rather than rejected. 734_DATETIME_FORMAT_DIRECTIVE_GRANULARITY: List[Tuple[str, datetime.timedelta]] = [ 735 ("%epoch_microseconds", datetime.timedelta(microseconds=1)), 736 ("%s_as_float", datetime.timedelta(microseconds=1)), 737 ("%f", datetime.timedelta(microseconds=1)), 738 ("%_ms", datetime.timedelta(milliseconds=1)), 739 ("%ms", datetime.timedelta(milliseconds=1)), 740 ("%S", datetime.timedelta(seconds=1)), 741 ("%s", datetime.timedelta(seconds=1)), 742 ("%M", datetime.timedelta(minutes=1)), 743 ("%H", datetime.timedelta(hours=1)), 744 ("%I", datetime.timedelta(hours=1)), 745 ("%d", datetime.timedelta(days=1)), 746] 747 748LOGGER = logging.getLogger(f"airbyte.model_to_component_factory") 749 750 751class ModelToComponentFactory: 752 EPOCH_DATETIME_FORMAT = "%s" 753 754 def __init__( 755 self, 756 limit_pages_fetched_per_slice: Optional[int] = None, 757 limit_slices_fetched: Optional[int] = None, 758 emit_connector_builder_messages: bool = False, 759 disable_retries: bool = False, 760 disable_cache: bool = False, 761 message_repository: Optional[MessageRepository] = None, 762 connector_state_manager: Optional[ConnectorStateManager] = None, 763 max_concurrent_async_job_count: Optional[int] = None, 764 configured_catalog: Optional[ConfiguredAirbyteCatalog] = None, 765 api_budget: Optional[APIBudget] = None, 766 rate_limited_authenticators: Optional[ 767 Dict[str, RateLimitedMultipleTokenAuthenticator] 768 ] = None, 769 custom_components_trusted: bool = True, 770 ): 771 self._init_mappings() 772 self._custom_components_trusted = custom_components_trusted 773 self._limit_pages_fetched_per_slice = limit_pages_fetched_per_slice 774 self._limit_slices_fetched = limit_slices_fetched 775 self._emit_connector_builder_messages = emit_connector_builder_messages 776 self._disable_retries = disable_retries 777 self._disable_cache = disable_cache 778 self._message_repository = message_repository or InMemoryMessageRepository( 779 self._evaluate_log_level(emit_connector_builder_messages) 780 ) 781 self._stream_name_to_configured_stream = self._create_stream_name_to_configured_stream( 782 configured_catalog 783 ) 784 self._connector_state_manager = connector_state_manager or ConnectorStateManager() 785 self._api_budget: Optional[Union[APIBudget]] = api_budget 786 # Shared instances so all streams see the same token quota counters (like api_budget) 787 self._rate_limited_authenticators: Dict[str, RateLimitedMultipleTokenAuthenticator] = ( 788 rate_limited_authenticators if rate_limited_authenticators is not None else {} 789 ) 790 self._job_tracker: JobTracker = JobTracker(max_concurrent_async_job_count or 1) 791 # placeholder for deprecation warnings 792 self._collected_deprecation_logs: List[ConnectorBuilderLogMessage] = [] 793 794 def _init_mappings(self) -> None: 795 self.PYDANTIC_MODEL_TO_CONSTRUCTOR: Mapping[Type[BaseModel], Callable[..., Any]] = { 796 AddedFieldDefinitionModel: self.create_added_field_definition, 797 AddFieldsModel: self.create_add_fields, 798 ApiKeyAuthenticatorModel: self.create_api_key_authenticator, 799 BasicHttpAuthenticatorModel: self.create_basic_http_authenticator, 800 BearerAuthenticatorModel: self.create_bearer_authenticator, 801 CheckStreamModel: self.create_check_stream, 802 DynamicStreamCheckConfigModel: self.create_dynamic_stream_check_config, 803 CheckDynamicStreamModel: self.create_check_dynamic_stream, 804 CompositeErrorHandlerModel: self.create_composite_error_handler, 805 ConcurrencyLevelModel: self.create_concurrency_level, 806 ConfigMigrationModel: self.create_config_migration, 807 ConfigAddFieldsModel: self.create_config_add_fields, 808 ConfigRemapFieldModel: self.create_config_remap_field, 809 ConfigRemoveFieldsModel: self.create_config_remove_fields, 810 ConstantBackoffStrategyModel: self.create_constant_backoff_strategy, 811 CsvDecoderModel: self.create_csv_decoder, 812 CursorPaginationModel: self.create_cursor_pagination, 813 CustomAuthenticatorModel: self.create_custom_component, 814 CustomBackoffStrategyModel: self.create_custom_component, 815 CustomDecoderModel: self.create_custom_component, 816 CustomErrorHandlerModel: self.create_custom_component, 817 CustomRecordExtractorModel: self.create_custom_component, 818 CustomRecordFilterModel: self.create_custom_component, 819 CustomRequesterModel: self.create_custom_component, 820 CustomRetrieverModel: self.create_custom_component, 821 CustomSchemaLoader: self.create_custom_component, 822 CustomSchemaNormalizationModel: self.create_custom_component, 823 CustomStateMigration: self.create_custom_component, 824 CustomPaginationStrategyModel: self.create_custom_component, 825 CustomPartitionRouterModel: self.create_custom_component, 826 CustomTransformationModel: self.create_custom_component, 827 CustomValidationStrategyModel: self.create_custom_component, 828 CustomConfigTransformationModel: self.create_custom_component, 829 DeclarativeStreamModel: self.create_default_stream, 830 DefaultErrorHandlerModel: self.create_default_error_handler, 831 DefaultPaginatorModel: self.create_default_paginator, 832 CombinedExtractorModel: self.create_combined_extractor, 833 DpathExtractorModel: self.create_dpath_extractor, 834 DpathValidatorModel: self.create_dpath_validator, 835 ResponseToFileExtractorModel: self.create_response_to_file_extractor, 836 ExponentialBackoffStrategyModel: self.create_exponential_backoff_strategy, 837 SessionTokenAuthenticatorModel: self.create_session_token_authenticator, 838 GroupByKeyMergeStrategyModel: self.create_group_by_key, 839 HttpRequesterModel: self.create_http_requester, 840 HttpResponseFilterModel: self.create_http_response_filter, 841 InlineSchemaLoaderModel: self.create_inline_schema_loader, 842 JsonDecoderModel: self.create_json_decoder, 843 JsonItemsDecoderModel: self.create_json_items_decoder, 844 JsonlDecoderModel: self.create_jsonl_decoder, 845 JsonSchemaPropertySelectorModel: self.create_json_schema_property_selector, 846 GzipDecoderModel: self.create_gzip_decoder, 847 KeysToLowerModel: self.create_keys_to_lower_transformation, 848 KeysToSnakeCaseModel: self.create_keys_to_snake_transformation, 849 KeysReplaceModel: self.create_keys_replace_transformation, 850 FlattenFieldsModel: self.create_flatten_fields, 851 DpathFlattenFieldsModel: self.create_dpath_flatten_fields, 852 IterableDecoderModel: self.create_iterable_decoder, 853 XmlDecoderModel: self.create_xml_decoder, 854 JsonFileSchemaLoaderModel: self.create_json_file_schema_loader, 855 DynamicSchemaLoaderModel: self.create_dynamic_schema_loader, 856 SchemaTypeIdentifierModel: self.create_schema_type_identifier, 857 TypesMapModel: self.create_types_map, 858 ComplexFieldTypeModel: self.create_complex_field_type, 859 JwtAuthenticatorModel: self.create_jwt_authenticator, 860 LegacyToPerPartitionStateMigrationModel: self.create_legacy_to_per_partition_state_migration, 861 ListPartitionRouterModel: self.create_list_partition_router, 862 MinMaxDatetimeModel: self.create_min_max_datetime, 863 NoAuthModel: self.create_no_auth, 864 NoPaginationModel: self.create_no_pagination, 865 OAuthAuthenticatorModel: self.create_oauth_authenticator, 866 OffsetIncrementModel: self.create_offset_increment, 867 PageIncrementModel: self.create_page_increment, 868 ParentStreamConfigModel: self.create_parent_stream_config_with_substream_wrapper, 869 PredicateValidatorModel: self.create_predicate_validator, 870 PropertiesFromEndpointModel: self.create_properties_from_endpoint, 871 PropertyChunkingModel: self.create_property_chunking, 872 QueryPropertiesModel: self.create_query_properties, 873 ParentFieldPathModel: self.create_parent_field_path, 874 RecordExpanderModel: self.create_record_expander, 875 RecordFilterModel: self.create_record_filter, 876 RecordSelectorModel: self.create_record_selector, 877 RemoveFieldsModel: self.create_remove_fields, 878 RequestPathModel: self.create_request_path, 879 RequestOptionModel: self.create_request_option, 880 LegacySessionTokenAuthenticatorModel: self.create_legacy_session_token_authenticator, 881 SelectiveAuthenticatorModel: self.create_selective_authenticator, 882 SimpleRetrieverModel: self.create_simple_retriever, 883 StateDelegatingStreamModel: self.create_state_delegating_stream, 884 SpecModel: self.create_spec, 885 SubstreamPartitionRouterModel: self.create_substream_partition_router, 886 ValidateAdheresToSchemaModel: self.create_validate_adheres_to_schema, 887 WaitTimeFromHeaderModel: self.create_wait_time_from_header, 888 WaitUntilTimeFromHeaderModel: self.create_wait_until_time_from_header, 889 AsyncRetrieverModel: self.create_async_retriever, 890 HttpComponentsResolverModel: self.create_http_components_resolver, 891 ConfigComponentsResolverModel: self.create_config_components_resolver, 892 ParametrizedComponentsResolverModel: self.create_parametrized_components_resolver, 893 StreamConfigModel: self.create_stream_config, 894 ComponentMappingDefinitionModel: self.create_components_mapping_definition, 895 ZipfileDecoderModel: self.create_zipfile_decoder, 896 HTTPAPIBudgetModel: self.create_http_api_budget, 897 FileUploaderModel: self.create_file_uploader, 898 FixedWindowCallRatePolicyModel: self.create_fixed_window_call_rate_policy, 899 MovingWindowCallRatePolicyModel: self.create_moving_window_call_rate_policy, 900 UnlimitedCallRatePolicyModel: self.create_unlimited_call_rate_policy, 901 RateModel: self.create_rate, 902 HttpRequestRegexMatcherModel: self.create_http_request_matcher, 903 RateLimitedMultipleTokenAuthenticatorModel: self.create_rate_limited_multiple_token_authenticator, 904 GroupingPartitionRouterModel: self.create_grouping_partition_router, 905 UnionPartitionRouterModel: self.create_union_partition_router, 906 } 907 908 # Needed for the case where we need to perform a second parse on the fields of a custom component 909 self.TYPE_NAME_TO_MODEL = {cls.__name__: cls for cls in self.PYDANTIC_MODEL_TO_CONSTRUCTOR} 910 911 @staticmethod 912 def _create_stream_name_to_configured_stream( 913 configured_catalog: Optional[ConfiguredAirbyteCatalog], 914 ) -> Mapping[str, ConfiguredAirbyteStream]: 915 return ( 916 {stream.stream.name: stream for stream in configured_catalog.streams} 917 if configured_catalog 918 else {} 919 ) 920 921 def create_component( 922 self, 923 model_type: Type[BaseModel], 924 component_definition: ComponentDefinition, 925 config: Config, 926 **kwargs: Any, 927 ) -> Any: 928 """ 929 Takes a given Pydantic model type and Mapping representing a component definition and creates a declarative component and 930 subcomponents which will be used at runtime. This is done by first parsing the mapping into a Pydantic model and then creating 931 creating declarative components from that model. 932 933 :param model_type: The type of declarative component that is being initialized 934 :param component_definition: The mapping that represents a declarative component 935 :param config: The connector config that is provided by the customer 936 :return: The declarative component to be used at runtime 937 """ 938 939 component_type = component_definition.get("type") 940 if component_definition.get("type") != model_type.__name__: 941 raise ValueError( 942 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 943 ) 944 945 declarative_component_model = model_type.parse_obj(component_definition) 946 947 if not isinstance(declarative_component_model, model_type): 948 raise ValueError( 949 f"Expected {model_type.__name__} component, but received {declarative_component_model.__class__.__name__}" 950 ) 951 952 return self._create_component_from_model( 953 model=declarative_component_model, config=config, **kwargs 954 ) 955 956 def _create_component_from_model(self, model: BaseModel, config: Config, **kwargs: Any) -> Any: 957 if model.__class__ not in self.PYDANTIC_MODEL_TO_CONSTRUCTOR: 958 raise ValueError( 959 f"{model.__class__} with attributes {model} is not a valid component type" 960 ) 961 component_constructor = self.PYDANTIC_MODEL_TO_CONSTRUCTOR.get(model.__class__) 962 if not component_constructor: 963 raise ValueError(f"Could not find constructor for {model.__class__}") 964 965 # collect deprecation warnings for supported models. 966 if isinstance(model, BaseModelWithDeprecations): 967 self._collect_model_deprecations(model) 968 969 return component_constructor(model=model, config=config, **kwargs) 970 971 def get_model_deprecations(self) -> List[ConnectorBuilderLogMessage]: 972 """ 973 Returns the deprecation warnings that were collected during the creation of components. 974 """ 975 return self._collected_deprecation_logs 976 977 def _collect_model_deprecations(self, model: BaseModelWithDeprecations) -> None: 978 """ 979 Collects deprecation logs from the given model and appends any new logs to the internal collection. 980 981 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. 982 983 Args: 984 model (BaseModelWithDeprecations): The model instance from which to collect deprecation logs. 985 """ 986 if hasattr(model, DEPRECATION_LOGS_TAG) and model._deprecation_logs is not None: 987 for log in model._deprecation_logs: 988 # avoid duplicates for deprecation logs observed. 989 if log not in self._collected_deprecation_logs: 990 self._collected_deprecation_logs.append(log) 991 992 def create_config_migration( 993 self, model: ConfigMigrationModel, config: Config 994 ) -> ConfigMigration: 995 transformations: List[ConfigTransformation] = [ 996 self._create_component_from_model(transformation, config) 997 for transformation in model.transformations 998 ] 999 1000 return ConfigMigration( 1001 description=model.description, 1002 transformations=transformations, 1003 ) 1004 1005 def create_config_add_fields( 1006 self, model: ConfigAddFieldsModel, config: Config, **kwargs: Any 1007 ) -> ConfigAddFields: 1008 fields = [self._create_component_from_model(field, config) for field in model.fields] 1009 return ConfigAddFields( 1010 fields=fields, 1011 condition=model.condition or "", 1012 ) 1013 1014 @staticmethod 1015 def create_config_remove_fields( 1016 model: ConfigRemoveFieldsModel, config: Config, **kwargs: Any 1017 ) -> ConfigRemoveFields: 1018 return ConfigRemoveFields( 1019 field_pointers=model.field_pointers, 1020 condition=model.condition or "", 1021 ) 1022 1023 @staticmethod 1024 def create_config_remap_field( 1025 model: ConfigRemapFieldModel, config: Config, **kwargs: Any 1026 ) -> ConfigRemapField: 1027 mapping = cast(Mapping[str, Any], model.map) 1028 return ConfigRemapField( 1029 map=mapping, 1030 field_path=model.field_path, 1031 config=config, 1032 ) 1033 1034 def create_dpath_validator(self, model: DpathValidatorModel, config: Config) -> DpathValidator: 1035 strategy = self._create_component_from_model(model.validation_strategy, config) 1036 1037 return DpathValidator( 1038 field_path=model.field_path, 1039 strategy=strategy, 1040 ) 1041 1042 def create_predicate_validator( 1043 self, model: PredicateValidatorModel, config: Config 1044 ) -> PredicateValidator: 1045 strategy = self._create_component_from_model(model.validation_strategy, config) 1046 1047 return PredicateValidator( 1048 value=model.value, 1049 strategy=strategy, 1050 ) 1051 1052 @staticmethod 1053 def create_validate_adheres_to_schema( 1054 model: ValidateAdheresToSchemaModel, config: Config, **kwargs: Any 1055 ) -> ValidateAdheresToSchema: 1056 base_schema = cast(Mapping[str, Any], model.base_schema) 1057 return ValidateAdheresToSchema( 1058 schema=base_schema, 1059 ) 1060 1061 @staticmethod 1062 def create_added_field_definition( 1063 model: AddedFieldDefinitionModel, config: Config, **kwargs: Any 1064 ) -> AddedFieldDefinition: 1065 interpolated_value = InterpolatedString.create( 1066 model.value, parameters=model.parameters or {} 1067 ) 1068 return AddedFieldDefinition( 1069 path=model.path, 1070 value=interpolated_value, 1071 value_type=ModelToComponentFactory._json_schema_type_name_to_type(model.value_type), 1072 parameters=model.parameters or {}, 1073 ) 1074 1075 def create_add_fields(self, model: AddFieldsModel, config: Config, **kwargs: Any) -> AddFields: 1076 added_field_definitions = [ 1077 self._create_component_from_model( 1078 model=added_field_definition_model, 1079 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 1080 added_field_definition_model.value_type 1081 ), 1082 config=config, 1083 ) 1084 for added_field_definition_model in model.fields 1085 ] 1086 return AddFields( 1087 fields=added_field_definitions, 1088 condition=model.condition or "", 1089 parameters=model.parameters or {}, 1090 ) 1091 1092 def create_keys_to_lower_transformation( 1093 self, model: KeysToLowerModel, config: Config, **kwargs: Any 1094 ) -> KeysToLowerTransformation: 1095 return KeysToLowerTransformation() 1096 1097 def create_keys_to_snake_transformation( 1098 self, model: KeysToSnakeCaseModel, config: Config, **kwargs: Any 1099 ) -> KeysToSnakeCaseTransformation: 1100 return KeysToSnakeCaseTransformation() 1101 1102 def create_keys_replace_transformation( 1103 self, model: KeysReplaceModel, config: Config, **kwargs: Any 1104 ) -> KeysReplaceTransformation: 1105 return KeysReplaceTransformation( 1106 old=model.old, new=model.new, parameters=model.parameters or {} 1107 ) 1108 1109 def create_flatten_fields( 1110 self, model: FlattenFieldsModel, config: Config, **kwargs: Any 1111 ) -> FlattenFields: 1112 return FlattenFields( 1113 flatten_lists=model.flatten_lists if model.flatten_lists is not None else True 1114 ) 1115 1116 def create_dpath_flatten_fields( 1117 self, model: DpathFlattenFieldsModel, config: Config, **kwargs: Any 1118 ) -> DpathFlattenFields: 1119 model_field_path: List[Union[InterpolatedString, str]] = [x for x in model.field_path] 1120 key_transformation = ( 1121 KeyTransformation( 1122 config=config, 1123 prefix=model.key_transformation.prefix, 1124 suffix=model.key_transformation.suffix, 1125 parameters=model.parameters or {}, 1126 ) 1127 if model.key_transformation is not None 1128 else None 1129 ) 1130 return DpathFlattenFields( 1131 config=config, 1132 field_path=model_field_path, 1133 delete_origin_value=model.delete_origin_value 1134 if model.delete_origin_value is not None 1135 else False, 1136 replace_record=model.replace_record if model.replace_record is not None else False, 1137 key_transformation=key_transformation, 1138 parameters=model.parameters or {}, 1139 ) 1140 1141 @staticmethod 1142 def _json_schema_type_name_to_type(value_type: Optional[ValueType]) -> Optional[Type[Any]]: 1143 if not value_type: 1144 return None 1145 names_to_types = { 1146 ValueType.string: str, 1147 ValueType.number: float, 1148 ValueType.integer: int, 1149 ValueType.boolean: bool, 1150 } 1151 return names_to_types[value_type] 1152 1153 def create_api_key_authenticator( 1154 self, 1155 model: ApiKeyAuthenticatorModel, 1156 config: Config, 1157 token_provider: Optional[TokenProvider] = None, 1158 **kwargs: Any, 1159 ) -> ApiKeyAuthenticator: 1160 if model.inject_into is None and model.header is None: 1161 raise ValueError( 1162 "Expected either inject_into or header to be set for ApiKeyAuthenticator" 1163 ) 1164 1165 if model.inject_into is not None and model.header is not None: 1166 raise ValueError( 1167 "inject_into and header cannot be set both for ApiKeyAuthenticator - remove the deprecated header option" 1168 ) 1169 1170 if token_provider is not None and model.api_token != "": 1171 raise ValueError( 1172 "If token_provider is set, api_token is ignored and has to be set to empty string." 1173 ) 1174 1175 request_option = ( 1176 self._create_component_from_model( 1177 model.inject_into, config, parameters=model.parameters or {} 1178 ) 1179 if model.inject_into 1180 else RequestOption( 1181 inject_into=RequestOptionType.header, 1182 field_name=model.header or "", 1183 parameters=model.parameters or {}, 1184 ) 1185 ) 1186 1187 return ApiKeyAuthenticator( 1188 token_provider=( 1189 token_provider 1190 if token_provider is not None 1191 else InterpolatedStringTokenProvider( 1192 api_token=model.api_token or "", 1193 config=config, 1194 parameters=model.parameters or {}, 1195 ) 1196 ), 1197 request_option=request_option, 1198 config=config, 1199 parameters=model.parameters or {}, 1200 ) 1201 1202 def create_legacy_to_per_partition_state_migration( 1203 self, 1204 model: LegacyToPerPartitionStateMigrationModel, 1205 config: Mapping[str, Any], 1206 declarative_stream: DeclarativeStreamModel, 1207 ) -> LegacyToPerPartitionStateMigration: 1208 retriever = declarative_stream.retriever 1209 if not isinstance(retriever, (SimpleRetrieverModel, AsyncRetrieverModel)): 1210 raise ValueError( 1211 f"LegacyToPerPartitionStateMigrations can only be applied on a DeclarativeStream with a SimpleRetriever or AsyncRetriever. Got {type(retriever)}" 1212 ) 1213 partition_router = retriever.partition_router 1214 if not isinstance( 1215 partition_router, 1216 ( 1217 SubstreamPartitionRouterModel, 1218 CustomPartitionRouterModel, 1219 UnionPartitionRouterModel, 1220 ), 1221 ): 1222 raise ValueError( 1223 f"LegacyToPerPartitionStateMigrations can only be applied on a SimpleRetriever with a SubstreamPartitionRouter, UnionPartitionRouter or CustomPartitionRouter. Got {type(partition_router)}" 1224 ) 1225 if not isinstance(partition_router, UnionPartitionRouterModel) and not hasattr( 1226 partition_router, "parent_stream_configs" 1227 ): 1228 raise ValueError( 1229 "LegacyToPerPartitionStateMigrations can only be applied with a parent stream configuration." 1230 ) 1231 1232 if not hasattr(declarative_stream, "incremental_sync"): 1233 raise ValueError( 1234 "LegacyToPerPartitionStateMigrations can only be applied with an incremental_sync configuration." 1235 ) 1236 1237 return LegacyToPerPartitionStateMigration( 1238 partition_router, # type: ignore # was already checked above 1239 declarative_stream.incremental_sync, # type: ignore # was already checked. Migration can be applied only to incremental streams. 1240 config, 1241 declarative_stream.parameters, # type: ignore # different type is expected here Mapping[str, Any], got Dict[str, Any] 1242 ) 1243 1244 def create_session_token_authenticator( 1245 self, model: SessionTokenAuthenticatorModel, config: Config, *, name: str, **kwargs: Any 1246 ) -> Union[ApiKeyAuthenticator, BearerAuthenticator]: 1247 self._reject_reduce_page_size_action( 1248 model.login_requester, f"`login_requester` of the SessionTokenAuthenticator of {name}" 1249 ) 1250 self._reject_split_request_window_action( 1251 model.login_requester, f"`login_requester` of the SessionTokenAuthenticator of {name}" 1252 ) 1253 decoder = ( 1254 self._create_component_from_model(model=model.decoder, config=config) 1255 if model.decoder 1256 else JsonDecoder(parameters={}) 1257 ) 1258 login_requester = self._create_component_from_model( 1259 model=model.login_requester, 1260 config=config, 1261 name=f"{name}_login_requester", 1262 decoder=decoder, 1263 ) 1264 token_provider = SessionTokenProvider( 1265 login_requester=login_requester, 1266 session_token_path=model.session_token_path, 1267 expiration_duration=parse_duration(model.expiration_duration) 1268 if model.expiration_duration 1269 else None, 1270 parameters=model.parameters or {}, 1271 message_repository=self._message_repository, 1272 decoder=decoder, 1273 ) 1274 if model.request_authentication.type == "Bearer": 1275 return ModelToComponentFactory.create_bearer_authenticator( 1276 BearerAuthenticatorModel(type="BearerAuthenticator", api_token=""), # type: ignore # $parameters has a default value 1277 config, 1278 token_provider=token_provider, 1279 ) 1280 else: 1281 # Get the api_token template if specified, default to just the session token 1282 api_token_template = ( 1283 getattr(model.request_authentication, "api_token", None) or "{{ session_token }}" 1284 ) 1285 final_token_provider: TokenProvider = InterpolatedSessionTokenProvider( 1286 config=config, 1287 api_token=api_token_template, 1288 session_token_provider=token_provider, 1289 parameters=model.parameters or {}, 1290 ) 1291 return self.create_api_key_authenticator( 1292 ApiKeyAuthenticatorModel( 1293 type="ApiKeyAuthenticator", 1294 api_token="", 1295 inject_into=model.request_authentication.inject_into, 1296 ), # type: ignore # $parameters and headers default to None 1297 config=config, 1298 token_provider=final_token_provider, 1299 ) 1300 1301 @staticmethod 1302 def create_basic_http_authenticator( 1303 model: BasicHttpAuthenticatorModel, config: Config, **kwargs: Any 1304 ) -> BasicHttpAuthenticator: 1305 return BasicHttpAuthenticator( 1306 password=model.password or "", 1307 username=model.username, 1308 config=config, 1309 parameters=model.parameters or {}, 1310 ) 1311 1312 @staticmethod 1313 def create_bearer_authenticator( 1314 model: BearerAuthenticatorModel, 1315 config: Config, 1316 token_provider: Optional[TokenProvider] = None, 1317 **kwargs: Any, 1318 ) -> BearerAuthenticator: 1319 if token_provider is not None and model.api_token != "": 1320 raise ValueError( 1321 "If token_provider is set, api_token is ignored and has to be set to empty string." 1322 ) 1323 return BearerAuthenticator( 1324 token_provider=( 1325 token_provider 1326 if token_provider is not None 1327 else InterpolatedStringTokenProvider( 1328 api_token=model.api_token or "", 1329 config=config, 1330 parameters=model.parameters or {}, 1331 ) 1332 ), 1333 config=config, 1334 parameters=model.parameters or {}, 1335 ) 1336 1337 @staticmethod 1338 def create_dynamic_stream_check_config( 1339 model: DynamicStreamCheckConfigModel, config: Config, **kwargs: Any 1340 ) -> DynamicStreamCheckConfig: 1341 return DynamicStreamCheckConfig( 1342 dynamic_stream_name=model.dynamic_stream_name, 1343 stream_count=model.stream_count, 1344 ) 1345 1346 def create_check_stream( 1347 self, model: CheckStreamModel, config: Config, **kwargs: Any 1348 ) -> CheckStream: 1349 if model.dynamic_streams_check_configs is None and model.stream_names is None: 1350 raise ValueError( 1351 "Expected either stream_names or dynamic_streams_check_configs to be set for CheckStream" 1352 ) 1353 1354 dynamic_streams_check_configs = ( 1355 [ 1356 self._create_component_from_model(model=dynamic_stream_check_config, config=config) 1357 for dynamic_stream_check_config in model.dynamic_streams_check_configs 1358 ] 1359 if model.dynamic_streams_check_configs 1360 else [] 1361 ) 1362 1363 # `model.config_overrides` is deliberately not read here. The source applies it around the whole 1364 # check operation (`ConcurrentDeclarativeSource._config_overridden_for_check`), which is what makes 1365 # it work for every checker type rather than only this one. Do not wire it in a second time. 1366 return CheckStream( 1367 stream_names=model.stream_names or [], 1368 dynamic_streams_check_configs=dynamic_streams_check_configs, 1369 parameters={}, 1370 ) 1371 1372 @staticmethod 1373 def create_check_dynamic_stream( 1374 model: CheckDynamicStreamModel, config: Config, **kwargs: Any 1375 ) -> CheckDynamicStream: 1376 assert model.use_check_availability is not None # for mypy 1377 1378 use_check_availability = model.use_check_availability 1379 1380 # See `create_check_stream`: `model.config_overrides` is applied by the source, not here. 1381 return CheckDynamicStream( 1382 stream_count=model.stream_count, 1383 use_check_availability=use_check_availability, 1384 parameters={}, 1385 ) 1386 1387 def create_composite_error_handler( 1388 self, model: CompositeErrorHandlerModel, config: Config, **kwargs: Any 1389 ) -> CompositeErrorHandler: 1390 error_handlers = [ 1391 self._create_component_from_model(model=error_handler_model, config=config) 1392 for error_handler_model in model.error_handlers 1393 ] 1394 return CompositeErrorHandler( 1395 error_handlers=error_handlers, parameters=model.parameters or {} 1396 ) 1397 1398 @staticmethod 1399 def create_concurrency_level( 1400 model: ConcurrencyLevelModel, config: Config, **kwargs: Any 1401 ) -> ConcurrencyLevel: 1402 return ConcurrencyLevel( 1403 default_concurrency=model.default_concurrency, 1404 max_concurrency=model.max_concurrency, 1405 config=config, 1406 parameters={}, 1407 ) 1408 1409 @staticmethod 1410 def apply_stream_state_migrations( 1411 stream_state_migrations: List[Any] | None, stream_state: MutableMapping[str, Any] 1412 ) -> MutableMapping[str, Any]: 1413 if stream_state_migrations: 1414 for state_migration in stream_state_migrations: 1415 if state_migration.should_migrate(stream_state): 1416 # The state variable is expected to be mutable but the migrate method returns an immutable mapping. 1417 stream_state = dict(state_migration.migrate(stream_state)) 1418 return stream_state 1419 1420 def create_concurrent_cursor_from_datetime_based_cursor( 1421 self, 1422 model_type: Type[BaseModel], 1423 component_definition: ComponentDefinition, 1424 stream_name: str, 1425 stream_namespace: Optional[str], 1426 stream_state: MutableMapping[str, Any], 1427 config: Config, 1428 message_repository: Optional[MessageRepository] = None, 1429 runtime_lookback_window: Optional[datetime.timedelta] = None, 1430 **kwargs: Any, 1431 ) -> ConcurrentCursor: 1432 component_type = component_definition.get("type") 1433 if component_definition.get("type") != model_type.__name__: 1434 raise ValueError( 1435 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 1436 ) 1437 1438 # 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: 1439 # * The ComponentDefinition comes from model.__dict__ in which case we have `parameters` 1440 # * The ComponentDefinition comes from the manifest as a dict in which case we have `$parameters` 1441 # 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. 1442 if "$parameters" not in component_definition and "parameters" in component_definition: 1443 component_definition["$parameters"] = component_definition.get("parameters") # type: ignore # This is a dict 1444 datetime_based_cursor_model = model_type.parse_obj(component_definition) 1445 1446 if not isinstance(datetime_based_cursor_model, DatetimeBasedCursorModel): 1447 raise ValueError( 1448 f"Expected {model_type.__name__} component, but received {datetime_based_cursor_model.__class__.__name__}" 1449 ) 1450 1451 model_parameters = datetime_based_cursor_model.parameters or {} 1452 1453 cursor_field = self._get_catalog_defined_cursor_field( 1454 stream_name=stream_name, 1455 allow_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1456 or False, 1457 ) 1458 1459 if not cursor_field: 1460 interpolated_cursor_field = InterpolatedString.create( 1461 datetime_based_cursor_model.cursor_field, 1462 parameters=model_parameters, 1463 ) 1464 cursor_field = CursorField( 1465 cursor_field_key=interpolated_cursor_field.eval(config=config), 1466 supports_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1467 or False, 1468 ) 1469 1470 interpolated_partition_field_start = InterpolatedString.create( 1471 datetime_based_cursor_model.partition_field_start or "start_time", 1472 parameters=model_parameters, 1473 ) 1474 interpolated_partition_field_end = InterpolatedString.create( 1475 datetime_based_cursor_model.partition_field_end or "end_time", 1476 parameters=model_parameters, 1477 ) 1478 1479 slice_boundary_fields = ( 1480 interpolated_partition_field_start.eval(config=config), 1481 interpolated_partition_field_end.eval(config=config), 1482 ) 1483 1484 datetime_format = datetime_based_cursor_model.datetime_format 1485 1486 cursor_granularity = ( 1487 parse_duration(datetime_based_cursor_model.cursor_granularity) 1488 if datetime_based_cursor_model.cursor_granularity 1489 else None 1490 ) 1491 1492 lookback_window = None 1493 interpolated_lookback_window = ( 1494 InterpolatedString.create( 1495 datetime_based_cursor_model.lookback_window, 1496 parameters=model_parameters, 1497 ) 1498 if datetime_based_cursor_model.lookback_window 1499 else None 1500 ) 1501 if interpolated_lookback_window: 1502 evaluated_lookback_window = interpolated_lookback_window.eval(config=config) 1503 if evaluated_lookback_window: 1504 lookback_window = parse_duration(evaluated_lookback_window) 1505 1506 connector_state_converter: DateTimeStreamStateConverter 1507 connector_state_converter = CustomFormatConcurrentStreamStateConverter( 1508 datetime_format=datetime_format, 1509 input_datetime_formats=datetime_based_cursor_model.cursor_datetime_formats, 1510 is_sequential_state=True, # ConcurrentPerPartitionCursor only works with sequential state 1511 cursor_granularity=cursor_granularity, 1512 ) 1513 1514 # Adjusts the stream state by applying the runtime lookback window. 1515 # This is used to ensure correct state handling in case of failed partitions. 1516 stream_state_value = stream_state.get(cursor_field.cursor_field_key) 1517 if runtime_lookback_window and stream_state_value: 1518 new_stream_state = ( 1519 connector_state_converter.parse_timestamp(stream_state_value) 1520 - runtime_lookback_window 1521 ) 1522 stream_state[cursor_field.cursor_field_key] = connector_state_converter.output_format( 1523 new_stream_state 1524 ) 1525 1526 start_date_runtime_value: Union[InterpolatedString, str, MinMaxDatetime] 1527 if isinstance(datetime_based_cursor_model.start_datetime, MinMaxDatetimeModel): 1528 start_date_runtime_value = self.create_min_max_datetime( 1529 model=datetime_based_cursor_model.start_datetime, config=config 1530 ) 1531 else: 1532 start_date_runtime_value = datetime_based_cursor_model.start_datetime 1533 1534 end_date_runtime_value: Optional[Union[InterpolatedString, str, MinMaxDatetime]] 1535 if isinstance(datetime_based_cursor_model.end_datetime, MinMaxDatetimeModel): 1536 end_date_runtime_value = self.create_min_max_datetime( 1537 model=datetime_based_cursor_model.end_datetime, config=config 1538 ) 1539 else: 1540 end_date_runtime_value = datetime_based_cursor_model.end_datetime 1541 1542 interpolated_start_date = MinMaxDatetime.create( 1543 interpolated_string_or_min_max_datetime=start_date_runtime_value, 1544 parameters=datetime_based_cursor_model.parameters, 1545 ) 1546 interpolated_end_date = ( 1547 None 1548 if not end_date_runtime_value 1549 else MinMaxDatetime.create( 1550 end_date_runtime_value, datetime_based_cursor_model.parameters 1551 ) 1552 ) 1553 1554 # If datetime format is not specified then start/end datetime should inherit it from the stream slicer 1555 if not interpolated_start_date.datetime_format: 1556 interpolated_start_date.datetime_format = datetime_format 1557 if interpolated_end_date and not interpolated_end_date.datetime_format: 1558 interpolated_end_date.datetime_format = datetime_format 1559 1560 start_date = interpolated_start_date.get_datetime(config=config) 1561 end_date_provider = ( 1562 partial(interpolated_end_date.get_datetime, config) 1563 if interpolated_end_date 1564 else connector_state_converter.get_end_provider() 1565 ) 1566 1567 if ( 1568 datetime_based_cursor_model.step and not datetime_based_cursor_model.cursor_granularity 1569 ) or ( 1570 not datetime_based_cursor_model.step and datetime_based_cursor_model.cursor_granularity 1571 ): 1572 raise ValueError( 1573 f"If step is defined, cursor_granularity should be as well and vice-versa. " 1574 f"Right now, step is `{datetime_based_cursor_model.step}` and cursor_granularity is `{datetime_based_cursor_model.cursor_granularity}`" 1575 ) 1576 1577 # When step is not defined, default to a step size from the starting date to the present moment 1578 step_length = datetime.timedelta.max 1579 interpolated_step = ( 1580 InterpolatedString.create( 1581 datetime_based_cursor_model.step, 1582 parameters=model_parameters, 1583 ) 1584 if datetime_based_cursor_model.step 1585 else None 1586 ) 1587 if interpolated_step: 1588 evaluated_step = interpolated_step.eval(config) 1589 if evaluated_step: 1590 step_length = parse_duration(evaluated_step) 1591 1592 clamping_strategy: ClampingStrategy = NoClamping() 1593 if datetime_based_cursor_model.clamping: 1594 # While it is undesirable to interpolate within the model factory (as opposed to at runtime), 1595 # it is still better than shifting interpolation low-code concept into the ConcurrentCursor runtime 1596 # object which we want to keep agnostic of being low-code 1597 target = InterpolatedString( 1598 string=datetime_based_cursor_model.clamping.target, 1599 parameters=model_parameters, 1600 ) 1601 evaluated_target = target.eval(config=config) 1602 match evaluated_target: 1603 case "DAY": 1604 clamping_strategy = DayClampingStrategy() 1605 end_date_provider = ClampingEndProvider( 1606 DayClampingStrategy(is_ceiling=False), 1607 end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1608 granularity=cursor_granularity or datetime.timedelta(seconds=1), 1609 ) 1610 case "WEEK": 1611 if ( 1612 not datetime_based_cursor_model.clamping.target_details 1613 or "weekday" not in datetime_based_cursor_model.clamping.target_details 1614 ): 1615 raise ValueError( 1616 "Given WEEK clamping, weekday needs to be provided as target_details" 1617 ) 1618 weekday = self._assemble_weekday( 1619 datetime_based_cursor_model.clamping.target_details["weekday"] 1620 ) 1621 clamping_strategy = WeekClampingStrategy(weekday) 1622 end_date_provider = ClampingEndProvider( 1623 WeekClampingStrategy(weekday, is_ceiling=False), 1624 end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1625 granularity=cursor_granularity or datetime.timedelta(days=1), 1626 ) 1627 case "MONTH": 1628 clamping_strategy = MonthClampingStrategy() 1629 end_date_provider = ClampingEndProvider( 1630 MonthClampingStrategy(is_ceiling=False), 1631 end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1632 granularity=cursor_granularity or datetime.timedelta(days=1), 1633 ) 1634 case _: 1635 raise ValueError( 1636 f"Invalid clamping target {evaluated_target}, expected DAY, WEEK, MONTH" 1637 ) 1638 1639 return ConcurrentCursor( 1640 stream_name=stream_name, 1641 stream_namespace=stream_namespace, 1642 stream_state=stream_state, 1643 message_repository=message_repository or self._message_repository, 1644 connector_state_manager=self._connector_state_manager, 1645 connector_state_converter=connector_state_converter, 1646 cursor_field=cursor_field, 1647 slice_boundary_fields=slice_boundary_fields, 1648 start=start_date, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1649 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 1650 lookback_window=lookback_window, 1651 slice_range=step_length, 1652 cursor_granularity=cursor_granularity, 1653 clamping_strategy=clamping_strategy, 1654 is_compare_strictly=datetime_based_cursor_model.is_compare_strictly or False, 1655 ) 1656 1657 def create_concurrent_cursor_from_incrementing_count_cursor( 1658 self, 1659 model_type: Type[BaseModel], 1660 component_definition: ComponentDefinition, 1661 stream_name: str, 1662 stream_namespace: Optional[str], 1663 stream_state: MutableMapping[str, Any], 1664 config: Config, 1665 message_repository: Optional[MessageRepository] = None, 1666 **kwargs: Any, 1667 ) -> ConcurrentCursor: 1668 component_type = component_definition.get("type") 1669 if component_definition.get("type") != model_type.__name__: 1670 raise ValueError( 1671 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 1672 ) 1673 1674 incrementing_count_cursor_model = model_type.parse_obj(component_definition) 1675 1676 if not isinstance(incrementing_count_cursor_model, IncrementingCountCursorModel): 1677 raise ValueError( 1678 f"Expected {model_type.__name__} component, but received {incrementing_count_cursor_model.__class__.__name__}" 1679 ) 1680 1681 start_value: Union[int, str, None] = incrementing_count_cursor_model.start_value 1682 # Pydantic Union type coercion can convert int 0 to string '0' depending on Union order. 1683 # We need to handle both int and str representations of numeric values. 1684 # Evaluate the InterpolatedString and convert to int for the ConcurrentCursor. 1685 if start_value is not None: 1686 interpolated_start_value = InterpolatedString.create( 1687 str(start_value), # Ensure we pass a string to InterpolatedString.create 1688 parameters=incrementing_count_cursor_model.parameters or {}, 1689 ) 1690 evaluated_start_value: int = int(interpolated_start_value.eval(config=config)) 1691 else: 1692 evaluated_start_value = 0 1693 1694 cursor_field = self._get_catalog_defined_cursor_field( 1695 stream_name=stream_name, 1696 allow_catalog_defined_cursor_field=incrementing_count_cursor_model.allow_catalog_defined_cursor_field 1697 or False, 1698 ) 1699 1700 if not cursor_field: 1701 interpolated_cursor_field = InterpolatedString.create( 1702 incrementing_count_cursor_model.cursor_field, 1703 parameters=incrementing_count_cursor_model.parameters or {}, 1704 ) 1705 cursor_field = CursorField( 1706 cursor_field_key=interpolated_cursor_field.eval(config=config), 1707 supports_catalog_defined_cursor_field=incrementing_count_cursor_model.allow_catalog_defined_cursor_field 1708 or False, 1709 ) 1710 1711 connector_state_converter = IncrementingCountStreamStateConverter( 1712 is_sequential_state=True, # ConcurrentPerPartitionCursor only works with sequential state 1713 ) 1714 1715 return ConcurrentCursor( 1716 stream_name=stream_name, 1717 stream_namespace=stream_namespace, 1718 stream_state=stream_state, 1719 message_repository=message_repository or self._message_repository, 1720 connector_state_manager=self._connector_state_manager, 1721 connector_state_converter=connector_state_converter, 1722 cursor_field=cursor_field, 1723 slice_boundary_fields=None, 1724 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 1725 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 1726 ) 1727 1728 def _assemble_weekday(self, weekday: str) -> Weekday: 1729 match weekday: 1730 case "MONDAY": 1731 return Weekday.MONDAY 1732 case "TUESDAY": 1733 return Weekday.TUESDAY 1734 case "WEDNESDAY": 1735 return Weekday.WEDNESDAY 1736 case "THURSDAY": 1737 return Weekday.THURSDAY 1738 case "FRIDAY": 1739 return Weekday.FRIDAY 1740 case "SATURDAY": 1741 return Weekday.SATURDAY 1742 case "SUNDAY": 1743 return Weekday.SUNDAY 1744 case _: 1745 raise ValueError(f"Unknown weekday {weekday}") 1746 1747 def create_concurrent_cursor_from_perpartition_cursor( 1748 self, 1749 state_manager: ConnectorStateManager, 1750 model_type: Type[BaseModel], 1751 component_definition: ComponentDefinition, 1752 stream_name: str, 1753 stream_namespace: Optional[str], 1754 config: Config, 1755 stream_state: MutableMapping[str, Any], 1756 partition_router: PartitionRouter, 1757 attempt_to_create_cursor_if_not_provided: bool = False, 1758 **kwargs: Any, 1759 ) -> ConcurrentPerPartitionCursor: 1760 component_type = component_definition.get("type") 1761 if component_definition.get("type") != model_type.__name__: 1762 raise ValueError( 1763 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 1764 ) 1765 1766 # 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: 1767 # * The ComponentDefinition comes from model.__dict__ in which case we have `parameters` 1768 # * The ComponentDefinition comes from the manifest as a dict in which case we have `$parameters` 1769 # 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. 1770 if "$parameters" not in component_definition and "parameters" in component_definition: 1771 component_definition["$parameters"] = component_definition.get("parameters") # type: ignore # This is a dict 1772 datetime_based_cursor_model = model_type.parse_obj(component_definition) 1773 1774 if not isinstance(datetime_based_cursor_model, DatetimeBasedCursorModel): 1775 raise ValueError( 1776 f"Expected {model_type.__name__} component, but received {datetime_based_cursor_model.__class__.__name__}" 1777 ) 1778 1779 cursor_field = self._get_catalog_defined_cursor_field( 1780 stream_name=stream_name, 1781 allow_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1782 or False, 1783 ) 1784 1785 if not cursor_field: 1786 interpolated_cursor_field = InterpolatedString.create( 1787 datetime_based_cursor_model.cursor_field, 1788 # 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: 1789 # * The ComponentDefinition comes from model.__dict__ in which case we have `parameters` 1790 # * The ComponentDefinition comes from the manifest as a dict in which case we have `$parameters` 1791 # 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. 1792 parameters=datetime_based_cursor_model.parameters or {}, 1793 ) 1794 cursor_field = CursorField( 1795 cursor_field_key=interpolated_cursor_field.eval(config=config), 1796 supports_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1797 or False, 1798 ) 1799 1800 datetime_format = datetime_based_cursor_model.datetime_format 1801 1802 cursor_granularity = ( 1803 parse_duration(datetime_based_cursor_model.cursor_granularity) 1804 if datetime_based_cursor_model.cursor_granularity 1805 else None 1806 ) 1807 1808 connector_state_converter: DateTimeStreamStateConverter 1809 connector_state_converter = CustomFormatConcurrentStreamStateConverter( 1810 datetime_format=datetime_format, 1811 input_datetime_formats=datetime_based_cursor_model.cursor_datetime_formats, 1812 is_sequential_state=True, # ConcurrentPerPartitionCursor only works with sequential state 1813 cursor_granularity=cursor_granularity, 1814 ) 1815 1816 # Create the cursor factory 1817 cursor_factory = ConcurrentCursorFactory( 1818 partial( 1819 self.create_concurrent_cursor_from_datetime_based_cursor, 1820 state_manager=state_manager, 1821 model_type=model_type, 1822 component_definition=component_definition, 1823 stream_name=stream_name, 1824 stream_namespace=stream_namespace, 1825 config=config, 1826 message_repository=NoopMessageRepository(), 1827 ) 1828 ) 1829 1830 # Per-partition state doesn't make sense for GroupingPartitionRouter, so force the global state 1831 use_global_cursor = isinstance( 1832 partition_router, GroupingPartitionRouter 1833 ) or component_definition.get("global_substream_cursor", False) 1834 1835 # Return the concurrent cursor and state converter 1836 return ConcurrentPerPartitionCursor( 1837 cursor_factory=cursor_factory, 1838 partition_router=partition_router, 1839 stream_name=stream_name, 1840 stream_namespace=stream_namespace, 1841 stream_state=stream_state, 1842 message_repository=self._message_repository, # type: ignore 1843 connector_state_manager=state_manager, 1844 connector_state_converter=connector_state_converter, 1845 cursor_field=cursor_field, 1846 use_global_cursor=use_global_cursor, 1847 attempt_to_create_cursor_if_not_provided=attempt_to_create_cursor_if_not_provided, 1848 ) 1849 1850 @staticmethod 1851 def create_constant_backoff_strategy( 1852 model: ConstantBackoffStrategyModel, config: Config, **kwargs: Any 1853 ) -> ConstantBackoffStrategy: 1854 ModelToComponentFactory._validate_jitter_range(model.jitter_range_in_seconds) 1855 return ConstantBackoffStrategy( 1856 backoff_time_in_seconds=model.backoff_time_in_seconds, 1857 jitter_range_in_seconds=model.jitter_range_in_seconds, 1858 config=config, 1859 parameters=model.parameters or {}, 1860 ) 1861 1862 @staticmethod 1863 def _validate_jitter_range(jitter_range_in_seconds: Optional[float]) -> None: 1864 if jitter_range_in_seconds is not None and jitter_range_in_seconds < 0: 1865 raise ValueError("jitter_range_in_seconds must be greater than or equal to 0") 1866 1867 def create_cursor_pagination( 1868 self, 1869 model: CursorPaginationModel, 1870 config: Config, 1871 decoder: Optional[Decoder] = None, 1872 **kwargs: Any, 1873 ) -> CursorPaginationStrategy: 1874 # None when nested under a custom component, which cannot pass a decoder 1875 if decoder is None: 1876 decoder = JsonDecoder(parameters={}) 1877 if isinstance(decoder, PaginationDecoderDecorator): 1878 inner_decoder = decoder.decoder 1879 else: 1880 inner_decoder = decoder 1881 decoder = PaginationDecoderDecorator(decoder=decoder) 1882 1883 if self._is_supported_decoder_for_pagination(inner_decoder): 1884 decoder_to_use = decoder 1885 else: 1886 raise ValueError( 1887 self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(inner_decoder)) 1888 ) 1889 1890 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 1891 # If page_size is a string that represents an integer (not an interpolation), convert it back. 1892 page_size = model.page_size 1893 if isinstance(page_size, str) and page_size.isdigit(): 1894 page_size = int(page_size) 1895 1896 return CursorPaginationStrategy( 1897 cursor_value=model.cursor_value, 1898 decoder=decoder_to_use, 1899 page_size=page_size, 1900 stop_condition=model.stop_condition, 1901 config=config, 1902 parameters=model.parameters or {}, 1903 ) 1904 1905 def create_custom_component(self, model: Any, config: Config, **kwargs: Any) -> Any: 1906 """ 1907 Generically creates a custom component based on the model type and a class_name reference to the custom Python class being 1908 instantiated. Only the model's additional properties that match the custom class definition are passed to the constructor 1909 :param model: The Pydantic model of the custom component being created 1910 :param config: The custom defined connector config 1911 :return: The declarative component built from the Pydantic model to be used at runtime 1912 """ 1913 # Instantiating a custom component means importing and executing arbitrary code referenced 1914 # by `class_name`. Manifests supplied by a caller, whether through the config or directly to 1915 # the manifest server, are untrusted input and could point `class_name` at any importable 1916 # callable, so they honor the same `AIRBYTE_ENABLE_UNSAFE_CODE` gate as injected 1917 # `components.py` code. Manifests bundled in a published connector image are trusted and may 1918 # always use their bundled custom components. 1919 manifest_is_untrusted = not self._custom_components_trusted or bool( 1920 config.get(INJECTED_MANIFEST) 1921 ) 1922 if manifest_is_untrusted and not custom_code_execution_permitted(): 1923 raise AirbyteCustomCodeNotPermittedError 1924 1925 custom_component_class = self._get_class_from_fully_qualified_class_name(model.class_name) 1926 component_fields = get_type_hints(custom_component_class) 1927 model_args = model.dict() 1928 model_args["config"] = config 1929 1930 # There are cases where a parent component will pass arguments to a child component via kwargs. When there are field collisions 1931 # we defer to these arguments over the component's definition 1932 for key, arg in kwargs.items(): 1933 model_args[key] = arg 1934 1935 # Pydantic is unable to parse a custom component's fields that are subcomponents into models because their fields and types are not 1936 # defined in the schema. The fields and types are defined within the Python class implementation. Pydantic can only parse down to 1937 # the custom component and this code performs a second parse to convert the sub-fields first into models, then declarative components 1938 for model_field, model_value in model_args.items(): 1939 # If a custom component field doesn't have a type set, we try to use the type hints to infer the type 1940 if ( 1941 isinstance(model_value, dict) 1942 and "type" not in model_value 1943 and model_field in component_fields 1944 ): 1945 derived_type = self._derive_component_type_from_type_hints( 1946 component_fields.get(model_field) 1947 ) 1948 if derived_type: 1949 model_value["type"] = derived_type 1950 1951 if self._is_component(model_value): 1952 model_args[model_field] = self._create_nested_component( 1953 model, 1954 model_field, 1955 model_value, 1956 config, 1957 **kwargs, 1958 ) 1959 elif isinstance(model_value, list): 1960 vals = [] 1961 for v in model_value: 1962 if isinstance(v, dict) and "type" not in v and model_field in component_fields: 1963 derived_type = self._derive_component_type_from_type_hints( 1964 component_fields.get(model_field) 1965 ) 1966 if derived_type: 1967 v["type"] = derived_type 1968 if self._is_component(v): 1969 vals.append( 1970 self._create_nested_component( 1971 model, 1972 model_field, 1973 v, 1974 config, 1975 **kwargs, 1976 ) 1977 ) 1978 else: 1979 vals.append(v) 1980 model_args[model_field] = vals 1981 1982 kwargs = { 1983 class_field: model_args[class_field] 1984 for class_field in component_fields.keys() 1985 if class_field in model_args 1986 } 1987 1988 if "api_budget" in component_fields and kwargs.get("api_budget") is None: 1989 kwargs["api_budget"] = self._api_budget 1990 1991 return custom_component_class(**kwargs) 1992 1993 @staticmethod 1994 def _get_class_from_fully_qualified_class_name( 1995 full_qualified_class_name: str, 1996 ) -> Any: 1997 """Get a class from its fully qualified name. 1998 1999 If a custom components module is needed, we assume it is already registered - probably 2000 as `source_declarative_manifest.components` or `components`. 2001 2002 Args: 2003 full_qualified_class_name (str): The fully qualified name of the class (e.g., "module.ClassName"). 2004 2005 Returns: 2006 Any: The class object. 2007 2008 Raises: 2009 ValueError: If the class cannot be loaded. 2010 """ 2011 split = full_qualified_class_name.split(".") 2012 module_name_full = ".".join(split[:-1]) 2013 class_name = split[-1] 2014 2015 try: 2016 module_ref = importlib.import_module(module_name_full) 2017 except ModuleNotFoundError as e: 2018 if split[0] == "source_declarative_manifest": 2019 # 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 2020 try: 2021 import os 2022 2023 module_name_with_source_declarative_manifest = ".".join(split[1:-1]) 2024 module_ref = importlib.import_module( 2025 module_name_with_source_declarative_manifest 2026 ) 2027 except ModuleNotFoundError: 2028 raise ValueError(f"Could not load module `{module_name_full}`.") from e 2029 else: 2030 raise ValueError(f"Could not load module `{module_name_full}`.") from e 2031 2032 try: 2033 return getattr(module_ref, class_name) 2034 except AttributeError as e: 2035 raise ValueError( 2036 f"Could not load class `{class_name}` from module `{module_name_full}`.", 2037 ) from e 2038 2039 @staticmethod 2040 def _derive_component_type_from_type_hints(field_type: Any) -> Optional[str]: 2041 interface = field_type 2042 while True: 2043 origin = get_origin(interface) 2044 if origin: 2045 # Unnest types until we reach the raw type 2046 # List[T] -> T 2047 # Optional[List[T]] -> T 2048 args = get_args(interface) 2049 interface = args[0] 2050 else: 2051 break 2052 if isinstance(interface, type) and not ModelToComponentFactory.is_builtin_type(interface): 2053 return interface.__name__ 2054 return None 2055 2056 @staticmethod 2057 def is_builtin_type(cls: Optional[Type[Any]]) -> bool: 2058 if not cls: 2059 return False 2060 return cls.__module__ == "builtins" 2061 2062 @staticmethod 2063 def _extract_missing_parameters(error: TypeError) -> List[str]: 2064 parameter_search = re.search(r"keyword-only.*:\s(.*)", str(error)) 2065 if parameter_search: 2066 return re.findall(r"\'(.+?)\'", parameter_search.group(1)) 2067 else: 2068 return [] 2069 2070 def _create_nested_component( 2071 self, model: Any, model_field: str, model_value: Any, config: Config, **kwargs: Any 2072 ) -> Any: 2073 type_name = model_value.get("type", None) 2074 if not type_name: 2075 # If no type is specified, we can assume this is a dictionary object which can be returned instead of a subcomponent 2076 return model_value 2077 2078 model_type = self.TYPE_NAME_TO_MODEL.get(type_name, None) 2079 if model_type: 2080 parsed_model = model_type.parse_obj(model_value) 2081 try: 2082 # To improve usability of the language, certain fields are shared between components. This can come in the form of 2083 # a parent component passing some of its fields to a child component or the parent extracting fields from other child 2084 # components and passing it to others. One example is the DefaultPaginator referencing the HttpRequester url_base 2085 # while constructing a SimpleRetriever. However, custom components don't support this behavior because they are created 2086 # generically in create_custom_component(). This block allows developers to specify extra arguments in $parameters that 2087 # are needed by a component and could not be shared. 2088 model_constructor = self.PYDANTIC_MODEL_TO_CONSTRUCTOR.get(parsed_model.__class__) 2089 constructor_kwargs = inspect.getfullargspec(model_constructor).kwonlyargs 2090 model_parameters = model_value.get("$parameters", {}) 2091 matching_parameters = { 2092 kwarg: model_parameters[kwarg] 2093 for kwarg in constructor_kwargs 2094 if kwarg in model_parameters 2095 } 2096 matching_kwargs = { 2097 kwarg: kwargs[kwarg] for kwarg in constructor_kwargs if kwarg in kwargs 2098 } 2099 return self._create_component_from_model( 2100 model=parsed_model, config=config, **(matching_parameters | matching_kwargs) 2101 ) 2102 except TypeError as error: 2103 missing_parameters = self._extract_missing_parameters(error) 2104 if missing_parameters: 2105 raise ValueError( 2106 f"Error creating component '{type_name}' with parent custom component {model.class_name}: Please provide " 2107 + ", ".join( 2108 ( 2109 f"{type_name}.$parameters.{parameter}" 2110 for parameter in missing_parameters 2111 ) 2112 ) 2113 ) 2114 raise TypeError( 2115 f"Error creating component '{type_name}' with parent custom component {model.class_name}: {error}" 2116 ) 2117 else: 2118 raise ValueError( 2119 f"Error creating custom component {model.class_name}. Subcomponent creation has not been implemented for '{type_name}'" 2120 ) 2121 2122 @staticmethod 2123 def _is_component(model_value: Any) -> bool: 2124 return isinstance(model_value, dict) and model_value.get("type") is not None 2125 2126 def create_default_stream( 2127 self, model: DeclarativeStreamModel, config: Config, is_parent: bool = False, **kwargs: Any 2128 ) -> AbstractStream: 2129 primary_key = model.primary_key.__root__ if model.primary_key else None 2130 self._migrate_state(model, config) 2131 self._warn_on_ineffective_incremental_dependency(model) 2132 2133 partition_router = self._build_stream_slicer_from_partition_router( 2134 model.retriever, 2135 config, 2136 stream_name=model.name, 2137 **kwargs, 2138 ) 2139 concurrent_cursor = self._build_concurrent_cursor(model, partition_router, config) 2140 if model.incremental_sync and isinstance(model.incremental_sync, DatetimeBasedCursorModel): 2141 cursor_model: DatetimeBasedCursorModel = model.incremental_sync 2142 2143 end_time_option = ( 2144 self._create_component_from_model( 2145 cursor_model.end_time_option, config, parameters=cursor_model.parameters or {} 2146 ) 2147 if cursor_model.end_time_option 2148 else None 2149 ) 2150 start_time_option = ( 2151 self._create_component_from_model( 2152 cursor_model.start_time_option, config, parameters=cursor_model.parameters or {} 2153 ) 2154 if cursor_model.start_time_option 2155 else None 2156 ) 2157 2158 datetime_request_options_provider = DatetimeBasedRequestOptionsProvider( 2159 start_time_option=start_time_option, 2160 end_time_option=end_time_option, 2161 partition_field_start=cursor_model.partition_field_start, 2162 partition_field_end=cursor_model.partition_field_end, 2163 config=config, 2164 parameters=model.parameters or {}, 2165 ) 2166 request_options_provider = ( 2167 datetime_request_options_provider 2168 if not isinstance(concurrent_cursor, ConcurrentPerPartitionCursor) 2169 else PerPartitionRequestOptionsProvider( 2170 partition_router, datetime_request_options_provider 2171 ) 2172 ) 2173 elif model.incremental_sync and isinstance( 2174 model.incremental_sync, IncrementingCountCursorModel 2175 ): 2176 if isinstance(concurrent_cursor, ConcurrentPerPartitionCursor): 2177 raise ValueError( 2178 "PerPartition does not support per partition states because switching to global state is time based" 2179 ) 2180 2181 cursor_model: IncrementingCountCursorModel = model.incremental_sync # type: ignore 2182 2183 start_time_option = ( 2184 self._create_component_from_model( 2185 cursor_model.start_value_option, # type: ignore # mypy still thinks cursor_model of type DatetimeBasedCursor 2186 config, 2187 parameters=cursor_model.parameters or {}, 2188 ) 2189 if cursor_model.start_value_option # type: ignore # mypy still thinks cursor_model of type DatetimeBasedCursor 2190 else None 2191 ) 2192 2193 # The concurrent engine defaults the start/end fields on the slice to "start" and "end", but 2194 # the default DatetimeBasedRequestOptionsProvider() sets them to start_time/end_time 2195 partition_field_start = "start" 2196 2197 request_options_provider = DatetimeBasedRequestOptionsProvider( 2198 start_time_option=start_time_option, 2199 partition_field_start=partition_field_start, 2200 config=config, 2201 parameters=model.parameters or {}, 2202 ) 2203 else: 2204 request_options_provider = None 2205 2206 transformations = [] 2207 if model.transformations: 2208 for transformation_model in model.transformations: 2209 transformations.append( 2210 self._create_component_from_model(model=transformation_model, config=config) 2211 ) 2212 file_uploader = None 2213 if model.file_uploader: 2214 file_uploader = self._create_component_from_model( 2215 model=model.file_uploader, config=config 2216 ) 2217 2218 stream_slicer: ConcurrentStreamSlicer = ( 2219 partition_router 2220 if isinstance(concurrent_cursor, FinalStateCursor) 2221 else concurrent_cursor 2222 ) 2223 2224 retriever = self._create_component_from_model( 2225 model=model.retriever, 2226 config=config, 2227 name=model.name, 2228 primary_key=primary_key, 2229 request_options_provider=request_options_provider, 2230 stream_slicer=stream_slicer, 2231 partition_router=partition_router, 2232 has_stop_condition_cursor=self._is_stop_condition_on_cursor(model), 2233 is_client_side_incremental_sync=self._is_client_side_filtering_enabled(model), 2234 cursor=concurrent_cursor, 2235 transformations=transformations, 2236 file_uploader=file_uploader, 2237 incremental_sync=model.incremental_sync, 2238 ) 2239 if isinstance(retriever, AsyncRetriever): 2240 stream_slicer = retriever.stream_slicer 2241 2242 schema_loader: SchemaLoader 2243 if model.schema_loader and isinstance(model.schema_loader, list): 2244 nested_schema_loaders = [ 2245 self._create_component_from_model(model=nested_schema_loader, config=config) 2246 for nested_schema_loader in model.schema_loader 2247 ] 2248 schema_loader = CompositeSchemaLoader( 2249 schema_loaders=nested_schema_loaders, parameters={} 2250 ) 2251 elif model.schema_loader: 2252 schema_loader = self._create_component_from_model( 2253 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 2254 config=config, 2255 ) 2256 else: 2257 options = model.parameters or {} 2258 if "name" not in options: 2259 options["name"] = model.name 2260 schema_loader = DefaultSchemaLoader(config=config, parameters=options) 2261 schema_loader = CachingSchemaLoaderDecorator(schema_loader) 2262 2263 stream_name = model.name or "" 2264 return DefaultStream( 2265 partition_generator=StreamSlicerPartitionGenerator( 2266 DeclarativePartitionFactory( 2267 stream_name, 2268 schema_loader, 2269 retriever, 2270 self._message_repository, 2271 ), 2272 stream_slicer, 2273 slice_limit=self._limit_slices_fetched, 2274 ), 2275 name=stream_name, 2276 json_schema=schema_loader.get_json_schema, 2277 primary_key=get_primary_key_from_stream(primary_key), 2278 cursor_field=( 2279 concurrent_cursor.cursor_field 2280 if hasattr(concurrent_cursor, "cursor_field") 2281 else None 2282 ), 2283 logger=logging.getLogger(f"airbyte.{stream_name}"), 2284 cursor=concurrent_cursor, 2285 supports_file_transfer=hasattr(model, "file_uploader") and bool(model.file_uploader), 2286 ) 2287 2288 def _warn_on_ineffective_incremental_dependency(self, model: DeclarativeStreamModel) -> None: 2289 """ 2290 `incremental_dependency: true` only takes effect when the substream defines its own 2291 `incremental_sync`: the parent cursor is persisted under the `parent_state` key of the 2292 substream's state, which is only emitted by incremental substreams. On a stream without 2293 `incremental_sync`, the setting is silently ignored and all parent records are re-read on 2294 every sync, so we warn about the misconfiguration instead. 2295 """ 2296 if model.incremental_sync: 2297 return 2298 2299 partition_router = getattr(model.retriever, "partition_router", None) 2300 if not partition_router: 2301 return 2302 2303 routers = partition_router if isinstance(partition_router, list) else [partition_router] 2304 for router in routers: 2305 if isinstance(router, GroupingPartitionRouterModel): 2306 router = router.underlying_partition_router 2307 if isinstance(router, SubstreamPartitionRouterModel) and any( 2308 parent_stream_config.incremental_dependency 2309 for parent_stream_config in router.parent_stream_configs 2310 ): 2311 LOGGER.warning( 2312 f"Stream `{model.name}` has `incremental_dependency: true` in its parent stream configuration but does not define `incremental_sync`. " 2313 "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. " 2314 "Define `incremental_sync` on this stream or remove `incremental_dependency`." 2315 ) 2316 return 2317 2318 def _migrate_state(self, model: DeclarativeStreamModel, config: Config) -> None: 2319 stream_name = model.name or "" 2320 stream_state = self._connector_state_manager.get_stream_state( 2321 stream_name=stream_name, namespace=None 2322 ) 2323 if model.state_migrations: 2324 state_transformations = [ 2325 self._create_component_from_model(state_migration, config, declarative_stream=model) 2326 for state_migration in model.state_migrations 2327 ] 2328 else: 2329 state_transformations = [] 2330 stream_state = self.apply_stream_state_migrations(state_transformations, stream_state) 2331 self._connector_state_manager.update_state_for_stream( 2332 stream_name=stream_name, namespace=None, value=stream_state 2333 ) 2334 2335 def _is_stop_condition_on_cursor(self, model: DeclarativeStreamModel) -> bool: 2336 return bool( 2337 model.incremental_sync 2338 and hasattr(model.incremental_sync, "is_data_feed") 2339 and model.incremental_sync.is_data_feed 2340 ) 2341 2342 def _is_client_side_filtering_enabled(self, model: DeclarativeStreamModel) -> bool: 2343 return bool( 2344 model.incremental_sync 2345 and hasattr(model.incremental_sync, "is_client_side_incremental") 2346 and model.incremental_sync.is_client_side_incremental 2347 ) 2348 2349 def _build_stream_slicer_from_partition_router( 2350 self, 2351 model: Union[ 2352 AsyncRetrieverModel, 2353 CustomRetrieverModel, 2354 SimpleRetrieverModel, 2355 ], 2356 config: Config, 2357 stream_name: Optional[str] = None, 2358 **kwargs: Any, 2359 ) -> PartitionRouter: 2360 if ( 2361 hasattr(model, "partition_router") 2362 and isinstance(model, (SimpleRetrieverModel, AsyncRetrieverModel, CustomRetrieverModel)) 2363 and model.partition_router 2364 ): 2365 stream_slicer_model = model.partition_router 2366 if isinstance(stream_slicer_model, list): 2367 return CartesianProductStreamSlicer( 2368 [ 2369 self._create_component_from_model( 2370 model=slicer, config=config, stream_name=stream_name or "" 2371 ) 2372 for slicer in stream_slicer_model 2373 ], 2374 parameters={}, 2375 ) 2376 elif isinstance(stream_slicer_model, dict): 2377 # partition router comes from CustomRetrieverModel therefore has not been parsed as a model 2378 params = stream_slicer_model.get("$parameters") 2379 if not isinstance(params, dict): 2380 params = {} 2381 stream_slicer_model["$parameters"] = params 2382 2383 if stream_name is not None: 2384 params["stream_name"] = stream_name 2385 2386 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` 2387 model, 2388 "partition_router", 2389 stream_slicer_model, 2390 config, 2391 **kwargs, 2392 ) 2393 else: 2394 return self._create_component_from_model( # type: ignore[no-any-return] # Will be created PartitionRouter as stream_slicer_model is model.partition_router 2395 model=stream_slicer_model, config=config, stream_name=stream_name or "" 2396 ) 2397 return SinglePartitionRouter(parameters={}) 2398 2399 def _build_concurrent_cursor( 2400 self, 2401 model: DeclarativeStreamModel, 2402 stream_slicer: Optional[PartitionRouter], 2403 config: Config, 2404 ) -> Cursor: 2405 stream_name = model.name or "" 2406 stream_state = self._connector_state_manager.get_stream_state(stream_name, None) 2407 2408 if ( 2409 model.incremental_sync 2410 and stream_slicer 2411 and not isinstance(stream_slicer, SinglePartitionRouter) 2412 ): 2413 if isinstance(model.incremental_sync, IncrementingCountCursorModel): 2414 # We don't currently support usage of partition routing and IncrementingCountCursor at the 2415 # same time because we didn't solve for design questions like what the lookback window would 2416 # be as well as global cursor fall backs. We have not seen customers that have needed both 2417 # at the same time yet and are currently punting on this until we need to solve it. 2418 raise ValueError( 2419 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}." 2420 ) 2421 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 2422 state_manager=self._connector_state_manager, 2423 model_type=DatetimeBasedCursorModel, 2424 component_definition=model.incremental_sync.__dict__, 2425 stream_name=stream_name, 2426 stream_state=stream_state, 2427 stream_namespace=None, 2428 config=config or {}, 2429 partition_router=stream_slicer, 2430 attempt_to_create_cursor_if_not_provided=True, # FIXME can we remove that now? 2431 ) 2432 elif model.incremental_sync: 2433 if type(model.incremental_sync) == IncrementingCountCursorModel: 2434 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 2435 model_type=IncrementingCountCursorModel, 2436 component_definition=model.incremental_sync.__dict__, 2437 stream_name=stream_name, 2438 stream_namespace=None, 2439 stream_state=stream_state, 2440 config=config or {}, 2441 ) 2442 elif type(model.incremental_sync) == DatetimeBasedCursorModel: 2443 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 2444 model_type=type(model.incremental_sync), 2445 component_definition=model.incremental_sync.__dict__, 2446 stream_name=stream_name, 2447 stream_namespace=None, 2448 stream_state=stream_state, 2449 config=config or {}, 2450 attempt_to_create_cursor_if_not_provided=True, 2451 ) 2452 else: 2453 raise ValueError( 2454 f"Incremental sync of type {type(model.incremental_sync)} is not supported" 2455 ) 2456 return FinalStateCursor(stream_name, None, self._message_repository) 2457 2458 def create_default_error_handler( 2459 self, model: DefaultErrorHandlerModel, config: Config, **kwargs: Any 2460 ) -> DefaultErrorHandler: 2461 backoff_strategies = [] 2462 if model.backoff_strategies: 2463 for backoff_strategy_model in model.backoff_strategies: 2464 backoff_strategies.append( 2465 self._create_component_from_model(model=backoff_strategy_model, config=config) 2466 ) 2467 2468 response_filters = [] 2469 if model.response_filters: 2470 for response_filter_model in model.response_filters: 2471 response_filters.append( 2472 self._create_component_from_model(model=response_filter_model, config=config) 2473 ) 2474 response_filters.append( 2475 HttpResponseFilter(config=config, parameters=model.parameters or {}) 2476 ) 2477 2478 return DefaultErrorHandler( 2479 backoff_strategies=backoff_strategies, 2480 max_retries=model.max_retries, 2481 response_filters=response_filters, 2482 config=config, 2483 parameters=model.parameters or {}, 2484 ) 2485 2486 def create_default_paginator( 2487 self, 2488 model: DefaultPaginatorModel, 2489 config: Config, 2490 *, 2491 url_base: str, 2492 extractor_model: Optional[ 2493 Union[CustomRecordExtractorModel, DpathExtractorModel, CombinedExtractorModel] 2494 ] = None, 2495 decoder: Optional[Decoder] = None, 2496 cursor_used_for_stop_condition: Optional[Cursor] = None, 2497 ) -> Union[DefaultPaginator, PaginatorTestReadDecorator]: 2498 if decoder: 2499 if self._is_supported_decoder_for_pagination(decoder): 2500 decoder_to_use = PaginationDecoderDecorator(decoder=decoder) 2501 else: 2502 raise ValueError(self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(decoder))) 2503 else: 2504 decoder_to_use = PaginationDecoderDecorator(decoder=JsonDecoder(parameters={})) 2505 page_size_option = ( 2506 self._create_component_from_model(model=model.page_size_option, config=config) 2507 if model.page_size_option 2508 else None 2509 ) 2510 page_token_option = ( 2511 self._create_component_from_model(model=model.page_token_option, config=config) 2512 if model.page_token_option 2513 else None 2514 ) 2515 pagination_strategy = self._create_component_from_model( 2516 model=model.pagination_strategy, 2517 config=config, 2518 decoder=decoder_to_use, 2519 extractor_model=extractor_model, 2520 ) 2521 if cursor_used_for_stop_condition: 2522 pagination_strategy = StopConditionPaginationStrategyDecorator( 2523 pagination_strategy, CursorStopCondition(cursor_used_for_stop_condition) 2524 ) 2525 paginator = DefaultPaginator( 2526 decoder=decoder_to_use, 2527 page_size_option=page_size_option, 2528 page_token_option=page_token_option, 2529 pagination_strategy=pagination_strategy, 2530 url_base=url_base, 2531 config=config, 2532 parameters=model.parameters or {}, 2533 ) 2534 if self._limit_pages_fetched_per_slice: 2535 return PaginatorTestReadDecorator(paginator, self._limit_pages_fetched_per_slice) 2536 return paginator 2537 2538 @staticmethod 2539 def _is_decoder_downgraded_by_connector_builder(decoder: Decoder) -> bool: 2540 """Is this the buffered stand-in the Connector Builder builds for a streaming decoder? 2541 2542 `create_csv_decoder`, `create_jsonl_decoder`, `create_json_items_decoder` and 2543 `create_gzip_decoder` build a `CompositeRawDecoder` with `stream_response=False` when 2544 `_emit_connector_builder_messages` is set, so a Builder test read can be replayed. Those 2545 four instances are the only ones that stream in production while reporting 2546 `is_stream_response() == False` in the Builder. 2547 2548 The match is on the exact class and on the parser, not on `isinstance`: a `CustomDecoder` 2549 subclassing `CompositeRawDecoder` with `stream_response=False`, or a buffered 2550 `CompositeRawDecoder` built around a `JsonParser`, reads the body from `response.content` 2551 in production too and must not be rejected. 2552 """ 2553 return type(decoder) is CompositeRawDecoder and isinstance( 2554 decoder.parser, (CsvParser, JsonLineParser, JsonItemsParser, GzipParser) 2555 ) 2556 2557 def _reject_combined_extractor_over_streaming_decoder(self, decoder: Optional[Decoder]) -> None: 2558 """Refuse to build a `CombinedExtractor` whose response body can only be read once. 2559 2560 Every sub-extractor is handed the same `requests.Response`. A streaming decoder consumes 2561 and closes `response.raw`, so every sub-extractor after the first reads a closed 2562 `urllib3.HTTPResponse`, which returns an empty body instead of raising: `union` emits only 2563 the first sub-extractor's records and `first_match` emits nothing when the first path 2564 misses. Rejecting the manifest is the only way to make that loud. 2565 2566 The Connector Builder downgrades those same decoders to `stream_response=False` so a test 2567 read can be replayed, which would let a manifest look correct in the Builder and lose 2568 records once published. The downgraded decoders are therefore rejected in the Builder too. 2569 """ 2570 if decoder is None: 2571 return 2572 inner_decoder = ( 2573 decoder.decoder if isinstance(decoder, PaginationDecoderDecorator) else decoder 2574 ) 2575 streams_in_production = inner_decoder.is_stream_response() or ( 2576 self._emit_connector_builder_messages 2577 and self._is_decoder_downgraded_by_connector_builder(inner_decoder) 2578 ) 2579 if not streams_in_production: 2580 return 2581 raise AirbyteTracedException( 2582 message="CombinedExtractor is not supported with a streaming decoder.", 2583 internal_message=( 2584 f"CombinedExtractor was configured with {type(inner_decoder).__name__}, which " 2585 f"streams the response and can only be read once. Sub-extractors after the first " 2586 f"would read a closed response and silently return no records. The streaming " 2587 f"decoders are CsvDecoder, JsonlDecoder, JsonItemsDecoder, GzipDecoder and " 2588 f"IterableDecoder; use JsonDecoder, XmlDecoder or ZipfileDecoder, or declare a " 2589 f"single extractor." 2590 ), 2591 failure_type=FailureType.config_error, 2592 ) 2593 2594 def create_combined_extractor( 2595 self, 2596 model: CombinedExtractorModel, 2597 config: Config, 2598 decoder: Optional[Decoder] = None, 2599 **kwargs: Any, 2600 ) -> CombinedExtractor: 2601 self._reject_combined_extractor_over_streaming_decoder(decoder) 2602 extractors = [ 2603 self._create_component_from_model(model=sub_extractor, config=config, decoder=decoder) 2604 for sub_extractor in model.extractors 2605 ] 2606 return CombinedExtractor( 2607 extractors=extractors, 2608 mode=CombineMode(model.mode.value) if model.mode else CombineMode.union, 2609 skip_empty_records=bool(model.skip_empty_records), 2610 parameters=model.parameters or {}, 2611 ) 2612 2613 def create_dpath_extractor( 2614 self, 2615 model: DpathExtractorModel, 2616 config: Config, 2617 decoder: Optional[Decoder] = None, 2618 **kwargs: Any, 2619 ) -> DpathExtractor: 2620 if decoder: 2621 decoder_to_use = decoder 2622 else: 2623 decoder_to_use = JsonDecoder(parameters={}) 2624 model_field_path: List[Union[InterpolatedString, str]] = [x for x in model.field_path] 2625 2626 record_expander = None 2627 if model.record_expander: 2628 record_expander = self._create_component_from_model( 2629 model=model.record_expander, 2630 config=config, 2631 ) 2632 2633 return DpathExtractor( 2634 decoder=decoder_to_use, 2635 field_path=model_field_path, 2636 config=config, 2637 parameters=model.parameters or {}, 2638 record_expander=record_expander, 2639 ) 2640 2641 def create_record_expander( 2642 self, 2643 model: RecordExpanderModel, 2644 config: Config, 2645 **kwargs: Any, 2646 ) -> RecordExpander: 2647 truncated_list_retriever = None 2648 suppress_incomplete_fetch_warning = False 2649 if model.truncated_list_retriever: 2650 retriever_model = model.truncated_list_retriever 2651 name = "record_expander_truncated_list" 2652 # `CustomRetriever` allows extra fields, so read from the dumped model to cover both types. 2653 retriever_fields = retriever_model.dict() 2654 for unsupported_option in ("partition_router", "pagination_reset"): 2655 if retriever_fields.get(unsupported_option): 2656 raise ValueError( 2657 f"`{unsupported_option}` is not supported on `truncated_list_retriever`." 2658 ) 2659 log_formatter = lambda response: format_http_message( 2660 response, 2661 f"Record expander '{name}' request", 2662 "Request performed in order to fetch the complete nested list of a truncated record.", 2663 name, 2664 is_auxiliary=True, 2665 ) 2666 # `name`/`primary_key`/`transformations` are also what a `CustomRetriever` forwards to its 2667 # nested `requester`/`record_selector`, so they are passed for both retriever types. 2668 truncated_list_retriever = self._create_component_from_model( 2669 model=retriever_model, 2670 config=config, 2671 name=name, 2672 primary_key=None, 2673 transformations=[], 2674 log_formatter=log_formatter, 2675 ) 2676 # Only a capped paginator makes a shortfall expected; `NoPagination` and retrievers 2677 # without a paginator are never capped. 2678 suppress_incomplete_fetch_warning = isinstance( 2679 truncated_list_retriever, SimpleRetriever 2680 ) and isinstance(truncated_list_retriever.paginator, PaginatorTestReadDecorator) 2681 return RecordExpander( 2682 expand_records_from_field=model.expand_records_from_field, 2683 config=config, 2684 parameters=model.parameters or {}, 2685 remain_original_record=model.remain_original_record or False, 2686 parent_fields=[ 2687 self._create_component_from_model(model=parent_field, config=config) 2688 for parent_field in model.parent_fields 2689 ] 2690 if model.parent_fields 2691 else None, 2692 merge_parent=model.merge_parent or False, 2693 on_no_records=OnNoRecords(model.on_no_records.value) 2694 if model.on_no_records 2695 else OnNoRecords.skip, 2696 truncation_indicator_path=model.truncation_indicator_path, 2697 truncated_list_retriever=truncated_list_retriever, 2698 message_repository=self._message_repository, 2699 suppress_incomplete_fetch_warning=suppress_incomplete_fetch_warning, 2700 ) 2701 2702 @staticmethod 2703 def create_parent_field_path( 2704 model: ParentFieldPathModel, 2705 config: Config, 2706 **kwargs: Any, 2707 ) -> ParentFieldPath: 2708 return ParentFieldPath( 2709 parent_path=model.parent_path, 2710 record_path=model.record_path, 2711 config=config, 2712 parameters=model.parameters or {}, 2713 ) 2714 2715 @staticmethod 2716 def create_response_to_file_extractor( 2717 model: ResponseToFileExtractorModel, 2718 **kwargs: Any, 2719 ) -> ResponseToFileExtractor: 2720 return ResponseToFileExtractor( 2721 parameters=model.parameters or {}, 2722 preserve_na_values=model.preserve_na_values or False, 2723 ) 2724 2725 @staticmethod 2726 def create_exponential_backoff_strategy( 2727 model: ExponentialBackoffStrategyModel, config: Config 2728 ) -> ExponentialBackoffStrategy: 2729 ModelToComponentFactory._validate_jitter_range(model.jitter_range_in_seconds) 2730 return ExponentialBackoffStrategy( 2731 factor=model.factor or 5, 2732 jitter_range_in_seconds=model.jitter_range_in_seconds, 2733 parameters=model.parameters or {}, 2734 config=config, 2735 ) 2736 2737 @staticmethod 2738 def create_group_by_key(model: GroupByKeyMergeStrategyModel, config: Config) -> GroupByKey: 2739 return GroupByKey(model.key, config=config, parameters=model.parameters or {}) 2740 2741 def create_http_requester( 2742 self, 2743 model: HttpRequesterModel, 2744 config: Config, 2745 decoder: Decoder = JsonDecoder(parameters={}), 2746 query_properties_key: Optional[str] = None, 2747 use_cache: Optional[bool] = None, 2748 *, 2749 name: str, 2750 ) -> HttpRequester: 2751 authenticator = ( 2752 self._create_component_from_model( 2753 model=model.authenticator, 2754 config=config, 2755 url_base=model.url or model.url_base, 2756 name=name, 2757 decoder=decoder, 2758 ) 2759 if model.authenticator 2760 else None 2761 ) 2762 error_handler = ( 2763 self._create_component_from_model(model=model.error_handler, config=config) 2764 if model.error_handler 2765 else DefaultErrorHandler( 2766 backoff_strategies=[], 2767 response_filters=[], 2768 config=config, 2769 parameters=model.parameters or {}, 2770 ) 2771 ) 2772 2773 api_budget = self._api_budget 2774 2775 request_options_provider = InterpolatedRequestOptionsProvider( 2776 request_body=model.request_body, 2777 request_body_data=model.request_body_data, 2778 request_body_json=model.request_body_json, 2779 request_headers=model.request_headers, 2780 request_parameters=model.request_parameters, # type: ignore # QueryProperties have been removed in `create_simple_retriever` 2781 query_properties_key=query_properties_key, 2782 config=config, 2783 parameters=model.parameters or {}, 2784 ) 2785 2786 assert model.use_cache is not None # for mypy 2787 assert model.http_method is not None # for mypy 2788 2789 should_use_cache = (model.use_cache or bool(use_cache)) and not self._disable_cache 2790 2791 return HttpRequester( 2792 name=name, 2793 url=model.url, 2794 url_base=model.url_base, 2795 path=model.path, 2796 authenticator=authenticator, 2797 error_handler=error_handler, 2798 api_budget=api_budget, 2799 http_method=HttpMethod[model.http_method.value], 2800 request_options_provider=request_options_provider, 2801 config=config, 2802 disable_retries=self._disable_retries, 2803 parameters=model.parameters or {}, 2804 message_repository=self._message_repository, 2805 use_cache=should_use_cache, 2806 connect_timeout_in_seconds=model.connect_timeout_in_seconds, 2807 read_timeout_in_seconds=model.read_timeout_in_seconds, 2808 use_tcp_keepalive=model.use_tcp_keepalive or False, 2809 decoder=decoder, 2810 stream_response=decoder.is_stream_response() if decoder else False, 2811 ) 2812 2813 @staticmethod 2814 def create_http_response_filter( 2815 model: HttpResponseFilterModel, config: Config, **kwargs: Any 2816 ) -> HttpResponseFilter: 2817 if model.action: 2818 action = ResponseAction(model.action.value) 2819 else: 2820 action = None 2821 2822 failure_type = FailureType(model.failure_type.value) if model.failure_type else None 2823 2824 http_codes = ( 2825 set(model.http_codes) if model.http_codes else set() 2826 ) # JSON schema notation has no set data type. The schema enforces an array of unique elements 2827 2828 return HttpResponseFilter( 2829 action=action, 2830 failure_type=failure_type, 2831 error_message=model.error_message or "", 2832 error_message_contains=model.error_message_contains or "", 2833 http_codes=http_codes, 2834 predicate=model.predicate or "", 2835 config=config, 2836 parameters=model.parameters or {}, 2837 ) 2838 2839 @staticmethod 2840 def create_inline_schema_loader( 2841 model: InlineSchemaLoaderModel, config: Config, **kwargs: Any 2842 ) -> InlineSchemaLoader: 2843 return InlineSchemaLoader(schema=model.schema_ or {}, parameters={}) 2844 2845 def create_complex_field_type( 2846 self, model: ComplexFieldTypeModel, config: Config, **kwargs: Any 2847 ) -> ComplexFieldType: 2848 items = ( 2849 self._create_component_from_model(model=model.items, config=config) 2850 if isinstance(model.items, ComplexFieldTypeModel) 2851 else model.items 2852 ) 2853 2854 return ComplexFieldType(field_type=model.field_type, items=items) 2855 2856 def create_types_map(self, model: TypesMapModel, config: Config, **kwargs: Any) -> TypesMap: 2857 target_type = ( 2858 self._create_component_from_model(model=model.target_type, config=config) 2859 if isinstance(model.target_type, ComplexFieldTypeModel) 2860 else model.target_type 2861 ) 2862 2863 return TypesMap( 2864 target_type=target_type, 2865 current_type=model.current_type, 2866 condition=model.condition if model.condition is not None else "True", 2867 ) 2868 2869 def create_schema_type_identifier( 2870 self, model: SchemaTypeIdentifierModel, config: Config, **kwargs: Any 2871 ) -> SchemaTypeIdentifier: 2872 types_mapping = [] 2873 if model.types_mapping: 2874 types_mapping.extend( 2875 [ 2876 self._create_component_from_model(types_map, config=config) 2877 for types_map in model.types_mapping 2878 ] 2879 ) 2880 model_schema_pointer: List[Union[InterpolatedString, str]] = ( 2881 [x for x in model.schema_pointer] if model.schema_pointer else [] 2882 ) 2883 model_key_pointer: List[Union[InterpolatedString, str]] = [x for x in model.key_pointer] 2884 model_type_pointer: Optional[List[Union[InterpolatedString, str]]] = ( 2885 [x for x in model.type_pointer] if model.type_pointer else None 2886 ) 2887 2888 return SchemaTypeIdentifier( 2889 schema_pointer=model_schema_pointer, 2890 key_pointer=model_key_pointer, 2891 type_pointer=model_type_pointer, 2892 types_mapping=types_mapping, 2893 parameters=model.parameters or {}, 2894 ) 2895 2896 def create_dynamic_schema_loader( 2897 self, model: DynamicSchemaLoaderModel, config: Config, **kwargs: Any 2898 ) -> DynamicSchemaLoader: 2899 schema_transformations = [] 2900 if model.schema_transformations: 2901 for transformation_model in model.schema_transformations: 2902 schema_transformations.append( 2903 self._create_component_from_model(model=transformation_model, config=config) 2904 ) 2905 name = "dynamic_properties" 2906 partition_router = self._build_stream_slicer_from_partition_router(model.retriever, config) 2907 retriever_kwargs: Dict[str, Any] = {} 2908 if isinstance(model.retriever, AsyncRetrieverModel): 2909 if model.retriever.partition_router: 2910 raise ValueError( 2911 "DynamicSchemaLoader does not support an AsyncRetriever with a partition_router: only the first record is read, so jobs for other partitions would be orphaned. Remove the partition_router from the schema loader's AsyncRetriever." 2912 ) 2913 # create_async_retriever requires stream_slicer; passing it to a CustomRetriever would override its own 2914 retriever_kwargs["stream_slicer"] = partition_router 2915 retriever = self._create_component_from_model( 2916 model=model.retriever, 2917 config=config, 2918 name=name, 2919 primary_key=None, 2920 partition_router=partition_router, 2921 **retriever_kwargs, 2922 transformations=[], 2923 use_cache=True, 2924 log_formatter=( 2925 lambda response: format_http_message( 2926 response, 2927 f"Schema loader '{name}' request", 2928 f"Request performed in order to extract schema.", 2929 name, 2930 is_auxiliary=True, 2931 ) 2932 ), 2933 ) 2934 schema_type_identifier = self._create_component_from_model( 2935 model.schema_type_identifier, config=config, parameters=model.parameters or {} 2936 ) 2937 schema_filter = ( 2938 self._create_component_from_model( 2939 model.schema_filter, config=config, parameters=model.parameters or {} 2940 ) 2941 if model.schema_filter is not None 2942 else None 2943 ) 2944 2945 return DynamicSchemaLoader( 2946 retriever=retriever, 2947 config=config, 2948 schema_transformations=schema_transformations, 2949 schema_filter=schema_filter, 2950 schema_type_identifier=schema_type_identifier, 2951 parameters=model.parameters or {}, 2952 ) 2953 2954 @staticmethod 2955 def create_json_decoder(model: JsonDecoderModel, config: Config, **kwargs: Any) -> Decoder: 2956 return JsonDecoder(parameters={}) 2957 2958 def create_csv_decoder(self, model: CsvDecoderModel, config: Config, **kwargs: Any) -> Decoder: 2959 return CompositeRawDecoder( 2960 parser=ModelToComponentFactory._get_parser(model, config), 2961 stream_response=False if self._emit_connector_builder_messages else True, 2962 ) 2963 2964 def create_jsonl_decoder( 2965 self, model: JsonlDecoderModel, config: Config, **kwargs: Any 2966 ) -> Decoder: 2967 return CompositeRawDecoder( 2968 parser=ModelToComponentFactory._get_parser(model, config), 2969 stream_response=False if self._emit_connector_builder_messages else True, 2970 ) 2971 2972 def create_json_items_decoder( 2973 self, model: JsonItemsDecoderModel, config: Config, **kwargs: Any 2974 ) -> Decoder: 2975 return CompositeRawDecoder( 2976 parser=ModelToComponentFactory._get_parser(model, config), 2977 stream_response=False if self._emit_connector_builder_messages else True, 2978 ) 2979 2980 def create_gzip_decoder( 2981 self, model: GzipDecoderModel, config: Config, **kwargs: Any 2982 ) -> Decoder: 2983 _compressed_response_types = { 2984 "gzip", 2985 "x-gzip", 2986 "gzip, deflate", 2987 "x-gzip, deflate", 2988 "application/zip", 2989 "application/gzip", 2990 "application/x-gzip", 2991 "application/x-zip-compressed", 2992 } 2993 2994 gzip_parser: GzipParser = ModelToComponentFactory._get_parser(model, config) # type: ignore # based on the model, we know this will be a GzipParser 2995 2996 if self._emit_connector_builder_messages: 2997 return CompositeRawDecoder(gzip_parser, False) 2998 2999 transport_gzip_parser = GzipParser(inner_parser=gzip_parser) 3000 return CompositeRawDecoder.by_headers( 3001 [ 3002 ({"Content-Encoding"}, {"gzip"}, transport_gzip_parser), 3003 ({"Content-Type"}, _compressed_response_types, gzip_parser), 3004 ], 3005 stream_response=True, 3006 fallback_parser=gzip_parser, 3007 ) 3008 3009 @staticmethod 3010 def create_iterable_decoder( 3011 model: IterableDecoderModel, config: Config, **kwargs: Any 3012 ) -> IterableDecoder: 3013 return IterableDecoder(parameters={}) 3014 3015 @staticmethod 3016 def create_xml_decoder(model: XmlDecoderModel, config: Config, **kwargs: Any) -> XmlDecoder: 3017 return XmlDecoder(parameters={}) 3018 3019 def create_zipfile_decoder( 3020 self, model: ZipfileDecoderModel, config: Config, **kwargs: Any 3021 ) -> ZipfileDecoder: 3022 return ZipfileDecoder(parser=ModelToComponentFactory._get_parser(model.decoder, config)) 3023 3024 @staticmethod 3025 def _get_parser(model: BaseModel, config: Config) -> Parser: 3026 if isinstance(model, JsonDecoderModel): 3027 # Note that the logic is a bit different from the JsonDecoder as there is some legacy that is maintained to return {} on error cases 3028 return JsonParser() 3029 elif isinstance(model, JsonItemsDecoderModel): 3030 return JsonItemsParser( 3031 items_path=model.items_path, 3032 encoding=model.encoding, 3033 ) 3034 elif isinstance(model, JsonlDecoderModel): 3035 return JsonLineParser() 3036 elif isinstance(model, CsvDecoderModel): 3037 return CsvParser( 3038 encoding=model.encoding, 3039 delimiter=model.delimiter, 3040 set_values_to_none=model.set_values_to_none, 3041 ) 3042 elif isinstance(model, GzipDecoderModel): 3043 return GzipParser( 3044 inner_parser=ModelToComponentFactory._get_parser(model.decoder, config) 3045 ) 3046 elif isinstance( 3047 model, (CustomDecoderModel, IterableDecoderModel, XmlDecoderModel, ZipfileDecoderModel) 3048 ): 3049 raise ValueError(f"Decoder type {model} does not have parser associated to it") 3050 3051 raise ValueError(f"Unknown decoder type {model}") 3052 3053 @staticmethod 3054 def create_json_file_schema_loader( 3055 model: JsonFileSchemaLoaderModel, config: Config, **kwargs: Any 3056 ) -> JsonFileSchemaLoader: 3057 return JsonFileSchemaLoader( 3058 file_path=model.file_path or "", config=config, parameters=model.parameters or {} 3059 ) 3060 3061 def create_jwt_authenticator( 3062 self, model: JwtAuthenticatorModel, config: Config, **kwargs: Any 3063 ) -> JwtAuthenticator: 3064 jwt_headers = model.jwt_headers or JwtHeadersModel(kid=None, typ="JWT", cty=None) 3065 jwt_payload = model.jwt_payload or JwtPayloadModel(iss=None, sub=None, aud=None) 3066 request_option = ( 3067 self._create_component_from_model(model.request_option, config) 3068 if model.request_option 3069 else None 3070 ) 3071 return JwtAuthenticator( 3072 config=config, 3073 parameters=model.parameters or {}, 3074 algorithm=JwtAlgorithm(model.algorithm.value), 3075 secret_key=model.secret_key, 3076 base64_encode_secret_key=model.base64_encode_secret_key, 3077 token_duration=model.token_duration, 3078 header_prefix=model.header_prefix, 3079 kid=jwt_headers.kid, 3080 typ=jwt_headers.typ, 3081 cty=jwt_headers.cty, 3082 iss=jwt_payload.iss, 3083 sub=jwt_payload.sub, 3084 aud=jwt_payload.aud, 3085 additional_jwt_headers=model.additional_jwt_headers, 3086 additional_jwt_payload=model.additional_jwt_payload, 3087 passphrase=model.passphrase, 3088 request_option=request_option, 3089 ) 3090 3091 def create_list_partition_router( 3092 self, model: ListPartitionRouterModel, config: Config, **kwargs: Any 3093 ) -> ListPartitionRouter: 3094 request_option = ( 3095 self._create_component_from_model(model.request_option, config) 3096 if model.request_option 3097 else None 3098 ) 3099 return ListPartitionRouter( 3100 cursor_field=model.cursor_field, 3101 request_option=request_option, 3102 values=model.values, 3103 config=config, 3104 parameters=model.parameters or {}, 3105 ) 3106 3107 @staticmethod 3108 def create_min_max_datetime( 3109 model: MinMaxDatetimeModel, config: Config, **kwargs: Any 3110 ) -> MinMaxDatetime: 3111 return MinMaxDatetime( 3112 datetime=model.datetime, 3113 datetime_format=model.datetime_format or "", 3114 max_datetime=model.max_datetime or "", 3115 min_datetime=model.min_datetime or "", 3116 parameters=model.parameters or {}, 3117 ) 3118 3119 @staticmethod 3120 def create_no_auth(model: NoAuthModel, config: Config, **kwargs: Any) -> NoAuth: 3121 return NoAuth(parameters=model.parameters or {}) 3122 3123 @staticmethod 3124 def create_no_pagination( 3125 model: NoPaginationModel, config: Config, **kwargs: Any 3126 ) -> NoPagination: 3127 return NoPagination(parameters={}) 3128 3129 def create_oauth_authenticator( 3130 self, model: OAuthAuthenticatorModel, config: Config, **kwargs: Any 3131 ) -> DeclarativeOauth2Authenticator: 3132 profile_assertion = ( 3133 self._create_component_from_model(model.profile_assertion, config=config) 3134 if model.profile_assertion 3135 else None 3136 ) 3137 3138 refresh_token_error_status_codes, refresh_token_error_key, refresh_token_error_values = ( 3139 self._get_refresh_token_error_information(model) 3140 ) 3141 if model.refresh_token_updater: 3142 # ignore type error because fixing it would have a lot of dependencies, revisit later 3143 return DeclarativeSingleUseRefreshTokenOauth2Authenticator( # type: ignore 3144 config, 3145 InterpolatedString.create( 3146 model.token_refresh_endpoint, # type: ignore 3147 parameters=model.parameters or {}, 3148 ).eval(config), 3149 access_token_name=InterpolatedString.create( 3150 model.access_token_name or "access_token", parameters=model.parameters or {} 3151 ).eval(config), 3152 refresh_token_name=model.refresh_token_updater.refresh_token_name, 3153 expires_in_name=InterpolatedString.create( 3154 model.expires_in_name or "expires_in", parameters=model.parameters or {} 3155 ).eval(config), 3156 client_id_name=InterpolatedString.create( 3157 model.client_id_name or "client_id", parameters=model.parameters or {} 3158 ).eval(config), 3159 client_id=InterpolatedString.create( 3160 model.client_id, parameters=model.parameters or {} 3161 ).eval(config) 3162 if model.client_id 3163 else model.client_id, 3164 client_secret_name=InterpolatedString.create( 3165 model.client_secret_name or "client_secret", parameters=model.parameters or {} 3166 ).eval(config), 3167 client_secret=InterpolatedString.create( 3168 model.client_secret, parameters=model.parameters or {} 3169 ).eval(config) 3170 if model.client_secret 3171 else model.client_secret, 3172 access_token_config_path=model.refresh_token_updater.access_token_config_path, 3173 refresh_token_config_path=model.refresh_token_updater.refresh_token_config_path, 3174 token_expiry_date_config_path=model.refresh_token_updater.token_expiry_date_config_path, 3175 grant_type_name=InterpolatedString.create( 3176 model.grant_type_name or "grant_type", parameters=model.parameters or {} 3177 ).eval(config), 3178 grant_type=InterpolatedString.create( 3179 model.grant_type or "refresh_token", parameters=model.parameters or {} 3180 ).eval(config), 3181 refresh_request_body=InterpolatedMapping( 3182 model.refresh_request_body or {}, parameters=model.parameters or {} 3183 ).eval(config), 3184 refresh_request_headers=InterpolatedMapping( 3185 model.refresh_request_headers or {}, parameters=model.parameters or {} 3186 ).eval(config), 3187 send_refresh_request_as_query_params=bool( 3188 model.send_refresh_request_as_query_params 3189 ), 3190 scopes=model.scopes, 3191 token_expiry_date_format=model.token_expiry_date_format, 3192 token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), 3193 message_repository=self._message_repository, 3194 refresh_token_error_status_codes=refresh_token_error_status_codes, 3195 refresh_token_error_key=refresh_token_error_key, 3196 refresh_token_error_values=refresh_token_error_values, 3197 ) 3198 # ignore type error because fixing it would have a lot of dependencies, revisit later 3199 return DeclarativeOauth2Authenticator( # type: ignore 3200 access_token_name=model.access_token_name or "access_token", 3201 access_token_value=model.access_token_value, 3202 client_id_name=model.client_id_name or "client_id", 3203 client_id=model.client_id, 3204 client_secret_name=model.client_secret_name or "client_secret", 3205 client_secret=model.client_secret, 3206 expires_in_name=model.expires_in_name or "expires_in", 3207 grant_type_name=model.grant_type_name or "grant_type", 3208 grant_type=model.grant_type or "refresh_token", 3209 refresh_request_body=model.refresh_request_body, 3210 refresh_request_headers=model.refresh_request_headers, 3211 send_refresh_request_as_query_params=bool(model.send_refresh_request_as_query_params), 3212 refresh_token_name=model.refresh_token_name or "refresh_token", 3213 refresh_token=model.refresh_token, 3214 scopes=model.scopes, 3215 token_expiry_date=model.token_expiry_date, 3216 token_expiry_date_format=model.token_expiry_date_format, 3217 token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), 3218 token_refresh_endpoint=model.token_refresh_endpoint, 3219 config=config, 3220 parameters=model.parameters or {}, 3221 message_repository=self._message_repository, 3222 profile_assertion=profile_assertion, 3223 use_profile_assertion=model.use_profile_assertion, 3224 refresh_token_error_status_codes=refresh_token_error_status_codes, 3225 refresh_token_error_key=refresh_token_error_key, 3226 refresh_token_error_values=refresh_token_error_values, 3227 ) 3228 3229 @staticmethod 3230 def _get_refresh_token_error_information( 3231 model: OAuthAuthenticatorModel, 3232 ) -> Tuple[Tuple[int, ...], str, Tuple[str, ...]]: 3233 """ 3234 In a previous version of the CDK, the auth error as config_error was only done if a refresh token updater was 3235 defined. As a transition, we added those fields on the OAuthAuthenticatorModel. This method ensures that the 3236 information is defined only once and return the right fields. 3237 """ 3238 refresh_token_updater = model.refresh_token_updater 3239 is_defined_on_refresh_token_updated = refresh_token_updater and ( 3240 refresh_token_updater.refresh_token_error_status_codes 3241 or refresh_token_updater.refresh_token_error_key 3242 or refresh_token_updater.refresh_token_error_values 3243 ) 3244 is_defined_on_oauth_authenticator = ( 3245 model.refresh_token_error_status_codes 3246 or model.refresh_token_error_key 3247 or model.refresh_token_error_values 3248 ) 3249 if is_defined_on_refresh_token_updated and is_defined_on_oauth_authenticator: 3250 raise ValueError( 3251 "refresh_token_error should either be defined on the OAuthAuthenticatorModel or the RefreshTokenUpdaterModel, not both" 3252 ) 3253 3254 if is_defined_on_refresh_token_updated: 3255 not_optional_refresh_token_updater: RefreshTokenUpdaterModel = refresh_token_updater # type: ignore # we know from the condition that this is not None 3256 return ( 3257 tuple(not_optional_refresh_token_updater.refresh_token_error_status_codes) 3258 if not_optional_refresh_token_updater.refresh_token_error_status_codes 3259 else (), 3260 not_optional_refresh_token_updater.refresh_token_error_key or "", 3261 tuple(not_optional_refresh_token_updater.refresh_token_error_values) 3262 if not_optional_refresh_token_updater.refresh_token_error_values 3263 else (), 3264 ) 3265 elif is_defined_on_oauth_authenticator: 3266 return ( 3267 tuple(model.refresh_token_error_status_codes) 3268 if model.refresh_token_error_status_codes 3269 else (), 3270 model.refresh_token_error_key or "", 3271 tuple(model.refresh_token_error_values) if model.refresh_token_error_values else (), 3272 ) 3273 3274 # returning default values we think cover most cases 3275 return (400,), "error", ("invalid_grant", "invalid_permissions") 3276 3277 @staticmethod 3278 def _reject_union_combined_extractor_for_offset_increment( 3279 extractor_model: Optional[BaseModel], 3280 ) -> None: 3281 """Refuse an `OffsetIncrement` paginator driven by a `union` `CombinedExtractor`. 3282 3283 `OffsetIncrement.next_page_token` advances the offset by the number of records its 3284 extractor returns for the page. Under `union` that number is the sum over all 3285 sub-extractors, so the offset overshoots the API page size and every page after the first 3286 starts past the records that were never read: with two sub-extractors returning two records 3287 each and `page_size: 2`, the requested offsets are 0, 4, 8 instead of 0, 2, 4 and two 3288 thirds of the records are silently dropped. 3289 3290 `first_match` returns the winning sub-extractor's count, which does not inflate the count, 3291 and is left alone. A `union` nested anywhere in the tree inflates the count of the node 3292 above it, so the whole tree is walked. 3293 """ 3294 if not isinstance(extractor_model, CombinedExtractorModel): 3295 return 3296 if not ModelToComponentFactory._combined_extractor_tree_contains_union(extractor_model): 3297 return 3298 raise AirbyteTracedException( 3299 message=( 3300 'CombinedExtractor mode "union" is not supported with an OffsetIncrement paginator.' 3301 ), 3302 internal_message=( 3303 "OffsetIncrement counts the records of its extractor to advance the offset. A " 3304 "`union` CombinedExtractor returns the sum of its sub-extractors' records, which " 3305 "overshoots the page the API returned, so records would be skipped. Use the " 3306 "`first_match` mode, a CursorPagination or PageIncrement paginator, or a single " 3307 "extractor." 3308 ), 3309 failure_type=FailureType.config_error, 3310 ) 3311 3312 @staticmethod 3313 def _combined_extractor_tree_contains_union(model: CombinedExtractorModel) -> bool: 3314 mode = CombineMode(model.mode.value) if model.mode else CombineMode.union 3315 if mode == CombineMode.union: 3316 return True 3317 return any( 3318 isinstance(sub_extractor, CombinedExtractorModel) 3319 and ModelToComponentFactory._combined_extractor_tree_contains_union(sub_extractor) 3320 for sub_extractor in model.extractors 3321 ) 3322 3323 @staticmethod 3324 def _count_dropped_empty_records(extractor: RecordExtractor) -> None: 3325 """Make the record selector count the empty records a `CombinedExtractor` drops. 3326 3327 `OffsetIncrement` and `PageIncrement` stop when that count falls below the page size, so a 3328 full page holding a null would otherwise look short and silently end pagination. 3329 """ 3330 if not isinstance(extractor, CombinedExtractor): 3331 return 3332 extractor.count_dropped_empty_records = True 3333 for sub_extractor in extractor.extractors: 3334 ModelToComponentFactory._count_dropped_empty_records(sub_extractor) 3335 3336 def create_offset_increment( 3337 self, 3338 model: OffsetIncrementModel, 3339 config: Config, 3340 decoder: Optional[Decoder] = None, 3341 extractor_model: Optional[ 3342 Union[CustomRecordExtractorModel, DpathExtractorModel, CombinedExtractorModel] 3343 ] = None, 3344 **kwargs: Any, 3345 ) -> OffsetIncrement: 3346 # None when nested under a custom component, which cannot pass a decoder 3347 if decoder is None: 3348 decoder = JsonDecoder(parameters={}) 3349 if isinstance(decoder, PaginationDecoderDecorator): 3350 inner_decoder = decoder.decoder 3351 else: 3352 inner_decoder = decoder 3353 decoder = PaginationDecoderDecorator(decoder=decoder) 3354 3355 if self._is_supported_decoder_for_pagination(inner_decoder): 3356 decoder_to_use = decoder 3357 else: 3358 raise ValueError( 3359 self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(inner_decoder)) 3360 ) 3361 3362 self._reject_union_combined_extractor_for_offset_increment(extractor_model) 3363 3364 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 3365 # If page_size is a string that represents an integer (not an interpolation), convert it back. 3366 page_size = model.page_size 3367 if isinstance(page_size, str) and page_size.isdigit(): 3368 page_size = int(page_size) 3369 3370 return OffsetIncrement( 3371 page_size=page_size, 3372 config=config, 3373 decoder=decoder_to_use, 3374 inject_on_first_request=model.inject_on_first_request or False, 3375 parameters=model.parameters or {}, 3376 ) 3377 3378 @staticmethod 3379 def create_page_increment( 3380 model: PageIncrementModel, config: Config, **kwargs: Any 3381 ) -> PageIncrement: 3382 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 3383 # If page_size is a string that represents an integer (not an interpolation), convert it back. 3384 page_size = model.page_size 3385 if isinstance(page_size, str) and page_size.isdigit(): 3386 page_size = int(page_size) 3387 3388 return PageIncrement( 3389 page_size=page_size, 3390 config=config, 3391 start_from_page=model.start_from_page or 0, 3392 inject_on_first_request=model.inject_on_first_request or False, 3393 parameters=model.parameters or {}, 3394 ) 3395 3396 def create_parent_stream_config( 3397 self, model: ParentStreamConfigModel, config: Config, *, stream_name: str, **kwargs: Any 3398 ) -> ParentStreamConfig: 3399 declarative_stream = self._create_component_from_model( 3400 model.stream, 3401 config=config, 3402 is_parent=True, 3403 **kwargs, 3404 ) 3405 request_option = ( 3406 self._create_component_from_model(model.request_option, config=config) 3407 if model.request_option 3408 else None 3409 ) 3410 3411 if model.lazy_read_pointer and any("*" in pointer for pointer in model.lazy_read_pointer): 3412 raise ValueError( 3413 "The '*' wildcard in 'lazy_read_pointer' is not supported — only direct paths are allowed." 3414 ) 3415 3416 model_lazy_read_pointer: List[Union[InterpolatedString, str]] = ( 3417 [x for x in model.lazy_read_pointer] if model.lazy_read_pointer else [] 3418 ) 3419 3420 return ParentStreamConfig( 3421 parent_key=model.parent_key, 3422 request_option=request_option, 3423 stream=declarative_stream, 3424 partition_field=model.partition_field, 3425 config=config, 3426 incremental_dependency=model.incremental_dependency or False, 3427 parameters=model.parameters or {}, 3428 extra_fields=model.extra_fields, 3429 lazy_read_pointer=model_lazy_read_pointer, 3430 ) 3431 3432 def create_properties_from_endpoint( 3433 self, model: PropertiesFromEndpointModel, config: Config, **kwargs: Any 3434 ) -> PropertiesFromEndpoint: 3435 retriever = self._create_component_from_model( 3436 model=model.retriever, 3437 config=config, 3438 name="dynamic_properties", 3439 primary_key=None, 3440 stream_slicer=None, 3441 transformations=[], 3442 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 3443 ) 3444 return PropertiesFromEndpoint( 3445 property_field_path=model.property_field_path, 3446 retriever=retriever, 3447 config=config, 3448 parameters=model.parameters or {}, 3449 ) 3450 3451 def create_property_chunking( 3452 self, model: PropertyChunkingModel, config: Config, **kwargs: Any 3453 ) -> PropertyChunking: 3454 record_merge_strategy = ( 3455 self._create_component_from_model( 3456 model=model.record_merge_strategy, config=config, **kwargs 3457 ) 3458 if model.record_merge_strategy 3459 else None 3460 ) 3461 3462 property_limit_type: PropertyLimitType 3463 match model.property_limit_type: 3464 case PropertyLimitTypeModel.property_count: 3465 property_limit_type = PropertyLimitType.property_count 3466 case PropertyLimitTypeModel.characters: 3467 property_limit_type = PropertyLimitType.characters 3468 case _: 3469 raise ValueError(f"Invalid PropertyLimitType {property_limit_type}") 3470 3471 return PropertyChunking( 3472 property_limit_type=property_limit_type, 3473 property_limit=model.property_limit, 3474 record_merge_strategy=record_merge_strategy, 3475 config=config, 3476 parameters=model.parameters or {}, 3477 ) 3478 3479 def create_query_properties( 3480 self, model: QueryPropertiesModel, config: Config, *, stream_name: str, **kwargs: Any 3481 ) -> QueryProperties: 3482 if isinstance(model.property_list, list): 3483 property_list = model.property_list 3484 else: 3485 property_list = self._create_component_from_model( 3486 model=model.property_list, config=config, **kwargs 3487 ) 3488 3489 property_chunking = ( 3490 self._create_component_from_model( 3491 model=model.property_chunking, config=config, **kwargs 3492 ) 3493 if model.property_chunking 3494 else None 3495 ) 3496 3497 property_selector = ( 3498 self._create_component_from_model( 3499 model=model.property_selector, config=config, stream_name=stream_name, **kwargs 3500 ) 3501 if model.property_selector 3502 else None 3503 ) 3504 3505 return QueryProperties( 3506 property_list=property_list, 3507 always_include_properties=model.always_include_properties, 3508 property_chunking=property_chunking, 3509 property_selector=property_selector, 3510 config=config, 3511 parameters=model.parameters or {}, 3512 ) 3513 3514 def create_json_schema_property_selector( 3515 self, 3516 model: JsonSchemaPropertySelectorModel, 3517 config: Config, 3518 *, 3519 stream_name: str, 3520 **kwargs: Any, 3521 ) -> JsonSchemaPropertySelector: 3522 configured_stream = self._stream_name_to_configured_stream.get(stream_name) 3523 3524 transformations = [] 3525 if model.transformations: 3526 for transformation_model in model.transformations: 3527 transformations.append( 3528 self._create_component_from_model(model=transformation_model, config=config) 3529 ) 3530 3531 return JsonSchemaPropertySelector( 3532 configured_stream=configured_stream, 3533 properties_transformations=transformations, 3534 config=config, 3535 parameters=model.parameters or {}, 3536 ) 3537 3538 @staticmethod 3539 def create_record_filter( 3540 model: RecordFilterModel, config: Config, **kwargs: Any 3541 ) -> RecordFilter: 3542 return RecordFilter( 3543 condition=model.condition or "", config=config, parameters=model.parameters or {} 3544 ) 3545 3546 @staticmethod 3547 def create_request_path(model: RequestPathModel, config: Config, **kwargs: Any) -> RequestPath: 3548 return RequestPath(parameters={}) 3549 3550 @staticmethod 3551 def create_request_option( 3552 model: RequestOptionModel, config: Config, **kwargs: Any 3553 ) -> RequestOption: 3554 inject_into = RequestOptionType(model.inject_into.value) 3555 field_path: Optional[List[Union[InterpolatedString, str]]] = ( 3556 [ 3557 InterpolatedString.create(segment, parameters=kwargs.get("parameters", {})) 3558 for segment in model.field_path 3559 ] 3560 if model.field_path 3561 else None 3562 ) 3563 field_name = ( 3564 InterpolatedString.create(model.field_name, parameters=kwargs.get("parameters", {})) 3565 if model.field_name 3566 else None 3567 ) 3568 return RequestOption( 3569 field_name=field_name, 3570 field_path=field_path, 3571 inject_into=inject_into, 3572 parameters=kwargs.get("parameters", {}), 3573 ) 3574 3575 def create_record_selector( 3576 self, 3577 model: RecordSelectorModel, 3578 config: Config, 3579 *, 3580 name: str, 3581 transformations: List[RecordTransformation] | None = None, 3582 decoder: Decoder | None = None, 3583 client_side_incremental_sync_cursor: Optional[Cursor] = None, 3584 is_client_side_incremental_sync: bool = False, 3585 file_uploader: Optional[DefaultFileUploader] = None, 3586 **kwargs: Any, 3587 ) -> RecordSelector: 3588 extractor = self._create_component_from_model( 3589 model=model.extractor, decoder=decoder, config=config 3590 ) 3591 self._count_dropped_empty_records(extractor) 3592 record_filter = ( 3593 self._create_component_from_model(model.record_filter, config=config) 3594 if model.record_filter 3595 else None 3596 ) 3597 3598 # A client-side incremental stream transforms before filtering by default. That default belongs to the flag, 3599 # not to the component that ends up doing the cursor comparison: a data feed does it in the retriever and 3600 # receives no cursor here, but its `record_filter` condition must keep running after the transformations. 3601 default_transform_before_filtering = bool( 3602 client_side_incremental_sync_cursor or is_client_side_incremental_sync 3603 ) 3604 transform_before_filtering = ( 3605 default_transform_before_filtering 3606 if model.transform_before_filtering is None 3607 else model.transform_before_filtering 3608 ) 3609 if client_side_incremental_sync_cursor: 3610 record_filter = ClientSideIncrementalRecordFilterDecorator( 3611 config=config, 3612 parameters=model.parameters, 3613 condition=model.record_filter.condition 3614 if (model.record_filter and hasattr(model.record_filter, "condition")) 3615 else None, 3616 cursor=client_side_incremental_sync_cursor, 3617 ) 3618 3619 if model.schema_normalization is None: 3620 # default to no schema normalization if not set 3621 model.schema_normalization = SchemaNormalizationModel.None_ 3622 3623 schema_normalization = ( 3624 TypeTransformer(SCHEMA_TRANSFORMER_TYPE_MAPPING[model.schema_normalization]) 3625 if isinstance(model.schema_normalization, SchemaNormalizationModel) 3626 else self._create_component_from_model(model.schema_normalization, config=config) # type: ignore[arg-type] # custom normalization model expected here 3627 ) 3628 3629 return RecordSelector( 3630 extractor=extractor, 3631 name=name, 3632 config=config, 3633 record_filter=record_filter, 3634 transformations=transformations or [], 3635 file_uploader=file_uploader, 3636 schema_normalization=schema_normalization, 3637 parameters=model.parameters or {}, 3638 transform_before_filtering=transform_before_filtering, 3639 ) 3640 3641 @staticmethod 3642 def create_remove_fields( 3643 model: RemoveFieldsModel, config: Config, **kwargs: Any 3644 ) -> RemoveFields: 3645 return RemoveFields( 3646 field_pointers=model.field_pointers, condition=model.condition or "", parameters={} 3647 ) 3648 3649 def create_selective_authenticator( 3650 self, 3651 model: SelectiveAuthenticatorModel, 3652 config: Config, 3653 *, 3654 name: Optional[str] = None, 3655 url_base: Optional[str] = None, 3656 **kwargs: Any, 3657 ) -> DeclarativeAuthenticator: 3658 # Keyword-only so that _create_nested_component also fills them under a custom component, 3659 # from the parent's kwargs or $parameters. None is not forwarded, so a missing required one 3660 # keeps its hint. 3661 nested_kwargs = { 3662 key: value 3663 for key, value in {"name": name, "url_base": url_base}.items() 3664 if value is not None 3665 } 3666 authenticators = { 3667 key: self._create_component_from_model( 3668 model=auth, config=config, **nested_kwargs, **kwargs 3669 ) 3670 for key, auth in model.authenticators.items() 3671 } 3672 # SelectiveAuthenticator will return instance of DeclarativeAuthenticator or raise ValueError error 3673 return SelectiveAuthenticator( # type: ignore[abstract] 3674 config=config, 3675 authenticators=authenticators, 3676 authenticator_selection_path=model.authenticator_selection_path, 3677 **kwargs, 3678 ) 3679 3680 @staticmethod 3681 def create_legacy_session_token_authenticator( 3682 model: LegacySessionTokenAuthenticatorModel, config: Config, *, url_base: str, **kwargs: Any 3683 ) -> LegacySessionTokenAuthenticator: 3684 return LegacySessionTokenAuthenticator( 3685 api_url=url_base, 3686 header=model.header, 3687 login_url=model.login_url, 3688 password=model.password or "", 3689 session_token=model.session_token or "", 3690 session_token_response_key=model.session_token_response_key or "", 3691 username=model.username or "", 3692 validate_session_url=model.validate_session_url, 3693 config=config, 3694 parameters=model.parameters or {}, 3695 ) 3696 3697 def create_simple_retriever( 3698 self, 3699 model: SimpleRetrieverModel, 3700 config: Config, 3701 *, 3702 name: str, 3703 primary_key: Optional[Union[str, List[str], List[List[str]]]], 3704 request_options_provider: Optional[RequestOptionsProvider] = None, 3705 cursor: Optional[Cursor] = None, 3706 has_stop_condition_cursor: bool = False, 3707 is_client_side_incremental_sync: bool = False, 3708 transformations: List[RecordTransformation], 3709 file_uploader: Optional[DefaultFileUploader] = None, 3710 incremental_sync: Optional[ 3711 Union[IncrementingCountCursorModel, DatetimeBasedCursorModel] 3712 ] = None, 3713 use_cache: Optional[bool] = None, 3714 log_formatter: Optional[Callable[[Response], Any]] = None, 3715 partition_router: Optional[PartitionRouter] = None, 3716 **kwargs: Any, 3717 ) -> SimpleRetriever: 3718 def _get_url(req: Requester) -> str: 3719 """ 3720 Closure to get the URL from the requester. This is used to get the URL in the case of a lazy retriever. 3721 This is needed because the URL is not set until the requester is created. 3722 """ 3723 3724 _url: str = ( 3725 model.requester.url 3726 if hasattr(model.requester, "url") and model.requester.url is not None 3727 else req.get_url(stream_state=None, stream_slice=None, next_page_token=None) 3728 ) 3729 _url_base: str = ( 3730 model.requester.url_base 3731 if hasattr(model.requester, "url_base") and model.requester.url_base is not None 3732 else req.get_url_base(stream_state=None, stream_slice=None, next_page_token=None) 3733 ) 3734 3735 return _url or _url_base 3736 3737 if cursor is None: 3738 cursor = FinalStateCursor(name, None, self._message_repository) 3739 3740 # A data feed drops the records the cursor considers already synced in the retriever, which sits downstream of 3741 # the paginator. Letting the record selector drop them as well would be redundant and would hide them from the 3742 # pagination stop condition, so a data feed never delegates that filtering to the record selector, whether 3743 # `is_client_side_incremental` is set or not. The `condition` from `record_filter` is intentionally left out of 3744 # the post-pagination filter and stays in the record selector, which preserves the existing behaviour: the 3745 # selector runs inside the page loop, so the records the condition rejects never reach the paginator's 3746 # accounting. Moving it downstream would start counting them. 3747 post_pagination_filter = ( 3748 ClientSideIncrementalRecordFilterDecorator( 3749 config=config, 3750 parameters=model.parameters or {}, 3751 condition=None, 3752 cursor=cursor, 3753 ) 3754 if has_stop_condition_cursor 3755 else None 3756 ) 3757 client_side_incremental_cursor = ( 3758 cursor if is_client_side_incremental_sync and not post_pagination_filter else None 3759 ) 3760 if post_pagination_filter and is_client_side_incremental_sync: 3761 LOGGER.warning( 3762 f"Stream {name}: `is_client_side_incremental` adds no record filtering when `is_data_feed` is set, " 3763 "as a data feed already filters on the cursor value. It still makes the record selector apply the " 3764 "transformations before the `record_filter` condition." 3765 ) 3766 3767 decoder = ( 3768 self._create_component_from_model(model=model.decoder, config=config) 3769 if model.decoder 3770 else JsonDecoder(parameters={}) 3771 ) 3772 record_selector = self._create_component_from_model( 3773 model=model.record_selector, 3774 name=name, 3775 config=config, 3776 decoder=decoder, 3777 transformations=transformations, 3778 client_side_incremental_sync_cursor=client_side_incremental_cursor, 3779 is_client_side_incremental_sync=is_client_side_incremental_sync, 3780 file_uploader=file_uploader, 3781 ) 3782 3783 query_properties: Optional[QueryProperties] = None 3784 query_properties_key: Optional[str] = None 3785 self._ensure_query_properties_to_model(model.requester) 3786 if self._has_query_properties_in_request_parameters(model.requester): 3787 # It is better to be explicit about an error if PropertiesFromEndpoint is defined in multiple 3788 # places instead of default to request_parameters which isn't clearly documented 3789 if ( 3790 hasattr(model.requester, "fetch_properties_from_endpoint") 3791 and model.requester.fetch_properties_from_endpoint 3792 ): 3793 raise ValueError( 3794 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" 3795 ) 3796 3797 query_properties_definitions = [] 3798 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() 3799 if isinstance(request_parameter, QueryPropertiesModel): 3800 query_properties_key = key 3801 query_properties_definitions.append(request_parameter) 3802 3803 if len(query_properties_definitions) > 1: 3804 raise ValueError( 3805 f"request_parameters only supports defining one QueryProperties field, but found {len(query_properties_definitions)} usages" 3806 ) 3807 3808 if len(query_properties_definitions) == 1: 3809 query_properties = self._create_component_from_model( 3810 model=query_properties_definitions[0], stream_name=name, config=config 3811 ) 3812 3813 # Removes QueryProperties components from the interpolated mappings because it has been designed 3814 # to be used by the SimpleRetriever and will be resolved from the provider from the slice directly 3815 # instead of through jinja interpolation 3816 if hasattr(model.requester, "request_parameters") and isinstance( 3817 model.requester.request_parameters, Mapping 3818 ): 3819 model.requester.request_parameters = self._remove_query_properties( 3820 model.requester.request_parameters 3821 ) 3822 elif ( 3823 hasattr(model.requester, "fetch_properties_from_endpoint") 3824 and model.requester.fetch_properties_from_endpoint 3825 ): 3826 # todo: Deprecate this condition once dependent connectors migrate to query_properties 3827 query_properties_definition = QueryPropertiesModel( 3828 type="QueryProperties", 3829 property_list=model.requester.fetch_properties_from_endpoint, 3830 always_include_properties=None, 3831 property_chunking=None, 3832 ) # type: ignore # $parameters has a default value 3833 3834 query_properties = self.create_query_properties( 3835 model=query_properties_definition, 3836 stream_name=name, 3837 config=config, 3838 ) 3839 elif hasattr(model.requester, "query_properties") and model.requester.query_properties: 3840 query_properties = self.create_query_properties( 3841 model=model.requester.query_properties, 3842 stream_name=name, 3843 config=config, 3844 ) 3845 3846 requester = self._create_component_from_model( 3847 model=model.requester, 3848 decoder=decoder, 3849 name=name, 3850 query_properties_key=query_properties_key, 3851 use_cache=use_cache, 3852 config=config, 3853 ) 3854 3855 if not request_options_provider: 3856 request_options_provider = DefaultRequestOptionsProvider(parameters={}) 3857 if isinstance(request_options_provider, DefaultRequestOptionsProvider) and isinstance( 3858 partition_router, PartitionRouter 3859 ): 3860 request_options_provider = partition_router 3861 3862 paginator = ( 3863 self._create_component_from_model( 3864 model=model.paginator, 3865 config=config, 3866 url_base=_get_url(requester), 3867 extractor_model=model.record_selector.extractor, 3868 decoder=decoder, 3869 cursor_used_for_stop_condition=cursor if has_stop_condition_cursor else None, 3870 ) 3871 if model.paginator 3872 else NoPagination(parameters={}) 3873 ) 3874 3875 ignore_stream_slicer_parameters_on_paginated_requests = ( 3876 model.ignore_stream_slicer_parameters_on_paginated_requests or False 3877 ) 3878 3879 reads_parent_stream_lazily = bool( 3880 model.partition_router 3881 and isinstance(model.partition_router, SubstreamPartitionRouterModel) 3882 and any( 3883 parent_stream_config.lazy_read_pointer 3884 for parent_stream_config in model.partition_router.parent_stream_configs 3885 ) 3886 ) 3887 if reads_parent_stream_lazily and ( 3888 model.page_size_reduction 3889 or self._uses_reduce_page_size_action(getattr(model.requester, "error_handler", None)) 3890 ): 3891 # Checked outside of the LazySimpleRetriever branch below, which only applies on the first 3892 # sync of a stream: gating it on the absence of state would accept the same manifest from the 3893 # second sync onwards. LazySimpleRetriever paginates the parent's embedded pages, so there is 3894 # no page of its own to re-issue with a smaller page size. 3895 raise ValueError( 3896 f"`page_size_reduction` and the REDUCE_PAGE_SIZE response action are not supported when " 3897 f"reading a parent stream lazily. Remove either the page size reduction or the parent " 3898 f"stream's `lazy_read_pointer` for stream {name}." 3899 ) 3900 3901 if reads_parent_stream_lazily and ( 3902 model.request_window_splitting 3903 or self._uses_split_request_window_action( 3904 getattr(model.requester, "error_handler", None) 3905 ) 3906 ): 3907 # Same reasoning as the page_size_reduction check above: LazySimpleRetriever reads records 3908 # embedded in the parent's own pages rather than requesting a window of its own, so there is 3909 # nothing here for a split child window to be read from. 3910 raise ValueError( 3911 f"`request_window_splitting` and the SPLIT_REQUEST_WINDOW response action are not " 3912 f"supported when reading a parent stream lazily. Remove either the request window " 3913 f"splitting or the parent stream's `lazy_read_pointer` for stream {name}." 3914 ) 3915 3916 if reads_parent_stream_lazily and not bool( 3917 self._connector_state_manager.get_stream_state(name, None) 3918 ): 3919 if incremental_sync: 3920 if incremental_sync.type != "DatetimeBasedCursor": 3921 raise ValueError( 3922 f"LazySimpleRetriever only supports DatetimeBasedCursor. Found: {incremental_sync.type}." 3923 ) 3924 3925 elif incremental_sync.step or incremental_sync.cursor_granularity: 3926 raise ValueError( 3927 f"Found more that one slice per parent. LazySimpleRetriever only supports single slice read for stream - {name}." 3928 ) 3929 3930 if model.decoder and model.decoder.type != "JsonDecoder": 3931 raise ValueError( 3932 f"LazySimpleRetriever only supports JsonDecoder. Found: {model.decoder.type}." 3933 ) 3934 3935 return LazySimpleRetriever( 3936 name=name, 3937 paginator=paginator, 3938 primary_key=primary_key, 3939 requester=requester, 3940 record_selector=record_selector, 3941 stream_slicer=_NO_STREAM_SLICING, 3942 request_option_provider=request_options_provider, 3943 config=config, 3944 ignore_stream_slicer_parameters_on_paginated_requests=ignore_stream_slicer_parameters_on_paginated_requests, 3945 post_pagination_filter=post_pagination_filter, 3946 parameters=model.parameters or {}, 3947 ) 3948 3949 if ( 3950 model.record_selector.record_filter 3951 and model.pagination_reset 3952 and model.pagination_reset.limits 3953 ): 3954 raise ValueError("PaginationResetLimits are not supported while having record filter.") 3955 3956 request_window_splitting = self._create_request_window_splitting( 3957 model, name, cursor, incremental_sync, query_properties, file_uploader 3958 ) 3959 return SimpleRetriever( 3960 name=name, 3961 paginator=paginator, 3962 primary_key=primary_key, 3963 requester=requester, 3964 record_selector=record_selector, 3965 stream_slicer=_NO_STREAM_SLICING, 3966 request_option_provider=request_options_provider, 3967 config=config, 3968 ignore_stream_slicer_parameters_on_paginated_requests=ignore_stream_slicer_parameters_on_paginated_requests, 3969 additional_query_properties=query_properties, 3970 log_formatter=self._get_log_formatter(log_formatter, name), 3971 pagination_tracker_factory=self._create_pagination_tracker_factory( 3972 model.pagination_reset, cursor 3973 ), 3974 page_size_reduction=self._create_page_size_reduction( 3975 model, name, query_properties, file_uploader 3976 ), 3977 request_window_splitting=request_window_splitting, 3978 request_window_splitter=( 3979 cursor.split_request_window 3980 if request_window_splitting and hasattr(cursor, "split_request_window") 3981 else None 3982 ), 3983 post_pagination_filter=post_pagination_filter, 3984 parameters=model.parameters or {}, 3985 ) 3986 3987 def _create_page_size_reduction( 3988 self, 3989 model: SimpleRetrieverModel, 3990 name: str, 3991 query_properties: Optional[QueryProperties], 3992 file_uploader: Optional[DefaultFileUploader] = None, 3993 ) -> Optional[PageSizeReduction]: 3994 # A CustomRequester does not necessarily define an error handler. A CustomRequester that does define 3995 # one keeps it as a raw dict rather than a typed model, so this returns False for it as well. 3996 error_handler = getattr(model.requester, "error_handler", None) 3997 uses_action = self._uses_reduce_page_size_action(error_handler) 3998 if uses_action and not model.page_size_reduction: 3999 raise ValueError( 4000 f"Stream {name} has a response filter with the REDUCE_PAGE_SIZE action but the retriever does not " 4001 f"define `page_size_reduction`. Add a `page_size_reduction` block to the retriever." 4002 ) 4003 4004 if not model.page_size_reduction: 4005 return None 4006 4007 if not uses_action: 4008 # Not raised: a CustomErrorHandler can resolve to REDUCE_PAGE_SIZE without us being able to see 4009 # it, so the only safe reaction to a block we cannot tie to an action is a warning. Without it a 4010 # misspelled action leaves the feature silently dead on a stream that only exists because it 4011 # would otherwise fail. 4012 LOGGER.warning( 4013 f"Stream {name} defines `page_size_reduction` but no response filter with the " 4014 f"REDUCE_PAGE_SIZE action was found on its requester. The page size will never be reduced " 4015 f"unless a custom error handler resolves to that action." 4016 ) 4017 4018 self._validate_page_size_reduction_is_supported( 4019 model, name, query_properties, file_uploader 4020 ) 4021 4022 reset_policy = model.page_size_reduction.reset_policy 4023 return PageSizeReduction( 4024 reduction_factor=model.page_size_reduction.reduction_factor, # type: ignore[arg-type] # the schema defines a default 4025 minimum_page_size=model.page_size_reduction.minimum_page_size, # type: ignore[arg-type] # the schema defines a default 4026 max_attempts=model.page_size_reduction.max_attempts, # type: ignore[arg-type] # the schema defines a default 4027 backoff_seconds=model.page_size_reduction.backoff_seconds, # type: ignore[arg-type] # the schema defines a default 4028 retries_at_minimum_page_size=model.page_size_reduction.retries_at_minimum_page_size, # type: ignore[arg-type] # the schema defines a default 4029 failure_message=model.page_size_reduction.failure_message, 4030 reset_policy=PageSizeResetPolicy(reset_policy.value) 4031 if reset_policy is not None 4032 else PageSizeResetPolicy.NEVER, 4033 ) 4034 4035 def _validate_page_size_reduction_is_supported( 4036 self, 4037 model: SimpleRetrieverModel, 4038 name: str, 4039 query_properties: Optional[QueryProperties], 4040 file_uploader: Optional[DefaultFileUploader] = None, 4041 ) -> None: 4042 """ 4043 Page size reduction re-issues the same page with a smaller page size. That is only correct when the next 4044 page does not depend on the page size, and it only has an effect when the paginator injects the page size 4045 in the request. A custom pagination strategy is accepted when it can receive the reduced page size, which 4046 is checked by inspecting its signature rather than by recognizing its type. 4047 """ 4048 if query_properties: 4049 raise ValueError( 4050 f"`page_size_reduction` cannot be used together with query properties on stream {name}. Records " 4051 f"from the earlier property chunks have already been emitted when a chunk asks for a smaller page, " 4052 f"so retrying the page would emit them twice." 4053 ) 4054 4055 if file_uploader: 4056 raise ValueError( 4057 f"`page_size_reduction` cannot be used together with a `file_uploader` on stream {name}. The " 4058 f"file uploader sends one request per record from inside the page's record generator, so a " 4059 f"reduction asked for halfway through a page would re-emit the records already yielded by it." 4060 ) 4061 4062 if not isinstance(model.paginator, DefaultPaginatorModel): 4063 raise ValueError( 4064 f"`page_size_reduction` requires a DefaultPaginator on stream {name} so that the connector can " 4065 f"send a smaller page size." 4066 ) 4067 4068 if not model.paginator.page_size_option: 4069 raise ValueError( 4070 f"`page_size_reduction` requires `page_size_option` on the paginator of stream {name}: without it " 4071 f"the connector cannot tell the API to send a smaller page." 4072 ) 4073 4074 if isinstance(model.paginator.page_token_option, RequestPathModel): 4075 # A RequestPath page token is a full URL built by the API, and it already carries the page size the 4076 # API echoed back. The reduced page size is injected as a request option on top of that URL, so the 4077 # request goes out with the page size twice - the original one from the URL and the reduced one - 4078 # and which of the two the API honors is up to the API. Every page after the first would then keep 4079 # asking for the page size that just failed. 4080 raise ValueError( 4081 f"`page_size_reduction` does not support a `page_token_option` of type RequestPath on stream " 4082 f"{name}. The next page is then requested through a URL returned by the API, which already " 4083 f"carries the page size, so the reduced page size would be sent alongside the original one. Use " 4084 f"a CursorPagination strategy with a `page_token_option` of type RequestOption instead." 4085 ) 4086 4087 strategy = model.paginator.pagination_strategy 4088 if isinstance(strategy, PageIncrementModel): 4089 raise ValueError( 4090 f"`page_size_reduction` does not support the PageIncrement pagination strategy used by stream " 4091 f"{name}. Pages are addressed as page number * page size, so a smaller page size shifts every " 4092 f"following page boundary and would skip records. Use OffsetIncrement or CursorPagination." 4093 ) 4094 if isinstance(strategy, CustomPaginationStrategyModel): 4095 # A custom strategy is written by the same person enabling the reduction, so the 4096 # question is not whether we recognize it but whether it can be told the reduced 4097 # page size. Checking the signature keeps a strategy that would raise TypeError 4098 # mid-sync from being accepted at config time. 4099 custom_class = self._get_class_from_fully_qualified_class_name(strategy.class_name) 4100 if isinstance(custom_class, type) and issubclass(custom_class, PageIncrement): 4101 # `PageIncrement.next_page_token` declares `page_size_override` only to reject it, so a 4102 # subclass that does not override the method would pass the signature check below and only 4103 # fail once the first reduction is requested, mid-sync. 4104 raise ValueError( 4105 f"`page_size_reduction` does not support the PageIncrement pagination strategy that the " 4106 f"custom pagination strategy {strategy.class_name} used by stream {name} inherits from. " 4107 f"Pages are addressed as page number * page size, so a smaller page size shifts every " 4108 f"following page boundary and would skip records. Use OffsetIncrement or CursorPagination." 4109 ) 4110 try: 4111 parameters = inspect.signature(custom_class.next_page_token).parameters 4112 except (AttributeError, TypeError, ValueError) as exception: 4113 raise ValueError( 4114 f"`page_size_reduction` could not check the signature of `next_page_token` on the custom " 4115 f"pagination strategy {strategy.class_name} used by stream {name}: {exception}. Make sure " 4116 f"`class_name` points at a PaginationStrategy subclass whose `next_page_token` accepts a " 4117 f"`page_size_override` keyword argument." 4118 ) 4119 accepts_override = "page_size_override" in parameters or any( 4120 parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters.values() 4121 ) 4122 if not accepts_override: 4123 raise ValueError( 4124 f"`page_size_reduction` requires the custom pagination strategy " 4125 f"{strategy.class_name} used by stream {name} to accept a `page_size_override` keyword " 4126 f"argument in `next_page_token`, so that it can honor the reduced page size. Add " 4127 f"`page_size_override: Optional[int] = None` to its signature; a strategy that does not " 4128 f"use its page size as a stop condition can ignore the value." 4129 ) 4130 elif not isinstance(strategy, (CursorPaginationModel, OffsetIncrementModel)): 4131 raise ValueError( 4132 f"`page_size_reduction` only supports the CursorPagination, OffsetIncrement and " 4133 f"CustomPaginationStrategy pagination strategies. Stream {name} uses " 4134 f"{type(strategy).__name__}." 4135 ) 4136 else: 4137 # A CustomPaginationStrategy carries its page size in its own code, so this is only checkable 4138 # for the strategies the CDK defines. `page_size` is optional on both of them, and without it 4139 # `get_page_size` returns None, the paginator injects nothing, and the reduction is a dead end 4140 # that only surfaces on the first failing response - after records have been emitted. 4141 self._validate_page_size_is_reducible(strategy, model.page_size_reduction, name) 4142 if isinstance(strategy, CursorPaginationModel): 4143 self._validate_stop_condition_is_reduction_aware( 4144 strategy, model.page_size_reduction, name 4145 ) 4146 4147 @staticmethod 4148 def _validate_stop_condition_is_reduction_aware( 4149 strategy: CursorPaginationModel, 4150 page_size_reduction: Optional[PageSizeReductionModel], 4151 name: str, 4152 ) -> None: 4153 """ 4154 A `stop_condition` comparing `last_page_size` to a hardcoded page size reads a full reduced page as a 4155 short page and ends the pagination early, dropping the rest of the partition without failing. The 4156 strategy exposes the page size that was actually requested as `page_size`, so the condition can be 4157 written correctly - but only if it is, which is what this checks. 4158 4159 The condition is parsed as a Jinja expression rather than matched as a string: only the AST tells 4160 `page_size`, which follows the reduction, apart from `config['page_size']`, which does not, and only the 4161 AST tells an inequality, which a reduction can invalidate, apart from `last_page_size == 0`, which it 4162 cannot. A condition that never names `last_page_size` is not waved through: a count read from the 4163 response body - `{{ response['data'] | length < 100 }}` - truncates in exactly the same way, and which 4164 response field counts the records of a page is not knowable here. A shape the analysis does not 4165 understand is warned about rather than rejected - this runs at stream construction, so a false 4166 rejection takes `check`, `discover` and `read` down with it. 4167 """ 4168 stop_condition = strategy.stop_condition 4169 if not stop_condition: 4170 return 4171 4172 minimum_page_size = ( 4173 page_size_reduction.minimum_page_size if page_size_reduction else None 4174 ) or 1 4175 verdict, reason = classify_stop_condition(stop_condition, minimum_page_size) 4176 if verdict is StopConditionSafety.TRUNCATES: 4177 raise ValueError( 4178 f"`page_size_reduction` on stream {name} cannot be used with the `stop_condition` " 4179 f"{stop_condition!r}: {reason}. The pagination would then end early, silently dropping the " 4180 f"rest of the partition. Compare against the `page_size` interpolation variable instead, " 4181 f"which holds the page size that was actually requested (for example " 4182 f"`{{{{ last_page_size < page_size }}}}`), or test the page for emptiness with " 4183 f"`{{{{ last_page_size == 0 }}}}`." 4184 ) 4185 if verdict is StopConditionSafety.UNKNOWN: 4186 LOGGER.warning( 4187 f"Stream {name} uses `page_size_reduction` with the `stop_condition` {stop_condition!r}, " 4188 f"which could not be checked against the reduction because {reason}. Make sure a page that is " 4189 f"full at a reduced page size does not satisfy it, otherwise the pagination ends early and the " 4190 f"rest of the partition is silently dropped. Comparing against the `page_size` interpolation " 4191 f"variable, which holds the page size that was actually requested, is always safe." 4192 ) 4193 4194 @staticmethod 4195 def _validate_page_size_is_reducible( 4196 strategy: Union[CursorPaginationModel, OffsetIncrementModel], 4197 page_size_reduction: Optional[PageSizeReductionModel], 4198 name: str, 4199 ) -> None: 4200 page_size = strategy.page_size 4201 if page_size is None: 4202 raise ValueError( 4203 f"`page_size_reduction` requires `page_size` on the pagination strategy of stream {name}: " 4204 f"without it the paginator does not send a page size, so there is nothing to reduce." 4205 ) 4206 4207 # The schema allows a string so that the page size can be interpolated. `page_size` is 4208 # `Optional[Union[int, str]]` and pydantic v1 tries `int` first, so both `100` and `"100"` arrive as 4209 # an int and only a genuinely non-numeric template stays a str. Such a template is only known once 4210 # the config is available, so it is left to the runtime check in `PageSizeReducer.reduce`. 4211 try: 4212 configured_page_size: Optional[int] = int(page_size) 4213 except ValueError: 4214 configured_page_size = None 4215 4216 minimum_page_size = ( 4217 page_size_reduction.minimum_page_size if page_size_reduction else None 4218 ) or 1 4219 if configured_page_size is not None and configured_page_size <= minimum_page_size: 4220 raise ValueError( 4221 f"`page_size_reduction` on stream {name} can never reduce its page size: the pagination " 4222 f"strategy's `page_size` is {configured_page_size} and `minimum_page_size` is " 4223 f"{minimum_page_size}. Lower `minimum_page_size` or raise `page_size`." 4224 ) 4225 4226 # Each reduction divides the page size by `reduction_factor` and spends one attempt, so an unbroken 4227 # run of failures bottoms out at `page_size / reduction_factor ** max_attempts` whatever 4228 # `minimum_page_size` says. Only warned about, and not raised: pages that succeed in between restart 4229 # the budget while `NEVER` keeps the page size, so the floor is reachable over a partition even when 4230 # it is out of reach of a single run - and a manifest that deliberately gives up earlier than its 4231 # floor is not wrong, only worth pointing out. 4232 reduction_factor = ( 4233 page_size_reduction.reduction_factor if page_size_reduction else None 4234 ) or 2.0 4235 max_attempts = (page_size_reduction.max_attempts if page_size_reduction else None) or 5 4236 # Only when there is a floor worth reaching. The default of 1 is out of reach of the default budget on 4237 # any page size above 32, so this would otherwise fire on nearly every stream that opts in - and 4238 # `__fields_set__` would not help, since it records that a value was supplied and not that it differs 4239 # from the default, so spelling `minimum_page_size: 1` out longhand would earn the warning. 4240 if ( 4241 configured_page_size is not None 4242 and minimum_page_size > 1 4243 and reduction_factor**max_attempts < configured_page_size / minimum_page_size 4244 ): 4245 reachable_page_size = max( 4246 minimum_page_size, int(configured_page_size // reduction_factor**max_attempts) 4247 ) 4248 LOGGER.warning( 4249 f"Stream {name} sets `minimum_page_size` to {minimum_page_size}, which `page_size_reduction` " 4250 f"cannot reach in one run of failing pages: `max_attempts` is {max_attempts} and " 4251 f"`reduction_factor` is {reduction_factor}, so {max_attempts} reductions of a page size of " 4252 f"{configured_page_size} stop at {reachable_page_size} records per page and the sync then " 4253 f"fails with a transient error. Whichever of the two bounds is tighter wins; reaching " 4254 f"{minimum_page_size} in one run needs `max_attempts` of at least " 4255 f"{math.ceil(math.log(configured_page_size / minimum_page_size, reduction_factor))}." 4256 ) 4257 4258 def _reject_reduce_page_size_action(self, requester: Any, description: str) -> None: 4259 """ 4260 `error_handler` is defined on `HttpRequester`, which is referenced by requesters that have no page of 4261 their own, so REDUCE_PAGE_SIZE is schema-legal in places where nothing can honor it. Only 4262 `SimpleRetriever._read_pages` re-issues a page, so every other requester is rejected here rather than 4263 surfacing the exception mid-sync as a generic failure. 4264 """ 4265 if requester is None: 4266 return 4267 if self._uses_reduce_page_size_action(getattr(requester, "error_handler", None)): 4268 raise ValueError( 4269 f"The REDUCE_PAGE_SIZE response action is not supported on the {description}: only the main " 4270 f"requester of a SimpleRetriever can re-issue its page with a smaller page size. Use a " 4271 f"different action on that error handler." 4272 ) 4273 4274 def _uses_reduce_page_size_action(self, error_handler: Any) -> bool: 4275 if isinstance(error_handler, CompositeErrorHandlerModel): 4276 return any( 4277 self._uses_reduce_page_size_action(nested) 4278 for nested in error_handler.error_handlers 4279 ) 4280 if isinstance(error_handler, DefaultErrorHandlerModel): 4281 return any( 4282 response_filter.action == HttpResponseFilterActionModel.REDUCE_PAGE_SIZE 4283 for response_filter in error_handler.response_filters or [] 4284 ) 4285 # A CustomErrorHandler can return any action and we cannot inspect it, so we do not validate it. 4286 return False 4287 4288 def _create_request_window_splitting( 4289 self, 4290 model: SimpleRetrieverModel, 4291 name: str, 4292 cursor: Optional[Cursor], 4293 incremental_sync: Optional[ 4294 Union[IncrementingCountCursorModel, DatetimeBasedCursorModel] 4295 ] = None, 4296 query_properties: Optional[QueryProperties] = None, 4297 file_uploader: Optional[DefaultFileUploader] = None, 4298 ) -> Optional[RequestWindowSplitting]: 4299 # A CustomRequester does not necessarily define an error handler. A CustomRequester that does define 4300 # one keeps it as a raw dict rather than a typed model, so this returns False for it as well. 4301 error_handler = getattr(model.requester, "error_handler", None) 4302 uses_action = self._uses_split_request_window_action(error_handler) 4303 if uses_action and not model.request_window_splitting: 4304 raise ValueError( 4305 f"Stream {name} has a response filter with the SPLIT_REQUEST_WINDOW action but the " 4306 f"retriever does not define `request_window_splitting`. Add a `request_window_splitting` " 4307 f"block to the retriever." 4308 ) 4309 4310 if not model.request_window_splitting: 4311 return None 4312 4313 if not uses_action: 4314 # Not raised: a CustomErrorHandler can resolve to SPLIT_REQUEST_WINDOW without us being able to 4315 # see it, and custom code can raise RequestWindowSplitRequiredException directly without 4316 # going through any error handler at all - so the only safe reaction to a block we cannot tie to 4317 # a known trigger is a warning. 4318 LOGGER.warning( 4319 f"Stream {name} defines `request_window_splitting` but no response filter with the " 4320 f"SPLIT_REQUEST_WINDOW action was found on its requester. The request window will never " 4321 f"be split unless a custom error handler or custom code resolves to that action." 4322 ) 4323 4324 self._validate_request_window_splitting_is_supported( 4325 name, 4326 cursor, 4327 incremental_sync, 4328 model.request_window_splitting.min_split_window, 4329 query_properties, 4330 file_uploader, 4331 ) 4332 4333 return RequestWindowSplitting( 4334 failure_message=model.request_window_splitting.failure_message, 4335 min_split_window=parse_duration(model.request_window_splitting.min_split_window) 4336 if model.request_window_splitting.min_split_window 4337 else None, 4338 ) 4339 4340 def _validate_request_window_splitting_is_supported( 4341 self, 4342 name: str, 4343 cursor: Optional[Cursor], 4344 incremental_sync: Optional[ 4345 Union[IncrementingCountCursorModel, DatetimeBasedCursorModel] 4346 ] = None, 4347 min_split_window: Optional[str] = None, 4348 query_properties: Optional[QueryProperties] = None, 4349 file_uploader: Optional[DefaultFileUploader] = None, 4350 ) -> None: 4351 """ 4352 Request window splitting replaces a failing slice with smaller children derived from the stream's own 4353 cursor, so it only makes sense on a stream whose cursor exposes a `split_request_window` method and it 4354 is rejected for the same structural reasons `page_size_reduction` is: `additional_query_properties` and 4355 a `file_uploader` both mean records of the failing window may already have been emitted by the time the 4356 split is requested, from inside the record generator rather than from a page boundary this retriever 4357 controls. 4358 """ 4359 if not hasattr(cursor, "split_request_window"): 4360 raise ValueError( 4361 f"`request_window_splitting` requires an incremental cursor that supports window splitting on " 4362 f"stream {name}. Found {type(cursor).__name__ if cursor is not None else 'no cursor'}: this is " 4363 f"only supported today for a `DatetimeBasedCursor` with `cursor_granularity` set." 4364 ) 4365 4366 if not ( 4367 isinstance(incremental_sync, DatetimeBasedCursorModel) 4368 and incremental_sync.cursor_granularity 4369 ): 4370 # `cursor_granularity` is both the smallest window the connector will ever request and what keeps 4371 # two child windows from overlapping at their shared edge, so `split_request_window` cannot split 4372 # at all without it - checked here, on the manifest model, rather than by reaching into the 4373 # cursor's private state from outside the class it belongs to. 4374 raise ValueError( 4375 f"`request_window_splitting` requires `cursor_granularity` on the `DatetimeBasedCursor` of " 4376 f"stream {name}: without it there is no smallest window to stop splitting at, and no way to " 4377 f"keep two child windows from overlapping at their shared edge." 4378 ) 4379 4380 if incremental_sync.is_client_side_incremental: 4381 # A client-side-incremental cursor filters records after they are read rather than sending the 4382 # window as request parameters, so every child window sends the exact same request as its parent: 4383 # splitting changes nothing, and the sync would keep splitting all the way to `min_split_window` 4384 # or the safety-net depth before failing. 4385 raise ValueError( 4386 f"`request_window_splitting` cannot be used together with `is_client_side_incremental` on " 4387 f"stream {name}: the window is filtered client-side rather than sent to the API, so splitting " 4388 f"it would not change the request and could not resolve a SPLIT_REQUEST_WINDOW response." 4389 ) 4390 4391 parsed_cursor_granularity = parse_duration(incremental_sync.cursor_granularity) 4392 if isinstance( 4393 parsed_cursor_granularity, datetime.timedelta 4394 ) and parsed_cursor_granularity <= datetime.timedelta(0): 4395 # Passes the truthy check above (the string is non-empty), but a zero-or-negative granularity can 4396 # never produce a child strictly smaller than the parent. 4397 raise ValueError( 4398 f"`request_window_splitting` requires a `cursor_granularity` greater than zero on stream " 4399 f"{name}: `{incremental_sync.cursor_granularity}` parses to a zero-length duration, which can " 4400 f"never produce a smaller child window." 4401 ) 4402 4403 smallest_format_unit = self._smallest_datetime_format_unit(incremental_sync.datetime_format) 4404 if ( 4405 smallest_format_unit is not None 4406 and isinstance(parsed_cursor_granularity, datetime.timedelta) 4407 and parsed_cursor_granularity < smallest_format_unit 4408 ): 4409 # Two children split closer together than `datetime_format` can render would format to the same 4410 # boundary value instead of splitting. 4411 raise ValueError( 4412 f"`request_window_splitting` requires `cursor_granularity` to be no finer than what " 4413 f"`datetime_format` can represent on stream {name}: `datetime_format` " 4414 f"{incremental_sync.datetime_format!r} cannot represent a value finer than " 4415 f"{smallest_format_unit}, but `cursor_granularity` {incremental_sync.cursor_granularity!r} is " 4416 f"finer than that. Use a `datetime_format` precise enough to represent `cursor_granularity`, " 4417 f"or a coarser `cursor_granularity`." 4418 ) 4419 4420 if min_split_window: 4421 parsed_min_split_window = parse_duration(min_split_window) 4422 if not isinstance( 4423 parsed_min_split_window, datetime.timedelta 4424 ) or parsed_min_split_window <= datetime.timedelta(0): 4425 # A duration with years or months (e.g. `P1M`) parses to an `isodate.Duration`, which has no 4426 # fixed length to compare a window's span against - `split_request_window` would raise 4427 # mid-sync trying to. A zero-or-negative duration would never stop splitting. 4428 raise ValueError( 4429 f"`min_split_window` must be a positive duration expressible as a fixed number of " 4430 f"days/hours/minutes/seconds on stream {name}: `{min_split_window}` is not." 4431 ) 4432 4433 if query_properties: 4434 raise ValueError( 4435 f"`request_window_splitting` cannot be used together with query properties on stream {name}. " 4436 f"Records from the earlier property chunks may have already been emitted when a chunk asks to " 4437 f"split the window, so splitting and re-reading it could duplicate them." 4438 ) 4439 4440 if file_uploader: 4441 raise ValueError( 4442 f"`request_window_splitting` cannot be used together with a `file_uploader` on stream {name}. " 4443 f"The file uploader sends one request per record from inside the window's record generator, so " 4444 f"a split requested partway through could re-emit records already yielded by it." 4445 ) 4446 4447 @staticmethod 4448 def _smallest_datetime_format_unit(datetime_format: str) -> Optional[datetime.timedelta]: 4449 """ 4450 :return: the finest duration `datetime_format` can distinguish, per 4451 `_DATETIME_FORMAT_DIRECTIVE_GRANULARITY`, or `None` if it contains none of those directives. 4452 """ 4453 matches = [ 4454 granularity 4455 for directive, granularity in _DATETIME_FORMAT_DIRECTIVE_GRANULARITY 4456 if directive in datetime_format 4457 ] 4458 return min(matches) if matches else None 4459 4460 def _reject_split_request_window_action(self, requester: Any, description: str) -> None: 4461 """ 4462 `error_handler` is defined on `HttpRequester`, which is referenced by requesters that have no window of 4463 their own to split, so SPLIT_REQUEST_WINDOW is schema-legal in places where nothing can honor it. Only 4464 the main requester of a `SimpleRetriever` reads a cursor-sliced window, so every other requester is 4465 rejected here rather than surfacing the exception mid-sync as a generic failure. 4466 """ 4467 if requester is None: 4468 return 4469 if self._uses_split_request_window_action(getattr(requester, "error_handler", None)): 4470 raise ValueError( 4471 f"The SPLIT_REQUEST_WINDOW response action is not supported on the {description}: only the " 4472 f"main requester of a SimpleRetriever reads a window that can be split. Use a different " 4473 f"action on that error handler." 4474 ) 4475 4476 def _uses_split_request_window_action(self, error_handler: Any) -> bool: 4477 if isinstance(error_handler, CompositeErrorHandlerModel): 4478 return any( 4479 self._uses_split_request_window_action(nested) 4480 for nested in error_handler.error_handlers 4481 ) 4482 if isinstance(error_handler, DefaultErrorHandlerModel): 4483 return any( 4484 response_filter.action == HttpResponseFilterActionModel.SPLIT_REQUEST_WINDOW 4485 for response_filter in error_handler.response_filters or [] 4486 ) 4487 # A CustomErrorHandler can return any action and we cannot inspect it, so we do not validate it. 4488 return False 4489 4490 def _create_pagination_tracker_factory( 4491 self, model: Optional[PaginationResetModel], cursor: Cursor 4492 ) -> Callable[[], PaginationTracker]: 4493 if model is None: 4494 return lambda: PaginationTracker() 4495 4496 # Until we figure out a way to use any cursor for PaginationTracker, we will have to have this cursor selector logic 4497 cursor_factory: Callable[[], Optional[ConcurrentCursor]] = lambda: None 4498 if model.action == PaginationResetActionModel.RESET: 4499 # in that case, we will let cursor_factory to return None even if the stream has a cursor 4500 pass 4501 elif model.action == PaginationResetActionModel.SPLIT_USING_CURSOR: 4502 if isinstance(cursor, ConcurrentCursor): 4503 cursor_factory = lambda: cursor.copy_without_state() # type: ignore # the if condition validates that it is a ConcurrentCursor 4504 elif isinstance(cursor, ConcurrentPerPartitionCursor): 4505 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 4506 {}, datetime.timedelta(0) 4507 ) 4508 elif not isinstance(cursor, FinalStateCursor): 4509 LOGGER.warning( 4510 "Unknown cursor for PaginationTracker. Pagination resets might not work properly" 4511 ) 4512 else: 4513 raise ValueError(f"Unknown PaginationReset action: {model.action}") 4514 4515 limit = model.limits.number_of_records if model and model.limits else None 4516 return lambda: PaginationTracker(cursor_factory(), limit) 4517 4518 def _get_log_formatter( 4519 self, log_formatter: Callable[[Response], Any] | None, name: str 4520 ) -> Callable[[Response], Any] | None: 4521 if self._should_limit_slices_fetched(): 4522 return ( 4523 ( 4524 lambda response: format_http_message( 4525 response, 4526 f"Stream '{name}' request", 4527 f"Request performed in order to extract records for stream '{name}'", 4528 name, 4529 ) 4530 ) 4531 if not log_formatter 4532 else log_formatter 4533 ) 4534 return None 4535 4536 def _should_limit_slices_fetched(self) -> bool: 4537 """ 4538 Returns True if the number of slices fetched should be limited, False otherwise. 4539 This is used to limit the number of slices fetched during tests. 4540 """ 4541 return bool(self._limit_slices_fetched or self._emit_connector_builder_messages) 4542 4543 @staticmethod 4544 def _has_query_properties_in_request_parameters( 4545 requester: Union[HttpRequesterModel, CustomRequesterModel], 4546 ) -> bool: 4547 if not hasattr(requester, "request_parameters"): 4548 return False 4549 request_parameters = requester.request_parameters 4550 if request_parameters and isinstance(request_parameters, Mapping): 4551 for request_parameter in request_parameters.values(): 4552 if isinstance(request_parameter, QueryPropertiesModel): 4553 return True 4554 return False 4555 4556 @staticmethod 4557 def _remove_query_properties( 4558 request_parameters: Mapping[str, Union[str, QueryPropertiesModel]], 4559 ) -> Mapping[str, str]: 4560 return { 4561 parameter_field: request_parameter 4562 for parameter_field, request_parameter in request_parameters.items() 4563 if not isinstance(request_parameter, QueryPropertiesModel) 4564 } 4565 4566 def create_state_delegating_stream( 4567 self, 4568 model: StateDelegatingStreamModel, 4569 config: Config, 4570 **kwargs: Any, 4571 ) -> DefaultStream: 4572 if ( 4573 model.full_refresh_stream.name != model.name 4574 or model.name != model.incremental_stream.name 4575 ): 4576 raise ValueError( 4577 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}." 4578 ) 4579 4580 # Resolve api_retention_period with config context (supports Jinja2 interpolation) 4581 resolved_retention_period: Optional[str] = None 4582 if model.api_retention_period: 4583 interpolated_retention = InterpolatedString.create( 4584 model.api_retention_period, parameters=model.parameters or {} 4585 ) 4586 resolved_value = interpolated_retention.eval(config=config) 4587 if resolved_value: 4588 resolved_retention_period = str(resolved_value) 4589 4590 if resolved_retention_period: 4591 for stream_model in (model.full_refresh_stream, model.incremental_stream): 4592 if isinstance(stream_model.incremental_sync, IncrementingCountCursorModel): 4593 raise ValueError( 4594 f"Stream '{model.name}' uses IncrementingCountCursor which is not supported " 4595 f"with api_retention_period. IncrementingCountCursor does not use datetime-based " 4596 f"cursors, so cursor age validation cannot be performed." 4597 ) 4598 4599 stream_state = self._connector_state_manager.get_stream_state(model.name, None) 4600 4601 if not stream_state: 4602 return self._create_component_from_model( # type: ignore[no-any-return] 4603 model.full_refresh_stream, config=config, **kwargs 4604 ) 4605 4606 incremental_stream: DefaultStream = self._create_component_from_model( 4607 model.incremental_stream, config=config, **kwargs 4608 ) # type: ignore[assignment] 4609 4610 # Only run cursor age validation for streams that are in the configured 4611 # catalog (or when no catalog was provided, e.g. during discover / connector 4612 # builder). Streams not selected by the user but instantiated as parent-stream 4613 # dependencies must not go through this path because it emits state messages 4614 # that the destination does not know about, causing "Stream not found" crashes. 4615 stream_is_in_catalog = ( 4616 not self._stream_name_to_configured_stream # no catalog → validate by default 4617 or model.name in self._stream_name_to_configured_stream 4618 ) 4619 if resolved_retention_period and stream_is_in_catalog: 4620 full_refresh_stream: DefaultStream = self._create_component_from_model( 4621 model.full_refresh_stream, config=config, **kwargs 4622 ) # type: ignore[assignment] 4623 if self._is_cursor_older_than_retention_period( 4624 stream_state, 4625 full_refresh_stream.cursor, 4626 incremental_stream.cursor, 4627 resolved_retention_period, 4628 model.name, 4629 ): 4630 # Clear state BEFORE constructing the full_refresh_stream so that 4631 # its cursor starts from start_date instead of the stale cursor. 4632 self._connector_state_manager.update_state_for_stream(model.name, None, {}) 4633 state_message = self._connector_state_manager.create_state_message(model.name, None) 4634 self._message_repository.emit_message(state_message) 4635 return self._create_component_from_model( # type: ignore[no-any-return] 4636 model.full_refresh_stream, config=config, **kwargs 4637 ) 4638 4639 return incremental_stream 4640 4641 @staticmethod 4642 def _is_cursor_older_than_retention_period( 4643 stream_state: Mapping[str, Any], 4644 full_refresh_cursor: Cursor, 4645 incremental_cursor: Cursor, 4646 api_retention_period: str, 4647 stream_name: str, 4648 ) -> bool: 4649 """Check if the cursor value in the state is older than the API's retention period. 4650 4651 Checks cursors in sequence: full refresh cursor first, then incremental cursor. 4652 FinalStateCursor returns now() for completed full refresh state (NO_CURSOR_STATE_KEY), 4653 which is always within retention, so we use incremental. For other states, it returns 4654 None and we fall back to checking the incremental cursor. 4655 4656 Returns True if the cursor is older than the retention period (should use full refresh). 4657 Returns False if the cursor is within the retention period (safe to use incremental). 4658 """ 4659 retention_duration = parse_duration(api_retention_period) 4660 retention_cutoff = datetime.datetime.now(datetime.timezone.utc) - retention_duration 4661 4662 # Check full refresh cursor first 4663 cursor_datetime = full_refresh_cursor.get_cursor_datetime_from_state(stream_state) 4664 4665 # If full refresh cursor returns None, check incremental cursor 4666 if cursor_datetime is None: 4667 cursor_datetime = incremental_cursor.get_cursor_datetime_from_state(stream_state) 4668 4669 if cursor_datetime is None: 4670 # Neither cursor could parse the state - fall back to full refresh to be safe 4671 return True 4672 4673 if cursor_datetime < retention_cutoff: 4674 logging.warning( 4675 f"Stream '{stream_name}' has a cursor value older than " 4676 f"the API's retention period of {api_retention_period} " 4677 f"(cutoff: {retention_cutoff.isoformat()}). " 4678 f"Falling back to full refresh to avoid data loss." 4679 ) 4680 return True 4681 4682 return False 4683 4684 def _get_state_delegating_stream_model( 4685 self, 4686 model: StateDelegatingStreamModel, 4687 parent_state: Optional[Mapping[str, Any]] = None, 4688 ) -> DeclarativeStreamModel: 4689 """Return the appropriate underlying stream model based on state.""" 4690 return ( 4691 model.incremental_stream 4692 if self._connector_state_manager.get_stream_state(model.name, None) or parent_state 4693 else model.full_refresh_stream 4694 ) 4695 4696 _OPTIONAL_ASYNC_STATUS_FIELDS = {"skipped"} 4697 4698 def _create_async_job_status_mapping( 4699 self, model: AsyncJobStatusMapModel, config: Config, **kwargs: Any 4700 ) -> Mapping[str, AsyncJobStatus]: 4701 api_status_to_cdk_status = {} 4702 for cdk_status, api_statuses in model.dict().items(): 4703 if cdk_status == "type": 4704 # This is an element of the dict because of the typing of the CDK but it is not a CDK status 4705 continue 4706 4707 if api_statuses is None: 4708 if cdk_status in self._OPTIONAL_ASYNC_STATUS_FIELDS: 4709 continue 4710 raise ValueError( 4711 f"Required CDK status '{cdk_status}' has no API statuses mapped. " 4712 f"Please provide at least an empty list for required status fields." 4713 ) 4714 4715 for status in api_statuses: 4716 if status in api_status_to_cdk_status: 4717 raise ValueError( 4718 f"API status {status} is already set for CDK status {cdk_status}. Please ensure API statuses are only provided once" 4719 ) 4720 api_status_to_cdk_status[status] = self._get_async_job_status(cdk_status) 4721 return api_status_to_cdk_status 4722 4723 def _get_async_job_status(self, status: str) -> AsyncJobStatus: 4724 match status: 4725 case "running": 4726 return AsyncJobStatus.RUNNING 4727 case "completed": 4728 return AsyncJobStatus.COMPLETED 4729 case "failed": 4730 return AsyncJobStatus.FAILED 4731 case "timeout": 4732 return AsyncJobStatus.TIMED_OUT 4733 case "skipped": 4734 return AsyncJobStatus.SKIPPED 4735 case _: 4736 raise ValueError(f"Unsupported CDK status {status}") 4737 4738 def create_async_retriever( 4739 self, 4740 model: AsyncRetrieverModel, 4741 config: Config, 4742 *, 4743 name: str, 4744 primary_key: Optional[ 4745 Union[str, List[str], List[List[str]]] 4746 ], # this seems to be needed to match create_simple_retriever 4747 stream_slicer: Optional[StreamSlicer], 4748 client_side_incremental_sync: Optional[Dict[str, Any]] = None, 4749 transformations: List[RecordTransformation], 4750 **kwargs: Any, 4751 ) -> AsyncRetriever: 4752 if model.download_target_requester and not model.download_target_extractor: 4753 raise ValueError( 4754 f"`download_target_extractor` required if using a `download_target_requester`" 4755 ) 4756 4757 for requester_field in ( 4758 "creation_requester", 4759 "polling_requester", 4760 "download_requester", 4761 "download_target_requester", 4762 "abort_requester", 4763 "delete_requester", 4764 ): 4765 self._reject_reduce_page_size_action( 4766 getattr(model, requester_field, None), 4767 f"`{requester_field}` of the AsyncRetriever of stream {name}", 4768 ) 4769 self._reject_split_request_window_action( 4770 getattr(model, requester_field, None), 4771 f"`{requester_field}` of the AsyncRetriever of stream {name}", 4772 ) 4773 4774 def _get_download_retriever( 4775 requester: Requester, extractor: RecordExtractor, _decoder: Decoder 4776 ) -> SimpleRetriever: 4777 self._count_dropped_empty_records(extractor) 4778 # We create a record selector for the download retriever 4779 # with no schema normalization and no transformations, neither record filter 4780 # as all this occurs in the record_selector of the AsyncRetriever 4781 record_selector = RecordSelector( 4782 extractor=extractor, 4783 name=name, 4784 record_filter=None, 4785 transformations=[], 4786 schema_normalization=TypeTransformer(TransformConfig.NoTransform), 4787 config=config, 4788 parameters={}, 4789 ) 4790 paginator = ( 4791 self._create_component_from_model( 4792 model=model.download_paginator, 4793 decoder=_decoder, 4794 config=config, 4795 url_base="", 4796 # Only read by the `union` rejection of `OffsetIncrement`. 4797 extractor_model=( 4798 model.download_extractor 4799 if isinstance(model.download_extractor, CombinedExtractorModel) 4800 else None 4801 ), 4802 ) 4803 if model.download_paginator 4804 else NoPagination(parameters={}) 4805 ) 4806 4807 return SimpleRetriever( 4808 requester=requester, 4809 record_selector=record_selector, 4810 primary_key=None, 4811 name=name, 4812 paginator=paginator, 4813 config=config, 4814 parameters={}, 4815 log_formatter=self._get_log_formatter(None, name), 4816 ) 4817 4818 def _get_job_timeout() -> datetime.timedelta: 4819 user_defined_timeout: Optional[int] = ( 4820 int( 4821 InterpolatedString.create( 4822 str(model.polling_job_timeout), 4823 parameters={}, 4824 ).eval(config) 4825 ) 4826 if model.polling_job_timeout 4827 else None 4828 ) 4829 4830 # check for user defined timeout during the test read or 15 minutes 4831 test_read_timeout = datetime.timedelta(minutes=user_defined_timeout or 15) 4832 # default value for non-connector builder is 60 minutes. 4833 default_sync_timeout = datetime.timedelta(minutes=user_defined_timeout or 60) 4834 4835 return ( 4836 test_read_timeout if self._emit_connector_builder_messages else default_sync_timeout 4837 ) 4838 4839 decoder = ( 4840 self._create_component_from_model(model=model.decoder, config=config) 4841 if model.decoder 4842 else JsonDecoder(parameters={}) 4843 ) 4844 record_selector = self._create_component_from_model( 4845 model=model.record_selector, 4846 config=config, 4847 decoder=decoder, 4848 name=name, 4849 transformations=transformations, 4850 client_side_incremental_sync=client_side_incremental_sync, 4851 ) 4852 4853 stream_slicer = stream_slicer or SinglePartitionRouter(parameters={}) 4854 if self._should_limit_slices_fetched(): 4855 stream_slicer = cast( 4856 StreamSlicer, 4857 StreamSlicerTestReadDecorator( 4858 wrapped_slicer=stream_slicer, 4859 maximum_number_of_slices=self._limit_slices_fetched or 5, 4860 ), 4861 ) 4862 4863 creation_requester = self._create_component_from_model( 4864 model=model.creation_requester, 4865 decoder=decoder, 4866 config=config, 4867 name=f"job creation - {name}", 4868 ) 4869 polling_requester = self._create_component_from_model( 4870 model=model.polling_requester, 4871 decoder=decoder, 4872 config=config, 4873 name=f"job polling - {name}", 4874 ) 4875 job_download_components_name = f"job download - {name}" 4876 download_decoder = ( 4877 self._create_component_from_model(model=model.download_decoder, config=config) 4878 if model.download_decoder 4879 else JsonDecoder(parameters={}) 4880 ) 4881 download_extractor = ( 4882 self._create_component_from_model( 4883 model=model.download_extractor, 4884 config=config, 4885 decoder=download_decoder, 4886 parameters=model.parameters, 4887 ) 4888 if model.download_extractor 4889 else DpathExtractor( 4890 [], 4891 config=config, 4892 decoder=download_decoder, 4893 parameters=model.parameters or {}, 4894 ) 4895 ) 4896 download_requester = self._create_component_from_model( 4897 model=model.download_requester, 4898 decoder=download_decoder, 4899 config=config, 4900 name=job_download_components_name, 4901 ) 4902 download_retriever = _get_download_retriever( 4903 download_requester, download_extractor, download_decoder 4904 ) 4905 abort_requester = ( 4906 self._create_component_from_model( 4907 model=model.abort_requester, 4908 decoder=decoder, 4909 config=config, 4910 name=f"job abort - {name}", 4911 ) 4912 if model.abort_requester 4913 else None 4914 ) 4915 delete_requester = ( 4916 self._create_component_from_model( 4917 model=model.delete_requester, 4918 decoder=decoder, 4919 config=config, 4920 name=f"job delete - {name}", 4921 ) 4922 if model.delete_requester 4923 else None 4924 ) 4925 download_target_requester = ( 4926 self._create_component_from_model( 4927 model=model.download_target_requester, 4928 decoder=decoder, 4929 config=config, 4930 name=f"job extract_url - {name}", 4931 ) 4932 if model.download_target_requester 4933 else None 4934 ) 4935 status_extractor = self._create_component_from_model( 4936 model=model.status_extractor, decoder=decoder, config=config, name=name 4937 ) 4938 download_target_extractor = ( 4939 self._create_component_from_model( 4940 model=model.download_target_extractor, 4941 decoder=decoder, 4942 config=config, 4943 name=name, 4944 ) 4945 if model.download_target_extractor 4946 else None 4947 ) 4948 4949 job_repository: AsyncJobRepository = AsyncHttpJobRepository( 4950 creation_requester=creation_requester, 4951 polling_requester=polling_requester, 4952 download_retriever=download_retriever, 4953 download_target_requester=download_target_requester, 4954 abort_requester=abort_requester, 4955 delete_requester=delete_requester, 4956 status_extractor=status_extractor, 4957 status_mapping=self._create_async_job_status_mapping(model.status_mapping, config), 4958 download_target_extractor=download_target_extractor, 4959 job_timeout=_get_job_timeout(), 4960 ) 4961 4962 failed_retry_wait_time_in_seconds: Optional[int] = ( 4963 int( 4964 InterpolatedString.create( 4965 str(model.failed_retry_wait_time_in_seconds), 4966 parameters={}, 4967 ).eval(config) 4968 ) 4969 if model.failed_retry_wait_time_in_seconds 4970 else None 4971 ) 4972 4973 async_job_partition_router = AsyncJobPartitionRouter( 4974 job_orchestrator_factory=lambda stream_slices: AsyncJobOrchestrator( 4975 job_repository, 4976 stream_slices, 4977 self._job_tracker, 4978 self._message_repository, 4979 # FIXME work would need to be done here in order to detect if a stream as a parent stream that is bulk 4980 has_bulk_parent=False, 4981 # set the `job_max_retry` to 1 for the `Connector Builder`` use-case. 4982 # `None` == default retry is set to 3 attempts, under the hood. 4983 job_max_retry=1 if self._emit_connector_builder_messages else None, 4984 failed_retry_wait_time_in_seconds=failed_retry_wait_time_in_seconds, 4985 ), 4986 stream_slicer=stream_slicer, 4987 config=config, 4988 parameters=model.parameters or {}, 4989 ) 4990 4991 return AsyncRetriever( 4992 record_selector=record_selector, 4993 stream_slicer=async_job_partition_router, 4994 config=config, 4995 parameters=model.parameters or {}, 4996 ) 4997 4998 def create_spec(self, model: SpecModel, config: Config, **kwargs: Any) -> Spec: 4999 config_migrations = [ 5000 self._create_component_from_model(migration, config) 5001 for migration in ( 5002 model.config_normalization_rules.config_migrations 5003 if ( 5004 model.config_normalization_rules 5005 and model.config_normalization_rules.config_migrations 5006 ) 5007 else [] 5008 ) 5009 ] 5010 config_transformations = [ 5011 self._create_component_from_model(transformation, config) 5012 for transformation in ( 5013 model.config_normalization_rules.transformations 5014 if ( 5015 model.config_normalization_rules 5016 and model.config_normalization_rules.transformations 5017 ) 5018 else [] 5019 ) 5020 ] 5021 config_validations = [ 5022 self._create_component_from_model(validation, config) 5023 for validation in ( 5024 model.config_normalization_rules.validations 5025 if ( 5026 model.config_normalization_rules 5027 and model.config_normalization_rules.validations 5028 ) 5029 else [] 5030 ) 5031 ] 5032 5033 return Spec( 5034 connection_specification=model.connection_specification, 5035 documentation_url=model.documentation_url, 5036 advanced_auth=model.advanced_auth, 5037 parameters={}, 5038 config_migrations=config_migrations, 5039 config_transformations=config_transformations, 5040 config_validations=config_validations, 5041 ) 5042 5043 def create_substream_partition_router( 5044 self, 5045 model: SubstreamPartitionRouterModel, 5046 config: Config, 5047 *, 5048 stream_name: str, 5049 **kwargs: Any, 5050 ) -> SubstreamPartitionRouter: 5051 parent_stream_configs = [] 5052 if model.parent_stream_configs: 5053 parent_stream_configs.extend( 5054 [ 5055 self.create_parent_stream_config_with_substream_wrapper( 5056 model=parent_stream_config, config=config, stream_name=stream_name, **kwargs 5057 ) 5058 for parent_stream_config in model.parent_stream_configs 5059 ] 5060 ) 5061 5062 return SubstreamPartitionRouter( 5063 parent_stream_configs=parent_stream_configs, 5064 parameters=model.parameters or {}, 5065 config=config, 5066 ) 5067 5068 def create_parent_stream_config_with_substream_wrapper( 5069 self, model: ParentStreamConfigModel, config: Config, *, stream_name: str, **kwargs: Any 5070 ) -> Any: 5071 child_state = self._connector_state_manager.get_stream_state(stream_name, None) 5072 if NO_CURSOR_STATE_KEY in child_state: 5073 # Full refresh streams checkpoint a `{NO_CURSOR_STATE_KEY: true}` sentinel. When such a 5074 # stream is later converted to incremental with an incremental_dependency parent, 5075 # `_instantiate_parent_stream_state_manager` would treat the sentinel's boolean as a legacy 5076 # cursor value and re-key it under the parent's cursor field, crashing cursor initialization. 5077 child_state = { 5078 key: value for key, value in child_state.items() if key != NO_CURSOR_STATE_KEY 5079 } 5080 5081 parent_state: Optional[Mapping[str, Any]] = ( 5082 child_state if model.incremental_dependency and child_state else None 5083 ) 5084 connector_state_manager = self._instantiate_parent_stream_state_manager( 5085 child_state, config, model, parent_state 5086 ) 5087 5088 substream_factory = ModelToComponentFactory( 5089 custom_components_trusted=self._custom_components_trusted, 5090 connector_state_manager=connector_state_manager, 5091 limit_pages_fetched_per_slice=self._limit_pages_fetched_per_slice, 5092 limit_slices_fetched=self._limit_slices_fetched, 5093 emit_connector_builder_messages=self._emit_connector_builder_messages, 5094 disable_retries=self._disable_retries, 5095 disable_cache=self._disable_cache, 5096 message_repository=StateFilteringMessageRepository( 5097 LogAppenderMessageRepositoryDecorator( 5098 { 5099 "airbyte_cdk": {"stream": {"is_substream": True}}, 5100 "http": {"is_auxiliary": True}, 5101 }, 5102 self._message_repository, 5103 self._evaluate_log_level(self._emit_connector_builder_messages), 5104 ), 5105 ), 5106 api_budget=self._api_budget, 5107 # Share the authenticator registry so parent and child streams draw from the 5108 # same token quota counters 5109 rate_limited_authenticators=self._rate_limited_authenticators, 5110 ) 5111 5112 return substream_factory.create_parent_stream_config( 5113 model=model, config=config, stream_name=stream_name, **kwargs 5114 ) 5115 5116 def _instantiate_parent_stream_state_manager( 5117 self, 5118 child_state: MutableMapping[str, Any], 5119 config: Config, 5120 model: ParentStreamConfigModel, 5121 parent_state: Optional[Mapping[str, Any]] = None, 5122 ) -> ConnectorStateManager: 5123 """ 5124 With DefaultStream, the state needs to be provided during __init__ of the cursor as opposed to the 5125 `set_initial_state` flow that existed for the declarative cursors. This state is taken from 5126 self._connector_state_manager.get_stream_state (`self` being a newly created ModelToComponentFactory to account 5127 for the MessageRepository being different). So we need to pass a ConnectorStateManager to the 5128 ModelToComponentFactory that has the parent states. This method populates this if there is a child state and if 5129 incremental_dependency is set. 5130 """ 5131 if model.incremental_dependency and child_state: 5132 parent_stream_name = model.stream.name or "" 5133 extracted_parent_state = ConcurrentPerPartitionCursor.get_parent_state( 5134 child_state, parent_stream_name 5135 ) 5136 5137 if not extracted_parent_state: 5138 extracted_parent_state = ConcurrentPerPartitionCursor.get_global_state( 5139 child_state, parent_stream_name 5140 ) 5141 5142 if not extracted_parent_state and not isinstance(extracted_parent_state, dict): 5143 cursor_values = list(child_state.values()) 5144 # Only a scalar legacy cursor value can seed the parent. Sentinels such as 5145 # `{"__ab_full_refresh_sync_complete": true}` or legacy per-partition `states` 5146 # would crash the parent cursor initialization. 5147 if ( 5148 len(cursor_values) == 1 5149 and isinstance(cursor_values[0], (str, int, float)) 5150 and not isinstance(cursor_values[0], bool) 5151 ): 5152 incremental_sync_model: Union[ 5153 DatetimeBasedCursorModel, 5154 IncrementingCountCursorModel, 5155 ] = ( 5156 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 5157 if isinstance(model.stream, DeclarativeStreamModel) 5158 else self._get_state_delegating_stream_model( 5159 model.stream, parent_state=parent_state 5160 ).incremental_sync 5161 ) 5162 cursor_field = InterpolatedString.create( 5163 incremental_sync_model.cursor_field, 5164 parameters=incremental_sync_model.parameters or {}, 5165 ).eval(config) 5166 extracted_parent_state = AirbyteStateMessage( 5167 type=AirbyteStateType.STREAM, 5168 stream=AirbyteStreamState( 5169 stream_descriptor=StreamDescriptor( 5170 name=parent_stream_name, namespace=None 5171 ), 5172 stream_state=AirbyteStateBlob({cursor_field: cursor_values[0]}), 5173 ), 5174 ) 5175 return ConnectorStateManager([extracted_parent_state] if extracted_parent_state else []) 5176 5177 return ConnectorStateManager([]) 5178 5179 @staticmethod 5180 def create_wait_time_from_header( 5181 model: WaitTimeFromHeaderModel, config: Config, **kwargs: Any 5182 ) -> WaitTimeFromHeaderBackoffStrategy: 5183 return WaitTimeFromHeaderBackoffStrategy( 5184 header=model.header, 5185 parameters=model.parameters or {}, 5186 config=config, 5187 regex=model.regex, 5188 max_waiting_time_in_seconds=model.max_waiting_time_in_seconds, 5189 ) 5190 5191 @staticmethod 5192 def create_wait_until_time_from_header( 5193 model: WaitUntilTimeFromHeaderModel, config: Config, **kwargs: Any 5194 ) -> WaitUntilTimeFromHeaderBackoffStrategy: 5195 return WaitUntilTimeFromHeaderBackoffStrategy( 5196 header=model.header, 5197 parameters=model.parameters or {}, 5198 config=config, 5199 min_wait=model.min_wait, 5200 regex=model.regex, 5201 max_waiting_time_in_seconds=model.max_waiting_time_in_seconds, 5202 ) 5203 5204 def get_message_repository(self) -> MessageRepository: 5205 return self._message_repository 5206 5207 def _evaluate_log_level(self, emit_connector_builder_messages: bool) -> Level: 5208 return Level.DEBUG if emit_connector_builder_messages else Level.INFO 5209 5210 @staticmethod 5211 def create_components_mapping_definition( 5212 model: ComponentMappingDefinitionModel, config: Config, **kwargs: Any 5213 ) -> ComponentMappingDefinition: 5214 interpolated_value = InterpolatedString.create( 5215 model.value, parameters=model.parameters or {} 5216 ) 5217 field_path = [ 5218 InterpolatedString.create(path, parameters=model.parameters or {}) 5219 for path in model.field_path 5220 ] 5221 return ComponentMappingDefinition( 5222 field_path=field_path, # type: ignore[arg-type] # field_path can be str and InterpolatedString 5223 value=interpolated_value, 5224 value_type=ModelToComponentFactory._json_schema_type_name_to_type(model.value_type), 5225 create_or_update=model.create_or_update, 5226 condition=model.condition, 5227 parameters=model.parameters or {}, 5228 ) 5229 5230 def create_http_components_resolver( 5231 self, model: HttpComponentsResolverModel, config: Config, stream_name: Optional[str] = None 5232 ) -> Any: 5233 partition_router = self._build_stream_slicer_from_partition_router(model.retriever, config) 5234 retriever = self._create_component_from_model( 5235 model=model.retriever, 5236 config=config, 5237 name=f"{stream_name if stream_name else '__http_components_resolver'}", 5238 primary_key=None, 5239 stream_slicer=partition_router, 5240 partition_router=partition_router, 5241 transformations=[], 5242 ) 5243 5244 components_mapping = [] 5245 for component_mapping_definition_model in model.components_mapping: 5246 if component_mapping_definition_model.condition: 5247 raise ValueError("`condition` is only supported for `ConfigComponentsResolver`") 5248 components_mapping.append( 5249 self._create_component_from_model( 5250 model=component_mapping_definition_model, 5251 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 5252 component_mapping_definition_model.value_type 5253 ), 5254 config=config, 5255 ) 5256 ) 5257 5258 return HttpComponentsResolver( 5259 retriever=retriever, 5260 # AsyncRetriever reads records only from the job slices its own slicer yields 5261 stream_slicer=retriever.stream_slicer 5262 if isinstance(retriever, AsyncRetriever) 5263 else partition_router, 5264 config=config, 5265 components_mapping=components_mapping, 5266 parameters=model.parameters or {}, 5267 ) 5268 5269 @staticmethod 5270 def create_stream_config( 5271 model: StreamConfigModel, config: Config, **kwargs: Any 5272 ) -> StreamConfig: 5273 model_configs_pointer: List[Union[InterpolatedString, str]] = ( 5274 [x for x in model.configs_pointer] if model.configs_pointer else [] 5275 ) 5276 5277 return StreamConfig( 5278 configs_pointer=model_configs_pointer, 5279 default_values=model.default_values, 5280 parameters=model.parameters or {}, 5281 ) 5282 5283 def create_config_components_resolver( 5284 self, 5285 model: ConfigComponentsResolverModel, 5286 config: Config, 5287 ) -> Any: 5288 model_stream_configs = ( 5289 model.stream_config if isinstance(model.stream_config, list) else [model.stream_config] 5290 ) 5291 5292 stream_configs = [ 5293 self._create_component_from_model( 5294 stream_config, config=config, parameters=model.parameters or {} 5295 ) 5296 for stream_config in model_stream_configs 5297 ] 5298 5299 components_mapping = [ 5300 self._create_component_from_model( 5301 model=components_mapping_definition_model, 5302 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 5303 components_mapping_definition_model.value_type 5304 ), 5305 config=config, 5306 parameters=model.parameters, 5307 ) 5308 for components_mapping_definition_model in model.components_mapping 5309 ] 5310 5311 return ConfigComponentsResolver( 5312 stream_configs=stream_configs, 5313 config=config, 5314 components_mapping=components_mapping, 5315 parameters=model.parameters or {}, 5316 ) 5317 5318 def create_parametrized_components_resolver( 5319 self, 5320 model: ParametrizedComponentsResolverModel, 5321 config: Config, 5322 ) -> ParametrizedComponentsResolver: 5323 stream_parameters = StreamParametersDefinition( 5324 list_of_parameters_for_stream=model.stream_parameters.list_of_parameters_for_stream 5325 ) 5326 5327 components_mapping = [] 5328 for components_mapping_definition_model in model.components_mapping: 5329 if components_mapping_definition_model.condition: 5330 raise ValueError("`condition` is only supported for `ConfigComponentsResolver`") 5331 components_mapping.append( 5332 self._create_component_from_model( 5333 model=components_mapping_definition_model, 5334 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 5335 components_mapping_definition_model.value_type 5336 ), 5337 config=config, 5338 ) 5339 ) 5340 return ParametrizedComponentsResolver( 5341 stream_parameters=stream_parameters, 5342 config=config, 5343 components_mapping=components_mapping, 5344 parameters=model.parameters or {}, 5345 ) 5346 5347 _UNSUPPORTED_DECODER_ERROR = ( 5348 "Specified decoder of {decoder_type} is not supported for pagination." 5349 "Please set as `JsonDecoder`, `XmlDecoder`, or a `CompositeRawDecoder` with an inner_parser of `JsonParser` or `GzipParser` instead." 5350 "If using `GzipParser`, please ensure that the lowest level inner_parser is a `JsonParser`." 5351 ) 5352 5353 def _is_supported_decoder_for_pagination(self, decoder: Decoder) -> bool: 5354 if isinstance(decoder, (JsonDecoder, XmlDecoder)): 5355 return True 5356 elif isinstance(decoder, CompositeRawDecoder): 5357 return self._is_supported_parser_for_pagination(decoder.parser) 5358 else: 5359 return False 5360 5361 def _is_supported_parser_for_pagination(self, parser: Parser) -> bool: 5362 if isinstance(parser, JsonParser): 5363 return True 5364 elif isinstance(parser, GzipParser): 5365 return isinstance(parser.inner_parser, JsonParser) 5366 else: 5367 return False 5368 5369 def create_http_api_budget( 5370 self, model: HTTPAPIBudgetModel, config: Config, **kwargs: Any 5371 ) -> HttpAPIBudget: 5372 policies = [ 5373 self._create_component_from_model(model=policy, config=config) 5374 for policy in model.policies 5375 ] 5376 5377 return HttpAPIBudget( 5378 policies=policies, 5379 ratelimit_reset_header=model.ratelimit_reset_header or "ratelimit-reset", 5380 ratelimit_remaining_header=model.ratelimit_remaining_header or "ratelimit-remaining", 5381 status_codes_for_ratelimit_hit=model.status_codes_for_ratelimit_hit or [429], 5382 ) 5383 5384 def create_fixed_window_call_rate_policy( 5385 self, model: FixedWindowCallRatePolicyModel, config: Config, **kwargs: Any 5386 ) -> FixedWindowCallRatePolicy: 5387 matchers = [ 5388 self._create_component_from_model(model=matcher, config=config) 5389 for matcher in model.matchers 5390 ] 5391 5392 # Set the initial reset timestamp to 10 days from now. 5393 # This value will be updated by the first request. 5394 return FixedWindowCallRatePolicy( 5395 next_reset_ts=datetime.datetime.now() + datetime.timedelta(days=10), 5396 period=parse_duration(model.period), 5397 call_limit=model.call_limit, 5398 matchers=matchers, 5399 ) 5400 5401 def create_file_uploader( 5402 self, model: FileUploaderModel, config: Config, **kwargs: Any 5403 ) -> FileUploader: 5404 self._reject_reduce_page_size_action(model.requester, "requester of a `file_uploader`") 5405 self._reject_split_request_window_action(model.requester, "requester of a `file_uploader`") 5406 name = "File Uploader" 5407 requester = self._create_component_from_model( 5408 model=model.requester, 5409 config=config, 5410 name=name, 5411 **kwargs, 5412 ) 5413 download_target_extractor = self._create_component_from_model( 5414 model=model.download_target_extractor, 5415 config=config, 5416 name=name, 5417 **kwargs, 5418 ) 5419 emit_connector_builder_messages = self._emit_connector_builder_messages 5420 file_uploader = DefaultFileUploader( 5421 requester=requester, 5422 download_target_extractor=download_target_extractor, 5423 config=config, 5424 file_writer=NoopFileWriter() 5425 if emit_connector_builder_messages 5426 else LocalFileSystemFileWriter(), 5427 parameters=model.parameters or {}, 5428 filename_extractor=model.filename_extractor if model.filename_extractor else None, 5429 ) 5430 5431 return ( 5432 ConnectorBuilderFileUploader(file_uploader) 5433 if emit_connector_builder_messages 5434 else file_uploader 5435 ) 5436 5437 def create_moving_window_call_rate_policy( 5438 self, model: MovingWindowCallRatePolicyModel, config: Config, **kwargs: Any 5439 ) -> MovingWindowCallRatePolicy: 5440 rates = [ 5441 self._create_component_from_model(model=rate, config=config) for rate in model.rates 5442 ] 5443 matchers = [ 5444 self._create_component_from_model(model=matcher, config=config) 5445 for matcher in model.matchers 5446 ] 5447 return MovingWindowCallRatePolicy( 5448 rates=rates, 5449 matchers=matchers, 5450 ) 5451 5452 def create_unlimited_call_rate_policy( 5453 self, model: UnlimitedCallRatePolicyModel, config: Config, **kwargs: Any 5454 ) -> UnlimitedCallRatePolicy: 5455 matchers = [ 5456 self._create_component_from_model(model=matcher, config=config) 5457 for matcher in model.matchers 5458 ] 5459 5460 return UnlimitedCallRatePolicy( 5461 matchers=matchers, 5462 ) 5463 5464 def create_rate(self, model: RateModel, config: Config, **kwargs: Any) -> Rate: 5465 interpolated_limit = InterpolatedString.create(str(model.limit), parameters={}) 5466 return Rate( 5467 limit=int(interpolated_limit.eval(config=config)), 5468 interval=parse_duration(model.interval), 5469 ) 5470 5471 def create_http_request_matcher( 5472 self, model: HttpRequestRegexMatcherModel, config: Config, **kwargs: Any 5473 ) -> HttpRequestRegexMatcher: 5474 weight = model.weight 5475 if weight is not None: 5476 if isinstance(weight, str): 5477 weight = int(InterpolatedString.create(weight, parameters={}).eval(config)) 5478 else: 5479 weight = int(weight) 5480 if weight < 1: 5481 raise ValueError(f"weight must be >= 1, got {weight}") 5482 return HttpRequestRegexMatcher( 5483 method=model.method, 5484 url_base=model.url_base, 5485 url_path_pattern=model.url_path_pattern, 5486 params=model.params, 5487 headers=model.headers, 5488 weight=weight, 5489 ) 5490 5491 def create_rate_limited_multiple_token_authenticator( 5492 self, 5493 model: RateLimitedMultipleTokenAuthenticatorModel, 5494 config: Config, 5495 **kwargs: Any, 5496 ) -> RateLimitedMultipleTokenAuthenticator: 5497 if isinstance(model.tokens, str): 5498 tokens_value = InterpolatedString.create(model.tokens, parameters={}).eval(config) 5499 delimiter = model.token_delimiter or "," 5500 tokens = [ 5501 token.strip() for token in str(tokens_value).split(delimiter) if token.strip() 5502 ] 5503 else: 5504 tokens = [ 5505 token_value 5506 for token in model.tokens 5507 if ( 5508 token_value := str( 5509 InterpolatedString.create(token, parameters={}).eval(config) 5510 ).strip() 5511 ) 5512 ] 5513 5514 quota_specs = [ 5515 { 5516 "name": quota_model.name, 5517 "remaining_path": quota_model.remaining_path, 5518 "reset_path": quota_model.reset_path, 5519 "limit_path": quota_model.limit_path, 5520 "remaining_header": quota_model.remaining_header, 5521 "reset_header": quota_model.reset_header, 5522 "limit_header": quota_model.limit_header, 5523 # Normalize the same way as the runtime TokenQuota below, so an omitted field 5524 # and an explicit `[]` key identically and keep sharing one set of counters. 5525 "exhaustion_status_codes": quota_model.exhaustion_status_codes or [], 5526 "matchers": [ 5527 { 5528 "method": matcher_model.method, 5529 "url_base": matcher_model.url_base, 5530 "url_path_pattern": matcher_model.url_path_pattern, 5531 "params": matcher_model.params, 5532 "headers": matcher_model.headers, 5533 "weight": matcher_model.weight, 5534 } 5535 for matcher_model in quota_model.matchers or [] 5536 ], 5537 } 5538 for quota_model in model.quotas 5539 ] 5540 5541 quota_status_url = str( 5542 InterpolatedString.create(model.quota_status_source.url, parameters={}).eval(config) 5543 ) 5544 quota_status_http_method = ( 5545 model.quota_status_source.http_method.value 5546 if model.quota_status_source.http_method 5547 else "GET" 5548 ) 5549 quota_status_headers = { 5550 key: str(InterpolatedString.create(value, parameters={}).eval(config)) 5551 for key, value in (model.quota_status_source.request_headers or {}).items() 5552 } 5553 # Normalize the same way as the quota specs above, so an omitted field and an explicit 5554 # `[]` key identically and keep sharing one set of counters. Deduplicated as well as 5555 # sorted, because the runtime turns this into a set: without it `[404]` and `[404, 404]` 5556 # would key differently and stop sharing counters while behaving identically. 5557 quota_status_unavailable_status_codes = sorted( 5558 set(model.quota_status_source.unavailable_status_codes or []) 5559 ) 5560 auth_method = model.auth_method or "Bearer" 5561 header = model.header or "Authorization" 5562 max_wait_time_str = str( 5563 InterpolatedString.create(model.max_wait_time or "PT2H", parameters={}).eval(config) 5564 ) 5565 max_wait_time = parse_duration(max_wait_time_str) 5566 if not isinstance(max_wait_time, datetime.timedelta): 5567 raise ValueError( 5568 f"max_wait_time must be a fixed-length ISO 8601 duration (e.g. 'PT2H'); " 5569 f"calendar-unit durations like '{max_wait_time_str}' are not supported" 5570 ) 5571 budget_reserve_fraction = ( 5572 model.budget_reserve_fraction if model.budget_reserve_fraction is not None else 0.1 5573 ) 5574 budget_min_reserve = ( 5575 model.budget_min_reserve if model.budget_min_reserve is not None else 50 5576 ) 5577 5578 # Reuse the same instance for identical definitions so that all streams share the 5579 # same token quota counters (similar to how api_budget is shared). The key is built 5580 # from the resolved constructor arguments rather than the raw model so that 5581 # stream-specific `$parameters` propagated onto the model (and its nested components) 5582 # cannot break instance sharing. 5583 cache_key = json.dumps( 5584 { 5585 "tokens": tokens, 5586 "quotas": quota_specs, 5587 "quota_status_url": quota_status_url, 5588 "quota_status_http_method": quota_status_http_method, 5589 "quota_status_headers": quota_status_headers, 5590 "quota_status_unavailable_status_codes": quota_status_unavailable_status_codes, 5591 "auth_method": auth_method, 5592 "header": header, 5593 "max_wait_time": max_wait_time.total_seconds(), 5594 "budget_reserve_fraction": budget_reserve_fraction, 5595 "budget_min_reserve": budget_min_reserve, 5596 }, 5597 sort_keys=True, 5598 ) 5599 if cache_key in self._rate_limited_authenticators: 5600 return self._rate_limited_authenticators[cache_key] 5601 5602 quotas = [ 5603 TokenQuota( 5604 name=quota_model.name, 5605 remaining_path=quota_model.remaining_path, 5606 reset_path=quota_model.reset_path, 5607 limit_path=quota_model.limit_path, 5608 remaining_header=quota_model.remaining_header, 5609 reset_header=quota_model.reset_header, 5610 limit_header=quota_model.limit_header, 5611 exhaustion_status_codes=quota_model.exhaustion_status_codes or [], 5612 matchers=[ 5613 self.create_http_request_matcher(matcher_model, config) 5614 for matcher_model in quota_model.matchers or [] 5615 ], 5616 ) 5617 for quota_model in model.quotas 5618 ] 5619 5620 authenticator = RateLimitedMultipleTokenAuthenticator( 5621 tokens=tokens, 5622 quotas=quotas, 5623 quota_status_url=quota_status_url, 5624 quota_status_http_method=quota_status_http_method, 5625 quota_status_headers=quota_status_headers, 5626 quota_status_unavailable_status_codes=quota_status_unavailable_status_codes, 5627 auth_method=auth_method, 5628 header=header, 5629 max_wait_time=max_wait_time, 5630 budget_reserve_fraction=budget_reserve_fraction, 5631 budget_min_reserve=budget_min_reserve, 5632 ) 5633 self._rate_limited_authenticators[cache_key] = authenticator 5634 return authenticator 5635 5636 def set_api_budget(self, component_definition: ComponentDefinition, config: Config) -> None: 5637 self._api_budget = self.create_component( 5638 model_type=HTTPAPIBudgetModel, component_definition=component_definition, config=config 5639 ) 5640 5641 def create_grouping_partition_router( 5642 self, 5643 model: GroupingPartitionRouterModel, 5644 config: Config, 5645 *, 5646 stream_name: str, 5647 **kwargs: Any, 5648 ) -> GroupingPartitionRouter: 5649 underlying_router = self._create_component_from_model( 5650 model=model.underlying_partition_router, 5651 config=config, 5652 stream_name=stream_name, 5653 **kwargs, 5654 ) 5655 if model.group_size < 1: 5656 raise ValueError(f"Group size must be greater than 0, got {model.group_size}") 5657 5658 # Request options in underlying partition routers are not supported for GroupingPartitionRouter 5659 # because they are specific to individual partitions and cannot be aggregated or handled 5660 # when grouping, potentially leading to incorrect API calls. Any request customization 5661 # should be managed at the stream level through the requester's configuration. 5662 if isinstance(underlying_router, SubstreamPartitionRouter): 5663 if any( 5664 parent_config.request_option 5665 for parent_config in underlying_router.parent_stream_configs 5666 ): 5667 raise ValueError("Request options are not supported for GroupingPartitionRouter.") 5668 5669 if isinstance(underlying_router, ListPartitionRouter): 5670 if underlying_router.request_option: 5671 raise ValueError("Request options are not supported for GroupingPartitionRouter.") 5672 5673 return GroupingPartitionRouter( 5674 group_size=model.group_size, 5675 underlying_partition_router=underlying_router, 5676 deduplicate=model.deduplicate if model.deduplicate is not None else True, 5677 config=config, 5678 ) 5679 5680 def create_union_partition_router( 5681 self, 5682 model: UnionPartitionRouterModel, 5683 config: Config, 5684 *, 5685 stream_name: str, 5686 **kwargs: Any, 5687 ) -> UnionPartitionRouter: 5688 # The schema enforces minItems: 2 for manifests; this guard covers construction paths 5689 # that bypass JSON-schema validation (the generated model carries no min_items constraint). 5690 if len(model.partition_routers) < 2: 5691 raise ValueError( 5692 f"UnionPartitionRouter for stream {stream_name} needs at least 2 child partition routers" 5693 ) 5694 5695 partition_routers = [ 5696 self._create_component_from_model( 5697 model=child, 5698 config=config, 5699 stream_name=stream_name, 5700 **kwargs, 5701 ) 5702 for child in model.partition_routers 5703 ] 5704 5705 # partition_field depends only on config/parameters, so it is evaluated once at build 5706 # time; the runtime component always receives a plain string. 5707 partition_field = InterpolatedString.create( 5708 model.partition_field, parameters=model.parameters or {} 5709 ).eval(config) 5710 5711 # Fail fast at build time when a built-in child router is statically known to emit a 5712 # partition field different from the union's. CustomPartitionRouter children are opaque 5713 # and can only be validated at runtime. 5714 for child_model in model.partition_routers: 5715 child_partition_fields: List[str] = [] 5716 if isinstance(child_model, ListPartitionRouterModel): 5717 child_partition_fields.append( 5718 InterpolatedString.create( 5719 child_model.cursor_field, parameters=child_model.parameters or {} 5720 ).eval(config) 5721 ) 5722 elif isinstance(child_model, SubstreamPartitionRouterModel): 5723 for parent_stream_config in child_model.parent_stream_configs: 5724 child_partition_fields.append( 5725 InterpolatedString.create( 5726 parent_stream_config.partition_field, 5727 parameters=parent_stream_config.parameters 5728 or child_model.parameters 5729 or {}, 5730 ).eval(config) 5731 ) 5732 elif isinstance(child_model, UnionPartitionRouterModel): 5733 child_partition_fields.append( 5734 InterpolatedString.create( 5735 child_model.partition_field, parameters=child_model.parameters or {} 5736 ).eval(config) 5737 ) 5738 for child_partition_field in child_partition_fields: 5739 if child_partition_field != partition_field: 5740 raise ValueError( 5741 f"UnionPartitionRouter expects all child partition routers to emit the " 5742 f"partition field '{partition_field}', but a " 5743 f"{child_model.type} child emits '{child_partition_field}'." 5744 ) 5745 5746 # A union slice comes from exactly one child partition router, so request options 5747 # declared on children cannot be applied consistently to requests built from the 5748 # normalized union slices. Partition values should be consumed via interpolation 5749 # (e.g. stream_partition) instead. Note that this validation only covers built-in 5750 # router types; CustomPartitionRouter children are opaque, so any request options 5751 # they implement internally cannot be detected or rejected here. 5752 for router in partition_routers: 5753 if isinstance(router, SubstreamPartitionRouter): 5754 if any( 5755 parent_config.request_option for parent_config in router.parent_stream_configs 5756 ): 5757 raise ValueError("Request options are not supported for UnionPartitionRouter.") 5758 if isinstance(router, ListPartitionRouter) and router.request_option: 5759 raise ValueError("Request options are not supported for UnionPartitionRouter.") 5760 5761 return UnionPartitionRouter( 5762 partition_routers=partition_routers, 5763 partition_field=partition_field, 5764 parameters=model.parameters or {}, 5765 ) 5766 5767 def _ensure_query_properties_to_model( 5768 self, requester: Union[HttpRequesterModel, CustomRequesterModel] 5769 ) -> None: 5770 """ 5771 For some reason, it seems like CustomRequesterModel request_parameters stays as dictionaries which means that 5772 the other conditions relying on it being QueryPropertiesModel instead of a dict fail. Here, we migrate them to 5773 proper model. 5774 """ 5775 if not hasattr(requester, "request_parameters"): 5776 return 5777 5778 request_parameters = requester.request_parameters 5779 if request_parameters and isinstance(request_parameters, Dict): 5780 for request_parameter_key in request_parameters.keys(): 5781 request_parameter = request_parameters[request_parameter_key] 5782 if ( 5783 isinstance(request_parameter, Dict) 5784 and request_parameter.get("type") == "QueryProperties" 5785 ): 5786 request_parameters[request_parameter_key] = QueryPropertiesModel.parse_obj( 5787 request_parameter 5788 ) 5789 5790 def _get_catalog_defined_cursor_field( 5791 self, stream_name: str, allow_catalog_defined_cursor_field: bool 5792 ) -> Optional[CursorField]: 5793 if not allow_catalog_defined_cursor_field: 5794 return None 5795 5796 configured_stream = self._stream_name_to_configured_stream.get(stream_name) 5797 5798 # Depending on the operation is being performed, there may not be a configured stream yet. In this 5799 # case we return None which will then use the default cursor field defined on the cursor model. 5800 # We also treat cursor_field: [""] (list with empty string) as no cursor field, since this can 5801 # occur when the platform serializes "no cursor configured" streams incorrectly. 5802 if ( 5803 not configured_stream 5804 or not configured_stream.cursor_field 5805 or not configured_stream.cursor_field[0] 5806 ): 5807 return None 5808 elif len(configured_stream.cursor_field) > 1: 5809 raise ValueError( 5810 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." 5811 ) 5812 else: 5813 return CursorField( 5814 cursor_field_key=configured_stream.cursor_field[0], 5815 supports_catalog_defined_cursor_field=allow_catalog_defined_cursor_field, 5816 )
752class ModelToComponentFactory: 753 EPOCH_DATETIME_FORMAT = "%s" 754 755 def __init__( 756 self, 757 limit_pages_fetched_per_slice: Optional[int] = None, 758 limit_slices_fetched: Optional[int] = None, 759 emit_connector_builder_messages: bool = False, 760 disable_retries: bool = False, 761 disable_cache: bool = False, 762 message_repository: Optional[MessageRepository] = None, 763 connector_state_manager: Optional[ConnectorStateManager] = None, 764 max_concurrent_async_job_count: Optional[int] = None, 765 configured_catalog: Optional[ConfiguredAirbyteCatalog] = None, 766 api_budget: Optional[APIBudget] = None, 767 rate_limited_authenticators: Optional[ 768 Dict[str, RateLimitedMultipleTokenAuthenticator] 769 ] = None, 770 custom_components_trusted: bool = True, 771 ): 772 self._init_mappings() 773 self._custom_components_trusted = custom_components_trusted 774 self._limit_pages_fetched_per_slice = limit_pages_fetched_per_slice 775 self._limit_slices_fetched = limit_slices_fetched 776 self._emit_connector_builder_messages = emit_connector_builder_messages 777 self._disable_retries = disable_retries 778 self._disable_cache = disable_cache 779 self._message_repository = message_repository or InMemoryMessageRepository( 780 self._evaluate_log_level(emit_connector_builder_messages) 781 ) 782 self._stream_name_to_configured_stream = self._create_stream_name_to_configured_stream( 783 configured_catalog 784 ) 785 self._connector_state_manager = connector_state_manager or ConnectorStateManager() 786 self._api_budget: Optional[Union[APIBudget]] = api_budget 787 # Shared instances so all streams see the same token quota counters (like api_budget) 788 self._rate_limited_authenticators: Dict[str, RateLimitedMultipleTokenAuthenticator] = ( 789 rate_limited_authenticators if rate_limited_authenticators is not None else {} 790 ) 791 self._job_tracker: JobTracker = JobTracker(max_concurrent_async_job_count or 1) 792 # placeholder for deprecation warnings 793 self._collected_deprecation_logs: List[ConnectorBuilderLogMessage] = [] 794 795 def _init_mappings(self) -> None: 796 self.PYDANTIC_MODEL_TO_CONSTRUCTOR: Mapping[Type[BaseModel], Callable[..., Any]] = { 797 AddedFieldDefinitionModel: self.create_added_field_definition, 798 AddFieldsModel: self.create_add_fields, 799 ApiKeyAuthenticatorModel: self.create_api_key_authenticator, 800 BasicHttpAuthenticatorModel: self.create_basic_http_authenticator, 801 BearerAuthenticatorModel: self.create_bearer_authenticator, 802 CheckStreamModel: self.create_check_stream, 803 DynamicStreamCheckConfigModel: self.create_dynamic_stream_check_config, 804 CheckDynamicStreamModel: self.create_check_dynamic_stream, 805 CompositeErrorHandlerModel: self.create_composite_error_handler, 806 ConcurrencyLevelModel: self.create_concurrency_level, 807 ConfigMigrationModel: self.create_config_migration, 808 ConfigAddFieldsModel: self.create_config_add_fields, 809 ConfigRemapFieldModel: self.create_config_remap_field, 810 ConfigRemoveFieldsModel: self.create_config_remove_fields, 811 ConstantBackoffStrategyModel: self.create_constant_backoff_strategy, 812 CsvDecoderModel: self.create_csv_decoder, 813 CursorPaginationModel: self.create_cursor_pagination, 814 CustomAuthenticatorModel: self.create_custom_component, 815 CustomBackoffStrategyModel: self.create_custom_component, 816 CustomDecoderModel: self.create_custom_component, 817 CustomErrorHandlerModel: self.create_custom_component, 818 CustomRecordExtractorModel: self.create_custom_component, 819 CustomRecordFilterModel: self.create_custom_component, 820 CustomRequesterModel: self.create_custom_component, 821 CustomRetrieverModel: self.create_custom_component, 822 CustomSchemaLoader: self.create_custom_component, 823 CustomSchemaNormalizationModel: self.create_custom_component, 824 CustomStateMigration: self.create_custom_component, 825 CustomPaginationStrategyModel: self.create_custom_component, 826 CustomPartitionRouterModel: self.create_custom_component, 827 CustomTransformationModel: self.create_custom_component, 828 CustomValidationStrategyModel: self.create_custom_component, 829 CustomConfigTransformationModel: self.create_custom_component, 830 DeclarativeStreamModel: self.create_default_stream, 831 DefaultErrorHandlerModel: self.create_default_error_handler, 832 DefaultPaginatorModel: self.create_default_paginator, 833 CombinedExtractorModel: self.create_combined_extractor, 834 DpathExtractorModel: self.create_dpath_extractor, 835 DpathValidatorModel: self.create_dpath_validator, 836 ResponseToFileExtractorModel: self.create_response_to_file_extractor, 837 ExponentialBackoffStrategyModel: self.create_exponential_backoff_strategy, 838 SessionTokenAuthenticatorModel: self.create_session_token_authenticator, 839 GroupByKeyMergeStrategyModel: self.create_group_by_key, 840 HttpRequesterModel: self.create_http_requester, 841 HttpResponseFilterModel: self.create_http_response_filter, 842 InlineSchemaLoaderModel: self.create_inline_schema_loader, 843 JsonDecoderModel: self.create_json_decoder, 844 JsonItemsDecoderModel: self.create_json_items_decoder, 845 JsonlDecoderModel: self.create_jsonl_decoder, 846 JsonSchemaPropertySelectorModel: self.create_json_schema_property_selector, 847 GzipDecoderModel: self.create_gzip_decoder, 848 KeysToLowerModel: self.create_keys_to_lower_transformation, 849 KeysToSnakeCaseModel: self.create_keys_to_snake_transformation, 850 KeysReplaceModel: self.create_keys_replace_transformation, 851 FlattenFieldsModel: self.create_flatten_fields, 852 DpathFlattenFieldsModel: self.create_dpath_flatten_fields, 853 IterableDecoderModel: self.create_iterable_decoder, 854 XmlDecoderModel: self.create_xml_decoder, 855 JsonFileSchemaLoaderModel: self.create_json_file_schema_loader, 856 DynamicSchemaLoaderModel: self.create_dynamic_schema_loader, 857 SchemaTypeIdentifierModel: self.create_schema_type_identifier, 858 TypesMapModel: self.create_types_map, 859 ComplexFieldTypeModel: self.create_complex_field_type, 860 JwtAuthenticatorModel: self.create_jwt_authenticator, 861 LegacyToPerPartitionStateMigrationModel: self.create_legacy_to_per_partition_state_migration, 862 ListPartitionRouterModel: self.create_list_partition_router, 863 MinMaxDatetimeModel: self.create_min_max_datetime, 864 NoAuthModel: self.create_no_auth, 865 NoPaginationModel: self.create_no_pagination, 866 OAuthAuthenticatorModel: self.create_oauth_authenticator, 867 OffsetIncrementModel: self.create_offset_increment, 868 PageIncrementModel: self.create_page_increment, 869 ParentStreamConfigModel: self.create_parent_stream_config_with_substream_wrapper, 870 PredicateValidatorModel: self.create_predicate_validator, 871 PropertiesFromEndpointModel: self.create_properties_from_endpoint, 872 PropertyChunkingModel: self.create_property_chunking, 873 QueryPropertiesModel: self.create_query_properties, 874 ParentFieldPathModel: self.create_parent_field_path, 875 RecordExpanderModel: self.create_record_expander, 876 RecordFilterModel: self.create_record_filter, 877 RecordSelectorModel: self.create_record_selector, 878 RemoveFieldsModel: self.create_remove_fields, 879 RequestPathModel: self.create_request_path, 880 RequestOptionModel: self.create_request_option, 881 LegacySessionTokenAuthenticatorModel: self.create_legacy_session_token_authenticator, 882 SelectiveAuthenticatorModel: self.create_selective_authenticator, 883 SimpleRetrieverModel: self.create_simple_retriever, 884 StateDelegatingStreamModel: self.create_state_delegating_stream, 885 SpecModel: self.create_spec, 886 SubstreamPartitionRouterModel: self.create_substream_partition_router, 887 ValidateAdheresToSchemaModel: self.create_validate_adheres_to_schema, 888 WaitTimeFromHeaderModel: self.create_wait_time_from_header, 889 WaitUntilTimeFromHeaderModel: self.create_wait_until_time_from_header, 890 AsyncRetrieverModel: self.create_async_retriever, 891 HttpComponentsResolverModel: self.create_http_components_resolver, 892 ConfigComponentsResolverModel: self.create_config_components_resolver, 893 ParametrizedComponentsResolverModel: self.create_parametrized_components_resolver, 894 StreamConfigModel: self.create_stream_config, 895 ComponentMappingDefinitionModel: self.create_components_mapping_definition, 896 ZipfileDecoderModel: self.create_zipfile_decoder, 897 HTTPAPIBudgetModel: self.create_http_api_budget, 898 FileUploaderModel: self.create_file_uploader, 899 FixedWindowCallRatePolicyModel: self.create_fixed_window_call_rate_policy, 900 MovingWindowCallRatePolicyModel: self.create_moving_window_call_rate_policy, 901 UnlimitedCallRatePolicyModel: self.create_unlimited_call_rate_policy, 902 RateModel: self.create_rate, 903 HttpRequestRegexMatcherModel: self.create_http_request_matcher, 904 RateLimitedMultipleTokenAuthenticatorModel: self.create_rate_limited_multiple_token_authenticator, 905 GroupingPartitionRouterModel: self.create_grouping_partition_router, 906 UnionPartitionRouterModel: self.create_union_partition_router, 907 } 908 909 # Needed for the case where we need to perform a second parse on the fields of a custom component 910 self.TYPE_NAME_TO_MODEL = {cls.__name__: cls for cls in self.PYDANTIC_MODEL_TO_CONSTRUCTOR} 911 912 @staticmethod 913 def _create_stream_name_to_configured_stream( 914 configured_catalog: Optional[ConfiguredAirbyteCatalog], 915 ) -> Mapping[str, ConfiguredAirbyteStream]: 916 return ( 917 {stream.stream.name: stream for stream in configured_catalog.streams} 918 if configured_catalog 919 else {} 920 ) 921 922 def create_component( 923 self, 924 model_type: Type[BaseModel], 925 component_definition: ComponentDefinition, 926 config: Config, 927 **kwargs: Any, 928 ) -> Any: 929 """ 930 Takes a given Pydantic model type and Mapping representing a component definition and creates a declarative component and 931 subcomponents which will be used at runtime. This is done by first parsing the mapping into a Pydantic model and then creating 932 creating declarative components from that model. 933 934 :param model_type: The type of declarative component that is being initialized 935 :param component_definition: The mapping that represents a declarative component 936 :param config: The connector config that is provided by the customer 937 :return: The declarative component to be used at runtime 938 """ 939 940 component_type = component_definition.get("type") 941 if component_definition.get("type") != model_type.__name__: 942 raise ValueError( 943 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 944 ) 945 946 declarative_component_model = model_type.parse_obj(component_definition) 947 948 if not isinstance(declarative_component_model, model_type): 949 raise ValueError( 950 f"Expected {model_type.__name__} component, but received {declarative_component_model.__class__.__name__}" 951 ) 952 953 return self._create_component_from_model( 954 model=declarative_component_model, config=config, **kwargs 955 ) 956 957 def _create_component_from_model(self, model: BaseModel, config: Config, **kwargs: Any) -> Any: 958 if model.__class__ not in self.PYDANTIC_MODEL_TO_CONSTRUCTOR: 959 raise ValueError( 960 f"{model.__class__} with attributes {model} is not a valid component type" 961 ) 962 component_constructor = self.PYDANTIC_MODEL_TO_CONSTRUCTOR.get(model.__class__) 963 if not component_constructor: 964 raise ValueError(f"Could not find constructor for {model.__class__}") 965 966 # collect deprecation warnings for supported models. 967 if isinstance(model, BaseModelWithDeprecations): 968 self._collect_model_deprecations(model) 969 970 return component_constructor(model=model, config=config, **kwargs) 971 972 def get_model_deprecations(self) -> List[ConnectorBuilderLogMessage]: 973 """ 974 Returns the deprecation warnings that were collected during the creation of components. 975 """ 976 return self._collected_deprecation_logs 977 978 def _collect_model_deprecations(self, model: BaseModelWithDeprecations) -> None: 979 """ 980 Collects deprecation logs from the given model and appends any new logs to the internal collection. 981 982 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. 983 984 Args: 985 model (BaseModelWithDeprecations): The model instance from which to collect deprecation logs. 986 """ 987 if hasattr(model, DEPRECATION_LOGS_TAG) and model._deprecation_logs is not None: 988 for log in model._deprecation_logs: 989 # avoid duplicates for deprecation logs observed. 990 if log not in self._collected_deprecation_logs: 991 self._collected_deprecation_logs.append(log) 992 993 def create_config_migration( 994 self, model: ConfigMigrationModel, config: Config 995 ) -> ConfigMigration: 996 transformations: List[ConfigTransformation] = [ 997 self._create_component_from_model(transformation, config) 998 for transformation in model.transformations 999 ] 1000 1001 return ConfigMigration( 1002 description=model.description, 1003 transformations=transformations, 1004 ) 1005 1006 def create_config_add_fields( 1007 self, model: ConfigAddFieldsModel, config: Config, **kwargs: Any 1008 ) -> ConfigAddFields: 1009 fields = [self._create_component_from_model(field, config) for field in model.fields] 1010 return ConfigAddFields( 1011 fields=fields, 1012 condition=model.condition or "", 1013 ) 1014 1015 @staticmethod 1016 def create_config_remove_fields( 1017 model: ConfigRemoveFieldsModel, config: Config, **kwargs: Any 1018 ) -> ConfigRemoveFields: 1019 return ConfigRemoveFields( 1020 field_pointers=model.field_pointers, 1021 condition=model.condition or "", 1022 ) 1023 1024 @staticmethod 1025 def create_config_remap_field( 1026 model: ConfigRemapFieldModel, config: Config, **kwargs: Any 1027 ) -> ConfigRemapField: 1028 mapping = cast(Mapping[str, Any], model.map) 1029 return ConfigRemapField( 1030 map=mapping, 1031 field_path=model.field_path, 1032 config=config, 1033 ) 1034 1035 def create_dpath_validator(self, model: DpathValidatorModel, config: Config) -> DpathValidator: 1036 strategy = self._create_component_from_model(model.validation_strategy, config) 1037 1038 return DpathValidator( 1039 field_path=model.field_path, 1040 strategy=strategy, 1041 ) 1042 1043 def create_predicate_validator( 1044 self, model: PredicateValidatorModel, config: Config 1045 ) -> PredicateValidator: 1046 strategy = self._create_component_from_model(model.validation_strategy, config) 1047 1048 return PredicateValidator( 1049 value=model.value, 1050 strategy=strategy, 1051 ) 1052 1053 @staticmethod 1054 def create_validate_adheres_to_schema( 1055 model: ValidateAdheresToSchemaModel, config: Config, **kwargs: Any 1056 ) -> ValidateAdheresToSchema: 1057 base_schema = cast(Mapping[str, Any], model.base_schema) 1058 return ValidateAdheresToSchema( 1059 schema=base_schema, 1060 ) 1061 1062 @staticmethod 1063 def create_added_field_definition( 1064 model: AddedFieldDefinitionModel, config: Config, **kwargs: Any 1065 ) -> AddedFieldDefinition: 1066 interpolated_value = InterpolatedString.create( 1067 model.value, parameters=model.parameters or {} 1068 ) 1069 return AddedFieldDefinition( 1070 path=model.path, 1071 value=interpolated_value, 1072 value_type=ModelToComponentFactory._json_schema_type_name_to_type(model.value_type), 1073 parameters=model.parameters or {}, 1074 ) 1075 1076 def create_add_fields(self, model: AddFieldsModel, config: Config, **kwargs: Any) -> AddFields: 1077 added_field_definitions = [ 1078 self._create_component_from_model( 1079 model=added_field_definition_model, 1080 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 1081 added_field_definition_model.value_type 1082 ), 1083 config=config, 1084 ) 1085 for added_field_definition_model in model.fields 1086 ] 1087 return AddFields( 1088 fields=added_field_definitions, 1089 condition=model.condition or "", 1090 parameters=model.parameters or {}, 1091 ) 1092 1093 def create_keys_to_lower_transformation( 1094 self, model: KeysToLowerModel, config: Config, **kwargs: Any 1095 ) -> KeysToLowerTransformation: 1096 return KeysToLowerTransformation() 1097 1098 def create_keys_to_snake_transformation( 1099 self, model: KeysToSnakeCaseModel, config: Config, **kwargs: Any 1100 ) -> KeysToSnakeCaseTransformation: 1101 return KeysToSnakeCaseTransformation() 1102 1103 def create_keys_replace_transformation( 1104 self, model: KeysReplaceModel, config: Config, **kwargs: Any 1105 ) -> KeysReplaceTransformation: 1106 return KeysReplaceTransformation( 1107 old=model.old, new=model.new, parameters=model.parameters or {} 1108 ) 1109 1110 def create_flatten_fields( 1111 self, model: FlattenFieldsModel, config: Config, **kwargs: Any 1112 ) -> FlattenFields: 1113 return FlattenFields( 1114 flatten_lists=model.flatten_lists if model.flatten_lists is not None else True 1115 ) 1116 1117 def create_dpath_flatten_fields( 1118 self, model: DpathFlattenFieldsModel, config: Config, **kwargs: Any 1119 ) -> DpathFlattenFields: 1120 model_field_path: List[Union[InterpolatedString, str]] = [x for x in model.field_path] 1121 key_transformation = ( 1122 KeyTransformation( 1123 config=config, 1124 prefix=model.key_transformation.prefix, 1125 suffix=model.key_transformation.suffix, 1126 parameters=model.parameters or {}, 1127 ) 1128 if model.key_transformation is not None 1129 else None 1130 ) 1131 return DpathFlattenFields( 1132 config=config, 1133 field_path=model_field_path, 1134 delete_origin_value=model.delete_origin_value 1135 if model.delete_origin_value is not None 1136 else False, 1137 replace_record=model.replace_record if model.replace_record is not None else False, 1138 key_transformation=key_transformation, 1139 parameters=model.parameters or {}, 1140 ) 1141 1142 @staticmethod 1143 def _json_schema_type_name_to_type(value_type: Optional[ValueType]) -> Optional[Type[Any]]: 1144 if not value_type: 1145 return None 1146 names_to_types = { 1147 ValueType.string: str, 1148 ValueType.number: float, 1149 ValueType.integer: int, 1150 ValueType.boolean: bool, 1151 } 1152 return names_to_types[value_type] 1153 1154 def create_api_key_authenticator( 1155 self, 1156 model: ApiKeyAuthenticatorModel, 1157 config: Config, 1158 token_provider: Optional[TokenProvider] = None, 1159 **kwargs: Any, 1160 ) -> ApiKeyAuthenticator: 1161 if model.inject_into is None and model.header is None: 1162 raise ValueError( 1163 "Expected either inject_into or header to be set for ApiKeyAuthenticator" 1164 ) 1165 1166 if model.inject_into is not None and model.header is not None: 1167 raise ValueError( 1168 "inject_into and header cannot be set both for ApiKeyAuthenticator - remove the deprecated header option" 1169 ) 1170 1171 if token_provider is not None and model.api_token != "": 1172 raise ValueError( 1173 "If token_provider is set, api_token is ignored and has to be set to empty string." 1174 ) 1175 1176 request_option = ( 1177 self._create_component_from_model( 1178 model.inject_into, config, parameters=model.parameters or {} 1179 ) 1180 if model.inject_into 1181 else RequestOption( 1182 inject_into=RequestOptionType.header, 1183 field_name=model.header or "", 1184 parameters=model.parameters or {}, 1185 ) 1186 ) 1187 1188 return ApiKeyAuthenticator( 1189 token_provider=( 1190 token_provider 1191 if token_provider is not None 1192 else InterpolatedStringTokenProvider( 1193 api_token=model.api_token or "", 1194 config=config, 1195 parameters=model.parameters or {}, 1196 ) 1197 ), 1198 request_option=request_option, 1199 config=config, 1200 parameters=model.parameters or {}, 1201 ) 1202 1203 def create_legacy_to_per_partition_state_migration( 1204 self, 1205 model: LegacyToPerPartitionStateMigrationModel, 1206 config: Mapping[str, Any], 1207 declarative_stream: DeclarativeStreamModel, 1208 ) -> LegacyToPerPartitionStateMigration: 1209 retriever = declarative_stream.retriever 1210 if not isinstance(retriever, (SimpleRetrieverModel, AsyncRetrieverModel)): 1211 raise ValueError( 1212 f"LegacyToPerPartitionStateMigrations can only be applied on a DeclarativeStream with a SimpleRetriever or AsyncRetriever. Got {type(retriever)}" 1213 ) 1214 partition_router = retriever.partition_router 1215 if not isinstance( 1216 partition_router, 1217 ( 1218 SubstreamPartitionRouterModel, 1219 CustomPartitionRouterModel, 1220 UnionPartitionRouterModel, 1221 ), 1222 ): 1223 raise ValueError( 1224 f"LegacyToPerPartitionStateMigrations can only be applied on a SimpleRetriever with a SubstreamPartitionRouter, UnionPartitionRouter or CustomPartitionRouter. Got {type(partition_router)}" 1225 ) 1226 if not isinstance(partition_router, UnionPartitionRouterModel) and not hasattr( 1227 partition_router, "parent_stream_configs" 1228 ): 1229 raise ValueError( 1230 "LegacyToPerPartitionStateMigrations can only be applied with a parent stream configuration." 1231 ) 1232 1233 if not hasattr(declarative_stream, "incremental_sync"): 1234 raise ValueError( 1235 "LegacyToPerPartitionStateMigrations can only be applied with an incremental_sync configuration." 1236 ) 1237 1238 return LegacyToPerPartitionStateMigration( 1239 partition_router, # type: ignore # was already checked above 1240 declarative_stream.incremental_sync, # type: ignore # was already checked. Migration can be applied only to incremental streams. 1241 config, 1242 declarative_stream.parameters, # type: ignore # different type is expected here Mapping[str, Any], got Dict[str, Any] 1243 ) 1244 1245 def create_session_token_authenticator( 1246 self, model: SessionTokenAuthenticatorModel, config: Config, *, name: str, **kwargs: Any 1247 ) -> Union[ApiKeyAuthenticator, BearerAuthenticator]: 1248 self._reject_reduce_page_size_action( 1249 model.login_requester, f"`login_requester` of the SessionTokenAuthenticator of {name}" 1250 ) 1251 self._reject_split_request_window_action( 1252 model.login_requester, f"`login_requester` of the SessionTokenAuthenticator of {name}" 1253 ) 1254 decoder = ( 1255 self._create_component_from_model(model=model.decoder, config=config) 1256 if model.decoder 1257 else JsonDecoder(parameters={}) 1258 ) 1259 login_requester = self._create_component_from_model( 1260 model=model.login_requester, 1261 config=config, 1262 name=f"{name}_login_requester", 1263 decoder=decoder, 1264 ) 1265 token_provider = SessionTokenProvider( 1266 login_requester=login_requester, 1267 session_token_path=model.session_token_path, 1268 expiration_duration=parse_duration(model.expiration_duration) 1269 if model.expiration_duration 1270 else None, 1271 parameters=model.parameters or {}, 1272 message_repository=self._message_repository, 1273 decoder=decoder, 1274 ) 1275 if model.request_authentication.type == "Bearer": 1276 return ModelToComponentFactory.create_bearer_authenticator( 1277 BearerAuthenticatorModel(type="BearerAuthenticator", api_token=""), # type: ignore # $parameters has a default value 1278 config, 1279 token_provider=token_provider, 1280 ) 1281 else: 1282 # Get the api_token template if specified, default to just the session token 1283 api_token_template = ( 1284 getattr(model.request_authentication, "api_token", None) or "{{ session_token }}" 1285 ) 1286 final_token_provider: TokenProvider = InterpolatedSessionTokenProvider( 1287 config=config, 1288 api_token=api_token_template, 1289 session_token_provider=token_provider, 1290 parameters=model.parameters or {}, 1291 ) 1292 return self.create_api_key_authenticator( 1293 ApiKeyAuthenticatorModel( 1294 type="ApiKeyAuthenticator", 1295 api_token="", 1296 inject_into=model.request_authentication.inject_into, 1297 ), # type: ignore # $parameters and headers default to None 1298 config=config, 1299 token_provider=final_token_provider, 1300 ) 1301 1302 @staticmethod 1303 def create_basic_http_authenticator( 1304 model: BasicHttpAuthenticatorModel, config: Config, **kwargs: Any 1305 ) -> BasicHttpAuthenticator: 1306 return BasicHttpAuthenticator( 1307 password=model.password or "", 1308 username=model.username, 1309 config=config, 1310 parameters=model.parameters or {}, 1311 ) 1312 1313 @staticmethod 1314 def create_bearer_authenticator( 1315 model: BearerAuthenticatorModel, 1316 config: Config, 1317 token_provider: Optional[TokenProvider] = None, 1318 **kwargs: Any, 1319 ) -> BearerAuthenticator: 1320 if token_provider is not None and model.api_token != "": 1321 raise ValueError( 1322 "If token_provider is set, api_token is ignored and has to be set to empty string." 1323 ) 1324 return BearerAuthenticator( 1325 token_provider=( 1326 token_provider 1327 if token_provider is not None 1328 else InterpolatedStringTokenProvider( 1329 api_token=model.api_token or "", 1330 config=config, 1331 parameters=model.parameters or {}, 1332 ) 1333 ), 1334 config=config, 1335 parameters=model.parameters or {}, 1336 ) 1337 1338 @staticmethod 1339 def create_dynamic_stream_check_config( 1340 model: DynamicStreamCheckConfigModel, config: Config, **kwargs: Any 1341 ) -> DynamicStreamCheckConfig: 1342 return DynamicStreamCheckConfig( 1343 dynamic_stream_name=model.dynamic_stream_name, 1344 stream_count=model.stream_count, 1345 ) 1346 1347 def create_check_stream( 1348 self, model: CheckStreamModel, config: Config, **kwargs: Any 1349 ) -> CheckStream: 1350 if model.dynamic_streams_check_configs is None and model.stream_names is None: 1351 raise ValueError( 1352 "Expected either stream_names or dynamic_streams_check_configs to be set for CheckStream" 1353 ) 1354 1355 dynamic_streams_check_configs = ( 1356 [ 1357 self._create_component_from_model(model=dynamic_stream_check_config, config=config) 1358 for dynamic_stream_check_config in model.dynamic_streams_check_configs 1359 ] 1360 if model.dynamic_streams_check_configs 1361 else [] 1362 ) 1363 1364 # `model.config_overrides` is deliberately not read here. The source applies it around the whole 1365 # check operation (`ConcurrentDeclarativeSource._config_overridden_for_check`), which is what makes 1366 # it work for every checker type rather than only this one. Do not wire it in a second time. 1367 return CheckStream( 1368 stream_names=model.stream_names or [], 1369 dynamic_streams_check_configs=dynamic_streams_check_configs, 1370 parameters={}, 1371 ) 1372 1373 @staticmethod 1374 def create_check_dynamic_stream( 1375 model: CheckDynamicStreamModel, config: Config, **kwargs: Any 1376 ) -> CheckDynamicStream: 1377 assert model.use_check_availability is not None # for mypy 1378 1379 use_check_availability = model.use_check_availability 1380 1381 # See `create_check_stream`: `model.config_overrides` is applied by the source, not here. 1382 return CheckDynamicStream( 1383 stream_count=model.stream_count, 1384 use_check_availability=use_check_availability, 1385 parameters={}, 1386 ) 1387 1388 def create_composite_error_handler( 1389 self, model: CompositeErrorHandlerModel, config: Config, **kwargs: Any 1390 ) -> CompositeErrorHandler: 1391 error_handlers = [ 1392 self._create_component_from_model(model=error_handler_model, config=config) 1393 for error_handler_model in model.error_handlers 1394 ] 1395 return CompositeErrorHandler( 1396 error_handlers=error_handlers, parameters=model.parameters or {} 1397 ) 1398 1399 @staticmethod 1400 def create_concurrency_level( 1401 model: ConcurrencyLevelModel, config: Config, **kwargs: Any 1402 ) -> ConcurrencyLevel: 1403 return ConcurrencyLevel( 1404 default_concurrency=model.default_concurrency, 1405 max_concurrency=model.max_concurrency, 1406 config=config, 1407 parameters={}, 1408 ) 1409 1410 @staticmethod 1411 def apply_stream_state_migrations( 1412 stream_state_migrations: List[Any] | None, stream_state: MutableMapping[str, Any] 1413 ) -> MutableMapping[str, Any]: 1414 if stream_state_migrations: 1415 for state_migration in stream_state_migrations: 1416 if state_migration.should_migrate(stream_state): 1417 # The state variable is expected to be mutable but the migrate method returns an immutable mapping. 1418 stream_state = dict(state_migration.migrate(stream_state)) 1419 return stream_state 1420 1421 def create_concurrent_cursor_from_datetime_based_cursor( 1422 self, 1423 model_type: Type[BaseModel], 1424 component_definition: ComponentDefinition, 1425 stream_name: str, 1426 stream_namespace: Optional[str], 1427 stream_state: MutableMapping[str, Any], 1428 config: Config, 1429 message_repository: Optional[MessageRepository] = None, 1430 runtime_lookback_window: Optional[datetime.timedelta] = None, 1431 **kwargs: Any, 1432 ) -> ConcurrentCursor: 1433 component_type = component_definition.get("type") 1434 if component_definition.get("type") != model_type.__name__: 1435 raise ValueError( 1436 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 1437 ) 1438 1439 # 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: 1440 # * The ComponentDefinition comes from model.__dict__ in which case we have `parameters` 1441 # * The ComponentDefinition comes from the manifest as a dict in which case we have `$parameters` 1442 # 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. 1443 if "$parameters" not in component_definition and "parameters" in component_definition: 1444 component_definition["$parameters"] = component_definition.get("parameters") # type: ignore # This is a dict 1445 datetime_based_cursor_model = model_type.parse_obj(component_definition) 1446 1447 if not isinstance(datetime_based_cursor_model, DatetimeBasedCursorModel): 1448 raise ValueError( 1449 f"Expected {model_type.__name__} component, but received {datetime_based_cursor_model.__class__.__name__}" 1450 ) 1451 1452 model_parameters = datetime_based_cursor_model.parameters or {} 1453 1454 cursor_field = self._get_catalog_defined_cursor_field( 1455 stream_name=stream_name, 1456 allow_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1457 or False, 1458 ) 1459 1460 if not cursor_field: 1461 interpolated_cursor_field = InterpolatedString.create( 1462 datetime_based_cursor_model.cursor_field, 1463 parameters=model_parameters, 1464 ) 1465 cursor_field = CursorField( 1466 cursor_field_key=interpolated_cursor_field.eval(config=config), 1467 supports_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1468 or False, 1469 ) 1470 1471 interpolated_partition_field_start = InterpolatedString.create( 1472 datetime_based_cursor_model.partition_field_start or "start_time", 1473 parameters=model_parameters, 1474 ) 1475 interpolated_partition_field_end = InterpolatedString.create( 1476 datetime_based_cursor_model.partition_field_end or "end_time", 1477 parameters=model_parameters, 1478 ) 1479 1480 slice_boundary_fields = ( 1481 interpolated_partition_field_start.eval(config=config), 1482 interpolated_partition_field_end.eval(config=config), 1483 ) 1484 1485 datetime_format = datetime_based_cursor_model.datetime_format 1486 1487 cursor_granularity = ( 1488 parse_duration(datetime_based_cursor_model.cursor_granularity) 1489 if datetime_based_cursor_model.cursor_granularity 1490 else None 1491 ) 1492 1493 lookback_window = None 1494 interpolated_lookback_window = ( 1495 InterpolatedString.create( 1496 datetime_based_cursor_model.lookback_window, 1497 parameters=model_parameters, 1498 ) 1499 if datetime_based_cursor_model.lookback_window 1500 else None 1501 ) 1502 if interpolated_lookback_window: 1503 evaluated_lookback_window = interpolated_lookback_window.eval(config=config) 1504 if evaluated_lookback_window: 1505 lookback_window = parse_duration(evaluated_lookback_window) 1506 1507 connector_state_converter: DateTimeStreamStateConverter 1508 connector_state_converter = CustomFormatConcurrentStreamStateConverter( 1509 datetime_format=datetime_format, 1510 input_datetime_formats=datetime_based_cursor_model.cursor_datetime_formats, 1511 is_sequential_state=True, # ConcurrentPerPartitionCursor only works with sequential state 1512 cursor_granularity=cursor_granularity, 1513 ) 1514 1515 # Adjusts the stream state by applying the runtime lookback window. 1516 # This is used to ensure correct state handling in case of failed partitions. 1517 stream_state_value = stream_state.get(cursor_field.cursor_field_key) 1518 if runtime_lookback_window and stream_state_value: 1519 new_stream_state = ( 1520 connector_state_converter.parse_timestamp(stream_state_value) 1521 - runtime_lookback_window 1522 ) 1523 stream_state[cursor_field.cursor_field_key] = connector_state_converter.output_format( 1524 new_stream_state 1525 ) 1526 1527 start_date_runtime_value: Union[InterpolatedString, str, MinMaxDatetime] 1528 if isinstance(datetime_based_cursor_model.start_datetime, MinMaxDatetimeModel): 1529 start_date_runtime_value = self.create_min_max_datetime( 1530 model=datetime_based_cursor_model.start_datetime, config=config 1531 ) 1532 else: 1533 start_date_runtime_value = datetime_based_cursor_model.start_datetime 1534 1535 end_date_runtime_value: Optional[Union[InterpolatedString, str, MinMaxDatetime]] 1536 if isinstance(datetime_based_cursor_model.end_datetime, MinMaxDatetimeModel): 1537 end_date_runtime_value = self.create_min_max_datetime( 1538 model=datetime_based_cursor_model.end_datetime, config=config 1539 ) 1540 else: 1541 end_date_runtime_value = datetime_based_cursor_model.end_datetime 1542 1543 interpolated_start_date = MinMaxDatetime.create( 1544 interpolated_string_or_min_max_datetime=start_date_runtime_value, 1545 parameters=datetime_based_cursor_model.parameters, 1546 ) 1547 interpolated_end_date = ( 1548 None 1549 if not end_date_runtime_value 1550 else MinMaxDatetime.create( 1551 end_date_runtime_value, datetime_based_cursor_model.parameters 1552 ) 1553 ) 1554 1555 # If datetime format is not specified then start/end datetime should inherit it from the stream slicer 1556 if not interpolated_start_date.datetime_format: 1557 interpolated_start_date.datetime_format = datetime_format 1558 if interpolated_end_date and not interpolated_end_date.datetime_format: 1559 interpolated_end_date.datetime_format = datetime_format 1560 1561 start_date = interpolated_start_date.get_datetime(config=config) 1562 end_date_provider = ( 1563 partial(interpolated_end_date.get_datetime, config) 1564 if interpolated_end_date 1565 else connector_state_converter.get_end_provider() 1566 ) 1567 1568 if ( 1569 datetime_based_cursor_model.step and not datetime_based_cursor_model.cursor_granularity 1570 ) or ( 1571 not datetime_based_cursor_model.step and datetime_based_cursor_model.cursor_granularity 1572 ): 1573 raise ValueError( 1574 f"If step is defined, cursor_granularity should be as well and vice-versa. " 1575 f"Right now, step is `{datetime_based_cursor_model.step}` and cursor_granularity is `{datetime_based_cursor_model.cursor_granularity}`" 1576 ) 1577 1578 # When step is not defined, default to a step size from the starting date to the present moment 1579 step_length = datetime.timedelta.max 1580 interpolated_step = ( 1581 InterpolatedString.create( 1582 datetime_based_cursor_model.step, 1583 parameters=model_parameters, 1584 ) 1585 if datetime_based_cursor_model.step 1586 else None 1587 ) 1588 if interpolated_step: 1589 evaluated_step = interpolated_step.eval(config) 1590 if evaluated_step: 1591 step_length = parse_duration(evaluated_step) 1592 1593 clamping_strategy: ClampingStrategy = NoClamping() 1594 if datetime_based_cursor_model.clamping: 1595 # While it is undesirable to interpolate within the model factory (as opposed to at runtime), 1596 # it is still better than shifting interpolation low-code concept into the ConcurrentCursor runtime 1597 # object which we want to keep agnostic of being low-code 1598 target = InterpolatedString( 1599 string=datetime_based_cursor_model.clamping.target, 1600 parameters=model_parameters, 1601 ) 1602 evaluated_target = target.eval(config=config) 1603 match evaluated_target: 1604 case "DAY": 1605 clamping_strategy = DayClampingStrategy() 1606 end_date_provider = ClampingEndProvider( 1607 DayClampingStrategy(is_ceiling=False), 1608 end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1609 granularity=cursor_granularity or datetime.timedelta(seconds=1), 1610 ) 1611 case "WEEK": 1612 if ( 1613 not datetime_based_cursor_model.clamping.target_details 1614 or "weekday" not in datetime_based_cursor_model.clamping.target_details 1615 ): 1616 raise ValueError( 1617 "Given WEEK clamping, weekday needs to be provided as target_details" 1618 ) 1619 weekday = self._assemble_weekday( 1620 datetime_based_cursor_model.clamping.target_details["weekday"] 1621 ) 1622 clamping_strategy = WeekClampingStrategy(weekday) 1623 end_date_provider = ClampingEndProvider( 1624 WeekClampingStrategy(weekday, is_ceiling=False), 1625 end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1626 granularity=cursor_granularity or datetime.timedelta(days=1), 1627 ) 1628 case "MONTH": 1629 clamping_strategy = MonthClampingStrategy() 1630 end_date_provider = ClampingEndProvider( 1631 MonthClampingStrategy(is_ceiling=False), 1632 end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1633 granularity=cursor_granularity or datetime.timedelta(days=1), 1634 ) 1635 case _: 1636 raise ValueError( 1637 f"Invalid clamping target {evaluated_target}, expected DAY, WEEK, MONTH" 1638 ) 1639 1640 return ConcurrentCursor( 1641 stream_name=stream_name, 1642 stream_namespace=stream_namespace, 1643 stream_state=stream_state, 1644 message_repository=message_repository or self._message_repository, 1645 connector_state_manager=self._connector_state_manager, 1646 connector_state_converter=connector_state_converter, 1647 cursor_field=cursor_field, 1648 slice_boundary_fields=slice_boundary_fields, 1649 start=start_date, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1650 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 1651 lookback_window=lookback_window, 1652 slice_range=step_length, 1653 cursor_granularity=cursor_granularity, 1654 clamping_strategy=clamping_strategy, 1655 is_compare_strictly=datetime_based_cursor_model.is_compare_strictly or False, 1656 ) 1657 1658 def create_concurrent_cursor_from_incrementing_count_cursor( 1659 self, 1660 model_type: Type[BaseModel], 1661 component_definition: ComponentDefinition, 1662 stream_name: str, 1663 stream_namespace: Optional[str], 1664 stream_state: MutableMapping[str, Any], 1665 config: Config, 1666 message_repository: Optional[MessageRepository] = None, 1667 **kwargs: Any, 1668 ) -> ConcurrentCursor: 1669 component_type = component_definition.get("type") 1670 if component_definition.get("type") != model_type.__name__: 1671 raise ValueError( 1672 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 1673 ) 1674 1675 incrementing_count_cursor_model = model_type.parse_obj(component_definition) 1676 1677 if not isinstance(incrementing_count_cursor_model, IncrementingCountCursorModel): 1678 raise ValueError( 1679 f"Expected {model_type.__name__} component, but received {incrementing_count_cursor_model.__class__.__name__}" 1680 ) 1681 1682 start_value: Union[int, str, None] = incrementing_count_cursor_model.start_value 1683 # Pydantic Union type coercion can convert int 0 to string '0' depending on Union order. 1684 # We need to handle both int and str representations of numeric values. 1685 # Evaluate the InterpolatedString and convert to int for the ConcurrentCursor. 1686 if start_value is not None: 1687 interpolated_start_value = InterpolatedString.create( 1688 str(start_value), # Ensure we pass a string to InterpolatedString.create 1689 parameters=incrementing_count_cursor_model.parameters or {}, 1690 ) 1691 evaluated_start_value: int = int(interpolated_start_value.eval(config=config)) 1692 else: 1693 evaluated_start_value = 0 1694 1695 cursor_field = self._get_catalog_defined_cursor_field( 1696 stream_name=stream_name, 1697 allow_catalog_defined_cursor_field=incrementing_count_cursor_model.allow_catalog_defined_cursor_field 1698 or False, 1699 ) 1700 1701 if not cursor_field: 1702 interpolated_cursor_field = InterpolatedString.create( 1703 incrementing_count_cursor_model.cursor_field, 1704 parameters=incrementing_count_cursor_model.parameters or {}, 1705 ) 1706 cursor_field = CursorField( 1707 cursor_field_key=interpolated_cursor_field.eval(config=config), 1708 supports_catalog_defined_cursor_field=incrementing_count_cursor_model.allow_catalog_defined_cursor_field 1709 or False, 1710 ) 1711 1712 connector_state_converter = IncrementingCountStreamStateConverter( 1713 is_sequential_state=True, # ConcurrentPerPartitionCursor only works with sequential state 1714 ) 1715 1716 return ConcurrentCursor( 1717 stream_name=stream_name, 1718 stream_namespace=stream_namespace, 1719 stream_state=stream_state, 1720 message_repository=message_repository or self._message_repository, 1721 connector_state_manager=self._connector_state_manager, 1722 connector_state_converter=connector_state_converter, 1723 cursor_field=cursor_field, 1724 slice_boundary_fields=None, 1725 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 1726 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 1727 ) 1728 1729 def _assemble_weekday(self, weekday: str) -> Weekday: 1730 match weekday: 1731 case "MONDAY": 1732 return Weekday.MONDAY 1733 case "TUESDAY": 1734 return Weekday.TUESDAY 1735 case "WEDNESDAY": 1736 return Weekday.WEDNESDAY 1737 case "THURSDAY": 1738 return Weekday.THURSDAY 1739 case "FRIDAY": 1740 return Weekday.FRIDAY 1741 case "SATURDAY": 1742 return Weekday.SATURDAY 1743 case "SUNDAY": 1744 return Weekday.SUNDAY 1745 case _: 1746 raise ValueError(f"Unknown weekday {weekday}") 1747 1748 def create_concurrent_cursor_from_perpartition_cursor( 1749 self, 1750 state_manager: ConnectorStateManager, 1751 model_type: Type[BaseModel], 1752 component_definition: ComponentDefinition, 1753 stream_name: str, 1754 stream_namespace: Optional[str], 1755 config: Config, 1756 stream_state: MutableMapping[str, Any], 1757 partition_router: PartitionRouter, 1758 attempt_to_create_cursor_if_not_provided: bool = False, 1759 **kwargs: Any, 1760 ) -> ConcurrentPerPartitionCursor: 1761 component_type = component_definition.get("type") 1762 if component_definition.get("type") != model_type.__name__: 1763 raise ValueError( 1764 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 1765 ) 1766 1767 # 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: 1768 # * The ComponentDefinition comes from model.__dict__ in which case we have `parameters` 1769 # * The ComponentDefinition comes from the manifest as a dict in which case we have `$parameters` 1770 # 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. 1771 if "$parameters" not in component_definition and "parameters" in component_definition: 1772 component_definition["$parameters"] = component_definition.get("parameters") # type: ignore # This is a dict 1773 datetime_based_cursor_model = model_type.parse_obj(component_definition) 1774 1775 if not isinstance(datetime_based_cursor_model, DatetimeBasedCursorModel): 1776 raise ValueError( 1777 f"Expected {model_type.__name__} component, but received {datetime_based_cursor_model.__class__.__name__}" 1778 ) 1779 1780 cursor_field = self._get_catalog_defined_cursor_field( 1781 stream_name=stream_name, 1782 allow_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1783 or False, 1784 ) 1785 1786 if not cursor_field: 1787 interpolated_cursor_field = InterpolatedString.create( 1788 datetime_based_cursor_model.cursor_field, 1789 # 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: 1790 # * The ComponentDefinition comes from model.__dict__ in which case we have `parameters` 1791 # * The ComponentDefinition comes from the manifest as a dict in which case we have `$parameters` 1792 # 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. 1793 parameters=datetime_based_cursor_model.parameters or {}, 1794 ) 1795 cursor_field = CursorField( 1796 cursor_field_key=interpolated_cursor_field.eval(config=config), 1797 supports_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1798 or False, 1799 ) 1800 1801 datetime_format = datetime_based_cursor_model.datetime_format 1802 1803 cursor_granularity = ( 1804 parse_duration(datetime_based_cursor_model.cursor_granularity) 1805 if datetime_based_cursor_model.cursor_granularity 1806 else None 1807 ) 1808 1809 connector_state_converter: DateTimeStreamStateConverter 1810 connector_state_converter = CustomFormatConcurrentStreamStateConverter( 1811 datetime_format=datetime_format, 1812 input_datetime_formats=datetime_based_cursor_model.cursor_datetime_formats, 1813 is_sequential_state=True, # ConcurrentPerPartitionCursor only works with sequential state 1814 cursor_granularity=cursor_granularity, 1815 ) 1816 1817 # Create the cursor factory 1818 cursor_factory = ConcurrentCursorFactory( 1819 partial( 1820 self.create_concurrent_cursor_from_datetime_based_cursor, 1821 state_manager=state_manager, 1822 model_type=model_type, 1823 component_definition=component_definition, 1824 stream_name=stream_name, 1825 stream_namespace=stream_namespace, 1826 config=config, 1827 message_repository=NoopMessageRepository(), 1828 ) 1829 ) 1830 1831 # Per-partition state doesn't make sense for GroupingPartitionRouter, so force the global state 1832 use_global_cursor = isinstance( 1833 partition_router, GroupingPartitionRouter 1834 ) or component_definition.get("global_substream_cursor", False) 1835 1836 # Return the concurrent cursor and state converter 1837 return ConcurrentPerPartitionCursor( 1838 cursor_factory=cursor_factory, 1839 partition_router=partition_router, 1840 stream_name=stream_name, 1841 stream_namespace=stream_namespace, 1842 stream_state=stream_state, 1843 message_repository=self._message_repository, # type: ignore 1844 connector_state_manager=state_manager, 1845 connector_state_converter=connector_state_converter, 1846 cursor_field=cursor_field, 1847 use_global_cursor=use_global_cursor, 1848 attempt_to_create_cursor_if_not_provided=attempt_to_create_cursor_if_not_provided, 1849 ) 1850 1851 @staticmethod 1852 def create_constant_backoff_strategy( 1853 model: ConstantBackoffStrategyModel, config: Config, **kwargs: Any 1854 ) -> ConstantBackoffStrategy: 1855 ModelToComponentFactory._validate_jitter_range(model.jitter_range_in_seconds) 1856 return ConstantBackoffStrategy( 1857 backoff_time_in_seconds=model.backoff_time_in_seconds, 1858 jitter_range_in_seconds=model.jitter_range_in_seconds, 1859 config=config, 1860 parameters=model.parameters or {}, 1861 ) 1862 1863 @staticmethod 1864 def _validate_jitter_range(jitter_range_in_seconds: Optional[float]) -> None: 1865 if jitter_range_in_seconds is not None and jitter_range_in_seconds < 0: 1866 raise ValueError("jitter_range_in_seconds must be greater than or equal to 0") 1867 1868 def create_cursor_pagination( 1869 self, 1870 model: CursorPaginationModel, 1871 config: Config, 1872 decoder: Optional[Decoder] = None, 1873 **kwargs: Any, 1874 ) -> CursorPaginationStrategy: 1875 # None when nested under a custom component, which cannot pass a decoder 1876 if decoder is None: 1877 decoder = JsonDecoder(parameters={}) 1878 if isinstance(decoder, PaginationDecoderDecorator): 1879 inner_decoder = decoder.decoder 1880 else: 1881 inner_decoder = decoder 1882 decoder = PaginationDecoderDecorator(decoder=decoder) 1883 1884 if self._is_supported_decoder_for_pagination(inner_decoder): 1885 decoder_to_use = decoder 1886 else: 1887 raise ValueError( 1888 self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(inner_decoder)) 1889 ) 1890 1891 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 1892 # If page_size is a string that represents an integer (not an interpolation), convert it back. 1893 page_size = model.page_size 1894 if isinstance(page_size, str) and page_size.isdigit(): 1895 page_size = int(page_size) 1896 1897 return CursorPaginationStrategy( 1898 cursor_value=model.cursor_value, 1899 decoder=decoder_to_use, 1900 page_size=page_size, 1901 stop_condition=model.stop_condition, 1902 config=config, 1903 parameters=model.parameters or {}, 1904 ) 1905 1906 def create_custom_component(self, model: Any, config: Config, **kwargs: Any) -> Any: 1907 """ 1908 Generically creates a custom component based on the model type and a class_name reference to the custom Python class being 1909 instantiated. Only the model's additional properties that match the custom class definition are passed to the constructor 1910 :param model: The Pydantic model of the custom component being created 1911 :param config: The custom defined connector config 1912 :return: The declarative component built from the Pydantic model to be used at runtime 1913 """ 1914 # Instantiating a custom component means importing and executing arbitrary code referenced 1915 # by `class_name`. Manifests supplied by a caller, whether through the config or directly to 1916 # the manifest server, are untrusted input and could point `class_name` at any importable 1917 # callable, so they honor the same `AIRBYTE_ENABLE_UNSAFE_CODE` gate as injected 1918 # `components.py` code. Manifests bundled in a published connector image are trusted and may 1919 # always use their bundled custom components. 1920 manifest_is_untrusted = not self._custom_components_trusted or bool( 1921 config.get(INJECTED_MANIFEST) 1922 ) 1923 if manifest_is_untrusted and not custom_code_execution_permitted(): 1924 raise AirbyteCustomCodeNotPermittedError 1925 1926 custom_component_class = self._get_class_from_fully_qualified_class_name(model.class_name) 1927 component_fields = get_type_hints(custom_component_class) 1928 model_args = model.dict() 1929 model_args["config"] = config 1930 1931 # There are cases where a parent component will pass arguments to a child component via kwargs. When there are field collisions 1932 # we defer to these arguments over the component's definition 1933 for key, arg in kwargs.items(): 1934 model_args[key] = arg 1935 1936 # Pydantic is unable to parse a custom component's fields that are subcomponents into models because their fields and types are not 1937 # defined in the schema. The fields and types are defined within the Python class implementation. Pydantic can only parse down to 1938 # the custom component and this code performs a second parse to convert the sub-fields first into models, then declarative components 1939 for model_field, model_value in model_args.items(): 1940 # If a custom component field doesn't have a type set, we try to use the type hints to infer the type 1941 if ( 1942 isinstance(model_value, dict) 1943 and "type" not in model_value 1944 and model_field in component_fields 1945 ): 1946 derived_type = self._derive_component_type_from_type_hints( 1947 component_fields.get(model_field) 1948 ) 1949 if derived_type: 1950 model_value["type"] = derived_type 1951 1952 if self._is_component(model_value): 1953 model_args[model_field] = self._create_nested_component( 1954 model, 1955 model_field, 1956 model_value, 1957 config, 1958 **kwargs, 1959 ) 1960 elif isinstance(model_value, list): 1961 vals = [] 1962 for v in model_value: 1963 if isinstance(v, dict) and "type" not in v and model_field in component_fields: 1964 derived_type = self._derive_component_type_from_type_hints( 1965 component_fields.get(model_field) 1966 ) 1967 if derived_type: 1968 v["type"] = derived_type 1969 if self._is_component(v): 1970 vals.append( 1971 self._create_nested_component( 1972 model, 1973 model_field, 1974 v, 1975 config, 1976 **kwargs, 1977 ) 1978 ) 1979 else: 1980 vals.append(v) 1981 model_args[model_field] = vals 1982 1983 kwargs = { 1984 class_field: model_args[class_field] 1985 for class_field in component_fields.keys() 1986 if class_field in model_args 1987 } 1988 1989 if "api_budget" in component_fields and kwargs.get("api_budget") is None: 1990 kwargs["api_budget"] = self._api_budget 1991 1992 return custom_component_class(**kwargs) 1993 1994 @staticmethod 1995 def _get_class_from_fully_qualified_class_name( 1996 full_qualified_class_name: str, 1997 ) -> Any: 1998 """Get a class from its fully qualified name. 1999 2000 If a custom components module is needed, we assume it is already registered - probably 2001 as `source_declarative_manifest.components` or `components`. 2002 2003 Args: 2004 full_qualified_class_name (str): The fully qualified name of the class (e.g., "module.ClassName"). 2005 2006 Returns: 2007 Any: The class object. 2008 2009 Raises: 2010 ValueError: If the class cannot be loaded. 2011 """ 2012 split = full_qualified_class_name.split(".") 2013 module_name_full = ".".join(split[:-1]) 2014 class_name = split[-1] 2015 2016 try: 2017 module_ref = importlib.import_module(module_name_full) 2018 except ModuleNotFoundError as e: 2019 if split[0] == "source_declarative_manifest": 2020 # 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 2021 try: 2022 import os 2023 2024 module_name_with_source_declarative_manifest = ".".join(split[1:-1]) 2025 module_ref = importlib.import_module( 2026 module_name_with_source_declarative_manifest 2027 ) 2028 except ModuleNotFoundError: 2029 raise ValueError(f"Could not load module `{module_name_full}`.") from e 2030 else: 2031 raise ValueError(f"Could not load module `{module_name_full}`.") from e 2032 2033 try: 2034 return getattr(module_ref, class_name) 2035 except AttributeError as e: 2036 raise ValueError( 2037 f"Could not load class `{class_name}` from module `{module_name_full}`.", 2038 ) from e 2039 2040 @staticmethod 2041 def _derive_component_type_from_type_hints(field_type: Any) -> Optional[str]: 2042 interface = field_type 2043 while True: 2044 origin = get_origin(interface) 2045 if origin: 2046 # Unnest types until we reach the raw type 2047 # List[T] -> T 2048 # Optional[List[T]] -> T 2049 args = get_args(interface) 2050 interface = args[0] 2051 else: 2052 break 2053 if isinstance(interface, type) and not ModelToComponentFactory.is_builtin_type(interface): 2054 return interface.__name__ 2055 return None 2056 2057 @staticmethod 2058 def is_builtin_type(cls: Optional[Type[Any]]) -> bool: 2059 if not cls: 2060 return False 2061 return cls.__module__ == "builtins" 2062 2063 @staticmethod 2064 def _extract_missing_parameters(error: TypeError) -> List[str]: 2065 parameter_search = re.search(r"keyword-only.*:\s(.*)", str(error)) 2066 if parameter_search: 2067 return re.findall(r"\'(.+?)\'", parameter_search.group(1)) 2068 else: 2069 return [] 2070 2071 def _create_nested_component( 2072 self, model: Any, model_field: str, model_value: Any, config: Config, **kwargs: Any 2073 ) -> Any: 2074 type_name = model_value.get("type", None) 2075 if not type_name: 2076 # If no type is specified, we can assume this is a dictionary object which can be returned instead of a subcomponent 2077 return model_value 2078 2079 model_type = self.TYPE_NAME_TO_MODEL.get(type_name, None) 2080 if model_type: 2081 parsed_model = model_type.parse_obj(model_value) 2082 try: 2083 # To improve usability of the language, certain fields are shared between components. This can come in the form of 2084 # a parent component passing some of its fields to a child component or the parent extracting fields from other child 2085 # components and passing it to others. One example is the DefaultPaginator referencing the HttpRequester url_base 2086 # while constructing a SimpleRetriever. However, custom components don't support this behavior because they are created 2087 # generically in create_custom_component(). This block allows developers to specify extra arguments in $parameters that 2088 # are needed by a component and could not be shared. 2089 model_constructor = self.PYDANTIC_MODEL_TO_CONSTRUCTOR.get(parsed_model.__class__) 2090 constructor_kwargs = inspect.getfullargspec(model_constructor).kwonlyargs 2091 model_parameters = model_value.get("$parameters", {}) 2092 matching_parameters = { 2093 kwarg: model_parameters[kwarg] 2094 for kwarg in constructor_kwargs 2095 if kwarg in model_parameters 2096 } 2097 matching_kwargs = { 2098 kwarg: kwargs[kwarg] for kwarg in constructor_kwargs if kwarg in kwargs 2099 } 2100 return self._create_component_from_model( 2101 model=parsed_model, config=config, **(matching_parameters | matching_kwargs) 2102 ) 2103 except TypeError as error: 2104 missing_parameters = self._extract_missing_parameters(error) 2105 if missing_parameters: 2106 raise ValueError( 2107 f"Error creating component '{type_name}' with parent custom component {model.class_name}: Please provide " 2108 + ", ".join( 2109 ( 2110 f"{type_name}.$parameters.{parameter}" 2111 for parameter in missing_parameters 2112 ) 2113 ) 2114 ) 2115 raise TypeError( 2116 f"Error creating component '{type_name}' with parent custom component {model.class_name}: {error}" 2117 ) 2118 else: 2119 raise ValueError( 2120 f"Error creating custom component {model.class_name}. Subcomponent creation has not been implemented for '{type_name}'" 2121 ) 2122 2123 @staticmethod 2124 def _is_component(model_value: Any) -> bool: 2125 return isinstance(model_value, dict) and model_value.get("type") is not None 2126 2127 def create_default_stream( 2128 self, model: DeclarativeStreamModel, config: Config, is_parent: bool = False, **kwargs: Any 2129 ) -> AbstractStream: 2130 primary_key = model.primary_key.__root__ if model.primary_key else None 2131 self._migrate_state(model, config) 2132 self._warn_on_ineffective_incremental_dependency(model) 2133 2134 partition_router = self._build_stream_slicer_from_partition_router( 2135 model.retriever, 2136 config, 2137 stream_name=model.name, 2138 **kwargs, 2139 ) 2140 concurrent_cursor = self._build_concurrent_cursor(model, partition_router, config) 2141 if model.incremental_sync and isinstance(model.incremental_sync, DatetimeBasedCursorModel): 2142 cursor_model: DatetimeBasedCursorModel = model.incremental_sync 2143 2144 end_time_option = ( 2145 self._create_component_from_model( 2146 cursor_model.end_time_option, config, parameters=cursor_model.parameters or {} 2147 ) 2148 if cursor_model.end_time_option 2149 else None 2150 ) 2151 start_time_option = ( 2152 self._create_component_from_model( 2153 cursor_model.start_time_option, config, parameters=cursor_model.parameters or {} 2154 ) 2155 if cursor_model.start_time_option 2156 else None 2157 ) 2158 2159 datetime_request_options_provider = DatetimeBasedRequestOptionsProvider( 2160 start_time_option=start_time_option, 2161 end_time_option=end_time_option, 2162 partition_field_start=cursor_model.partition_field_start, 2163 partition_field_end=cursor_model.partition_field_end, 2164 config=config, 2165 parameters=model.parameters or {}, 2166 ) 2167 request_options_provider = ( 2168 datetime_request_options_provider 2169 if not isinstance(concurrent_cursor, ConcurrentPerPartitionCursor) 2170 else PerPartitionRequestOptionsProvider( 2171 partition_router, datetime_request_options_provider 2172 ) 2173 ) 2174 elif model.incremental_sync and isinstance( 2175 model.incremental_sync, IncrementingCountCursorModel 2176 ): 2177 if isinstance(concurrent_cursor, ConcurrentPerPartitionCursor): 2178 raise ValueError( 2179 "PerPartition does not support per partition states because switching to global state is time based" 2180 ) 2181 2182 cursor_model: IncrementingCountCursorModel = model.incremental_sync # type: ignore 2183 2184 start_time_option = ( 2185 self._create_component_from_model( 2186 cursor_model.start_value_option, # type: ignore # mypy still thinks cursor_model of type DatetimeBasedCursor 2187 config, 2188 parameters=cursor_model.parameters or {}, 2189 ) 2190 if cursor_model.start_value_option # type: ignore # mypy still thinks cursor_model of type DatetimeBasedCursor 2191 else None 2192 ) 2193 2194 # The concurrent engine defaults the start/end fields on the slice to "start" and "end", but 2195 # the default DatetimeBasedRequestOptionsProvider() sets them to start_time/end_time 2196 partition_field_start = "start" 2197 2198 request_options_provider = DatetimeBasedRequestOptionsProvider( 2199 start_time_option=start_time_option, 2200 partition_field_start=partition_field_start, 2201 config=config, 2202 parameters=model.parameters or {}, 2203 ) 2204 else: 2205 request_options_provider = None 2206 2207 transformations = [] 2208 if model.transformations: 2209 for transformation_model in model.transformations: 2210 transformations.append( 2211 self._create_component_from_model(model=transformation_model, config=config) 2212 ) 2213 file_uploader = None 2214 if model.file_uploader: 2215 file_uploader = self._create_component_from_model( 2216 model=model.file_uploader, config=config 2217 ) 2218 2219 stream_slicer: ConcurrentStreamSlicer = ( 2220 partition_router 2221 if isinstance(concurrent_cursor, FinalStateCursor) 2222 else concurrent_cursor 2223 ) 2224 2225 retriever = self._create_component_from_model( 2226 model=model.retriever, 2227 config=config, 2228 name=model.name, 2229 primary_key=primary_key, 2230 request_options_provider=request_options_provider, 2231 stream_slicer=stream_slicer, 2232 partition_router=partition_router, 2233 has_stop_condition_cursor=self._is_stop_condition_on_cursor(model), 2234 is_client_side_incremental_sync=self._is_client_side_filtering_enabled(model), 2235 cursor=concurrent_cursor, 2236 transformations=transformations, 2237 file_uploader=file_uploader, 2238 incremental_sync=model.incremental_sync, 2239 ) 2240 if isinstance(retriever, AsyncRetriever): 2241 stream_slicer = retriever.stream_slicer 2242 2243 schema_loader: SchemaLoader 2244 if model.schema_loader and isinstance(model.schema_loader, list): 2245 nested_schema_loaders = [ 2246 self._create_component_from_model(model=nested_schema_loader, config=config) 2247 for nested_schema_loader in model.schema_loader 2248 ] 2249 schema_loader = CompositeSchemaLoader( 2250 schema_loaders=nested_schema_loaders, parameters={} 2251 ) 2252 elif model.schema_loader: 2253 schema_loader = self._create_component_from_model( 2254 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 2255 config=config, 2256 ) 2257 else: 2258 options = model.parameters or {} 2259 if "name" not in options: 2260 options["name"] = model.name 2261 schema_loader = DefaultSchemaLoader(config=config, parameters=options) 2262 schema_loader = CachingSchemaLoaderDecorator(schema_loader) 2263 2264 stream_name = model.name or "" 2265 return DefaultStream( 2266 partition_generator=StreamSlicerPartitionGenerator( 2267 DeclarativePartitionFactory( 2268 stream_name, 2269 schema_loader, 2270 retriever, 2271 self._message_repository, 2272 ), 2273 stream_slicer, 2274 slice_limit=self._limit_slices_fetched, 2275 ), 2276 name=stream_name, 2277 json_schema=schema_loader.get_json_schema, 2278 primary_key=get_primary_key_from_stream(primary_key), 2279 cursor_field=( 2280 concurrent_cursor.cursor_field 2281 if hasattr(concurrent_cursor, "cursor_field") 2282 else None 2283 ), 2284 logger=logging.getLogger(f"airbyte.{stream_name}"), 2285 cursor=concurrent_cursor, 2286 supports_file_transfer=hasattr(model, "file_uploader") and bool(model.file_uploader), 2287 ) 2288 2289 def _warn_on_ineffective_incremental_dependency(self, model: DeclarativeStreamModel) -> None: 2290 """ 2291 `incremental_dependency: true` only takes effect when the substream defines its own 2292 `incremental_sync`: the parent cursor is persisted under the `parent_state` key of the 2293 substream's state, which is only emitted by incremental substreams. On a stream without 2294 `incremental_sync`, the setting is silently ignored and all parent records are re-read on 2295 every sync, so we warn about the misconfiguration instead. 2296 """ 2297 if model.incremental_sync: 2298 return 2299 2300 partition_router = getattr(model.retriever, "partition_router", None) 2301 if not partition_router: 2302 return 2303 2304 routers = partition_router if isinstance(partition_router, list) else [partition_router] 2305 for router in routers: 2306 if isinstance(router, GroupingPartitionRouterModel): 2307 router = router.underlying_partition_router 2308 if isinstance(router, SubstreamPartitionRouterModel) and any( 2309 parent_stream_config.incremental_dependency 2310 for parent_stream_config in router.parent_stream_configs 2311 ): 2312 LOGGER.warning( 2313 f"Stream `{model.name}` has `incremental_dependency: true` in its parent stream configuration but does not define `incremental_sync`. " 2314 "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. " 2315 "Define `incremental_sync` on this stream or remove `incremental_dependency`." 2316 ) 2317 return 2318 2319 def _migrate_state(self, model: DeclarativeStreamModel, config: Config) -> None: 2320 stream_name = model.name or "" 2321 stream_state = self._connector_state_manager.get_stream_state( 2322 stream_name=stream_name, namespace=None 2323 ) 2324 if model.state_migrations: 2325 state_transformations = [ 2326 self._create_component_from_model(state_migration, config, declarative_stream=model) 2327 for state_migration in model.state_migrations 2328 ] 2329 else: 2330 state_transformations = [] 2331 stream_state = self.apply_stream_state_migrations(state_transformations, stream_state) 2332 self._connector_state_manager.update_state_for_stream( 2333 stream_name=stream_name, namespace=None, value=stream_state 2334 ) 2335 2336 def _is_stop_condition_on_cursor(self, model: DeclarativeStreamModel) -> bool: 2337 return bool( 2338 model.incremental_sync 2339 and hasattr(model.incremental_sync, "is_data_feed") 2340 and model.incremental_sync.is_data_feed 2341 ) 2342 2343 def _is_client_side_filtering_enabled(self, model: DeclarativeStreamModel) -> bool: 2344 return bool( 2345 model.incremental_sync 2346 and hasattr(model.incremental_sync, "is_client_side_incremental") 2347 and model.incremental_sync.is_client_side_incremental 2348 ) 2349 2350 def _build_stream_slicer_from_partition_router( 2351 self, 2352 model: Union[ 2353 AsyncRetrieverModel, 2354 CustomRetrieverModel, 2355 SimpleRetrieverModel, 2356 ], 2357 config: Config, 2358 stream_name: Optional[str] = None, 2359 **kwargs: Any, 2360 ) -> PartitionRouter: 2361 if ( 2362 hasattr(model, "partition_router") 2363 and isinstance(model, (SimpleRetrieverModel, AsyncRetrieverModel, CustomRetrieverModel)) 2364 and model.partition_router 2365 ): 2366 stream_slicer_model = model.partition_router 2367 if isinstance(stream_slicer_model, list): 2368 return CartesianProductStreamSlicer( 2369 [ 2370 self._create_component_from_model( 2371 model=slicer, config=config, stream_name=stream_name or "" 2372 ) 2373 for slicer in stream_slicer_model 2374 ], 2375 parameters={}, 2376 ) 2377 elif isinstance(stream_slicer_model, dict): 2378 # partition router comes from CustomRetrieverModel therefore has not been parsed as a model 2379 params = stream_slicer_model.get("$parameters") 2380 if not isinstance(params, dict): 2381 params = {} 2382 stream_slicer_model["$parameters"] = params 2383 2384 if stream_name is not None: 2385 params["stream_name"] = stream_name 2386 2387 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` 2388 model, 2389 "partition_router", 2390 stream_slicer_model, 2391 config, 2392 **kwargs, 2393 ) 2394 else: 2395 return self._create_component_from_model( # type: ignore[no-any-return] # Will be created PartitionRouter as stream_slicer_model is model.partition_router 2396 model=stream_slicer_model, config=config, stream_name=stream_name or "" 2397 ) 2398 return SinglePartitionRouter(parameters={}) 2399 2400 def _build_concurrent_cursor( 2401 self, 2402 model: DeclarativeStreamModel, 2403 stream_slicer: Optional[PartitionRouter], 2404 config: Config, 2405 ) -> Cursor: 2406 stream_name = model.name or "" 2407 stream_state = self._connector_state_manager.get_stream_state(stream_name, None) 2408 2409 if ( 2410 model.incremental_sync 2411 and stream_slicer 2412 and not isinstance(stream_slicer, SinglePartitionRouter) 2413 ): 2414 if isinstance(model.incremental_sync, IncrementingCountCursorModel): 2415 # We don't currently support usage of partition routing and IncrementingCountCursor at the 2416 # same time because we didn't solve for design questions like what the lookback window would 2417 # be as well as global cursor fall backs. We have not seen customers that have needed both 2418 # at the same time yet and are currently punting on this until we need to solve it. 2419 raise ValueError( 2420 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}." 2421 ) 2422 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 2423 state_manager=self._connector_state_manager, 2424 model_type=DatetimeBasedCursorModel, 2425 component_definition=model.incremental_sync.__dict__, 2426 stream_name=stream_name, 2427 stream_state=stream_state, 2428 stream_namespace=None, 2429 config=config or {}, 2430 partition_router=stream_slicer, 2431 attempt_to_create_cursor_if_not_provided=True, # FIXME can we remove that now? 2432 ) 2433 elif model.incremental_sync: 2434 if type(model.incremental_sync) == IncrementingCountCursorModel: 2435 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 2436 model_type=IncrementingCountCursorModel, 2437 component_definition=model.incremental_sync.__dict__, 2438 stream_name=stream_name, 2439 stream_namespace=None, 2440 stream_state=stream_state, 2441 config=config or {}, 2442 ) 2443 elif type(model.incremental_sync) == DatetimeBasedCursorModel: 2444 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 2445 model_type=type(model.incremental_sync), 2446 component_definition=model.incremental_sync.__dict__, 2447 stream_name=stream_name, 2448 stream_namespace=None, 2449 stream_state=stream_state, 2450 config=config or {}, 2451 attempt_to_create_cursor_if_not_provided=True, 2452 ) 2453 else: 2454 raise ValueError( 2455 f"Incremental sync of type {type(model.incremental_sync)} is not supported" 2456 ) 2457 return FinalStateCursor(stream_name, None, self._message_repository) 2458 2459 def create_default_error_handler( 2460 self, model: DefaultErrorHandlerModel, config: Config, **kwargs: Any 2461 ) -> DefaultErrorHandler: 2462 backoff_strategies = [] 2463 if model.backoff_strategies: 2464 for backoff_strategy_model in model.backoff_strategies: 2465 backoff_strategies.append( 2466 self._create_component_from_model(model=backoff_strategy_model, config=config) 2467 ) 2468 2469 response_filters = [] 2470 if model.response_filters: 2471 for response_filter_model in model.response_filters: 2472 response_filters.append( 2473 self._create_component_from_model(model=response_filter_model, config=config) 2474 ) 2475 response_filters.append( 2476 HttpResponseFilter(config=config, parameters=model.parameters or {}) 2477 ) 2478 2479 return DefaultErrorHandler( 2480 backoff_strategies=backoff_strategies, 2481 max_retries=model.max_retries, 2482 response_filters=response_filters, 2483 config=config, 2484 parameters=model.parameters or {}, 2485 ) 2486 2487 def create_default_paginator( 2488 self, 2489 model: DefaultPaginatorModel, 2490 config: Config, 2491 *, 2492 url_base: str, 2493 extractor_model: Optional[ 2494 Union[CustomRecordExtractorModel, DpathExtractorModel, CombinedExtractorModel] 2495 ] = None, 2496 decoder: Optional[Decoder] = None, 2497 cursor_used_for_stop_condition: Optional[Cursor] = None, 2498 ) -> Union[DefaultPaginator, PaginatorTestReadDecorator]: 2499 if decoder: 2500 if self._is_supported_decoder_for_pagination(decoder): 2501 decoder_to_use = PaginationDecoderDecorator(decoder=decoder) 2502 else: 2503 raise ValueError(self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(decoder))) 2504 else: 2505 decoder_to_use = PaginationDecoderDecorator(decoder=JsonDecoder(parameters={})) 2506 page_size_option = ( 2507 self._create_component_from_model(model=model.page_size_option, config=config) 2508 if model.page_size_option 2509 else None 2510 ) 2511 page_token_option = ( 2512 self._create_component_from_model(model=model.page_token_option, config=config) 2513 if model.page_token_option 2514 else None 2515 ) 2516 pagination_strategy = self._create_component_from_model( 2517 model=model.pagination_strategy, 2518 config=config, 2519 decoder=decoder_to_use, 2520 extractor_model=extractor_model, 2521 ) 2522 if cursor_used_for_stop_condition: 2523 pagination_strategy = StopConditionPaginationStrategyDecorator( 2524 pagination_strategy, CursorStopCondition(cursor_used_for_stop_condition) 2525 ) 2526 paginator = DefaultPaginator( 2527 decoder=decoder_to_use, 2528 page_size_option=page_size_option, 2529 page_token_option=page_token_option, 2530 pagination_strategy=pagination_strategy, 2531 url_base=url_base, 2532 config=config, 2533 parameters=model.parameters or {}, 2534 ) 2535 if self._limit_pages_fetched_per_slice: 2536 return PaginatorTestReadDecorator(paginator, self._limit_pages_fetched_per_slice) 2537 return paginator 2538 2539 @staticmethod 2540 def _is_decoder_downgraded_by_connector_builder(decoder: Decoder) -> bool: 2541 """Is this the buffered stand-in the Connector Builder builds for a streaming decoder? 2542 2543 `create_csv_decoder`, `create_jsonl_decoder`, `create_json_items_decoder` and 2544 `create_gzip_decoder` build a `CompositeRawDecoder` with `stream_response=False` when 2545 `_emit_connector_builder_messages` is set, so a Builder test read can be replayed. Those 2546 four instances are the only ones that stream in production while reporting 2547 `is_stream_response() == False` in the Builder. 2548 2549 The match is on the exact class and on the parser, not on `isinstance`: a `CustomDecoder` 2550 subclassing `CompositeRawDecoder` with `stream_response=False`, or a buffered 2551 `CompositeRawDecoder` built around a `JsonParser`, reads the body from `response.content` 2552 in production too and must not be rejected. 2553 """ 2554 return type(decoder) is CompositeRawDecoder and isinstance( 2555 decoder.parser, (CsvParser, JsonLineParser, JsonItemsParser, GzipParser) 2556 ) 2557 2558 def _reject_combined_extractor_over_streaming_decoder(self, decoder: Optional[Decoder]) -> None: 2559 """Refuse to build a `CombinedExtractor` whose response body can only be read once. 2560 2561 Every sub-extractor is handed the same `requests.Response`. A streaming decoder consumes 2562 and closes `response.raw`, so every sub-extractor after the first reads a closed 2563 `urllib3.HTTPResponse`, which returns an empty body instead of raising: `union` emits only 2564 the first sub-extractor's records and `first_match` emits nothing when the first path 2565 misses. Rejecting the manifest is the only way to make that loud. 2566 2567 The Connector Builder downgrades those same decoders to `stream_response=False` so a test 2568 read can be replayed, which would let a manifest look correct in the Builder and lose 2569 records once published. The downgraded decoders are therefore rejected in the Builder too. 2570 """ 2571 if decoder is None: 2572 return 2573 inner_decoder = ( 2574 decoder.decoder if isinstance(decoder, PaginationDecoderDecorator) else decoder 2575 ) 2576 streams_in_production = inner_decoder.is_stream_response() or ( 2577 self._emit_connector_builder_messages 2578 and self._is_decoder_downgraded_by_connector_builder(inner_decoder) 2579 ) 2580 if not streams_in_production: 2581 return 2582 raise AirbyteTracedException( 2583 message="CombinedExtractor is not supported with a streaming decoder.", 2584 internal_message=( 2585 f"CombinedExtractor was configured with {type(inner_decoder).__name__}, which " 2586 f"streams the response and can only be read once. Sub-extractors after the first " 2587 f"would read a closed response and silently return no records. The streaming " 2588 f"decoders are CsvDecoder, JsonlDecoder, JsonItemsDecoder, GzipDecoder and " 2589 f"IterableDecoder; use JsonDecoder, XmlDecoder or ZipfileDecoder, or declare a " 2590 f"single extractor." 2591 ), 2592 failure_type=FailureType.config_error, 2593 ) 2594 2595 def create_combined_extractor( 2596 self, 2597 model: CombinedExtractorModel, 2598 config: Config, 2599 decoder: Optional[Decoder] = None, 2600 **kwargs: Any, 2601 ) -> CombinedExtractor: 2602 self._reject_combined_extractor_over_streaming_decoder(decoder) 2603 extractors = [ 2604 self._create_component_from_model(model=sub_extractor, config=config, decoder=decoder) 2605 for sub_extractor in model.extractors 2606 ] 2607 return CombinedExtractor( 2608 extractors=extractors, 2609 mode=CombineMode(model.mode.value) if model.mode else CombineMode.union, 2610 skip_empty_records=bool(model.skip_empty_records), 2611 parameters=model.parameters or {}, 2612 ) 2613 2614 def create_dpath_extractor( 2615 self, 2616 model: DpathExtractorModel, 2617 config: Config, 2618 decoder: Optional[Decoder] = None, 2619 **kwargs: Any, 2620 ) -> DpathExtractor: 2621 if decoder: 2622 decoder_to_use = decoder 2623 else: 2624 decoder_to_use = JsonDecoder(parameters={}) 2625 model_field_path: List[Union[InterpolatedString, str]] = [x for x in model.field_path] 2626 2627 record_expander = None 2628 if model.record_expander: 2629 record_expander = self._create_component_from_model( 2630 model=model.record_expander, 2631 config=config, 2632 ) 2633 2634 return DpathExtractor( 2635 decoder=decoder_to_use, 2636 field_path=model_field_path, 2637 config=config, 2638 parameters=model.parameters or {}, 2639 record_expander=record_expander, 2640 ) 2641 2642 def create_record_expander( 2643 self, 2644 model: RecordExpanderModel, 2645 config: Config, 2646 **kwargs: Any, 2647 ) -> RecordExpander: 2648 truncated_list_retriever = None 2649 suppress_incomplete_fetch_warning = False 2650 if model.truncated_list_retriever: 2651 retriever_model = model.truncated_list_retriever 2652 name = "record_expander_truncated_list" 2653 # `CustomRetriever` allows extra fields, so read from the dumped model to cover both types. 2654 retriever_fields = retriever_model.dict() 2655 for unsupported_option in ("partition_router", "pagination_reset"): 2656 if retriever_fields.get(unsupported_option): 2657 raise ValueError( 2658 f"`{unsupported_option}` is not supported on `truncated_list_retriever`." 2659 ) 2660 log_formatter = lambda response: format_http_message( 2661 response, 2662 f"Record expander '{name}' request", 2663 "Request performed in order to fetch the complete nested list of a truncated record.", 2664 name, 2665 is_auxiliary=True, 2666 ) 2667 # `name`/`primary_key`/`transformations` are also what a `CustomRetriever` forwards to its 2668 # nested `requester`/`record_selector`, so they are passed for both retriever types. 2669 truncated_list_retriever = self._create_component_from_model( 2670 model=retriever_model, 2671 config=config, 2672 name=name, 2673 primary_key=None, 2674 transformations=[], 2675 log_formatter=log_formatter, 2676 ) 2677 # Only a capped paginator makes a shortfall expected; `NoPagination` and retrievers 2678 # without a paginator are never capped. 2679 suppress_incomplete_fetch_warning = isinstance( 2680 truncated_list_retriever, SimpleRetriever 2681 ) and isinstance(truncated_list_retriever.paginator, PaginatorTestReadDecorator) 2682 return RecordExpander( 2683 expand_records_from_field=model.expand_records_from_field, 2684 config=config, 2685 parameters=model.parameters or {}, 2686 remain_original_record=model.remain_original_record or False, 2687 parent_fields=[ 2688 self._create_component_from_model(model=parent_field, config=config) 2689 for parent_field in model.parent_fields 2690 ] 2691 if model.parent_fields 2692 else None, 2693 merge_parent=model.merge_parent or False, 2694 on_no_records=OnNoRecords(model.on_no_records.value) 2695 if model.on_no_records 2696 else OnNoRecords.skip, 2697 truncation_indicator_path=model.truncation_indicator_path, 2698 truncated_list_retriever=truncated_list_retriever, 2699 message_repository=self._message_repository, 2700 suppress_incomplete_fetch_warning=suppress_incomplete_fetch_warning, 2701 ) 2702 2703 @staticmethod 2704 def create_parent_field_path( 2705 model: ParentFieldPathModel, 2706 config: Config, 2707 **kwargs: Any, 2708 ) -> ParentFieldPath: 2709 return ParentFieldPath( 2710 parent_path=model.parent_path, 2711 record_path=model.record_path, 2712 config=config, 2713 parameters=model.parameters or {}, 2714 ) 2715 2716 @staticmethod 2717 def create_response_to_file_extractor( 2718 model: ResponseToFileExtractorModel, 2719 **kwargs: Any, 2720 ) -> ResponseToFileExtractor: 2721 return ResponseToFileExtractor( 2722 parameters=model.parameters or {}, 2723 preserve_na_values=model.preserve_na_values or False, 2724 ) 2725 2726 @staticmethod 2727 def create_exponential_backoff_strategy( 2728 model: ExponentialBackoffStrategyModel, config: Config 2729 ) -> ExponentialBackoffStrategy: 2730 ModelToComponentFactory._validate_jitter_range(model.jitter_range_in_seconds) 2731 return ExponentialBackoffStrategy( 2732 factor=model.factor or 5, 2733 jitter_range_in_seconds=model.jitter_range_in_seconds, 2734 parameters=model.parameters or {}, 2735 config=config, 2736 ) 2737 2738 @staticmethod 2739 def create_group_by_key(model: GroupByKeyMergeStrategyModel, config: Config) -> GroupByKey: 2740 return GroupByKey(model.key, config=config, parameters=model.parameters or {}) 2741 2742 def create_http_requester( 2743 self, 2744 model: HttpRequesterModel, 2745 config: Config, 2746 decoder: Decoder = JsonDecoder(parameters={}), 2747 query_properties_key: Optional[str] = None, 2748 use_cache: Optional[bool] = None, 2749 *, 2750 name: str, 2751 ) -> HttpRequester: 2752 authenticator = ( 2753 self._create_component_from_model( 2754 model=model.authenticator, 2755 config=config, 2756 url_base=model.url or model.url_base, 2757 name=name, 2758 decoder=decoder, 2759 ) 2760 if model.authenticator 2761 else None 2762 ) 2763 error_handler = ( 2764 self._create_component_from_model(model=model.error_handler, config=config) 2765 if model.error_handler 2766 else DefaultErrorHandler( 2767 backoff_strategies=[], 2768 response_filters=[], 2769 config=config, 2770 parameters=model.parameters or {}, 2771 ) 2772 ) 2773 2774 api_budget = self._api_budget 2775 2776 request_options_provider = InterpolatedRequestOptionsProvider( 2777 request_body=model.request_body, 2778 request_body_data=model.request_body_data, 2779 request_body_json=model.request_body_json, 2780 request_headers=model.request_headers, 2781 request_parameters=model.request_parameters, # type: ignore # QueryProperties have been removed in `create_simple_retriever` 2782 query_properties_key=query_properties_key, 2783 config=config, 2784 parameters=model.parameters or {}, 2785 ) 2786 2787 assert model.use_cache is not None # for mypy 2788 assert model.http_method is not None # for mypy 2789 2790 should_use_cache = (model.use_cache or bool(use_cache)) and not self._disable_cache 2791 2792 return HttpRequester( 2793 name=name, 2794 url=model.url, 2795 url_base=model.url_base, 2796 path=model.path, 2797 authenticator=authenticator, 2798 error_handler=error_handler, 2799 api_budget=api_budget, 2800 http_method=HttpMethod[model.http_method.value], 2801 request_options_provider=request_options_provider, 2802 config=config, 2803 disable_retries=self._disable_retries, 2804 parameters=model.parameters or {}, 2805 message_repository=self._message_repository, 2806 use_cache=should_use_cache, 2807 connect_timeout_in_seconds=model.connect_timeout_in_seconds, 2808 read_timeout_in_seconds=model.read_timeout_in_seconds, 2809 use_tcp_keepalive=model.use_tcp_keepalive or False, 2810 decoder=decoder, 2811 stream_response=decoder.is_stream_response() if decoder else False, 2812 ) 2813 2814 @staticmethod 2815 def create_http_response_filter( 2816 model: HttpResponseFilterModel, config: Config, **kwargs: Any 2817 ) -> HttpResponseFilter: 2818 if model.action: 2819 action = ResponseAction(model.action.value) 2820 else: 2821 action = None 2822 2823 failure_type = FailureType(model.failure_type.value) if model.failure_type else None 2824 2825 http_codes = ( 2826 set(model.http_codes) if model.http_codes else set() 2827 ) # JSON schema notation has no set data type. The schema enforces an array of unique elements 2828 2829 return HttpResponseFilter( 2830 action=action, 2831 failure_type=failure_type, 2832 error_message=model.error_message or "", 2833 error_message_contains=model.error_message_contains or "", 2834 http_codes=http_codes, 2835 predicate=model.predicate or "", 2836 config=config, 2837 parameters=model.parameters or {}, 2838 ) 2839 2840 @staticmethod 2841 def create_inline_schema_loader( 2842 model: InlineSchemaLoaderModel, config: Config, **kwargs: Any 2843 ) -> InlineSchemaLoader: 2844 return InlineSchemaLoader(schema=model.schema_ or {}, parameters={}) 2845 2846 def create_complex_field_type( 2847 self, model: ComplexFieldTypeModel, config: Config, **kwargs: Any 2848 ) -> ComplexFieldType: 2849 items = ( 2850 self._create_component_from_model(model=model.items, config=config) 2851 if isinstance(model.items, ComplexFieldTypeModel) 2852 else model.items 2853 ) 2854 2855 return ComplexFieldType(field_type=model.field_type, items=items) 2856 2857 def create_types_map(self, model: TypesMapModel, config: Config, **kwargs: Any) -> TypesMap: 2858 target_type = ( 2859 self._create_component_from_model(model=model.target_type, config=config) 2860 if isinstance(model.target_type, ComplexFieldTypeModel) 2861 else model.target_type 2862 ) 2863 2864 return TypesMap( 2865 target_type=target_type, 2866 current_type=model.current_type, 2867 condition=model.condition if model.condition is not None else "True", 2868 ) 2869 2870 def create_schema_type_identifier( 2871 self, model: SchemaTypeIdentifierModel, config: Config, **kwargs: Any 2872 ) -> SchemaTypeIdentifier: 2873 types_mapping = [] 2874 if model.types_mapping: 2875 types_mapping.extend( 2876 [ 2877 self._create_component_from_model(types_map, config=config) 2878 for types_map in model.types_mapping 2879 ] 2880 ) 2881 model_schema_pointer: List[Union[InterpolatedString, str]] = ( 2882 [x for x in model.schema_pointer] if model.schema_pointer else [] 2883 ) 2884 model_key_pointer: List[Union[InterpolatedString, str]] = [x for x in model.key_pointer] 2885 model_type_pointer: Optional[List[Union[InterpolatedString, str]]] = ( 2886 [x for x in model.type_pointer] if model.type_pointer else None 2887 ) 2888 2889 return SchemaTypeIdentifier( 2890 schema_pointer=model_schema_pointer, 2891 key_pointer=model_key_pointer, 2892 type_pointer=model_type_pointer, 2893 types_mapping=types_mapping, 2894 parameters=model.parameters or {}, 2895 ) 2896 2897 def create_dynamic_schema_loader( 2898 self, model: DynamicSchemaLoaderModel, config: Config, **kwargs: Any 2899 ) -> DynamicSchemaLoader: 2900 schema_transformations = [] 2901 if model.schema_transformations: 2902 for transformation_model in model.schema_transformations: 2903 schema_transformations.append( 2904 self._create_component_from_model(model=transformation_model, config=config) 2905 ) 2906 name = "dynamic_properties" 2907 partition_router = self._build_stream_slicer_from_partition_router(model.retriever, config) 2908 retriever_kwargs: Dict[str, Any] = {} 2909 if isinstance(model.retriever, AsyncRetrieverModel): 2910 if model.retriever.partition_router: 2911 raise ValueError( 2912 "DynamicSchemaLoader does not support an AsyncRetriever with a partition_router: only the first record is read, so jobs for other partitions would be orphaned. Remove the partition_router from the schema loader's AsyncRetriever." 2913 ) 2914 # create_async_retriever requires stream_slicer; passing it to a CustomRetriever would override its own 2915 retriever_kwargs["stream_slicer"] = partition_router 2916 retriever = self._create_component_from_model( 2917 model=model.retriever, 2918 config=config, 2919 name=name, 2920 primary_key=None, 2921 partition_router=partition_router, 2922 **retriever_kwargs, 2923 transformations=[], 2924 use_cache=True, 2925 log_formatter=( 2926 lambda response: format_http_message( 2927 response, 2928 f"Schema loader '{name}' request", 2929 f"Request performed in order to extract schema.", 2930 name, 2931 is_auxiliary=True, 2932 ) 2933 ), 2934 ) 2935 schema_type_identifier = self._create_component_from_model( 2936 model.schema_type_identifier, config=config, parameters=model.parameters or {} 2937 ) 2938 schema_filter = ( 2939 self._create_component_from_model( 2940 model.schema_filter, config=config, parameters=model.parameters or {} 2941 ) 2942 if model.schema_filter is not None 2943 else None 2944 ) 2945 2946 return DynamicSchemaLoader( 2947 retriever=retriever, 2948 config=config, 2949 schema_transformations=schema_transformations, 2950 schema_filter=schema_filter, 2951 schema_type_identifier=schema_type_identifier, 2952 parameters=model.parameters or {}, 2953 ) 2954 2955 @staticmethod 2956 def create_json_decoder(model: JsonDecoderModel, config: Config, **kwargs: Any) -> Decoder: 2957 return JsonDecoder(parameters={}) 2958 2959 def create_csv_decoder(self, model: CsvDecoderModel, config: Config, **kwargs: Any) -> Decoder: 2960 return CompositeRawDecoder( 2961 parser=ModelToComponentFactory._get_parser(model, config), 2962 stream_response=False if self._emit_connector_builder_messages else True, 2963 ) 2964 2965 def create_jsonl_decoder( 2966 self, model: JsonlDecoderModel, config: Config, **kwargs: Any 2967 ) -> Decoder: 2968 return CompositeRawDecoder( 2969 parser=ModelToComponentFactory._get_parser(model, config), 2970 stream_response=False if self._emit_connector_builder_messages else True, 2971 ) 2972 2973 def create_json_items_decoder( 2974 self, model: JsonItemsDecoderModel, config: Config, **kwargs: Any 2975 ) -> Decoder: 2976 return CompositeRawDecoder( 2977 parser=ModelToComponentFactory._get_parser(model, config), 2978 stream_response=False if self._emit_connector_builder_messages else True, 2979 ) 2980 2981 def create_gzip_decoder( 2982 self, model: GzipDecoderModel, config: Config, **kwargs: Any 2983 ) -> Decoder: 2984 _compressed_response_types = { 2985 "gzip", 2986 "x-gzip", 2987 "gzip, deflate", 2988 "x-gzip, deflate", 2989 "application/zip", 2990 "application/gzip", 2991 "application/x-gzip", 2992 "application/x-zip-compressed", 2993 } 2994 2995 gzip_parser: GzipParser = ModelToComponentFactory._get_parser(model, config) # type: ignore # based on the model, we know this will be a GzipParser 2996 2997 if self._emit_connector_builder_messages: 2998 return CompositeRawDecoder(gzip_parser, False) 2999 3000 transport_gzip_parser = GzipParser(inner_parser=gzip_parser) 3001 return CompositeRawDecoder.by_headers( 3002 [ 3003 ({"Content-Encoding"}, {"gzip"}, transport_gzip_parser), 3004 ({"Content-Type"}, _compressed_response_types, gzip_parser), 3005 ], 3006 stream_response=True, 3007 fallback_parser=gzip_parser, 3008 ) 3009 3010 @staticmethod 3011 def create_iterable_decoder( 3012 model: IterableDecoderModel, config: Config, **kwargs: Any 3013 ) -> IterableDecoder: 3014 return IterableDecoder(parameters={}) 3015 3016 @staticmethod 3017 def create_xml_decoder(model: XmlDecoderModel, config: Config, **kwargs: Any) -> XmlDecoder: 3018 return XmlDecoder(parameters={}) 3019 3020 def create_zipfile_decoder( 3021 self, model: ZipfileDecoderModel, config: Config, **kwargs: Any 3022 ) -> ZipfileDecoder: 3023 return ZipfileDecoder(parser=ModelToComponentFactory._get_parser(model.decoder, config)) 3024 3025 @staticmethod 3026 def _get_parser(model: BaseModel, config: Config) -> Parser: 3027 if isinstance(model, JsonDecoderModel): 3028 # Note that the logic is a bit different from the JsonDecoder as there is some legacy that is maintained to return {} on error cases 3029 return JsonParser() 3030 elif isinstance(model, JsonItemsDecoderModel): 3031 return JsonItemsParser( 3032 items_path=model.items_path, 3033 encoding=model.encoding, 3034 ) 3035 elif isinstance(model, JsonlDecoderModel): 3036 return JsonLineParser() 3037 elif isinstance(model, CsvDecoderModel): 3038 return CsvParser( 3039 encoding=model.encoding, 3040 delimiter=model.delimiter, 3041 set_values_to_none=model.set_values_to_none, 3042 ) 3043 elif isinstance(model, GzipDecoderModel): 3044 return GzipParser( 3045 inner_parser=ModelToComponentFactory._get_parser(model.decoder, config) 3046 ) 3047 elif isinstance( 3048 model, (CustomDecoderModel, IterableDecoderModel, XmlDecoderModel, ZipfileDecoderModel) 3049 ): 3050 raise ValueError(f"Decoder type {model} does not have parser associated to it") 3051 3052 raise ValueError(f"Unknown decoder type {model}") 3053 3054 @staticmethod 3055 def create_json_file_schema_loader( 3056 model: JsonFileSchemaLoaderModel, config: Config, **kwargs: Any 3057 ) -> JsonFileSchemaLoader: 3058 return JsonFileSchemaLoader( 3059 file_path=model.file_path or "", config=config, parameters=model.parameters or {} 3060 ) 3061 3062 def create_jwt_authenticator( 3063 self, model: JwtAuthenticatorModel, config: Config, **kwargs: Any 3064 ) -> JwtAuthenticator: 3065 jwt_headers = model.jwt_headers or JwtHeadersModel(kid=None, typ="JWT", cty=None) 3066 jwt_payload = model.jwt_payload or JwtPayloadModel(iss=None, sub=None, aud=None) 3067 request_option = ( 3068 self._create_component_from_model(model.request_option, config) 3069 if model.request_option 3070 else None 3071 ) 3072 return JwtAuthenticator( 3073 config=config, 3074 parameters=model.parameters or {}, 3075 algorithm=JwtAlgorithm(model.algorithm.value), 3076 secret_key=model.secret_key, 3077 base64_encode_secret_key=model.base64_encode_secret_key, 3078 token_duration=model.token_duration, 3079 header_prefix=model.header_prefix, 3080 kid=jwt_headers.kid, 3081 typ=jwt_headers.typ, 3082 cty=jwt_headers.cty, 3083 iss=jwt_payload.iss, 3084 sub=jwt_payload.sub, 3085 aud=jwt_payload.aud, 3086 additional_jwt_headers=model.additional_jwt_headers, 3087 additional_jwt_payload=model.additional_jwt_payload, 3088 passphrase=model.passphrase, 3089 request_option=request_option, 3090 ) 3091 3092 def create_list_partition_router( 3093 self, model: ListPartitionRouterModel, config: Config, **kwargs: Any 3094 ) -> ListPartitionRouter: 3095 request_option = ( 3096 self._create_component_from_model(model.request_option, config) 3097 if model.request_option 3098 else None 3099 ) 3100 return ListPartitionRouter( 3101 cursor_field=model.cursor_field, 3102 request_option=request_option, 3103 values=model.values, 3104 config=config, 3105 parameters=model.parameters or {}, 3106 ) 3107 3108 @staticmethod 3109 def create_min_max_datetime( 3110 model: MinMaxDatetimeModel, config: Config, **kwargs: Any 3111 ) -> MinMaxDatetime: 3112 return MinMaxDatetime( 3113 datetime=model.datetime, 3114 datetime_format=model.datetime_format or "", 3115 max_datetime=model.max_datetime or "", 3116 min_datetime=model.min_datetime or "", 3117 parameters=model.parameters or {}, 3118 ) 3119 3120 @staticmethod 3121 def create_no_auth(model: NoAuthModel, config: Config, **kwargs: Any) -> NoAuth: 3122 return NoAuth(parameters=model.parameters or {}) 3123 3124 @staticmethod 3125 def create_no_pagination( 3126 model: NoPaginationModel, config: Config, **kwargs: Any 3127 ) -> NoPagination: 3128 return NoPagination(parameters={}) 3129 3130 def create_oauth_authenticator( 3131 self, model: OAuthAuthenticatorModel, config: Config, **kwargs: Any 3132 ) -> DeclarativeOauth2Authenticator: 3133 profile_assertion = ( 3134 self._create_component_from_model(model.profile_assertion, config=config) 3135 if model.profile_assertion 3136 else None 3137 ) 3138 3139 refresh_token_error_status_codes, refresh_token_error_key, refresh_token_error_values = ( 3140 self._get_refresh_token_error_information(model) 3141 ) 3142 if model.refresh_token_updater: 3143 # ignore type error because fixing it would have a lot of dependencies, revisit later 3144 return DeclarativeSingleUseRefreshTokenOauth2Authenticator( # type: ignore 3145 config, 3146 InterpolatedString.create( 3147 model.token_refresh_endpoint, # type: ignore 3148 parameters=model.parameters or {}, 3149 ).eval(config), 3150 access_token_name=InterpolatedString.create( 3151 model.access_token_name or "access_token", parameters=model.parameters or {} 3152 ).eval(config), 3153 refresh_token_name=model.refresh_token_updater.refresh_token_name, 3154 expires_in_name=InterpolatedString.create( 3155 model.expires_in_name or "expires_in", parameters=model.parameters or {} 3156 ).eval(config), 3157 client_id_name=InterpolatedString.create( 3158 model.client_id_name or "client_id", parameters=model.parameters or {} 3159 ).eval(config), 3160 client_id=InterpolatedString.create( 3161 model.client_id, parameters=model.parameters or {} 3162 ).eval(config) 3163 if model.client_id 3164 else model.client_id, 3165 client_secret_name=InterpolatedString.create( 3166 model.client_secret_name or "client_secret", parameters=model.parameters or {} 3167 ).eval(config), 3168 client_secret=InterpolatedString.create( 3169 model.client_secret, parameters=model.parameters or {} 3170 ).eval(config) 3171 if model.client_secret 3172 else model.client_secret, 3173 access_token_config_path=model.refresh_token_updater.access_token_config_path, 3174 refresh_token_config_path=model.refresh_token_updater.refresh_token_config_path, 3175 token_expiry_date_config_path=model.refresh_token_updater.token_expiry_date_config_path, 3176 grant_type_name=InterpolatedString.create( 3177 model.grant_type_name or "grant_type", parameters=model.parameters or {} 3178 ).eval(config), 3179 grant_type=InterpolatedString.create( 3180 model.grant_type or "refresh_token", parameters=model.parameters or {} 3181 ).eval(config), 3182 refresh_request_body=InterpolatedMapping( 3183 model.refresh_request_body or {}, parameters=model.parameters or {} 3184 ).eval(config), 3185 refresh_request_headers=InterpolatedMapping( 3186 model.refresh_request_headers or {}, parameters=model.parameters or {} 3187 ).eval(config), 3188 send_refresh_request_as_query_params=bool( 3189 model.send_refresh_request_as_query_params 3190 ), 3191 scopes=model.scopes, 3192 token_expiry_date_format=model.token_expiry_date_format, 3193 token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), 3194 message_repository=self._message_repository, 3195 refresh_token_error_status_codes=refresh_token_error_status_codes, 3196 refresh_token_error_key=refresh_token_error_key, 3197 refresh_token_error_values=refresh_token_error_values, 3198 ) 3199 # ignore type error because fixing it would have a lot of dependencies, revisit later 3200 return DeclarativeOauth2Authenticator( # type: ignore 3201 access_token_name=model.access_token_name or "access_token", 3202 access_token_value=model.access_token_value, 3203 client_id_name=model.client_id_name or "client_id", 3204 client_id=model.client_id, 3205 client_secret_name=model.client_secret_name or "client_secret", 3206 client_secret=model.client_secret, 3207 expires_in_name=model.expires_in_name or "expires_in", 3208 grant_type_name=model.grant_type_name or "grant_type", 3209 grant_type=model.grant_type or "refresh_token", 3210 refresh_request_body=model.refresh_request_body, 3211 refresh_request_headers=model.refresh_request_headers, 3212 send_refresh_request_as_query_params=bool(model.send_refresh_request_as_query_params), 3213 refresh_token_name=model.refresh_token_name or "refresh_token", 3214 refresh_token=model.refresh_token, 3215 scopes=model.scopes, 3216 token_expiry_date=model.token_expiry_date, 3217 token_expiry_date_format=model.token_expiry_date_format, 3218 token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), 3219 token_refresh_endpoint=model.token_refresh_endpoint, 3220 config=config, 3221 parameters=model.parameters or {}, 3222 message_repository=self._message_repository, 3223 profile_assertion=profile_assertion, 3224 use_profile_assertion=model.use_profile_assertion, 3225 refresh_token_error_status_codes=refresh_token_error_status_codes, 3226 refresh_token_error_key=refresh_token_error_key, 3227 refresh_token_error_values=refresh_token_error_values, 3228 ) 3229 3230 @staticmethod 3231 def _get_refresh_token_error_information( 3232 model: OAuthAuthenticatorModel, 3233 ) -> Tuple[Tuple[int, ...], str, Tuple[str, ...]]: 3234 """ 3235 In a previous version of the CDK, the auth error as config_error was only done if a refresh token updater was 3236 defined. As a transition, we added those fields on the OAuthAuthenticatorModel. This method ensures that the 3237 information is defined only once and return the right fields. 3238 """ 3239 refresh_token_updater = model.refresh_token_updater 3240 is_defined_on_refresh_token_updated = refresh_token_updater and ( 3241 refresh_token_updater.refresh_token_error_status_codes 3242 or refresh_token_updater.refresh_token_error_key 3243 or refresh_token_updater.refresh_token_error_values 3244 ) 3245 is_defined_on_oauth_authenticator = ( 3246 model.refresh_token_error_status_codes 3247 or model.refresh_token_error_key 3248 or model.refresh_token_error_values 3249 ) 3250 if is_defined_on_refresh_token_updated and is_defined_on_oauth_authenticator: 3251 raise ValueError( 3252 "refresh_token_error should either be defined on the OAuthAuthenticatorModel or the RefreshTokenUpdaterModel, not both" 3253 ) 3254 3255 if is_defined_on_refresh_token_updated: 3256 not_optional_refresh_token_updater: RefreshTokenUpdaterModel = refresh_token_updater # type: ignore # we know from the condition that this is not None 3257 return ( 3258 tuple(not_optional_refresh_token_updater.refresh_token_error_status_codes) 3259 if not_optional_refresh_token_updater.refresh_token_error_status_codes 3260 else (), 3261 not_optional_refresh_token_updater.refresh_token_error_key or "", 3262 tuple(not_optional_refresh_token_updater.refresh_token_error_values) 3263 if not_optional_refresh_token_updater.refresh_token_error_values 3264 else (), 3265 ) 3266 elif is_defined_on_oauth_authenticator: 3267 return ( 3268 tuple(model.refresh_token_error_status_codes) 3269 if model.refresh_token_error_status_codes 3270 else (), 3271 model.refresh_token_error_key or "", 3272 tuple(model.refresh_token_error_values) if model.refresh_token_error_values else (), 3273 ) 3274 3275 # returning default values we think cover most cases 3276 return (400,), "error", ("invalid_grant", "invalid_permissions") 3277 3278 @staticmethod 3279 def _reject_union_combined_extractor_for_offset_increment( 3280 extractor_model: Optional[BaseModel], 3281 ) -> None: 3282 """Refuse an `OffsetIncrement` paginator driven by a `union` `CombinedExtractor`. 3283 3284 `OffsetIncrement.next_page_token` advances the offset by the number of records its 3285 extractor returns for the page. Under `union` that number is the sum over all 3286 sub-extractors, so the offset overshoots the API page size and every page after the first 3287 starts past the records that were never read: with two sub-extractors returning two records 3288 each and `page_size: 2`, the requested offsets are 0, 4, 8 instead of 0, 2, 4 and two 3289 thirds of the records are silently dropped. 3290 3291 `first_match` returns the winning sub-extractor's count, which does not inflate the count, 3292 and is left alone. A `union` nested anywhere in the tree inflates the count of the node 3293 above it, so the whole tree is walked. 3294 """ 3295 if not isinstance(extractor_model, CombinedExtractorModel): 3296 return 3297 if not ModelToComponentFactory._combined_extractor_tree_contains_union(extractor_model): 3298 return 3299 raise AirbyteTracedException( 3300 message=( 3301 'CombinedExtractor mode "union" is not supported with an OffsetIncrement paginator.' 3302 ), 3303 internal_message=( 3304 "OffsetIncrement counts the records of its extractor to advance the offset. A " 3305 "`union` CombinedExtractor returns the sum of its sub-extractors' records, which " 3306 "overshoots the page the API returned, so records would be skipped. Use the " 3307 "`first_match` mode, a CursorPagination or PageIncrement paginator, or a single " 3308 "extractor." 3309 ), 3310 failure_type=FailureType.config_error, 3311 ) 3312 3313 @staticmethod 3314 def _combined_extractor_tree_contains_union(model: CombinedExtractorModel) -> bool: 3315 mode = CombineMode(model.mode.value) if model.mode else CombineMode.union 3316 if mode == CombineMode.union: 3317 return True 3318 return any( 3319 isinstance(sub_extractor, CombinedExtractorModel) 3320 and ModelToComponentFactory._combined_extractor_tree_contains_union(sub_extractor) 3321 for sub_extractor in model.extractors 3322 ) 3323 3324 @staticmethod 3325 def _count_dropped_empty_records(extractor: RecordExtractor) -> None: 3326 """Make the record selector count the empty records a `CombinedExtractor` drops. 3327 3328 `OffsetIncrement` and `PageIncrement` stop when that count falls below the page size, so a 3329 full page holding a null would otherwise look short and silently end pagination. 3330 """ 3331 if not isinstance(extractor, CombinedExtractor): 3332 return 3333 extractor.count_dropped_empty_records = True 3334 for sub_extractor in extractor.extractors: 3335 ModelToComponentFactory._count_dropped_empty_records(sub_extractor) 3336 3337 def create_offset_increment( 3338 self, 3339 model: OffsetIncrementModel, 3340 config: Config, 3341 decoder: Optional[Decoder] = None, 3342 extractor_model: Optional[ 3343 Union[CustomRecordExtractorModel, DpathExtractorModel, CombinedExtractorModel] 3344 ] = None, 3345 **kwargs: Any, 3346 ) -> OffsetIncrement: 3347 # None when nested under a custom component, which cannot pass a decoder 3348 if decoder is None: 3349 decoder = JsonDecoder(parameters={}) 3350 if isinstance(decoder, PaginationDecoderDecorator): 3351 inner_decoder = decoder.decoder 3352 else: 3353 inner_decoder = decoder 3354 decoder = PaginationDecoderDecorator(decoder=decoder) 3355 3356 if self._is_supported_decoder_for_pagination(inner_decoder): 3357 decoder_to_use = decoder 3358 else: 3359 raise ValueError( 3360 self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(inner_decoder)) 3361 ) 3362 3363 self._reject_union_combined_extractor_for_offset_increment(extractor_model) 3364 3365 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 3366 # If page_size is a string that represents an integer (not an interpolation), convert it back. 3367 page_size = model.page_size 3368 if isinstance(page_size, str) and page_size.isdigit(): 3369 page_size = int(page_size) 3370 3371 return OffsetIncrement( 3372 page_size=page_size, 3373 config=config, 3374 decoder=decoder_to_use, 3375 inject_on_first_request=model.inject_on_first_request or False, 3376 parameters=model.parameters or {}, 3377 ) 3378 3379 @staticmethod 3380 def create_page_increment( 3381 model: PageIncrementModel, config: Config, **kwargs: Any 3382 ) -> PageIncrement: 3383 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 3384 # If page_size is a string that represents an integer (not an interpolation), convert it back. 3385 page_size = model.page_size 3386 if isinstance(page_size, str) and page_size.isdigit(): 3387 page_size = int(page_size) 3388 3389 return PageIncrement( 3390 page_size=page_size, 3391 config=config, 3392 start_from_page=model.start_from_page or 0, 3393 inject_on_first_request=model.inject_on_first_request or False, 3394 parameters=model.parameters or {}, 3395 ) 3396 3397 def create_parent_stream_config( 3398 self, model: ParentStreamConfigModel, config: Config, *, stream_name: str, **kwargs: Any 3399 ) -> ParentStreamConfig: 3400 declarative_stream = self._create_component_from_model( 3401 model.stream, 3402 config=config, 3403 is_parent=True, 3404 **kwargs, 3405 ) 3406 request_option = ( 3407 self._create_component_from_model(model.request_option, config=config) 3408 if model.request_option 3409 else None 3410 ) 3411 3412 if model.lazy_read_pointer and any("*" in pointer for pointer in model.lazy_read_pointer): 3413 raise ValueError( 3414 "The '*' wildcard in 'lazy_read_pointer' is not supported — only direct paths are allowed." 3415 ) 3416 3417 model_lazy_read_pointer: List[Union[InterpolatedString, str]] = ( 3418 [x for x in model.lazy_read_pointer] if model.lazy_read_pointer else [] 3419 ) 3420 3421 return ParentStreamConfig( 3422 parent_key=model.parent_key, 3423 request_option=request_option, 3424 stream=declarative_stream, 3425 partition_field=model.partition_field, 3426 config=config, 3427 incremental_dependency=model.incremental_dependency or False, 3428 parameters=model.parameters or {}, 3429 extra_fields=model.extra_fields, 3430 lazy_read_pointer=model_lazy_read_pointer, 3431 ) 3432 3433 def create_properties_from_endpoint( 3434 self, model: PropertiesFromEndpointModel, config: Config, **kwargs: Any 3435 ) -> PropertiesFromEndpoint: 3436 retriever = self._create_component_from_model( 3437 model=model.retriever, 3438 config=config, 3439 name="dynamic_properties", 3440 primary_key=None, 3441 stream_slicer=None, 3442 transformations=[], 3443 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 3444 ) 3445 return PropertiesFromEndpoint( 3446 property_field_path=model.property_field_path, 3447 retriever=retriever, 3448 config=config, 3449 parameters=model.parameters or {}, 3450 ) 3451 3452 def create_property_chunking( 3453 self, model: PropertyChunkingModel, config: Config, **kwargs: Any 3454 ) -> PropertyChunking: 3455 record_merge_strategy = ( 3456 self._create_component_from_model( 3457 model=model.record_merge_strategy, config=config, **kwargs 3458 ) 3459 if model.record_merge_strategy 3460 else None 3461 ) 3462 3463 property_limit_type: PropertyLimitType 3464 match model.property_limit_type: 3465 case PropertyLimitTypeModel.property_count: 3466 property_limit_type = PropertyLimitType.property_count 3467 case PropertyLimitTypeModel.characters: 3468 property_limit_type = PropertyLimitType.characters 3469 case _: 3470 raise ValueError(f"Invalid PropertyLimitType {property_limit_type}") 3471 3472 return PropertyChunking( 3473 property_limit_type=property_limit_type, 3474 property_limit=model.property_limit, 3475 record_merge_strategy=record_merge_strategy, 3476 config=config, 3477 parameters=model.parameters or {}, 3478 ) 3479 3480 def create_query_properties( 3481 self, model: QueryPropertiesModel, config: Config, *, stream_name: str, **kwargs: Any 3482 ) -> QueryProperties: 3483 if isinstance(model.property_list, list): 3484 property_list = model.property_list 3485 else: 3486 property_list = self._create_component_from_model( 3487 model=model.property_list, config=config, **kwargs 3488 ) 3489 3490 property_chunking = ( 3491 self._create_component_from_model( 3492 model=model.property_chunking, config=config, **kwargs 3493 ) 3494 if model.property_chunking 3495 else None 3496 ) 3497 3498 property_selector = ( 3499 self._create_component_from_model( 3500 model=model.property_selector, config=config, stream_name=stream_name, **kwargs 3501 ) 3502 if model.property_selector 3503 else None 3504 ) 3505 3506 return QueryProperties( 3507 property_list=property_list, 3508 always_include_properties=model.always_include_properties, 3509 property_chunking=property_chunking, 3510 property_selector=property_selector, 3511 config=config, 3512 parameters=model.parameters or {}, 3513 ) 3514 3515 def create_json_schema_property_selector( 3516 self, 3517 model: JsonSchemaPropertySelectorModel, 3518 config: Config, 3519 *, 3520 stream_name: str, 3521 **kwargs: Any, 3522 ) -> JsonSchemaPropertySelector: 3523 configured_stream = self._stream_name_to_configured_stream.get(stream_name) 3524 3525 transformations = [] 3526 if model.transformations: 3527 for transformation_model in model.transformations: 3528 transformations.append( 3529 self._create_component_from_model(model=transformation_model, config=config) 3530 ) 3531 3532 return JsonSchemaPropertySelector( 3533 configured_stream=configured_stream, 3534 properties_transformations=transformations, 3535 config=config, 3536 parameters=model.parameters or {}, 3537 ) 3538 3539 @staticmethod 3540 def create_record_filter( 3541 model: RecordFilterModel, config: Config, **kwargs: Any 3542 ) -> RecordFilter: 3543 return RecordFilter( 3544 condition=model.condition or "", config=config, parameters=model.parameters or {} 3545 ) 3546 3547 @staticmethod 3548 def create_request_path(model: RequestPathModel, config: Config, **kwargs: Any) -> RequestPath: 3549 return RequestPath(parameters={}) 3550 3551 @staticmethod 3552 def create_request_option( 3553 model: RequestOptionModel, config: Config, **kwargs: Any 3554 ) -> RequestOption: 3555 inject_into = RequestOptionType(model.inject_into.value) 3556 field_path: Optional[List[Union[InterpolatedString, str]]] = ( 3557 [ 3558 InterpolatedString.create(segment, parameters=kwargs.get("parameters", {})) 3559 for segment in model.field_path 3560 ] 3561 if model.field_path 3562 else None 3563 ) 3564 field_name = ( 3565 InterpolatedString.create(model.field_name, parameters=kwargs.get("parameters", {})) 3566 if model.field_name 3567 else None 3568 ) 3569 return RequestOption( 3570 field_name=field_name, 3571 field_path=field_path, 3572 inject_into=inject_into, 3573 parameters=kwargs.get("parameters", {}), 3574 ) 3575 3576 def create_record_selector( 3577 self, 3578 model: RecordSelectorModel, 3579 config: Config, 3580 *, 3581 name: str, 3582 transformations: List[RecordTransformation] | None = None, 3583 decoder: Decoder | None = None, 3584 client_side_incremental_sync_cursor: Optional[Cursor] = None, 3585 is_client_side_incremental_sync: bool = False, 3586 file_uploader: Optional[DefaultFileUploader] = None, 3587 **kwargs: Any, 3588 ) -> RecordSelector: 3589 extractor = self._create_component_from_model( 3590 model=model.extractor, decoder=decoder, config=config 3591 ) 3592 self._count_dropped_empty_records(extractor) 3593 record_filter = ( 3594 self._create_component_from_model(model.record_filter, config=config) 3595 if model.record_filter 3596 else None 3597 ) 3598 3599 # A client-side incremental stream transforms before filtering by default. That default belongs to the flag, 3600 # not to the component that ends up doing the cursor comparison: a data feed does it in the retriever and 3601 # receives no cursor here, but its `record_filter` condition must keep running after the transformations. 3602 default_transform_before_filtering = bool( 3603 client_side_incremental_sync_cursor or is_client_side_incremental_sync 3604 ) 3605 transform_before_filtering = ( 3606 default_transform_before_filtering 3607 if model.transform_before_filtering is None 3608 else model.transform_before_filtering 3609 ) 3610 if client_side_incremental_sync_cursor: 3611 record_filter = ClientSideIncrementalRecordFilterDecorator( 3612 config=config, 3613 parameters=model.parameters, 3614 condition=model.record_filter.condition 3615 if (model.record_filter and hasattr(model.record_filter, "condition")) 3616 else None, 3617 cursor=client_side_incremental_sync_cursor, 3618 ) 3619 3620 if model.schema_normalization is None: 3621 # default to no schema normalization if not set 3622 model.schema_normalization = SchemaNormalizationModel.None_ 3623 3624 schema_normalization = ( 3625 TypeTransformer(SCHEMA_TRANSFORMER_TYPE_MAPPING[model.schema_normalization]) 3626 if isinstance(model.schema_normalization, SchemaNormalizationModel) 3627 else self._create_component_from_model(model.schema_normalization, config=config) # type: ignore[arg-type] # custom normalization model expected here 3628 ) 3629 3630 return RecordSelector( 3631 extractor=extractor, 3632 name=name, 3633 config=config, 3634 record_filter=record_filter, 3635 transformations=transformations or [], 3636 file_uploader=file_uploader, 3637 schema_normalization=schema_normalization, 3638 parameters=model.parameters or {}, 3639 transform_before_filtering=transform_before_filtering, 3640 ) 3641 3642 @staticmethod 3643 def create_remove_fields( 3644 model: RemoveFieldsModel, config: Config, **kwargs: Any 3645 ) -> RemoveFields: 3646 return RemoveFields( 3647 field_pointers=model.field_pointers, condition=model.condition or "", parameters={} 3648 ) 3649 3650 def create_selective_authenticator( 3651 self, 3652 model: SelectiveAuthenticatorModel, 3653 config: Config, 3654 *, 3655 name: Optional[str] = None, 3656 url_base: Optional[str] = None, 3657 **kwargs: Any, 3658 ) -> DeclarativeAuthenticator: 3659 # Keyword-only so that _create_nested_component also fills them under a custom component, 3660 # from the parent's kwargs or $parameters. None is not forwarded, so a missing required one 3661 # keeps its hint. 3662 nested_kwargs = { 3663 key: value 3664 for key, value in {"name": name, "url_base": url_base}.items() 3665 if value is not None 3666 } 3667 authenticators = { 3668 key: self._create_component_from_model( 3669 model=auth, config=config, **nested_kwargs, **kwargs 3670 ) 3671 for key, auth in model.authenticators.items() 3672 } 3673 # SelectiveAuthenticator will return instance of DeclarativeAuthenticator or raise ValueError error 3674 return SelectiveAuthenticator( # type: ignore[abstract] 3675 config=config, 3676 authenticators=authenticators, 3677 authenticator_selection_path=model.authenticator_selection_path, 3678 **kwargs, 3679 ) 3680 3681 @staticmethod 3682 def create_legacy_session_token_authenticator( 3683 model: LegacySessionTokenAuthenticatorModel, config: Config, *, url_base: str, **kwargs: Any 3684 ) -> LegacySessionTokenAuthenticator: 3685 return LegacySessionTokenAuthenticator( 3686 api_url=url_base, 3687 header=model.header, 3688 login_url=model.login_url, 3689 password=model.password or "", 3690 session_token=model.session_token or "", 3691 session_token_response_key=model.session_token_response_key or "", 3692 username=model.username or "", 3693 validate_session_url=model.validate_session_url, 3694 config=config, 3695 parameters=model.parameters or {}, 3696 ) 3697 3698 def create_simple_retriever( 3699 self, 3700 model: SimpleRetrieverModel, 3701 config: Config, 3702 *, 3703 name: str, 3704 primary_key: Optional[Union[str, List[str], List[List[str]]]], 3705 request_options_provider: Optional[RequestOptionsProvider] = None, 3706 cursor: Optional[Cursor] = None, 3707 has_stop_condition_cursor: bool = False, 3708 is_client_side_incremental_sync: bool = False, 3709 transformations: List[RecordTransformation], 3710 file_uploader: Optional[DefaultFileUploader] = None, 3711 incremental_sync: Optional[ 3712 Union[IncrementingCountCursorModel, DatetimeBasedCursorModel] 3713 ] = None, 3714 use_cache: Optional[bool] = None, 3715 log_formatter: Optional[Callable[[Response], Any]] = None, 3716 partition_router: Optional[PartitionRouter] = None, 3717 **kwargs: Any, 3718 ) -> SimpleRetriever: 3719 def _get_url(req: Requester) -> str: 3720 """ 3721 Closure to get the URL from the requester. This is used to get the URL in the case of a lazy retriever. 3722 This is needed because the URL is not set until the requester is created. 3723 """ 3724 3725 _url: str = ( 3726 model.requester.url 3727 if hasattr(model.requester, "url") and model.requester.url is not None 3728 else req.get_url(stream_state=None, stream_slice=None, next_page_token=None) 3729 ) 3730 _url_base: str = ( 3731 model.requester.url_base 3732 if hasattr(model.requester, "url_base") and model.requester.url_base is not None 3733 else req.get_url_base(stream_state=None, stream_slice=None, next_page_token=None) 3734 ) 3735 3736 return _url or _url_base 3737 3738 if cursor is None: 3739 cursor = FinalStateCursor(name, None, self._message_repository) 3740 3741 # A data feed drops the records the cursor considers already synced in the retriever, which sits downstream of 3742 # the paginator. Letting the record selector drop them as well would be redundant and would hide them from the 3743 # pagination stop condition, so a data feed never delegates that filtering to the record selector, whether 3744 # `is_client_side_incremental` is set or not. The `condition` from `record_filter` is intentionally left out of 3745 # the post-pagination filter and stays in the record selector, which preserves the existing behaviour: the 3746 # selector runs inside the page loop, so the records the condition rejects never reach the paginator's 3747 # accounting. Moving it downstream would start counting them. 3748 post_pagination_filter = ( 3749 ClientSideIncrementalRecordFilterDecorator( 3750 config=config, 3751 parameters=model.parameters or {}, 3752 condition=None, 3753 cursor=cursor, 3754 ) 3755 if has_stop_condition_cursor 3756 else None 3757 ) 3758 client_side_incremental_cursor = ( 3759 cursor if is_client_side_incremental_sync and not post_pagination_filter else None 3760 ) 3761 if post_pagination_filter and is_client_side_incremental_sync: 3762 LOGGER.warning( 3763 f"Stream {name}: `is_client_side_incremental` adds no record filtering when `is_data_feed` is set, " 3764 "as a data feed already filters on the cursor value. It still makes the record selector apply the " 3765 "transformations before the `record_filter` condition." 3766 ) 3767 3768 decoder = ( 3769 self._create_component_from_model(model=model.decoder, config=config) 3770 if model.decoder 3771 else JsonDecoder(parameters={}) 3772 ) 3773 record_selector = self._create_component_from_model( 3774 model=model.record_selector, 3775 name=name, 3776 config=config, 3777 decoder=decoder, 3778 transformations=transformations, 3779 client_side_incremental_sync_cursor=client_side_incremental_cursor, 3780 is_client_side_incremental_sync=is_client_side_incremental_sync, 3781 file_uploader=file_uploader, 3782 ) 3783 3784 query_properties: Optional[QueryProperties] = None 3785 query_properties_key: Optional[str] = None 3786 self._ensure_query_properties_to_model(model.requester) 3787 if self._has_query_properties_in_request_parameters(model.requester): 3788 # It is better to be explicit about an error if PropertiesFromEndpoint is defined in multiple 3789 # places instead of default to request_parameters which isn't clearly documented 3790 if ( 3791 hasattr(model.requester, "fetch_properties_from_endpoint") 3792 and model.requester.fetch_properties_from_endpoint 3793 ): 3794 raise ValueError( 3795 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" 3796 ) 3797 3798 query_properties_definitions = [] 3799 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() 3800 if isinstance(request_parameter, QueryPropertiesModel): 3801 query_properties_key = key 3802 query_properties_definitions.append(request_parameter) 3803 3804 if len(query_properties_definitions) > 1: 3805 raise ValueError( 3806 f"request_parameters only supports defining one QueryProperties field, but found {len(query_properties_definitions)} usages" 3807 ) 3808 3809 if len(query_properties_definitions) == 1: 3810 query_properties = self._create_component_from_model( 3811 model=query_properties_definitions[0], stream_name=name, config=config 3812 ) 3813 3814 # Removes QueryProperties components from the interpolated mappings because it has been designed 3815 # to be used by the SimpleRetriever and will be resolved from the provider from the slice directly 3816 # instead of through jinja interpolation 3817 if hasattr(model.requester, "request_parameters") and isinstance( 3818 model.requester.request_parameters, Mapping 3819 ): 3820 model.requester.request_parameters = self._remove_query_properties( 3821 model.requester.request_parameters 3822 ) 3823 elif ( 3824 hasattr(model.requester, "fetch_properties_from_endpoint") 3825 and model.requester.fetch_properties_from_endpoint 3826 ): 3827 # todo: Deprecate this condition once dependent connectors migrate to query_properties 3828 query_properties_definition = QueryPropertiesModel( 3829 type="QueryProperties", 3830 property_list=model.requester.fetch_properties_from_endpoint, 3831 always_include_properties=None, 3832 property_chunking=None, 3833 ) # type: ignore # $parameters has a default value 3834 3835 query_properties = self.create_query_properties( 3836 model=query_properties_definition, 3837 stream_name=name, 3838 config=config, 3839 ) 3840 elif hasattr(model.requester, "query_properties") and model.requester.query_properties: 3841 query_properties = self.create_query_properties( 3842 model=model.requester.query_properties, 3843 stream_name=name, 3844 config=config, 3845 ) 3846 3847 requester = self._create_component_from_model( 3848 model=model.requester, 3849 decoder=decoder, 3850 name=name, 3851 query_properties_key=query_properties_key, 3852 use_cache=use_cache, 3853 config=config, 3854 ) 3855 3856 if not request_options_provider: 3857 request_options_provider = DefaultRequestOptionsProvider(parameters={}) 3858 if isinstance(request_options_provider, DefaultRequestOptionsProvider) and isinstance( 3859 partition_router, PartitionRouter 3860 ): 3861 request_options_provider = partition_router 3862 3863 paginator = ( 3864 self._create_component_from_model( 3865 model=model.paginator, 3866 config=config, 3867 url_base=_get_url(requester), 3868 extractor_model=model.record_selector.extractor, 3869 decoder=decoder, 3870 cursor_used_for_stop_condition=cursor if has_stop_condition_cursor else None, 3871 ) 3872 if model.paginator 3873 else NoPagination(parameters={}) 3874 ) 3875 3876 ignore_stream_slicer_parameters_on_paginated_requests = ( 3877 model.ignore_stream_slicer_parameters_on_paginated_requests or False 3878 ) 3879 3880 reads_parent_stream_lazily = bool( 3881 model.partition_router 3882 and isinstance(model.partition_router, SubstreamPartitionRouterModel) 3883 and any( 3884 parent_stream_config.lazy_read_pointer 3885 for parent_stream_config in model.partition_router.parent_stream_configs 3886 ) 3887 ) 3888 if reads_parent_stream_lazily and ( 3889 model.page_size_reduction 3890 or self._uses_reduce_page_size_action(getattr(model.requester, "error_handler", None)) 3891 ): 3892 # Checked outside of the LazySimpleRetriever branch below, which only applies on the first 3893 # sync of a stream: gating it on the absence of state would accept the same manifest from the 3894 # second sync onwards. LazySimpleRetriever paginates the parent's embedded pages, so there is 3895 # no page of its own to re-issue with a smaller page size. 3896 raise ValueError( 3897 f"`page_size_reduction` and the REDUCE_PAGE_SIZE response action are not supported when " 3898 f"reading a parent stream lazily. Remove either the page size reduction or the parent " 3899 f"stream's `lazy_read_pointer` for stream {name}." 3900 ) 3901 3902 if reads_parent_stream_lazily and ( 3903 model.request_window_splitting 3904 or self._uses_split_request_window_action( 3905 getattr(model.requester, "error_handler", None) 3906 ) 3907 ): 3908 # Same reasoning as the page_size_reduction check above: LazySimpleRetriever reads records 3909 # embedded in the parent's own pages rather than requesting a window of its own, so there is 3910 # nothing here for a split child window to be read from. 3911 raise ValueError( 3912 f"`request_window_splitting` and the SPLIT_REQUEST_WINDOW response action are not " 3913 f"supported when reading a parent stream lazily. Remove either the request window " 3914 f"splitting or the parent stream's `lazy_read_pointer` for stream {name}." 3915 ) 3916 3917 if reads_parent_stream_lazily and not bool( 3918 self._connector_state_manager.get_stream_state(name, None) 3919 ): 3920 if incremental_sync: 3921 if incremental_sync.type != "DatetimeBasedCursor": 3922 raise ValueError( 3923 f"LazySimpleRetriever only supports DatetimeBasedCursor. Found: {incremental_sync.type}." 3924 ) 3925 3926 elif incremental_sync.step or incremental_sync.cursor_granularity: 3927 raise ValueError( 3928 f"Found more that one slice per parent. LazySimpleRetriever only supports single slice read for stream - {name}." 3929 ) 3930 3931 if model.decoder and model.decoder.type != "JsonDecoder": 3932 raise ValueError( 3933 f"LazySimpleRetriever only supports JsonDecoder. Found: {model.decoder.type}." 3934 ) 3935 3936 return LazySimpleRetriever( 3937 name=name, 3938 paginator=paginator, 3939 primary_key=primary_key, 3940 requester=requester, 3941 record_selector=record_selector, 3942 stream_slicer=_NO_STREAM_SLICING, 3943 request_option_provider=request_options_provider, 3944 config=config, 3945 ignore_stream_slicer_parameters_on_paginated_requests=ignore_stream_slicer_parameters_on_paginated_requests, 3946 post_pagination_filter=post_pagination_filter, 3947 parameters=model.parameters or {}, 3948 ) 3949 3950 if ( 3951 model.record_selector.record_filter 3952 and model.pagination_reset 3953 and model.pagination_reset.limits 3954 ): 3955 raise ValueError("PaginationResetLimits are not supported while having record filter.") 3956 3957 request_window_splitting = self._create_request_window_splitting( 3958 model, name, cursor, incremental_sync, query_properties, file_uploader 3959 ) 3960 return SimpleRetriever( 3961 name=name, 3962 paginator=paginator, 3963 primary_key=primary_key, 3964 requester=requester, 3965 record_selector=record_selector, 3966 stream_slicer=_NO_STREAM_SLICING, 3967 request_option_provider=request_options_provider, 3968 config=config, 3969 ignore_stream_slicer_parameters_on_paginated_requests=ignore_stream_slicer_parameters_on_paginated_requests, 3970 additional_query_properties=query_properties, 3971 log_formatter=self._get_log_formatter(log_formatter, name), 3972 pagination_tracker_factory=self._create_pagination_tracker_factory( 3973 model.pagination_reset, cursor 3974 ), 3975 page_size_reduction=self._create_page_size_reduction( 3976 model, name, query_properties, file_uploader 3977 ), 3978 request_window_splitting=request_window_splitting, 3979 request_window_splitter=( 3980 cursor.split_request_window 3981 if request_window_splitting and hasattr(cursor, "split_request_window") 3982 else None 3983 ), 3984 post_pagination_filter=post_pagination_filter, 3985 parameters=model.parameters or {}, 3986 ) 3987 3988 def _create_page_size_reduction( 3989 self, 3990 model: SimpleRetrieverModel, 3991 name: str, 3992 query_properties: Optional[QueryProperties], 3993 file_uploader: Optional[DefaultFileUploader] = None, 3994 ) -> Optional[PageSizeReduction]: 3995 # A CustomRequester does not necessarily define an error handler. A CustomRequester that does define 3996 # one keeps it as a raw dict rather than a typed model, so this returns False for it as well. 3997 error_handler = getattr(model.requester, "error_handler", None) 3998 uses_action = self._uses_reduce_page_size_action(error_handler) 3999 if uses_action and not model.page_size_reduction: 4000 raise ValueError( 4001 f"Stream {name} has a response filter with the REDUCE_PAGE_SIZE action but the retriever does not " 4002 f"define `page_size_reduction`. Add a `page_size_reduction` block to the retriever." 4003 ) 4004 4005 if not model.page_size_reduction: 4006 return None 4007 4008 if not uses_action: 4009 # Not raised: a CustomErrorHandler can resolve to REDUCE_PAGE_SIZE without us being able to see 4010 # it, so the only safe reaction to a block we cannot tie to an action is a warning. Without it a 4011 # misspelled action leaves the feature silently dead on a stream that only exists because it 4012 # would otherwise fail. 4013 LOGGER.warning( 4014 f"Stream {name} defines `page_size_reduction` but no response filter with the " 4015 f"REDUCE_PAGE_SIZE action was found on its requester. The page size will never be reduced " 4016 f"unless a custom error handler resolves to that action." 4017 ) 4018 4019 self._validate_page_size_reduction_is_supported( 4020 model, name, query_properties, file_uploader 4021 ) 4022 4023 reset_policy = model.page_size_reduction.reset_policy 4024 return PageSizeReduction( 4025 reduction_factor=model.page_size_reduction.reduction_factor, # type: ignore[arg-type] # the schema defines a default 4026 minimum_page_size=model.page_size_reduction.minimum_page_size, # type: ignore[arg-type] # the schema defines a default 4027 max_attempts=model.page_size_reduction.max_attempts, # type: ignore[arg-type] # the schema defines a default 4028 backoff_seconds=model.page_size_reduction.backoff_seconds, # type: ignore[arg-type] # the schema defines a default 4029 retries_at_minimum_page_size=model.page_size_reduction.retries_at_minimum_page_size, # type: ignore[arg-type] # the schema defines a default 4030 failure_message=model.page_size_reduction.failure_message, 4031 reset_policy=PageSizeResetPolicy(reset_policy.value) 4032 if reset_policy is not None 4033 else PageSizeResetPolicy.NEVER, 4034 ) 4035 4036 def _validate_page_size_reduction_is_supported( 4037 self, 4038 model: SimpleRetrieverModel, 4039 name: str, 4040 query_properties: Optional[QueryProperties], 4041 file_uploader: Optional[DefaultFileUploader] = None, 4042 ) -> None: 4043 """ 4044 Page size reduction re-issues the same page with a smaller page size. That is only correct when the next 4045 page does not depend on the page size, and it only has an effect when the paginator injects the page size 4046 in the request. A custom pagination strategy is accepted when it can receive the reduced page size, which 4047 is checked by inspecting its signature rather than by recognizing its type. 4048 """ 4049 if query_properties: 4050 raise ValueError( 4051 f"`page_size_reduction` cannot be used together with query properties on stream {name}. Records " 4052 f"from the earlier property chunks have already been emitted when a chunk asks for a smaller page, " 4053 f"so retrying the page would emit them twice." 4054 ) 4055 4056 if file_uploader: 4057 raise ValueError( 4058 f"`page_size_reduction` cannot be used together with a `file_uploader` on stream {name}. The " 4059 f"file uploader sends one request per record from inside the page's record generator, so a " 4060 f"reduction asked for halfway through a page would re-emit the records already yielded by it." 4061 ) 4062 4063 if not isinstance(model.paginator, DefaultPaginatorModel): 4064 raise ValueError( 4065 f"`page_size_reduction` requires a DefaultPaginator on stream {name} so that the connector can " 4066 f"send a smaller page size." 4067 ) 4068 4069 if not model.paginator.page_size_option: 4070 raise ValueError( 4071 f"`page_size_reduction` requires `page_size_option` on the paginator of stream {name}: without it " 4072 f"the connector cannot tell the API to send a smaller page." 4073 ) 4074 4075 if isinstance(model.paginator.page_token_option, RequestPathModel): 4076 # A RequestPath page token is a full URL built by the API, and it already carries the page size the 4077 # API echoed back. The reduced page size is injected as a request option on top of that URL, so the 4078 # request goes out with the page size twice - the original one from the URL and the reduced one - 4079 # and which of the two the API honors is up to the API. Every page after the first would then keep 4080 # asking for the page size that just failed. 4081 raise ValueError( 4082 f"`page_size_reduction` does not support a `page_token_option` of type RequestPath on stream " 4083 f"{name}. The next page is then requested through a URL returned by the API, which already " 4084 f"carries the page size, so the reduced page size would be sent alongside the original one. Use " 4085 f"a CursorPagination strategy with a `page_token_option` of type RequestOption instead." 4086 ) 4087 4088 strategy = model.paginator.pagination_strategy 4089 if isinstance(strategy, PageIncrementModel): 4090 raise ValueError( 4091 f"`page_size_reduction` does not support the PageIncrement pagination strategy used by stream " 4092 f"{name}. Pages are addressed as page number * page size, so a smaller page size shifts every " 4093 f"following page boundary and would skip records. Use OffsetIncrement or CursorPagination." 4094 ) 4095 if isinstance(strategy, CustomPaginationStrategyModel): 4096 # A custom strategy is written by the same person enabling the reduction, so the 4097 # question is not whether we recognize it but whether it can be told the reduced 4098 # page size. Checking the signature keeps a strategy that would raise TypeError 4099 # mid-sync from being accepted at config time. 4100 custom_class = self._get_class_from_fully_qualified_class_name(strategy.class_name) 4101 if isinstance(custom_class, type) and issubclass(custom_class, PageIncrement): 4102 # `PageIncrement.next_page_token` declares `page_size_override` only to reject it, so a 4103 # subclass that does not override the method would pass the signature check below and only 4104 # fail once the first reduction is requested, mid-sync. 4105 raise ValueError( 4106 f"`page_size_reduction` does not support the PageIncrement pagination strategy that the " 4107 f"custom pagination strategy {strategy.class_name} used by stream {name} inherits from. " 4108 f"Pages are addressed as page number * page size, so a smaller page size shifts every " 4109 f"following page boundary and would skip records. Use OffsetIncrement or CursorPagination." 4110 ) 4111 try: 4112 parameters = inspect.signature(custom_class.next_page_token).parameters 4113 except (AttributeError, TypeError, ValueError) as exception: 4114 raise ValueError( 4115 f"`page_size_reduction` could not check the signature of `next_page_token` on the custom " 4116 f"pagination strategy {strategy.class_name} used by stream {name}: {exception}. Make sure " 4117 f"`class_name` points at a PaginationStrategy subclass whose `next_page_token` accepts a " 4118 f"`page_size_override` keyword argument." 4119 ) 4120 accepts_override = "page_size_override" in parameters or any( 4121 parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters.values() 4122 ) 4123 if not accepts_override: 4124 raise ValueError( 4125 f"`page_size_reduction` requires the custom pagination strategy " 4126 f"{strategy.class_name} used by stream {name} to accept a `page_size_override` keyword " 4127 f"argument in `next_page_token`, so that it can honor the reduced page size. Add " 4128 f"`page_size_override: Optional[int] = None` to its signature; a strategy that does not " 4129 f"use its page size as a stop condition can ignore the value." 4130 ) 4131 elif not isinstance(strategy, (CursorPaginationModel, OffsetIncrementModel)): 4132 raise ValueError( 4133 f"`page_size_reduction` only supports the CursorPagination, OffsetIncrement and " 4134 f"CustomPaginationStrategy pagination strategies. Stream {name} uses " 4135 f"{type(strategy).__name__}." 4136 ) 4137 else: 4138 # A CustomPaginationStrategy carries its page size in its own code, so this is only checkable 4139 # for the strategies the CDK defines. `page_size` is optional on both of them, and without it 4140 # `get_page_size` returns None, the paginator injects nothing, and the reduction is a dead end 4141 # that only surfaces on the first failing response - after records have been emitted. 4142 self._validate_page_size_is_reducible(strategy, model.page_size_reduction, name) 4143 if isinstance(strategy, CursorPaginationModel): 4144 self._validate_stop_condition_is_reduction_aware( 4145 strategy, model.page_size_reduction, name 4146 ) 4147 4148 @staticmethod 4149 def _validate_stop_condition_is_reduction_aware( 4150 strategy: CursorPaginationModel, 4151 page_size_reduction: Optional[PageSizeReductionModel], 4152 name: str, 4153 ) -> None: 4154 """ 4155 A `stop_condition` comparing `last_page_size` to a hardcoded page size reads a full reduced page as a 4156 short page and ends the pagination early, dropping the rest of the partition without failing. The 4157 strategy exposes the page size that was actually requested as `page_size`, so the condition can be 4158 written correctly - but only if it is, which is what this checks. 4159 4160 The condition is parsed as a Jinja expression rather than matched as a string: only the AST tells 4161 `page_size`, which follows the reduction, apart from `config['page_size']`, which does not, and only the 4162 AST tells an inequality, which a reduction can invalidate, apart from `last_page_size == 0`, which it 4163 cannot. A condition that never names `last_page_size` is not waved through: a count read from the 4164 response body - `{{ response['data'] | length < 100 }}` - truncates in exactly the same way, and which 4165 response field counts the records of a page is not knowable here. A shape the analysis does not 4166 understand is warned about rather than rejected - this runs at stream construction, so a false 4167 rejection takes `check`, `discover` and `read` down with it. 4168 """ 4169 stop_condition = strategy.stop_condition 4170 if not stop_condition: 4171 return 4172 4173 minimum_page_size = ( 4174 page_size_reduction.minimum_page_size if page_size_reduction else None 4175 ) or 1 4176 verdict, reason = classify_stop_condition(stop_condition, minimum_page_size) 4177 if verdict is StopConditionSafety.TRUNCATES: 4178 raise ValueError( 4179 f"`page_size_reduction` on stream {name} cannot be used with the `stop_condition` " 4180 f"{stop_condition!r}: {reason}. The pagination would then end early, silently dropping the " 4181 f"rest of the partition. Compare against the `page_size` interpolation variable instead, " 4182 f"which holds the page size that was actually requested (for example " 4183 f"`{{{{ last_page_size < page_size }}}}`), or test the page for emptiness with " 4184 f"`{{{{ last_page_size == 0 }}}}`." 4185 ) 4186 if verdict is StopConditionSafety.UNKNOWN: 4187 LOGGER.warning( 4188 f"Stream {name} uses `page_size_reduction` with the `stop_condition` {stop_condition!r}, " 4189 f"which could not be checked against the reduction because {reason}. Make sure a page that is " 4190 f"full at a reduced page size does not satisfy it, otherwise the pagination ends early and the " 4191 f"rest of the partition is silently dropped. Comparing against the `page_size` interpolation " 4192 f"variable, which holds the page size that was actually requested, is always safe." 4193 ) 4194 4195 @staticmethod 4196 def _validate_page_size_is_reducible( 4197 strategy: Union[CursorPaginationModel, OffsetIncrementModel], 4198 page_size_reduction: Optional[PageSizeReductionModel], 4199 name: str, 4200 ) -> None: 4201 page_size = strategy.page_size 4202 if page_size is None: 4203 raise ValueError( 4204 f"`page_size_reduction` requires `page_size` on the pagination strategy of stream {name}: " 4205 f"without it the paginator does not send a page size, so there is nothing to reduce." 4206 ) 4207 4208 # The schema allows a string so that the page size can be interpolated. `page_size` is 4209 # `Optional[Union[int, str]]` and pydantic v1 tries `int` first, so both `100` and `"100"` arrive as 4210 # an int and only a genuinely non-numeric template stays a str. Such a template is only known once 4211 # the config is available, so it is left to the runtime check in `PageSizeReducer.reduce`. 4212 try: 4213 configured_page_size: Optional[int] = int(page_size) 4214 except ValueError: 4215 configured_page_size = None 4216 4217 minimum_page_size = ( 4218 page_size_reduction.minimum_page_size if page_size_reduction else None 4219 ) or 1 4220 if configured_page_size is not None and configured_page_size <= minimum_page_size: 4221 raise ValueError( 4222 f"`page_size_reduction` on stream {name} can never reduce its page size: the pagination " 4223 f"strategy's `page_size` is {configured_page_size} and `minimum_page_size` is " 4224 f"{minimum_page_size}. Lower `minimum_page_size` or raise `page_size`." 4225 ) 4226 4227 # Each reduction divides the page size by `reduction_factor` and spends one attempt, so an unbroken 4228 # run of failures bottoms out at `page_size / reduction_factor ** max_attempts` whatever 4229 # `minimum_page_size` says. Only warned about, and not raised: pages that succeed in between restart 4230 # the budget while `NEVER` keeps the page size, so the floor is reachable over a partition even when 4231 # it is out of reach of a single run - and a manifest that deliberately gives up earlier than its 4232 # floor is not wrong, only worth pointing out. 4233 reduction_factor = ( 4234 page_size_reduction.reduction_factor if page_size_reduction else None 4235 ) or 2.0 4236 max_attempts = (page_size_reduction.max_attempts if page_size_reduction else None) or 5 4237 # Only when there is a floor worth reaching. The default of 1 is out of reach of the default budget on 4238 # any page size above 32, so this would otherwise fire on nearly every stream that opts in - and 4239 # `__fields_set__` would not help, since it records that a value was supplied and not that it differs 4240 # from the default, so spelling `minimum_page_size: 1` out longhand would earn the warning. 4241 if ( 4242 configured_page_size is not None 4243 and minimum_page_size > 1 4244 and reduction_factor**max_attempts < configured_page_size / minimum_page_size 4245 ): 4246 reachable_page_size = max( 4247 minimum_page_size, int(configured_page_size // reduction_factor**max_attempts) 4248 ) 4249 LOGGER.warning( 4250 f"Stream {name} sets `minimum_page_size` to {minimum_page_size}, which `page_size_reduction` " 4251 f"cannot reach in one run of failing pages: `max_attempts` is {max_attempts} and " 4252 f"`reduction_factor` is {reduction_factor}, so {max_attempts} reductions of a page size of " 4253 f"{configured_page_size} stop at {reachable_page_size} records per page and the sync then " 4254 f"fails with a transient error. Whichever of the two bounds is tighter wins; reaching " 4255 f"{minimum_page_size} in one run needs `max_attempts` of at least " 4256 f"{math.ceil(math.log(configured_page_size / minimum_page_size, reduction_factor))}." 4257 ) 4258 4259 def _reject_reduce_page_size_action(self, requester: Any, description: str) -> None: 4260 """ 4261 `error_handler` is defined on `HttpRequester`, which is referenced by requesters that have no page of 4262 their own, so REDUCE_PAGE_SIZE is schema-legal in places where nothing can honor it. Only 4263 `SimpleRetriever._read_pages` re-issues a page, so every other requester is rejected here rather than 4264 surfacing the exception mid-sync as a generic failure. 4265 """ 4266 if requester is None: 4267 return 4268 if self._uses_reduce_page_size_action(getattr(requester, "error_handler", None)): 4269 raise ValueError( 4270 f"The REDUCE_PAGE_SIZE response action is not supported on the {description}: only the main " 4271 f"requester of a SimpleRetriever can re-issue its page with a smaller page size. Use a " 4272 f"different action on that error handler." 4273 ) 4274 4275 def _uses_reduce_page_size_action(self, error_handler: Any) -> bool: 4276 if isinstance(error_handler, CompositeErrorHandlerModel): 4277 return any( 4278 self._uses_reduce_page_size_action(nested) 4279 for nested in error_handler.error_handlers 4280 ) 4281 if isinstance(error_handler, DefaultErrorHandlerModel): 4282 return any( 4283 response_filter.action == HttpResponseFilterActionModel.REDUCE_PAGE_SIZE 4284 for response_filter in error_handler.response_filters or [] 4285 ) 4286 # A CustomErrorHandler can return any action and we cannot inspect it, so we do not validate it. 4287 return False 4288 4289 def _create_request_window_splitting( 4290 self, 4291 model: SimpleRetrieverModel, 4292 name: str, 4293 cursor: Optional[Cursor], 4294 incremental_sync: Optional[ 4295 Union[IncrementingCountCursorModel, DatetimeBasedCursorModel] 4296 ] = None, 4297 query_properties: Optional[QueryProperties] = None, 4298 file_uploader: Optional[DefaultFileUploader] = None, 4299 ) -> Optional[RequestWindowSplitting]: 4300 # A CustomRequester does not necessarily define an error handler. A CustomRequester that does define 4301 # one keeps it as a raw dict rather than a typed model, so this returns False for it as well. 4302 error_handler = getattr(model.requester, "error_handler", None) 4303 uses_action = self._uses_split_request_window_action(error_handler) 4304 if uses_action and not model.request_window_splitting: 4305 raise ValueError( 4306 f"Stream {name} has a response filter with the SPLIT_REQUEST_WINDOW action but the " 4307 f"retriever does not define `request_window_splitting`. Add a `request_window_splitting` " 4308 f"block to the retriever." 4309 ) 4310 4311 if not model.request_window_splitting: 4312 return None 4313 4314 if not uses_action: 4315 # Not raised: a CustomErrorHandler can resolve to SPLIT_REQUEST_WINDOW without us being able to 4316 # see it, and custom code can raise RequestWindowSplitRequiredException directly without 4317 # going through any error handler at all - so the only safe reaction to a block we cannot tie to 4318 # a known trigger is a warning. 4319 LOGGER.warning( 4320 f"Stream {name} defines `request_window_splitting` but no response filter with the " 4321 f"SPLIT_REQUEST_WINDOW action was found on its requester. The request window will never " 4322 f"be split unless a custom error handler or custom code resolves to that action." 4323 ) 4324 4325 self._validate_request_window_splitting_is_supported( 4326 name, 4327 cursor, 4328 incremental_sync, 4329 model.request_window_splitting.min_split_window, 4330 query_properties, 4331 file_uploader, 4332 ) 4333 4334 return RequestWindowSplitting( 4335 failure_message=model.request_window_splitting.failure_message, 4336 min_split_window=parse_duration(model.request_window_splitting.min_split_window) 4337 if model.request_window_splitting.min_split_window 4338 else None, 4339 ) 4340 4341 def _validate_request_window_splitting_is_supported( 4342 self, 4343 name: str, 4344 cursor: Optional[Cursor], 4345 incremental_sync: Optional[ 4346 Union[IncrementingCountCursorModel, DatetimeBasedCursorModel] 4347 ] = None, 4348 min_split_window: Optional[str] = None, 4349 query_properties: Optional[QueryProperties] = None, 4350 file_uploader: Optional[DefaultFileUploader] = None, 4351 ) -> None: 4352 """ 4353 Request window splitting replaces a failing slice with smaller children derived from the stream's own 4354 cursor, so it only makes sense on a stream whose cursor exposes a `split_request_window` method and it 4355 is rejected for the same structural reasons `page_size_reduction` is: `additional_query_properties` and 4356 a `file_uploader` both mean records of the failing window may already have been emitted by the time the 4357 split is requested, from inside the record generator rather than from a page boundary this retriever 4358 controls. 4359 """ 4360 if not hasattr(cursor, "split_request_window"): 4361 raise ValueError( 4362 f"`request_window_splitting` requires an incremental cursor that supports window splitting on " 4363 f"stream {name}. Found {type(cursor).__name__ if cursor is not None else 'no cursor'}: this is " 4364 f"only supported today for a `DatetimeBasedCursor` with `cursor_granularity` set." 4365 ) 4366 4367 if not ( 4368 isinstance(incremental_sync, DatetimeBasedCursorModel) 4369 and incremental_sync.cursor_granularity 4370 ): 4371 # `cursor_granularity` is both the smallest window the connector will ever request and what keeps 4372 # two child windows from overlapping at their shared edge, so `split_request_window` cannot split 4373 # at all without it - checked here, on the manifest model, rather than by reaching into the 4374 # cursor's private state from outside the class it belongs to. 4375 raise ValueError( 4376 f"`request_window_splitting` requires `cursor_granularity` on the `DatetimeBasedCursor` of " 4377 f"stream {name}: without it there is no smallest window to stop splitting at, and no way to " 4378 f"keep two child windows from overlapping at their shared edge." 4379 ) 4380 4381 if incremental_sync.is_client_side_incremental: 4382 # A client-side-incremental cursor filters records after they are read rather than sending the 4383 # window as request parameters, so every child window sends the exact same request as its parent: 4384 # splitting changes nothing, and the sync would keep splitting all the way to `min_split_window` 4385 # or the safety-net depth before failing. 4386 raise ValueError( 4387 f"`request_window_splitting` cannot be used together with `is_client_side_incremental` on " 4388 f"stream {name}: the window is filtered client-side rather than sent to the API, so splitting " 4389 f"it would not change the request and could not resolve a SPLIT_REQUEST_WINDOW response." 4390 ) 4391 4392 parsed_cursor_granularity = parse_duration(incremental_sync.cursor_granularity) 4393 if isinstance( 4394 parsed_cursor_granularity, datetime.timedelta 4395 ) and parsed_cursor_granularity <= datetime.timedelta(0): 4396 # Passes the truthy check above (the string is non-empty), but a zero-or-negative granularity can 4397 # never produce a child strictly smaller than the parent. 4398 raise ValueError( 4399 f"`request_window_splitting` requires a `cursor_granularity` greater than zero on stream " 4400 f"{name}: `{incremental_sync.cursor_granularity}` parses to a zero-length duration, which can " 4401 f"never produce a smaller child window." 4402 ) 4403 4404 smallest_format_unit = self._smallest_datetime_format_unit(incremental_sync.datetime_format) 4405 if ( 4406 smallest_format_unit is not None 4407 and isinstance(parsed_cursor_granularity, datetime.timedelta) 4408 and parsed_cursor_granularity < smallest_format_unit 4409 ): 4410 # Two children split closer together than `datetime_format` can render would format to the same 4411 # boundary value instead of splitting. 4412 raise ValueError( 4413 f"`request_window_splitting` requires `cursor_granularity` to be no finer than what " 4414 f"`datetime_format` can represent on stream {name}: `datetime_format` " 4415 f"{incremental_sync.datetime_format!r} cannot represent a value finer than " 4416 f"{smallest_format_unit}, but `cursor_granularity` {incremental_sync.cursor_granularity!r} is " 4417 f"finer than that. Use a `datetime_format` precise enough to represent `cursor_granularity`, " 4418 f"or a coarser `cursor_granularity`." 4419 ) 4420 4421 if min_split_window: 4422 parsed_min_split_window = parse_duration(min_split_window) 4423 if not isinstance( 4424 parsed_min_split_window, datetime.timedelta 4425 ) or parsed_min_split_window <= datetime.timedelta(0): 4426 # A duration with years or months (e.g. `P1M`) parses to an `isodate.Duration`, which has no 4427 # fixed length to compare a window's span against - `split_request_window` would raise 4428 # mid-sync trying to. A zero-or-negative duration would never stop splitting. 4429 raise ValueError( 4430 f"`min_split_window` must be a positive duration expressible as a fixed number of " 4431 f"days/hours/minutes/seconds on stream {name}: `{min_split_window}` is not." 4432 ) 4433 4434 if query_properties: 4435 raise ValueError( 4436 f"`request_window_splitting` cannot be used together with query properties on stream {name}. " 4437 f"Records from the earlier property chunks may have already been emitted when a chunk asks to " 4438 f"split the window, so splitting and re-reading it could duplicate them." 4439 ) 4440 4441 if file_uploader: 4442 raise ValueError( 4443 f"`request_window_splitting` cannot be used together with a `file_uploader` on stream {name}. " 4444 f"The file uploader sends one request per record from inside the window's record generator, so " 4445 f"a split requested partway through could re-emit records already yielded by it." 4446 ) 4447 4448 @staticmethod 4449 def _smallest_datetime_format_unit(datetime_format: str) -> Optional[datetime.timedelta]: 4450 """ 4451 :return: the finest duration `datetime_format` can distinguish, per 4452 `_DATETIME_FORMAT_DIRECTIVE_GRANULARITY`, or `None` if it contains none of those directives. 4453 """ 4454 matches = [ 4455 granularity 4456 for directive, granularity in _DATETIME_FORMAT_DIRECTIVE_GRANULARITY 4457 if directive in datetime_format 4458 ] 4459 return min(matches) if matches else None 4460 4461 def _reject_split_request_window_action(self, requester: Any, description: str) -> None: 4462 """ 4463 `error_handler` is defined on `HttpRequester`, which is referenced by requesters that have no window of 4464 their own to split, so SPLIT_REQUEST_WINDOW is schema-legal in places where nothing can honor it. Only 4465 the main requester of a `SimpleRetriever` reads a cursor-sliced window, so every other requester is 4466 rejected here rather than surfacing the exception mid-sync as a generic failure. 4467 """ 4468 if requester is None: 4469 return 4470 if self._uses_split_request_window_action(getattr(requester, "error_handler", None)): 4471 raise ValueError( 4472 f"The SPLIT_REQUEST_WINDOW response action is not supported on the {description}: only the " 4473 f"main requester of a SimpleRetriever reads a window that can be split. Use a different " 4474 f"action on that error handler." 4475 ) 4476 4477 def _uses_split_request_window_action(self, error_handler: Any) -> bool: 4478 if isinstance(error_handler, CompositeErrorHandlerModel): 4479 return any( 4480 self._uses_split_request_window_action(nested) 4481 for nested in error_handler.error_handlers 4482 ) 4483 if isinstance(error_handler, DefaultErrorHandlerModel): 4484 return any( 4485 response_filter.action == HttpResponseFilterActionModel.SPLIT_REQUEST_WINDOW 4486 for response_filter in error_handler.response_filters or [] 4487 ) 4488 # A CustomErrorHandler can return any action and we cannot inspect it, so we do not validate it. 4489 return False 4490 4491 def _create_pagination_tracker_factory( 4492 self, model: Optional[PaginationResetModel], cursor: Cursor 4493 ) -> Callable[[], PaginationTracker]: 4494 if model is None: 4495 return lambda: PaginationTracker() 4496 4497 # Until we figure out a way to use any cursor for PaginationTracker, we will have to have this cursor selector logic 4498 cursor_factory: Callable[[], Optional[ConcurrentCursor]] = lambda: None 4499 if model.action == PaginationResetActionModel.RESET: 4500 # in that case, we will let cursor_factory to return None even if the stream has a cursor 4501 pass 4502 elif model.action == PaginationResetActionModel.SPLIT_USING_CURSOR: 4503 if isinstance(cursor, ConcurrentCursor): 4504 cursor_factory = lambda: cursor.copy_without_state() # type: ignore # the if condition validates that it is a ConcurrentCursor 4505 elif isinstance(cursor, ConcurrentPerPartitionCursor): 4506 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 4507 {}, datetime.timedelta(0) 4508 ) 4509 elif not isinstance(cursor, FinalStateCursor): 4510 LOGGER.warning( 4511 "Unknown cursor for PaginationTracker. Pagination resets might not work properly" 4512 ) 4513 else: 4514 raise ValueError(f"Unknown PaginationReset action: {model.action}") 4515 4516 limit = model.limits.number_of_records if model and model.limits else None 4517 return lambda: PaginationTracker(cursor_factory(), limit) 4518 4519 def _get_log_formatter( 4520 self, log_formatter: Callable[[Response], Any] | None, name: str 4521 ) -> Callable[[Response], Any] | None: 4522 if self._should_limit_slices_fetched(): 4523 return ( 4524 ( 4525 lambda response: format_http_message( 4526 response, 4527 f"Stream '{name}' request", 4528 f"Request performed in order to extract records for stream '{name}'", 4529 name, 4530 ) 4531 ) 4532 if not log_formatter 4533 else log_formatter 4534 ) 4535 return None 4536 4537 def _should_limit_slices_fetched(self) -> bool: 4538 """ 4539 Returns True if the number of slices fetched should be limited, False otherwise. 4540 This is used to limit the number of slices fetched during tests. 4541 """ 4542 return bool(self._limit_slices_fetched or self._emit_connector_builder_messages) 4543 4544 @staticmethod 4545 def _has_query_properties_in_request_parameters( 4546 requester: Union[HttpRequesterModel, CustomRequesterModel], 4547 ) -> bool: 4548 if not hasattr(requester, "request_parameters"): 4549 return False 4550 request_parameters = requester.request_parameters 4551 if request_parameters and isinstance(request_parameters, Mapping): 4552 for request_parameter in request_parameters.values(): 4553 if isinstance(request_parameter, QueryPropertiesModel): 4554 return True 4555 return False 4556 4557 @staticmethod 4558 def _remove_query_properties( 4559 request_parameters: Mapping[str, Union[str, QueryPropertiesModel]], 4560 ) -> Mapping[str, str]: 4561 return { 4562 parameter_field: request_parameter 4563 for parameter_field, request_parameter in request_parameters.items() 4564 if not isinstance(request_parameter, QueryPropertiesModel) 4565 } 4566 4567 def create_state_delegating_stream( 4568 self, 4569 model: StateDelegatingStreamModel, 4570 config: Config, 4571 **kwargs: Any, 4572 ) -> DefaultStream: 4573 if ( 4574 model.full_refresh_stream.name != model.name 4575 or model.name != model.incremental_stream.name 4576 ): 4577 raise ValueError( 4578 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}." 4579 ) 4580 4581 # Resolve api_retention_period with config context (supports Jinja2 interpolation) 4582 resolved_retention_period: Optional[str] = None 4583 if model.api_retention_period: 4584 interpolated_retention = InterpolatedString.create( 4585 model.api_retention_period, parameters=model.parameters or {} 4586 ) 4587 resolved_value = interpolated_retention.eval(config=config) 4588 if resolved_value: 4589 resolved_retention_period = str(resolved_value) 4590 4591 if resolved_retention_period: 4592 for stream_model in (model.full_refresh_stream, model.incremental_stream): 4593 if isinstance(stream_model.incremental_sync, IncrementingCountCursorModel): 4594 raise ValueError( 4595 f"Stream '{model.name}' uses IncrementingCountCursor which is not supported " 4596 f"with api_retention_period. IncrementingCountCursor does not use datetime-based " 4597 f"cursors, so cursor age validation cannot be performed." 4598 ) 4599 4600 stream_state = self._connector_state_manager.get_stream_state(model.name, None) 4601 4602 if not stream_state: 4603 return self._create_component_from_model( # type: ignore[no-any-return] 4604 model.full_refresh_stream, config=config, **kwargs 4605 ) 4606 4607 incremental_stream: DefaultStream = self._create_component_from_model( 4608 model.incremental_stream, config=config, **kwargs 4609 ) # type: ignore[assignment] 4610 4611 # Only run cursor age validation for streams that are in the configured 4612 # catalog (or when no catalog was provided, e.g. during discover / connector 4613 # builder). Streams not selected by the user but instantiated as parent-stream 4614 # dependencies must not go through this path because it emits state messages 4615 # that the destination does not know about, causing "Stream not found" crashes. 4616 stream_is_in_catalog = ( 4617 not self._stream_name_to_configured_stream # no catalog → validate by default 4618 or model.name in self._stream_name_to_configured_stream 4619 ) 4620 if resolved_retention_period and stream_is_in_catalog: 4621 full_refresh_stream: DefaultStream = self._create_component_from_model( 4622 model.full_refresh_stream, config=config, **kwargs 4623 ) # type: ignore[assignment] 4624 if self._is_cursor_older_than_retention_period( 4625 stream_state, 4626 full_refresh_stream.cursor, 4627 incremental_stream.cursor, 4628 resolved_retention_period, 4629 model.name, 4630 ): 4631 # Clear state BEFORE constructing the full_refresh_stream so that 4632 # its cursor starts from start_date instead of the stale cursor. 4633 self._connector_state_manager.update_state_for_stream(model.name, None, {}) 4634 state_message = self._connector_state_manager.create_state_message(model.name, None) 4635 self._message_repository.emit_message(state_message) 4636 return self._create_component_from_model( # type: ignore[no-any-return] 4637 model.full_refresh_stream, config=config, **kwargs 4638 ) 4639 4640 return incremental_stream 4641 4642 @staticmethod 4643 def _is_cursor_older_than_retention_period( 4644 stream_state: Mapping[str, Any], 4645 full_refresh_cursor: Cursor, 4646 incremental_cursor: Cursor, 4647 api_retention_period: str, 4648 stream_name: str, 4649 ) -> bool: 4650 """Check if the cursor value in the state is older than the API's retention period. 4651 4652 Checks cursors in sequence: full refresh cursor first, then incremental cursor. 4653 FinalStateCursor returns now() for completed full refresh state (NO_CURSOR_STATE_KEY), 4654 which is always within retention, so we use incremental. For other states, it returns 4655 None and we fall back to checking the incremental cursor. 4656 4657 Returns True if the cursor is older than the retention period (should use full refresh). 4658 Returns False if the cursor is within the retention period (safe to use incremental). 4659 """ 4660 retention_duration = parse_duration(api_retention_period) 4661 retention_cutoff = datetime.datetime.now(datetime.timezone.utc) - retention_duration 4662 4663 # Check full refresh cursor first 4664 cursor_datetime = full_refresh_cursor.get_cursor_datetime_from_state(stream_state) 4665 4666 # If full refresh cursor returns None, check incremental cursor 4667 if cursor_datetime is None: 4668 cursor_datetime = incremental_cursor.get_cursor_datetime_from_state(stream_state) 4669 4670 if cursor_datetime is None: 4671 # Neither cursor could parse the state - fall back to full refresh to be safe 4672 return True 4673 4674 if cursor_datetime < retention_cutoff: 4675 logging.warning( 4676 f"Stream '{stream_name}' has a cursor value older than " 4677 f"the API's retention period of {api_retention_period} " 4678 f"(cutoff: {retention_cutoff.isoformat()}). " 4679 f"Falling back to full refresh to avoid data loss." 4680 ) 4681 return True 4682 4683 return False 4684 4685 def _get_state_delegating_stream_model( 4686 self, 4687 model: StateDelegatingStreamModel, 4688 parent_state: Optional[Mapping[str, Any]] = None, 4689 ) -> DeclarativeStreamModel: 4690 """Return the appropriate underlying stream model based on state.""" 4691 return ( 4692 model.incremental_stream 4693 if self._connector_state_manager.get_stream_state(model.name, None) or parent_state 4694 else model.full_refresh_stream 4695 ) 4696 4697 _OPTIONAL_ASYNC_STATUS_FIELDS = {"skipped"} 4698 4699 def _create_async_job_status_mapping( 4700 self, model: AsyncJobStatusMapModel, config: Config, **kwargs: Any 4701 ) -> Mapping[str, AsyncJobStatus]: 4702 api_status_to_cdk_status = {} 4703 for cdk_status, api_statuses in model.dict().items(): 4704 if cdk_status == "type": 4705 # This is an element of the dict because of the typing of the CDK but it is not a CDK status 4706 continue 4707 4708 if api_statuses is None: 4709 if cdk_status in self._OPTIONAL_ASYNC_STATUS_FIELDS: 4710 continue 4711 raise ValueError( 4712 f"Required CDK status '{cdk_status}' has no API statuses mapped. " 4713 f"Please provide at least an empty list for required status fields." 4714 ) 4715 4716 for status in api_statuses: 4717 if status in api_status_to_cdk_status: 4718 raise ValueError( 4719 f"API status {status} is already set for CDK status {cdk_status}. Please ensure API statuses are only provided once" 4720 ) 4721 api_status_to_cdk_status[status] = self._get_async_job_status(cdk_status) 4722 return api_status_to_cdk_status 4723 4724 def _get_async_job_status(self, status: str) -> AsyncJobStatus: 4725 match status: 4726 case "running": 4727 return AsyncJobStatus.RUNNING 4728 case "completed": 4729 return AsyncJobStatus.COMPLETED 4730 case "failed": 4731 return AsyncJobStatus.FAILED 4732 case "timeout": 4733 return AsyncJobStatus.TIMED_OUT 4734 case "skipped": 4735 return AsyncJobStatus.SKIPPED 4736 case _: 4737 raise ValueError(f"Unsupported CDK status {status}") 4738 4739 def create_async_retriever( 4740 self, 4741 model: AsyncRetrieverModel, 4742 config: Config, 4743 *, 4744 name: str, 4745 primary_key: Optional[ 4746 Union[str, List[str], List[List[str]]] 4747 ], # this seems to be needed to match create_simple_retriever 4748 stream_slicer: Optional[StreamSlicer], 4749 client_side_incremental_sync: Optional[Dict[str, Any]] = None, 4750 transformations: List[RecordTransformation], 4751 **kwargs: Any, 4752 ) -> AsyncRetriever: 4753 if model.download_target_requester and not model.download_target_extractor: 4754 raise ValueError( 4755 f"`download_target_extractor` required if using a `download_target_requester`" 4756 ) 4757 4758 for requester_field in ( 4759 "creation_requester", 4760 "polling_requester", 4761 "download_requester", 4762 "download_target_requester", 4763 "abort_requester", 4764 "delete_requester", 4765 ): 4766 self._reject_reduce_page_size_action( 4767 getattr(model, requester_field, None), 4768 f"`{requester_field}` of the AsyncRetriever of stream {name}", 4769 ) 4770 self._reject_split_request_window_action( 4771 getattr(model, requester_field, None), 4772 f"`{requester_field}` of the AsyncRetriever of stream {name}", 4773 ) 4774 4775 def _get_download_retriever( 4776 requester: Requester, extractor: RecordExtractor, _decoder: Decoder 4777 ) -> SimpleRetriever: 4778 self._count_dropped_empty_records(extractor) 4779 # We create a record selector for the download retriever 4780 # with no schema normalization and no transformations, neither record filter 4781 # as all this occurs in the record_selector of the AsyncRetriever 4782 record_selector = RecordSelector( 4783 extractor=extractor, 4784 name=name, 4785 record_filter=None, 4786 transformations=[], 4787 schema_normalization=TypeTransformer(TransformConfig.NoTransform), 4788 config=config, 4789 parameters={}, 4790 ) 4791 paginator = ( 4792 self._create_component_from_model( 4793 model=model.download_paginator, 4794 decoder=_decoder, 4795 config=config, 4796 url_base="", 4797 # Only read by the `union` rejection of `OffsetIncrement`. 4798 extractor_model=( 4799 model.download_extractor 4800 if isinstance(model.download_extractor, CombinedExtractorModel) 4801 else None 4802 ), 4803 ) 4804 if model.download_paginator 4805 else NoPagination(parameters={}) 4806 ) 4807 4808 return SimpleRetriever( 4809 requester=requester, 4810 record_selector=record_selector, 4811 primary_key=None, 4812 name=name, 4813 paginator=paginator, 4814 config=config, 4815 parameters={}, 4816 log_formatter=self._get_log_formatter(None, name), 4817 ) 4818 4819 def _get_job_timeout() -> datetime.timedelta: 4820 user_defined_timeout: Optional[int] = ( 4821 int( 4822 InterpolatedString.create( 4823 str(model.polling_job_timeout), 4824 parameters={}, 4825 ).eval(config) 4826 ) 4827 if model.polling_job_timeout 4828 else None 4829 ) 4830 4831 # check for user defined timeout during the test read or 15 minutes 4832 test_read_timeout = datetime.timedelta(minutes=user_defined_timeout or 15) 4833 # default value for non-connector builder is 60 minutes. 4834 default_sync_timeout = datetime.timedelta(minutes=user_defined_timeout or 60) 4835 4836 return ( 4837 test_read_timeout if self._emit_connector_builder_messages else default_sync_timeout 4838 ) 4839 4840 decoder = ( 4841 self._create_component_from_model(model=model.decoder, config=config) 4842 if model.decoder 4843 else JsonDecoder(parameters={}) 4844 ) 4845 record_selector = self._create_component_from_model( 4846 model=model.record_selector, 4847 config=config, 4848 decoder=decoder, 4849 name=name, 4850 transformations=transformations, 4851 client_side_incremental_sync=client_side_incremental_sync, 4852 ) 4853 4854 stream_slicer = stream_slicer or SinglePartitionRouter(parameters={}) 4855 if self._should_limit_slices_fetched(): 4856 stream_slicer = cast( 4857 StreamSlicer, 4858 StreamSlicerTestReadDecorator( 4859 wrapped_slicer=stream_slicer, 4860 maximum_number_of_slices=self._limit_slices_fetched or 5, 4861 ), 4862 ) 4863 4864 creation_requester = self._create_component_from_model( 4865 model=model.creation_requester, 4866 decoder=decoder, 4867 config=config, 4868 name=f"job creation - {name}", 4869 ) 4870 polling_requester = self._create_component_from_model( 4871 model=model.polling_requester, 4872 decoder=decoder, 4873 config=config, 4874 name=f"job polling - {name}", 4875 ) 4876 job_download_components_name = f"job download - {name}" 4877 download_decoder = ( 4878 self._create_component_from_model(model=model.download_decoder, config=config) 4879 if model.download_decoder 4880 else JsonDecoder(parameters={}) 4881 ) 4882 download_extractor = ( 4883 self._create_component_from_model( 4884 model=model.download_extractor, 4885 config=config, 4886 decoder=download_decoder, 4887 parameters=model.parameters, 4888 ) 4889 if model.download_extractor 4890 else DpathExtractor( 4891 [], 4892 config=config, 4893 decoder=download_decoder, 4894 parameters=model.parameters or {}, 4895 ) 4896 ) 4897 download_requester = self._create_component_from_model( 4898 model=model.download_requester, 4899 decoder=download_decoder, 4900 config=config, 4901 name=job_download_components_name, 4902 ) 4903 download_retriever = _get_download_retriever( 4904 download_requester, download_extractor, download_decoder 4905 ) 4906 abort_requester = ( 4907 self._create_component_from_model( 4908 model=model.abort_requester, 4909 decoder=decoder, 4910 config=config, 4911 name=f"job abort - {name}", 4912 ) 4913 if model.abort_requester 4914 else None 4915 ) 4916 delete_requester = ( 4917 self._create_component_from_model( 4918 model=model.delete_requester, 4919 decoder=decoder, 4920 config=config, 4921 name=f"job delete - {name}", 4922 ) 4923 if model.delete_requester 4924 else None 4925 ) 4926 download_target_requester = ( 4927 self._create_component_from_model( 4928 model=model.download_target_requester, 4929 decoder=decoder, 4930 config=config, 4931 name=f"job extract_url - {name}", 4932 ) 4933 if model.download_target_requester 4934 else None 4935 ) 4936 status_extractor = self._create_component_from_model( 4937 model=model.status_extractor, decoder=decoder, config=config, name=name 4938 ) 4939 download_target_extractor = ( 4940 self._create_component_from_model( 4941 model=model.download_target_extractor, 4942 decoder=decoder, 4943 config=config, 4944 name=name, 4945 ) 4946 if model.download_target_extractor 4947 else None 4948 ) 4949 4950 job_repository: AsyncJobRepository = AsyncHttpJobRepository( 4951 creation_requester=creation_requester, 4952 polling_requester=polling_requester, 4953 download_retriever=download_retriever, 4954 download_target_requester=download_target_requester, 4955 abort_requester=abort_requester, 4956 delete_requester=delete_requester, 4957 status_extractor=status_extractor, 4958 status_mapping=self._create_async_job_status_mapping(model.status_mapping, config), 4959 download_target_extractor=download_target_extractor, 4960 job_timeout=_get_job_timeout(), 4961 ) 4962 4963 failed_retry_wait_time_in_seconds: Optional[int] = ( 4964 int( 4965 InterpolatedString.create( 4966 str(model.failed_retry_wait_time_in_seconds), 4967 parameters={}, 4968 ).eval(config) 4969 ) 4970 if model.failed_retry_wait_time_in_seconds 4971 else None 4972 ) 4973 4974 async_job_partition_router = AsyncJobPartitionRouter( 4975 job_orchestrator_factory=lambda stream_slices: AsyncJobOrchestrator( 4976 job_repository, 4977 stream_slices, 4978 self._job_tracker, 4979 self._message_repository, 4980 # FIXME work would need to be done here in order to detect if a stream as a parent stream that is bulk 4981 has_bulk_parent=False, 4982 # set the `job_max_retry` to 1 for the `Connector Builder`` use-case. 4983 # `None` == default retry is set to 3 attempts, under the hood. 4984 job_max_retry=1 if self._emit_connector_builder_messages else None, 4985 failed_retry_wait_time_in_seconds=failed_retry_wait_time_in_seconds, 4986 ), 4987 stream_slicer=stream_slicer, 4988 config=config, 4989 parameters=model.parameters or {}, 4990 ) 4991 4992 return AsyncRetriever( 4993 record_selector=record_selector, 4994 stream_slicer=async_job_partition_router, 4995 config=config, 4996 parameters=model.parameters or {}, 4997 ) 4998 4999 def create_spec(self, model: SpecModel, config: Config, **kwargs: Any) -> Spec: 5000 config_migrations = [ 5001 self._create_component_from_model(migration, config) 5002 for migration in ( 5003 model.config_normalization_rules.config_migrations 5004 if ( 5005 model.config_normalization_rules 5006 and model.config_normalization_rules.config_migrations 5007 ) 5008 else [] 5009 ) 5010 ] 5011 config_transformations = [ 5012 self._create_component_from_model(transformation, config) 5013 for transformation in ( 5014 model.config_normalization_rules.transformations 5015 if ( 5016 model.config_normalization_rules 5017 and model.config_normalization_rules.transformations 5018 ) 5019 else [] 5020 ) 5021 ] 5022 config_validations = [ 5023 self._create_component_from_model(validation, config) 5024 for validation in ( 5025 model.config_normalization_rules.validations 5026 if ( 5027 model.config_normalization_rules 5028 and model.config_normalization_rules.validations 5029 ) 5030 else [] 5031 ) 5032 ] 5033 5034 return Spec( 5035 connection_specification=model.connection_specification, 5036 documentation_url=model.documentation_url, 5037 advanced_auth=model.advanced_auth, 5038 parameters={}, 5039 config_migrations=config_migrations, 5040 config_transformations=config_transformations, 5041 config_validations=config_validations, 5042 ) 5043 5044 def create_substream_partition_router( 5045 self, 5046 model: SubstreamPartitionRouterModel, 5047 config: Config, 5048 *, 5049 stream_name: str, 5050 **kwargs: Any, 5051 ) -> SubstreamPartitionRouter: 5052 parent_stream_configs = [] 5053 if model.parent_stream_configs: 5054 parent_stream_configs.extend( 5055 [ 5056 self.create_parent_stream_config_with_substream_wrapper( 5057 model=parent_stream_config, config=config, stream_name=stream_name, **kwargs 5058 ) 5059 for parent_stream_config in model.parent_stream_configs 5060 ] 5061 ) 5062 5063 return SubstreamPartitionRouter( 5064 parent_stream_configs=parent_stream_configs, 5065 parameters=model.parameters or {}, 5066 config=config, 5067 ) 5068 5069 def create_parent_stream_config_with_substream_wrapper( 5070 self, model: ParentStreamConfigModel, config: Config, *, stream_name: str, **kwargs: Any 5071 ) -> Any: 5072 child_state = self._connector_state_manager.get_stream_state(stream_name, None) 5073 if NO_CURSOR_STATE_KEY in child_state: 5074 # Full refresh streams checkpoint a `{NO_CURSOR_STATE_KEY: true}` sentinel. When such a 5075 # stream is later converted to incremental with an incremental_dependency parent, 5076 # `_instantiate_parent_stream_state_manager` would treat the sentinel's boolean as a legacy 5077 # cursor value and re-key it under the parent's cursor field, crashing cursor initialization. 5078 child_state = { 5079 key: value for key, value in child_state.items() if key != NO_CURSOR_STATE_KEY 5080 } 5081 5082 parent_state: Optional[Mapping[str, Any]] = ( 5083 child_state if model.incremental_dependency and child_state else None 5084 ) 5085 connector_state_manager = self._instantiate_parent_stream_state_manager( 5086 child_state, config, model, parent_state 5087 ) 5088 5089 substream_factory = ModelToComponentFactory( 5090 custom_components_trusted=self._custom_components_trusted, 5091 connector_state_manager=connector_state_manager, 5092 limit_pages_fetched_per_slice=self._limit_pages_fetched_per_slice, 5093 limit_slices_fetched=self._limit_slices_fetched, 5094 emit_connector_builder_messages=self._emit_connector_builder_messages, 5095 disable_retries=self._disable_retries, 5096 disable_cache=self._disable_cache, 5097 message_repository=StateFilteringMessageRepository( 5098 LogAppenderMessageRepositoryDecorator( 5099 { 5100 "airbyte_cdk": {"stream": {"is_substream": True}}, 5101 "http": {"is_auxiliary": True}, 5102 }, 5103 self._message_repository, 5104 self._evaluate_log_level(self._emit_connector_builder_messages), 5105 ), 5106 ), 5107 api_budget=self._api_budget, 5108 # Share the authenticator registry so parent and child streams draw from the 5109 # same token quota counters 5110 rate_limited_authenticators=self._rate_limited_authenticators, 5111 ) 5112 5113 return substream_factory.create_parent_stream_config( 5114 model=model, config=config, stream_name=stream_name, **kwargs 5115 ) 5116 5117 def _instantiate_parent_stream_state_manager( 5118 self, 5119 child_state: MutableMapping[str, Any], 5120 config: Config, 5121 model: ParentStreamConfigModel, 5122 parent_state: Optional[Mapping[str, Any]] = None, 5123 ) -> ConnectorStateManager: 5124 """ 5125 With DefaultStream, the state needs to be provided during __init__ of the cursor as opposed to the 5126 `set_initial_state` flow that existed for the declarative cursors. This state is taken from 5127 self._connector_state_manager.get_stream_state (`self` being a newly created ModelToComponentFactory to account 5128 for the MessageRepository being different). So we need to pass a ConnectorStateManager to the 5129 ModelToComponentFactory that has the parent states. This method populates this if there is a child state and if 5130 incremental_dependency is set. 5131 """ 5132 if model.incremental_dependency and child_state: 5133 parent_stream_name = model.stream.name or "" 5134 extracted_parent_state = ConcurrentPerPartitionCursor.get_parent_state( 5135 child_state, parent_stream_name 5136 ) 5137 5138 if not extracted_parent_state: 5139 extracted_parent_state = ConcurrentPerPartitionCursor.get_global_state( 5140 child_state, parent_stream_name 5141 ) 5142 5143 if not extracted_parent_state and not isinstance(extracted_parent_state, dict): 5144 cursor_values = list(child_state.values()) 5145 # Only a scalar legacy cursor value can seed the parent. Sentinels such as 5146 # `{"__ab_full_refresh_sync_complete": true}` or legacy per-partition `states` 5147 # would crash the parent cursor initialization. 5148 if ( 5149 len(cursor_values) == 1 5150 and isinstance(cursor_values[0], (str, int, float)) 5151 and not isinstance(cursor_values[0], bool) 5152 ): 5153 incremental_sync_model: Union[ 5154 DatetimeBasedCursorModel, 5155 IncrementingCountCursorModel, 5156 ] = ( 5157 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 5158 if isinstance(model.stream, DeclarativeStreamModel) 5159 else self._get_state_delegating_stream_model( 5160 model.stream, parent_state=parent_state 5161 ).incremental_sync 5162 ) 5163 cursor_field = InterpolatedString.create( 5164 incremental_sync_model.cursor_field, 5165 parameters=incremental_sync_model.parameters or {}, 5166 ).eval(config) 5167 extracted_parent_state = AirbyteStateMessage( 5168 type=AirbyteStateType.STREAM, 5169 stream=AirbyteStreamState( 5170 stream_descriptor=StreamDescriptor( 5171 name=parent_stream_name, namespace=None 5172 ), 5173 stream_state=AirbyteStateBlob({cursor_field: cursor_values[0]}), 5174 ), 5175 ) 5176 return ConnectorStateManager([extracted_parent_state] if extracted_parent_state else []) 5177 5178 return ConnectorStateManager([]) 5179 5180 @staticmethod 5181 def create_wait_time_from_header( 5182 model: WaitTimeFromHeaderModel, config: Config, **kwargs: Any 5183 ) -> WaitTimeFromHeaderBackoffStrategy: 5184 return WaitTimeFromHeaderBackoffStrategy( 5185 header=model.header, 5186 parameters=model.parameters or {}, 5187 config=config, 5188 regex=model.regex, 5189 max_waiting_time_in_seconds=model.max_waiting_time_in_seconds, 5190 ) 5191 5192 @staticmethod 5193 def create_wait_until_time_from_header( 5194 model: WaitUntilTimeFromHeaderModel, config: Config, **kwargs: Any 5195 ) -> WaitUntilTimeFromHeaderBackoffStrategy: 5196 return WaitUntilTimeFromHeaderBackoffStrategy( 5197 header=model.header, 5198 parameters=model.parameters or {}, 5199 config=config, 5200 min_wait=model.min_wait, 5201 regex=model.regex, 5202 max_waiting_time_in_seconds=model.max_waiting_time_in_seconds, 5203 ) 5204 5205 def get_message_repository(self) -> MessageRepository: 5206 return self._message_repository 5207 5208 def _evaluate_log_level(self, emit_connector_builder_messages: bool) -> Level: 5209 return Level.DEBUG if emit_connector_builder_messages else Level.INFO 5210 5211 @staticmethod 5212 def create_components_mapping_definition( 5213 model: ComponentMappingDefinitionModel, config: Config, **kwargs: Any 5214 ) -> ComponentMappingDefinition: 5215 interpolated_value = InterpolatedString.create( 5216 model.value, parameters=model.parameters or {} 5217 ) 5218 field_path = [ 5219 InterpolatedString.create(path, parameters=model.parameters or {}) 5220 for path in model.field_path 5221 ] 5222 return ComponentMappingDefinition( 5223 field_path=field_path, # type: ignore[arg-type] # field_path can be str and InterpolatedString 5224 value=interpolated_value, 5225 value_type=ModelToComponentFactory._json_schema_type_name_to_type(model.value_type), 5226 create_or_update=model.create_or_update, 5227 condition=model.condition, 5228 parameters=model.parameters or {}, 5229 ) 5230 5231 def create_http_components_resolver( 5232 self, model: HttpComponentsResolverModel, config: Config, stream_name: Optional[str] = None 5233 ) -> Any: 5234 partition_router = self._build_stream_slicer_from_partition_router(model.retriever, config) 5235 retriever = self._create_component_from_model( 5236 model=model.retriever, 5237 config=config, 5238 name=f"{stream_name if stream_name else '__http_components_resolver'}", 5239 primary_key=None, 5240 stream_slicer=partition_router, 5241 partition_router=partition_router, 5242 transformations=[], 5243 ) 5244 5245 components_mapping = [] 5246 for component_mapping_definition_model in model.components_mapping: 5247 if component_mapping_definition_model.condition: 5248 raise ValueError("`condition` is only supported for `ConfigComponentsResolver`") 5249 components_mapping.append( 5250 self._create_component_from_model( 5251 model=component_mapping_definition_model, 5252 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 5253 component_mapping_definition_model.value_type 5254 ), 5255 config=config, 5256 ) 5257 ) 5258 5259 return HttpComponentsResolver( 5260 retriever=retriever, 5261 # AsyncRetriever reads records only from the job slices its own slicer yields 5262 stream_slicer=retriever.stream_slicer 5263 if isinstance(retriever, AsyncRetriever) 5264 else partition_router, 5265 config=config, 5266 components_mapping=components_mapping, 5267 parameters=model.parameters or {}, 5268 ) 5269 5270 @staticmethod 5271 def create_stream_config( 5272 model: StreamConfigModel, config: Config, **kwargs: Any 5273 ) -> StreamConfig: 5274 model_configs_pointer: List[Union[InterpolatedString, str]] = ( 5275 [x for x in model.configs_pointer] if model.configs_pointer else [] 5276 ) 5277 5278 return StreamConfig( 5279 configs_pointer=model_configs_pointer, 5280 default_values=model.default_values, 5281 parameters=model.parameters or {}, 5282 ) 5283 5284 def create_config_components_resolver( 5285 self, 5286 model: ConfigComponentsResolverModel, 5287 config: Config, 5288 ) -> Any: 5289 model_stream_configs = ( 5290 model.stream_config if isinstance(model.stream_config, list) else [model.stream_config] 5291 ) 5292 5293 stream_configs = [ 5294 self._create_component_from_model( 5295 stream_config, config=config, parameters=model.parameters or {} 5296 ) 5297 for stream_config in model_stream_configs 5298 ] 5299 5300 components_mapping = [ 5301 self._create_component_from_model( 5302 model=components_mapping_definition_model, 5303 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 5304 components_mapping_definition_model.value_type 5305 ), 5306 config=config, 5307 parameters=model.parameters, 5308 ) 5309 for components_mapping_definition_model in model.components_mapping 5310 ] 5311 5312 return ConfigComponentsResolver( 5313 stream_configs=stream_configs, 5314 config=config, 5315 components_mapping=components_mapping, 5316 parameters=model.parameters or {}, 5317 ) 5318 5319 def create_parametrized_components_resolver( 5320 self, 5321 model: ParametrizedComponentsResolverModel, 5322 config: Config, 5323 ) -> ParametrizedComponentsResolver: 5324 stream_parameters = StreamParametersDefinition( 5325 list_of_parameters_for_stream=model.stream_parameters.list_of_parameters_for_stream 5326 ) 5327 5328 components_mapping = [] 5329 for components_mapping_definition_model in model.components_mapping: 5330 if components_mapping_definition_model.condition: 5331 raise ValueError("`condition` is only supported for `ConfigComponentsResolver`") 5332 components_mapping.append( 5333 self._create_component_from_model( 5334 model=components_mapping_definition_model, 5335 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 5336 components_mapping_definition_model.value_type 5337 ), 5338 config=config, 5339 ) 5340 ) 5341 return ParametrizedComponentsResolver( 5342 stream_parameters=stream_parameters, 5343 config=config, 5344 components_mapping=components_mapping, 5345 parameters=model.parameters or {}, 5346 ) 5347 5348 _UNSUPPORTED_DECODER_ERROR = ( 5349 "Specified decoder of {decoder_type} is not supported for pagination." 5350 "Please set as `JsonDecoder`, `XmlDecoder`, or a `CompositeRawDecoder` with an inner_parser of `JsonParser` or `GzipParser` instead." 5351 "If using `GzipParser`, please ensure that the lowest level inner_parser is a `JsonParser`." 5352 ) 5353 5354 def _is_supported_decoder_for_pagination(self, decoder: Decoder) -> bool: 5355 if isinstance(decoder, (JsonDecoder, XmlDecoder)): 5356 return True 5357 elif isinstance(decoder, CompositeRawDecoder): 5358 return self._is_supported_parser_for_pagination(decoder.parser) 5359 else: 5360 return False 5361 5362 def _is_supported_parser_for_pagination(self, parser: Parser) -> bool: 5363 if isinstance(parser, JsonParser): 5364 return True 5365 elif isinstance(parser, GzipParser): 5366 return isinstance(parser.inner_parser, JsonParser) 5367 else: 5368 return False 5369 5370 def create_http_api_budget( 5371 self, model: HTTPAPIBudgetModel, config: Config, **kwargs: Any 5372 ) -> HttpAPIBudget: 5373 policies = [ 5374 self._create_component_from_model(model=policy, config=config) 5375 for policy in model.policies 5376 ] 5377 5378 return HttpAPIBudget( 5379 policies=policies, 5380 ratelimit_reset_header=model.ratelimit_reset_header or "ratelimit-reset", 5381 ratelimit_remaining_header=model.ratelimit_remaining_header or "ratelimit-remaining", 5382 status_codes_for_ratelimit_hit=model.status_codes_for_ratelimit_hit or [429], 5383 ) 5384 5385 def create_fixed_window_call_rate_policy( 5386 self, model: FixedWindowCallRatePolicyModel, config: Config, **kwargs: Any 5387 ) -> FixedWindowCallRatePolicy: 5388 matchers = [ 5389 self._create_component_from_model(model=matcher, config=config) 5390 for matcher in model.matchers 5391 ] 5392 5393 # Set the initial reset timestamp to 10 days from now. 5394 # This value will be updated by the first request. 5395 return FixedWindowCallRatePolicy( 5396 next_reset_ts=datetime.datetime.now() + datetime.timedelta(days=10), 5397 period=parse_duration(model.period), 5398 call_limit=model.call_limit, 5399 matchers=matchers, 5400 ) 5401 5402 def create_file_uploader( 5403 self, model: FileUploaderModel, config: Config, **kwargs: Any 5404 ) -> FileUploader: 5405 self._reject_reduce_page_size_action(model.requester, "requester of a `file_uploader`") 5406 self._reject_split_request_window_action(model.requester, "requester of a `file_uploader`") 5407 name = "File Uploader" 5408 requester = self._create_component_from_model( 5409 model=model.requester, 5410 config=config, 5411 name=name, 5412 **kwargs, 5413 ) 5414 download_target_extractor = self._create_component_from_model( 5415 model=model.download_target_extractor, 5416 config=config, 5417 name=name, 5418 **kwargs, 5419 ) 5420 emit_connector_builder_messages = self._emit_connector_builder_messages 5421 file_uploader = DefaultFileUploader( 5422 requester=requester, 5423 download_target_extractor=download_target_extractor, 5424 config=config, 5425 file_writer=NoopFileWriter() 5426 if emit_connector_builder_messages 5427 else LocalFileSystemFileWriter(), 5428 parameters=model.parameters or {}, 5429 filename_extractor=model.filename_extractor if model.filename_extractor else None, 5430 ) 5431 5432 return ( 5433 ConnectorBuilderFileUploader(file_uploader) 5434 if emit_connector_builder_messages 5435 else file_uploader 5436 ) 5437 5438 def create_moving_window_call_rate_policy( 5439 self, model: MovingWindowCallRatePolicyModel, config: Config, **kwargs: Any 5440 ) -> MovingWindowCallRatePolicy: 5441 rates = [ 5442 self._create_component_from_model(model=rate, config=config) for rate in model.rates 5443 ] 5444 matchers = [ 5445 self._create_component_from_model(model=matcher, config=config) 5446 for matcher in model.matchers 5447 ] 5448 return MovingWindowCallRatePolicy( 5449 rates=rates, 5450 matchers=matchers, 5451 ) 5452 5453 def create_unlimited_call_rate_policy( 5454 self, model: UnlimitedCallRatePolicyModel, config: Config, **kwargs: Any 5455 ) -> UnlimitedCallRatePolicy: 5456 matchers = [ 5457 self._create_component_from_model(model=matcher, config=config) 5458 for matcher in model.matchers 5459 ] 5460 5461 return UnlimitedCallRatePolicy( 5462 matchers=matchers, 5463 ) 5464 5465 def create_rate(self, model: RateModel, config: Config, **kwargs: Any) -> Rate: 5466 interpolated_limit = InterpolatedString.create(str(model.limit), parameters={}) 5467 return Rate( 5468 limit=int(interpolated_limit.eval(config=config)), 5469 interval=parse_duration(model.interval), 5470 ) 5471 5472 def create_http_request_matcher( 5473 self, model: HttpRequestRegexMatcherModel, config: Config, **kwargs: Any 5474 ) -> HttpRequestRegexMatcher: 5475 weight = model.weight 5476 if weight is not None: 5477 if isinstance(weight, str): 5478 weight = int(InterpolatedString.create(weight, parameters={}).eval(config)) 5479 else: 5480 weight = int(weight) 5481 if weight < 1: 5482 raise ValueError(f"weight must be >= 1, got {weight}") 5483 return HttpRequestRegexMatcher( 5484 method=model.method, 5485 url_base=model.url_base, 5486 url_path_pattern=model.url_path_pattern, 5487 params=model.params, 5488 headers=model.headers, 5489 weight=weight, 5490 ) 5491 5492 def create_rate_limited_multiple_token_authenticator( 5493 self, 5494 model: RateLimitedMultipleTokenAuthenticatorModel, 5495 config: Config, 5496 **kwargs: Any, 5497 ) -> RateLimitedMultipleTokenAuthenticator: 5498 if isinstance(model.tokens, str): 5499 tokens_value = InterpolatedString.create(model.tokens, parameters={}).eval(config) 5500 delimiter = model.token_delimiter or "," 5501 tokens = [ 5502 token.strip() for token in str(tokens_value).split(delimiter) if token.strip() 5503 ] 5504 else: 5505 tokens = [ 5506 token_value 5507 for token in model.tokens 5508 if ( 5509 token_value := str( 5510 InterpolatedString.create(token, parameters={}).eval(config) 5511 ).strip() 5512 ) 5513 ] 5514 5515 quota_specs = [ 5516 { 5517 "name": quota_model.name, 5518 "remaining_path": quota_model.remaining_path, 5519 "reset_path": quota_model.reset_path, 5520 "limit_path": quota_model.limit_path, 5521 "remaining_header": quota_model.remaining_header, 5522 "reset_header": quota_model.reset_header, 5523 "limit_header": quota_model.limit_header, 5524 # Normalize the same way as the runtime TokenQuota below, so an omitted field 5525 # and an explicit `[]` key identically and keep sharing one set of counters. 5526 "exhaustion_status_codes": quota_model.exhaustion_status_codes or [], 5527 "matchers": [ 5528 { 5529 "method": matcher_model.method, 5530 "url_base": matcher_model.url_base, 5531 "url_path_pattern": matcher_model.url_path_pattern, 5532 "params": matcher_model.params, 5533 "headers": matcher_model.headers, 5534 "weight": matcher_model.weight, 5535 } 5536 for matcher_model in quota_model.matchers or [] 5537 ], 5538 } 5539 for quota_model in model.quotas 5540 ] 5541 5542 quota_status_url = str( 5543 InterpolatedString.create(model.quota_status_source.url, parameters={}).eval(config) 5544 ) 5545 quota_status_http_method = ( 5546 model.quota_status_source.http_method.value 5547 if model.quota_status_source.http_method 5548 else "GET" 5549 ) 5550 quota_status_headers = { 5551 key: str(InterpolatedString.create(value, parameters={}).eval(config)) 5552 for key, value in (model.quota_status_source.request_headers or {}).items() 5553 } 5554 # Normalize the same way as the quota specs above, so an omitted field and an explicit 5555 # `[]` key identically and keep sharing one set of counters. Deduplicated as well as 5556 # sorted, because the runtime turns this into a set: without it `[404]` and `[404, 404]` 5557 # would key differently and stop sharing counters while behaving identically. 5558 quota_status_unavailable_status_codes = sorted( 5559 set(model.quota_status_source.unavailable_status_codes or []) 5560 ) 5561 auth_method = model.auth_method or "Bearer" 5562 header = model.header or "Authorization" 5563 max_wait_time_str = str( 5564 InterpolatedString.create(model.max_wait_time or "PT2H", parameters={}).eval(config) 5565 ) 5566 max_wait_time = parse_duration(max_wait_time_str) 5567 if not isinstance(max_wait_time, datetime.timedelta): 5568 raise ValueError( 5569 f"max_wait_time must be a fixed-length ISO 8601 duration (e.g. 'PT2H'); " 5570 f"calendar-unit durations like '{max_wait_time_str}' are not supported" 5571 ) 5572 budget_reserve_fraction = ( 5573 model.budget_reserve_fraction if model.budget_reserve_fraction is not None else 0.1 5574 ) 5575 budget_min_reserve = ( 5576 model.budget_min_reserve if model.budget_min_reserve is not None else 50 5577 ) 5578 5579 # Reuse the same instance for identical definitions so that all streams share the 5580 # same token quota counters (similar to how api_budget is shared). The key is built 5581 # from the resolved constructor arguments rather than the raw model so that 5582 # stream-specific `$parameters` propagated onto the model (and its nested components) 5583 # cannot break instance sharing. 5584 cache_key = json.dumps( 5585 { 5586 "tokens": tokens, 5587 "quotas": quota_specs, 5588 "quota_status_url": quota_status_url, 5589 "quota_status_http_method": quota_status_http_method, 5590 "quota_status_headers": quota_status_headers, 5591 "quota_status_unavailable_status_codes": quota_status_unavailable_status_codes, 5592 "auth_method": auth_method, 5593 "header": header, 5594 "max_wait_time": max_wait_time.total_seconds(), 5595 "budget_reserve_fraction": budget_reserve_fraction, 5596 "budget_min_reserve": budget_min_reserve, 5597 }, 5598 sort_keys=True, 5599 ) 5600 if cache_key in self._rate_limited_authenticators: 5601 return self._rate_limited_authenticators[cache_key] 5602 5603 quotas = [ 5604 TokenQuota( 5605 name=quota_model.name, 5606 remaining_path=quota_model.remaining_path, 5607 reset_path=quota_model.reset_path, 5608 limit_path=quota_model.limit_path, 5609 remaining_header=quota_model.remaining_header, 5610 reset_header=quota_model.reset_header, 5611 limit_header=quota_model.limit_header, 5612 exhaustion_status_codes=quota_model.exhaustion_status_codes or [], 5613 matchers=[ 5614 self.create_http_request_matcher(matcher_model, config) 5615 for matcher_model in quota_model.matchers or [] 5616 ], 5617 ) 5618 for quota_model in model.quotas 5619 ] 5620 5621 authenticator = RateLimitedMultipleTokenAuthenticator( 5622 tokens=tokens, 5623 quotas=quotas, 5624 quota_status_url=quota_status_url, 5625 quota_status_http_method=quota_status_http_method, 5626 quota_status_headers=quota_status_headers, 5627 quota_status_unavailable_status_codes=quota_status_unavailable_status_codes, 5628 auth_method=auth_method, 5629 header=header, 5630 max_wait_time=max_wait_time, 5631 budget_reserve_fraction=budget_reserve_fraction, 5632 budget_min_reserve=budget_min_reserve, 5633 ) 5634 self._rate_limited_authenticators[cache_key] = authenticator 5635 return authenticator 5636 5637 def set_api_budget(self, component_definition: ComponentDefinition, config: Config) -> None: 5638 self._api_budget = self.create_component( 5639 model_type=HTTPAPIBudgetModel, component_definition=component_definition, config=config 5640 ) 5641 5642 def create_grouping_partition_router( 5643 self, 5644 model: GroupingPartitionRouterModel, 5645 config: Config, 5646 *, 5647 stream_name: str, 5648 **kwargs: Any, 5649 ) -> GroupingPartitionRouter: 5650 underlying_router = self._create_component_from_model( 5651 model=model.underlying_partition_router, 5652 config=config, 5653 stream_name=stream_name, 5654 **kwargs, 5655 ) 5656 if model.group_size < 1: 5657 raise ValueError(f"Group size must be greater than 0, got {model.group_size}") 5658 5659 # Request options in underlying partition routers are not supported for GroupingPartitionRouter 5660 # because they are specific to individual partitions and cannot be aggregated or handled 5661 # when grouping, potentially leading to incorrect API calls. Any request customization 5662 # should be managed at the stream level through the requester's configuration. 5663 if isinstance(underlying_router, SubstreamPartitionRouter): 5664 if any( 5665 parent_config.request_option 5666 for parent_config in underlying_router.parent_stream_configs 5667 ): 5668 raise ValueError("Request options are not supported for GroupingPartitionRouter.") 5669 5670 if isinstance(underlying_router, ListPartitionRouter): 5671 if underlying_router.request_option: 5672 raise ValueError("Request options are not supported for GroupingPartitionRouter.") 5673 5674 return GroupingPartitionRouter( 5675 group_size=model.group_size, 5676 underlying_partition_router=underlying_router, 5677 deduplicate=model.deduplicate if model.deduplicate is not None else True, 5678 config=config, 5679 ) 5680 5681 def create_union_partition_router( 5682 self, 5683 model: UnionPartitionRouterModel, 5684 config: Config, 5685 *, 5686 stream_name: str, 5687 **kwargs: Any, 5688 ) -> UnionPartitionRouter: 5689 # The schema enforces minItems: 2 for manifests; this guard covers construction paths 5690 # that bypass JSON-schema validation (the generated model carries no min_items constraint). 5691 if len(model.partition_routers) < 2: 5692 raise ValueError( 5693 f"UnionPartitionRouter for stream {stream_name} needs at least 2 child partition routers" 5694 ) 5695 5696 partition_routers = [ 5697 self._create_component_from_model( 5698 model=child, 5699 config=config, 5700 stream_name=stream_name, 5701 **kwargs, 5702 ) 5703 for child in model.partition_routers 5704 ] 5705 5706 # partition_field depends only on config/parameters, so it is evaluated once at build 5707 # time; the runtime component always receives a plain string. 5708 partition_field = InterpolatedString.create( 5709 model.partition_field, parameters=model.parameters or {} 5710 ).eval(config) 5711 5712 # Fail fast at build time when a built-in child router is statically known to emit a 5713 # partition field different from the union's. CustomPartitionRouter children are opaque 5714 # and can only be validated at runtime. 5715 for child_model in model.partition_routers: 5716 child_partition_fields: List[str] = [] 5717 if isinstance(child_model, ListPartitionRouterModel): 5718 child_partition_fields.append( 5719 InterpolatedString.create( 5720 child_model.cursor_field, parameters=child_model.parameters or {} 5721 ).eval(config) 5722 ) 5723 elif isinstance(child_model, SubstreamPartitionRouterModel): 5724 for parent_stream_config in child_model.parent_stream_configs: 5725 child_partition_fields.append( 5726 InterpolatedString.create( 5727 parent_stream_config.partition_field, 5728 parameters=parent_stream_config.parameters 5729 or child_model.parameters 5730 or {}, 5731 ).eval(config) 5732 ) 5733 elif isinstance(child_model, UnionPartitionRouterModel): 5734 child_partition_fields.append( 5735 InterpolatedString.create( 5736 child_model.partition_field, parameters=child_model.parameters or {} 5737 ).eval(config) 5738 ) 5739 for child_partition_field in child_partition_fields: 5740 if child_partition_field != partition_field: 5741 raise ValueError( 5742 f"UnionPartitionRouter expects all child partition routers to emit the " 5743 f"partition field '{partition_field}', but a " 5744 f"{child_model.type} child emits '{child_partition_field}'." 5745 ) 5746 5747 # A union slice comes from exactly one child partition router, so request options 5748 # declared on children cannot be applied consistently to requests built from the 5749 # normalized union slices. Partition values should be consumed via interpolation 5750 # (e.g. stream_partition) instead. Note that this validation only covers built-in 5751 # router types; CustomPartitionRouter children are opaque, so any request options 5752 # they implement internally cannot be detected or rejected here. 5753 for router in partition_routers: 5754 if isinstance(router, SubstreamPartitionRouter): 5755 if any( 5756 parent_config.request_option for parent_config in router.parent_stream_configs 5757 ): 5758 raise ValueError("Request options are not supported for UnionPartitionRouter.") 5759 if isinstance(router, ListPartitionRouter) and router.request_option: 5760 raise ValueError("Request options are not supported for UnionPartitionRouter.") 5761 5762 return UnionPartitionRouter( 5763 partition_routers=partition_routers, 5764 partition_field=partition_field, 5765 parameters=model.parameters or {}, 5766 ) 5767 5768 def _ensure_query_properties_to_model( 5769 self, requester: Union[HttpRequesterModel, CustomRequesterModel] 5770 ) -> None: 5771 """ 5772 For some reason, it seems like CustomRequesterModel request_parameters stays as dictionaries which means that 5773 the other conditions relying on it being QueryPropertiesModel instead of a dict fail. Here, we migrate them to 5774 proper model. 5775 """ 5776 if not hasattr(requester, "request_parameters"): 5777 return 5778 5779 request_parameters = requester.request_parameters 5780 if request_parameters and isinstance(request_parameters, Dict): 5781 for request_parameter_key in request_parameters.keys(): 5782 request_parameter = request_parameters[request_parameter_key] 5783 if ( 5784 isinstance(request_parameter, Dict) 5785 and request_parameter.get("type") == "QueryProperties" 5786 ): 5787 request_parameters[request_parameter_key] = QueryPropertiesModel.parse_obj( 5788 request_parameter 5789 ) 5790 5791 def _get_catalog_defined_cursor_field( 5792 self, stream_name: str, allow_catalog_defined_cursor_field: bool 5793 ) -> Optional[CursorField]: 5794 if not allow_catalog_defined_cursor_field: 5795 return None 5796 5797 configured_stream = self._stream_name_to_configured_stream.get(stream_name) 5798 5799 # Depending on the operation is being performed, there may not be a configured stream yet. In this 5800 # case we return None which will then use the default cursor field defined on the cursor model. 5801 # We also treat cursor_field: [""] (list with empty string) as no cursor field, since this can 5802 # occur when the platform serializes "no cursor configured" streams incorrectly. 5803 if ( 5804 not configured_stream 5805 or not configured_stream.cursor_field 5806 or not configured_stream.cursor_field[0] 5807 ): 5808 return None 5809 elif len(configured_stream.cursor_field) > 1: 5810 raise ValueError( 5811 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." 5812 ) 5813 else: 5814 return CursorField( 5815 cursor_field_key=configured_stream.cursor_field[0], 5816 supports_catalog_defined_cursor_field=allow_catalog_defined_cursor_field, 5817 )
755 def __init__( 756 self, 757 limit_pages_fetched_per_slice: Optional[int] = None, 758 limit_slices_fetched: Optional[int] = None, 759 emit_connector_builder_messages: bool = False, 760 disable_retries: bool = False, 761 disable_cache: bool = False, 762 message_repository: Optional[MessageRepository] = None, 763 connector_state_manager: Optional[ConnectorStateManager] = None, 764 max_concurrent_async_job_count: Optional[int] = None, 765 configured_catalog: Optional[ConfiguredAirbyteCatalog] = None, 766 api_budget: Optional[APIBudget] = None, 767 rate_limited_authenticators: Optional[ 768 Dict[str, RateLimitedMultipleTokenAuthenticator] 769 ] = None, 770 custom_components_trusted: bool = True, 771 ): 772 self._init_mappings() 773 self._custom_components_trusted = custom_components_trusted 774 self._limit_pages_fetched_per_slice = limit_pages_fetched_per_slice 775 self._limit_slices_fetched = limit_slices_fetched 776 self._emit_connector_builder_messages = emit_connector_builder_messages 777 self._disable_retries = disable_retries 778 self._disable_cache = disable_cache 779 self._message_repository = message_repository or InMemoryMessageRepository( 780 self._evaluate_log_level(emit_connector_builder_messages) 781 ) 782 self._stream_name_to_configured_stream = self._create_stream_name_to_configured_stream( 783 configured_catalog 784 ) 785 self._connector_state_manager = connector_state_manager or ConnectorStateManager() 786 self._api_budget: Optional[Union[APIBudget]] = api_budget 787 # Shared instances so all streams see the same token quota counters (like api_budget) 788 self._rate_limited_authenticators: Dict[str, RateLimitedMultipleTokenAuthenticator] = ( 789 rate_limited_authenticators if rate_limited_authenticators is not None else {} 790 ) 791 self._job_tracker: JobTracker = JobTracker(max_concurrent_async_job_count or 1) 792 # placeholder for deprecation warnings 793 self._collected_deprecation_logs: List[ConnectorBuilderLogMessage] = []
922 def create_component( 923 self, 924 model_type: Type[BaseModel], 925 component_definition: ComponentDefinition, 926 config: Config, 927 **kwargs: Any, 928 ) -> Any: 929 """ 930 Takes a given Pydantic model type and Mapping representing a component definition and creates a declarative component and 931 subcomponents which will be used at runtime. This is done by first parsing the mapping into a Pydantic model and then creating 932 creating declarative components from that model. 933 934 :param model_type: The type of declarative component that is being initialized 935 :param component_definition: The mapping that represents a declarative component 936 :param config: The connector config that is provided by the customer 937 :return: The declarative component to be used at runtime 938 """ 939 940 component_type = component_definition.get("type") 941 if component_definition.get("type") != model_type.__name__: 942 raise ValueError( 943 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 944 ) 945 946 declarative_component_model = model_type.parse_obj(component_definition) 947 948 if not isinstance(declarative_component_model, model_type): 949 raise ValueError( 950 f"Expected {model_type.__name__} component, but received {declarative_component_model.__class__.__name__}" 951 ) 952 953 return self._create_component_from_model( 954 model=declarative_component_model, config=config, **kwargs 955 )
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
972 def get_model_deprecations(self) -> List[ConnectorBuilderLogMessage]: 973 """ 974 Returns the deprecation warnings that were collected during the creation of components. 975 """ 976 return self._collected_deprecation_logs
Returns the deprecation warnings that were collected during the creation of components.
993 def create_config_migration( 994 self, model: ConfigMigrationModel, config: Config 995 ) -> ConfigMigration: 996 transformations: List[ConfigTransformation] = [ 997 self._create_component_from_model(transformation, config) 998 for transformation in model.transformations 999 ] 1000 1001 return ConfigMigration( 1002 description=model.description, 1003 transformations=transformations, 1004 )
1006 def create_config_add_fields( 1007 self, model: ConfigAddFieldsModel, config: Config, **kwargs: Any 1008 ) -> ConfigAddFields: 1009 fields = [self._create_component_from_model(field, config) for field in model.fields] 1010 return ConfigAddFields( 1011 fields=fields, 1012 condition=model.condition or "", 1013 )
1024 @staticmethod 1025 def create_config_remap_field( 1026 model: ConfigRemapFieldModel, config: Config, **kwargs: Any 1027 ) -> ConfigRemapField: 1028 mapping = cast(Mapping[str, Any], model.map) 1029 return ConfigRemapField( 1030 map=mapping, 1031 field_path=model.field_path, 1032 config=config, 1033 )
1043 def create_predicate_validator( 1044 self, model: PredicateValidatorModel, config: Config 1045 ) -> PredicateValidator: 1046 strategy = self._create_component_from_model(model.validation_strategy, config) 1047 1048 return PredicateValidator( 1049 value=model.value, 1050 strategy=strategy, 1051 )
1053 @staticmethod 1054 def create_validate_adheres_to_schema( 1055 model: ValidateAdheresToSchemaModel, config: Config, **kwargs: Any 1056 ) -> ValidateAdheresToSchema: 1057 base_schema = cast(Mapping[str, Any], model.base_schema) 1058 return ValidateAdheresToSchema( 1059 schema=base_schema, 1060 )
1062 @staticmethod 1063 def create_added_field_definition( 1064 model: AddedFieldDefinitionModel, config: Config, **kwargs: Any 1065 ) -> AddedFieldDefinition: 1066 interpolated_value = InterpolatedString.create( 1067 model.value, parameters=model.parameters or {} 1068 ) 1069 return AddedFieldDefinition( 1070 path=model.path, 1071 value=interpolated_value, 1072 value_type=ModelToComponentFactory._json_schema_type_name_to_type(model.value_type), 1073 parameters=model.parameters or {}, 1074 )
1076 def create_add_fields(self, model: AddFieldsModel, config: Config, **kwargs: Any) -> AddFields: 1077 added_field_definitions = [ 1078 self._create_component_from_model( 1079 model=added_field_definition_model, 1080 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 1081 added_field_definition_model.value_type 1082 ), 1083 config=config, 1084 ) 1085 for added_field_definition_model in model.fields 1086 ] 1087 return AddFields( 1088 fields=added_field_definitions, 1089 condition=model.condition or "", 1090 parameters=model.parameters or {}, 1091 )
1117 def create_dpath_flatten_fields( 1118 self, model: DpathFlattenFieldsModel, config: Config, **kwargs: Any 1119 ) -> DpathFlattenFields: 1120 model_field_path: List[Union[InterpolatedString, str]] = [x for x in model.field_path] 1121 key_transformation = ( 1122 KeyTransformation( 1123 config=config, 1124 prefix=model.key_transformation.prefix, 1125 suffix=model.key_transformation.suffix, 1126 parameters=model.parameters or {}, 1127 ) 1128 if model.key_transformation is not None 1129 else None 1130 ) 1131 return DpathFlattenFields( 1132 config=config, 1133 field_path=model_field_path, 1134 delete_origin_value=model.delete_origin_value 1135 if model.delete_origin_value is not None 1136 else False, 1137 replace_record=model.replace_record if model.replace_record is not None else False, 1138 key_transformation=key_transformation, 1139 parameters=model.parameters or {}, 1140 )
1154 def create_api_key_authenticator( 1155 self, 1156 model: ApiKeyAuthenticatorModel, 1157 config: Config, 1158 token_provider: Optional[TokenProvider] = None, 1159 **kwargs: Any, 1160 ) -> ApiKeyAuthenticator: 1161 if model.inject_into is None and model.header is None: 1162 raise ValueError( 1163 "Expected either inject_into or header to be set for ApiKeyAuthenticator" 1164 ) 1165 1166 if model.inject_into is not None and model.header is not None: 1167 raise ValueError( 1168 "inject_into and header cannot be set both for ApiKeyAuthenticator - remove the deprecated header option" 1169 ) 1170 1171 if token_provider is not None and model.api_token != "": 1172 raise ValueError( 1173 "If token_provider is set, api_token is ignored and has to be set to empty string." 1174 ) 1175 1176 request_option = ( 1177 self._create_component_from_model( 1178 model.inject_into, config, parameters=model.parameters or {} 1179 ) 1180 if model.inject_into 1181 else RequestOption( 1182 inject_into=RequestOptionType.header, 1183 field_name=model.header or "", 1184 parameters=model.parameters or {}, 1185 ) 1186 ) 1187 1188 return ApiKeyAuthenticator( 1189 token_provider=( 1190 token_provider 1191 if token_provider is not None 1192 else InterpolatedStringTokenProvider( 1193 api_token=model.api_token or "", 1194 config=config, 1195 parameters=model.parameters or {}, 1196 ) 1197 ), 1198 request_option=request_option, 1199 config=config, 1200 parameters=model.parameters or {}, 1201 )
1203 def create_legacy_to_per_partition_state_migration( 1204 self, 1205 model: LegacyToPerPartitionStateMigrationModel, 1206 config: Mapping[str, Any], 1207 declarative_stream: DeclarativeStreamModel, 1208 ) -> LegacyToPerPartitionStateMigration: 1209 retriever = declarative_stream.retriever 1210 if not isinstance(retriever, (SimpleRetrieverModel, AsyncRetrieverModel)): 1211 raise ValueError( 1212 f"LegacyToPerPartitionStateMigrations can only be applied on a DeclarativeStream with a SimpleRetriever or AsyncRetriever. Got {type(retriever)}" 1213 ) 1214 partition_router = retriever.partition_router 1215 if not isinstance( 1216 partition_router, 1217 ( 1218 SubstreamPartitionRouterModel, 1219 CustomPartitionRouterModel, 1220 UnionPartitionRouterModel, 1221 ), 1222 ): 1223 raise ValueError( 1224 f"LegacyToPerPartitionStateMigrations can only be applied on a SimpleRetriever with a SubstreamPartitionRouter, UnionPartitionRouter or CustomPartitionRouter. Got {type(partition_router)}" 1225 ) 1226 if not isinstance(partition_router, UnionPartitionRouterModel) and not hasattr( 1227 partition_router, "parent_stream_configs" 1228 ): 1229 raise ValueError( 1230 "LegacyToPerPartitionStateMigrations can only be applied with a parent stream configuration." 1231 ) 1232 1233 if not hasattr(declarative_stream, "incremental_sync"): 1234 raise ValueError( 1235 "LegacyToPerPartitionStateMigrations can only be applied with an incremental_sync configuration." 1236 ) 1237 1238 return LegacyToPerPartitionStateMigration( 1239 partition_router, # type: ignore # was already checked above 1240 declarative_stream.incremental_sync, # type: ignore # was already checked. Migration can be applied only to incremental streams. 1241 config, 1242 declarative_stream.parameters, # type: ignore # different type is expected here Mapping[str, Any], got Dict[str, Any] 1243 )
1245 def create_session_token_authenticator( 1246 self, model: SessionTokenAuthenticatorModel, config: Config, *, name: str, **kwargs: Any 1247 ) -> Union[ApiKeyAuthenticator, BearerAuthenticator]: 1248 self._reject_reduce_page_size_action( 1249 model.login_requester, f"`login_requester` of the SessionTokenAuthenticator of {name}" 1250 ) 1251 self._reject_split_request_window_action( 1252 model.login_requester, f"`login_requester` of the SessionTokenAuthenticator of {name}" 1253 ) 1254 decoder = ( 1255 self._create_component_from_model(model=model.decoder, config=config) 1256 if model.decoder 1257 else JsonDecoder(parameters={}) 1258 ) 1259 login_requester = self._create_component_from_model( 1260 model=model.login_requester, 1261 config=config, 1262 name=f"{name}_login_requester", 1263 decoder=decoder, 1264 ) 1265 token_provider = SessionTokenProvider( 1266 login_requester=login_requester, 1267 session_token_path=model.session_token_path, 1268 expiration_duration=parse_duration(model.expiration_duration) 1269 if model.expiration_duration 1270 else None, 1271 parameters=model.parameters or {}, 1272 message_repository=self._message_repository, 1273 decoder=decoder, 1274 ) 1275 if model.request_authentication.type == "Bearer": 1276 return ModelToComponentFactory.create_bearer_authenticator( 1277 BearerAuthenticatorModel(type="BearerAuthenticator", api_token=""), # type: ignore # $parameters has a default value 1278 config, 1279 token_provider=token_provider, 1280 ) 1281 else: 1282 # Get the api_token template if specified, default to just the session token 1283 api_token_template = ( 1284 getattr(model.request_authentication, "api_token", None) or "{{ session_token }}" 1285 ) 1286 final_token_provider: TokenProvider = InterpolatedSessionTokenProvider( 1287 config=config, 1288 api_token=api_token_template, 1289 session_token_provider=token_provider, 1290 parameters=model.parameters or {}, 1291 ) 1292 return self.create_api_key_authenticator( 1293 ApiKeyAuthenticatorModel( 1294 type="ApiKeyAuthenticator", 1295 api_token="", 1296 inject_into=model.request_authentication.inject_into, 1297 ), # type: ignore # $parameters and headers default to None 1298 config=config, 1299 token_provider=final_token_provider, 1300 )
1302 @staticmethod 1303 def create_basic_http_authenticator( 1304 model: BasicHttpAuthenticatorModel, config: Config, **kwargs: Any 1305 ) -> BasicHttpAuthenticator: 1306 return BasicHttpAuthenticator( 1307 password=model.password or "", 1308 username=model.username, 1309 config=config, 1310 parameters=model.parameters or {}, 1311 )
1313 @staticmethod 1314 def create_bearer_authenticator( 1315 model: BearerAuthenticatorModel, 1316 config: Config, 1317 token_provider: Optional[TokenProvider] = None, 1318 **kwargs: Any, 1319 ) -> BearerAuthenticator: 1320 if token_provider is not None and model.api_token != "": 1321 raise ValueError( 1322 "If token_provider is set, api_token is ignored and has to be set to empty string." 1323 ) 1324 return BearerAuthenticator( 1325 token_provider=( 1326 token_provider 1327 if token_provider is not None 1328 else InterpolatedStringTokenProvider( 1329 api_token=model.api_token or "", 1330 config=config, 1331 parameters=model.parameters or {}, 1332 ) 1333 ), 1334 config=config, 1335 parameters=model.parameters or {}, 1336 )
1338 @staticmethod 1339 def create_dynamic_stream_check_config( 1340 model: DynamicStreamCheckConfigModel, config: Config, **kwargs: Any 1341 ) -> DynamicStreamCheckConfig: 1342 return DynamicStreamCheckConfig( 1343 dynamic_stream_name=model.dynamic_stream_name, 1344 stream_count=model.stream_count, 1345 )
1347 def create_check_stream( 1348 self, model: CheckStreamModel, config: Config, **kwargs: Any 1349 ) -> CheckStream: 1350 if model.dynamic_streams_check_configs is None and model.stream_names is None: 1351 raise ValueError( 1352 "Expected either stream_names or dynamic_streams_check_configs to be set for CheckStream" 1353 ) 1354 1355 dynamic_streams_check_configs = ( 1356 [ 1357 self._create_component_from_model(model=dynamic_stream_check_config, config=config) 1358 for dynamic_stream_check_config in model.dynamic_streams_check_configs 1359 ] 1360 if model.dynamic_streams_check_configs 1361 else [] 1362 ) 1363 1364 # `model.config_overrides` is deliberately not read here. The source applies it around the whole 1365 # check operation (`ConcurrentDeclarativeSource._config_overridden_for_check`), which is what makes 1366 # it work for every checker type rather than only this one. Do not wire it in a second time. 1367 return CheckStream( 1368 stream_names=model.stream_names or [], 1369 dynamic_streams_check_configs=dynamic_streams_check_configs, 1370 parameters={}, 1371 )
1373 @staticmethod 1374 def create_check_dynamic_stream( 1375 model: CheckDynamicStreamModel, config: Config, **kwargs: Any 1376 ) -> CheckDynamicStream: 1377 assert model.use_check_availability is not None # for mypy 1378 1379 use_check_availability = model.use_check_availability 1380 1381 # See `create_check_stream`: `model.config_overrides` is applied by the source, not here. 1382 return CheckDynamicStream( 1383 stream_count=model.stream_count, 1384 use_check_availability=use_check_availability, 1385 parameters={}, 1386 )
1388 def create_composite_error_handler( 1389 self, model: CompositeErrorHandlerModel, config: Config, **kwargs: Any 1390 ) -> CompositeErrorHandler: 1391 error_handlers = [ 1392 self._create_component_from_model(model=error_handler_model, config=config) 1393 for error_handler_model in model.error_handlers 1394 ] 1395 return CompositeErrorHandler( 1396 error_handlers=error_handlers, parameters=model.parameters or {} 1397 )
1399 @staticmethod 1400 def create_concurrency_level( 1401 model: ConcurrencyLevelModel, config: Config, **kwargs: Any 1402 ) -> ConcurrencyLevel: 1403 return ConcurrencyLevel( 1404 default_concurrency=model.default_concurrency, 1405 max_concurrency=model.max_concurrency, 1406 config=config, 1407 parameters={}, 1408 )
1410 @staticmethod 1411 def apply_stream_state_migrations( 1412 stream_state_migrations: List[Any] | None, stream_state: MutableMapping[str, Any] 1413 ) -> MutableMapping[str, Any]: 1414 if stream_state_migrations: 1415 for state_migration in stream_state_migrations: 1416 if state_migration.should_migrate(stream_state): 1417 # The state variable is expected to be mutable but the migrate method returns an immutable mapping. 1418 stream_state = dict(state_migration.migrate(stream_state)) 1419 return stream_state
1421 def create_concurrent_cursor_from_datetime_based_cursor( 1422 self, 1423 model_type: Type[BaseModel], 1424 component_definition: ComponentDefinition, 1425 stream_name: str, 1426 stream_namespace: Optional[str], 1427 stream_state: MutableMapping[str, Any], 1428 config: Config, 1429 message_repository: Optional[MessageRepository] = None, 1430 runtime_lookback_window: Optional[datetime.timedelta] = None, 1431 **kwargs: Any, 1432 ) -> ConcurrentCursor: 1433 component_type = component_definition.get("type") 1434 if component_definition.get("type") != model_type.__name__: 1435 raise ValueError( 1436 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 1437 ) 1438 1439 # 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: 1440 # * The ComponentDefinition comes from model.__dict__ in which case we have `parameters` 1441 # * The ComponentDefinition comes from the manifest as a dict in which case we have `$parameters` 1442 # 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. 1443 if "$parameters" not in component_definition and "parameters" in component_definition: 1444 component_definition["$parameters"] = component_definition.get("parameters") # type: ignore # This is a dict 1445 datetime_based_cursor_model = model_type.parse_obj(component_definition) 1446 1447 if not isinstance(datetime_based_cursor_model, DatetimeBasedCursorModel): 1448 raise ValueError( 1449 f"Expected {model_type.__name__} component, but received {datetime_based_cursor_model.__class__.__name__}" 1450 ) 1451 1452 model_parameters = datetime_based_cursor_model.parameters or {} 1453 1454 cursor_field = self._get_catalog_defined_cursor_field( 1455 stream_name=stream_name, 1456 allow_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1457 or False, 1458 ) 1459 1460 if not cursor_field: 1461 interpolated_cursor_field = InterpolatedString.create( 1462 datetime_based_cursor_model.cursor_field, 1463 parameters=model_parameters, 1464 ) 1465 cursor_field = CursorField( 1466 cursor_field_key=interpolated_cursor_field.eval(config=config), 1467 supports_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1468 or False, 1469 ) 1470 1471 interpolated_partition_field_start = InterpolatedString.create( 1472 datetime_based_cursor_model.partition_field_start or "start_time", 1473 parameters=model_parameters, 1474 ) 1475 interpolated_partition_field_end = InterpolatedString.create( 1476 datetime_based_cursor_model.partition_field_end or "end_time", 1477 parameters=model_parameters, 1478 ) 1479 1480 slice_boundary_fields = ( 1481 interpolated_partition_field_start.eval(config=config), 1482 interpolated_partition_field_end.eval(config=config), 1483 ) 1484 1485 datetime_format = datetime_based_cursor_model.datetime_format 1486 1487 cursor_granularity = ( 1488 parse_duration(datetime_based_cursor_model.cursor_granularity) 1489 if datetime_based_cursor_model.cursor_granularity 1490 else None 1491 ) 1492 1493 lookback_window = None 1494 interpolated_lookback_window = ( 1495 InterpolatedString.create( 1496 datetime_based_cursor_model.lookback_window, 1497 parameters=model_parameters, 1498 ) 1499 if datetime_based_cursor_model.lookback_window 1500 else None 1501 ) 1502 if interpolated_lookback_window: 1503 evaluated_lookback_window = interpolated_lookback_window.eval(config=config) 1504 if evaluated_lookback_window: 1505 lookback_window = parse_duration(evaluated_lookback_window) 1506 1507 connector_state_converter: DateTimeStreamStateConverter 1508 connector_state_converter = CustomFormatConcurrentStreamStateConverter( 1509 datetime_format=datetime_format, 1510 input_datetime_formats=datetime_based_cursor_model.cursor_datetime_formats, 1511 is_sequential_state=True, # ConcurrentPerPartitionCursor only works with sequential state 1512 cursor_granularity=cursor_granularity, 1513 ) 1514 1515 # Adjusts the stream state by applying the runtime lookback window. 1516 # This is used to ensure correct state handling in case of failed partitions. 1517 stream_state_value = stream_state.get(cursor_field.cursor_field_key) 1518 if runtime_lookback_window and stream_state_value: 1519 new_stream_state = ( 1520 connector_state_converter.parse_timestamp(stream_state_value) 1521 - runtime_lookback_window 1522 ) 1523 stream_state[cursor_field.cursor_field_key] = connector_state_converter.output_format( 1524 new_stream_state 1525 ) 1526 1527 start_date_runtime_value: Union[InterpolatedString, str, MinMaxDatetime] 1528 if isinstance(datetime_based_cursor_model.start_datetime, MinMaxDatetimeModel): 1529 start_date_runtime_value = self.create_min_max_datetime( 1530 model=datetime_based_cursor_model.start_datetime, config=config 1531 ) 1532 else: 1533 start_date_runtime_value = datetime_based_cursor_model.start_datetime 1534 1535 end_date_runtime_value: Optional[Union[InterpolatedString, str, MinMaxDatetime]] 1536 if isinstance(datetime_based_cursor_model.end_datetime, MinMaxDatetimeModel): 1537 end_date_runtime_value = self.create_min_max_datetime( 1538 model=datetime_based_cursor_model.end_datetime, config=config 1539 ) 1540 else: 1541 end_date_runtime_value = datetime_based_cursor_model.end_datetime 1542 1543 interpolated_start_date = MinMaxDatetime.create( 1544 interpolated_string_or_min_max_datetime=start_date_runtime_value, 1545 parameters=datetime_based_cursor_model.parameters, 1546 ) 1547 interpolated_end_date = ( 1548 None 1549 if not end_date_runtime_value 1550 else MinMaxDatetime.create( 1551 end_date_runtime_value, datetime_based_cursor_model.parameters 1552 ) 1553 ) 1554 1555 # If datetime format is not specified then start/end datetime should inherit it from the stream slicer 1556 if not interpolated_start_date.datetime_format: 1557 interpolated_start_date.datetime_format = datetime_format 1558 if interpolated_end_date and not interpolated_end_date.datetime_format: 1559 interpolated_end_date.datetime_format = datetime_format 1560 1561 start_date = interpolated_start_date.get_datetime(config=config) 1562 end_date_provider = ( 1563 partial(interpolated_end_date.get_datetime, config) 1564 if interpolated_end_date 1565 else connector_state_converter.get_end_provider() 1566 ) 1567 1568 if ( 1569 datetime_based_cursor_model.step and not datetime_based_cursor_model.cursor_granularity 1570 ) or ( 1571 not datetime_based_cursor_model.step and datetime_based_cursor_model.cursor_granularity 1572 ): 1573 raise ValueError( 1574 f"If step is defined, cursor_granularity should be as well and vice-versa. " 1575 f"Right now, step is `{datetime_based_cursor_model.step}` and cursor_granularity is `{datetime_based_cursor_model.cursor_granularity}`" 1576 ) 1577 1578 # When step is not defined, default to a step size from the starting date to the present moment 1579 step_length = datetime.timedelta.max 1580 interpolated_step = ( 1581 InterpolatedString.create( 1582 datetime_based_cursor_model.step, 1583 parameters=model_parameters, 1584 ) 1585 if datetime_based_cursor_model.step 1586 else None 1587 ) 1588 if interpolated_step: 1589 evaluated_step = interpolated_step.eval(config) 1590 if evaluated_step: 1591 step_length = parse_duration(evaluated_step) 1592 1593 clamping_strategy: ClampingStrategy = NoClamping() 1594 if datetime_based_cursor_model.clamping: 1595 # While it is undesirable to interpolate within the model factory (as opposed to at runtime), 1596 # it is still better than shifting interpolation low-code concept into the ConcurrentCursor runtime 1597 # object which we want to keep agnostic of being low-code 1598 target = InterpolatedString( 1599 string=datetime_based_cursor_model.clamping.target, 1600 parameters=model_parameters, 1601 ) 1602 evaluated_target = target.eval(config=config) 1603 match evaluated_target: 1604 case "DAY": 1605 clamping_strategy = DayClampingStrategy() 1606 end_date_provider = ClampingEndProvider( 1607 DayClampingStrategy(is_ceiling=False), 1608 end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1609 granularity=cursor_granularity or datetime.timedelta(seconds=1), 1610 ) 1611 case "WEEK": 1612 if ( 1613 not datetime_based_cursor_model.clamping.target_details 1614 or "weekday" not in datetime_based_cursor_model.clamping.target_details 1615 ): 1616 raise ValueError( 1617 "Given WEEK clamping, weekday needs to be provided as target_details" 1618 ) 1619 weekday = self._assemble_weekday( 1620 datetime_based_cursor_model.clamping.target_details["weekday"] 1621 ) 1622 clamping_strategy = WeekClampingStrategy(weekday) 1623 end_date_provider = ClampingEndProvider( 1624 WeekClampingStrategy(weekday, is_ceiling=False), 1625 end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1626 granularity=cursor_granularity or datetime.timedelta(days=1), 1627 ) 1628 case "MONTH": 1629 clamping_strategy = MonthClampingStrategy() 1630 end_date_provider = ClampingEndProvider( 1631 MonthClampingStrategy(is_ceiling=False), 1632 end_date_provider, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1633 granularity=cursor_granularity or datetime.timedelta(days=1), 1634 ) 1635 case _: 1636 raise ValueError( 1637 f"Invalid clamping target {evaluated_target}, expected DAY, WEEK, MONTH" 1638 ) 1639 1640 return ConcurrentCursor( 1641 stream_name=stream_name, 1642 stream_namespace=stream_namespace, 1643 stream_state=stream_state, 1644 message_repository=message_repository or self._message_repository, 1645 connector_state_manager=self._connector_state_manager, 1646 connector_state_converter=connector_state_converter, 1647 cursor_field=cursor_field, 1648 slice_boundary_fields=slice_boundary_fields, 1649 start=start_date, # type: ignore # Having issues w/ inspection for GapType and CursorValueType as shown in existing tests. Confirmed functionality is working in practice 1650 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 1651 lookback_window=lookback_window, 1652 slice_range=step_length, 1653 cursor_granularity=cursor_granularity, 1654 clamping_strategy=clamping_strategy, 1655 is_compare_strictly=datetime_based_cursor_model.is_compare_strictly or False, 1656 )
1658 def create_concurrent_cursor_from_incrementing_count_cursor( 1659 self, 1660 model_type: Type[BaseModel], 1661 component_definition: ComponentDefinition, 1662 stream_name: str, 1663 stream_namespace: Optional[str], 1664 stream_state: MutableMapping[str, Any], 1665 config: Config, 1666 message_repository: Optional[MessageRepository] = None, 1667 **kwargs: Any, 1668 ) -> ConcurrentCursor: 1669 component_type = component_definition.get("type") 1670 if component_definition.get("type") != model_type.__name__: 1671 raise ValueError( 1672 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 1673 ) 1674 1675 incrementing_count_cursor_model = model_type.parse_obj(component_definition) 1676 1677 if not isinstance(incrementing_count_cursor_model, IncrementingCountCursorModel): 1678 raise ValueError( 1679 f"Expected {model_type.__name__} component, but received {incrementing_count_cursor_model.__class__.__name__}" 1680 ) 1681 1682 start_value: Union[int, str, None] = incrementing_count_cursor_model.start_value 1683 # Pydantic Union type coercion can convert int 0 to string '0' depending on Union order. 1684 # We need to handle both int and str representations of numeric values. 1685 # Evaluate the InterpolatedString and convert to int for the ConcurrentCursor. 1686 if start_value is not None: 1687 interpolated_start_value = InterpolatedString.create( 1688 str(start_value), # Ensure we pass a string to InterpolatedString.create 1689 parameters=incrementing_count_cursor_model.parameters or {}, 1690 ) 1691 evaluated_start_value: int = int(interpolated_start_value.eval(config=config)) 1692 else: 1693 evaluated_start_value = 0 1694 1695 cursor_field = self._get_catalog_defined_cursor_field( 1696 stream_name=stream_name, 1697 allow_catalog_defined_cursor_field=incrementing_count_cursor_model.allow_catalog_defined_cursor_field 1698 or False, 1699 ) 1700 1701 if not cursor_field: 1702 interpolated_cursor_field = InterpolatedString.create( 1703 incrementing_count_cursor_model.cursor_field, 1704 parameters=incrementing_count_cursor_model.parameters or {}, 1705 ) 1706 cursor_field = CursorField( 1707 cursor_field_key=interpolated_cursor_field.eval(config=config), 1708 supports_catalog_defined_cursor_field=incrementing_count_cursor_model.allow_catalog_defined_cursor_field 1709 or False, 1710 ) 1711 1712 connector_state_converter = IncrementingCountStreamStateConverter( 1713 is_sequential_state=True, # ConcurrentPerPartitionCursor only works with sequential state 1714 ) 1715 1716 return ConcurrentCursor( 1717 stream_name=stream_name, 1718 stream_namespace=stream_namespace, 1719 stream_state=stream_state, 1720 message_repository=message_repository or self._message_repository, 1721 connector_state_manager=self._connector_state_manager, 1722 connector_state_converter=connector_state_converter, 1723 cursor_field=cursor_field, 1724 slice_boundary_fields=None, 1725 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 1726 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 1727 )
1748 def create_concurrent_cursor_from_perpartition_cursor( 1749 self, 1750 state_manager: ConnectorStateManager, 1751 model_type: Type[BaseModel], 1752 component_definition: ComponentDefinition, 1753 stream_name: str, 1754 stream_namespace: Optional[str], 1755 config: Config, 1756 stream_state: MutableMapping[str, Any], 1757 partition_router: PartitionRouter, 1758 attempt_to_create_cursor_if_not_provided: bool = False, 1759 **kwargs: Any, 1760 ) -> ConcurrentPerPartitionCursor: 1761 component_type = component_definition.get("type") 1762 if component_definition.get("type") != model_type.__name__: 1763 raise ValueError( 1764 f"Expected manifest component of type {model_type.__name__}, but received {component_type} instead" 1765 ) 1766 1767 # 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: 1768 # * The ComponentDefinition comes from model.__dict__ in which case we have `parameters` 1769 # * The ComponentDefinition comes from the manifest as a dict in which case we have `$parameters` 1770 # 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. 1771 if "$parameters" not in component_definition and "parameters" in component_definition: 1772 component_definition["$parameters"] = component_definition.get("parameters") # type: ignore # This is a dict 1773 datetime_based_cursor_model = model_type.parse_obj(component_definition) 1774 1775 if not isinstance(datetime_based_cursor_model, DatetimeBasedCursorModel): 1776 raise ValueError( 1777 f"Expected {model_type.__name__} component, but received {datetime_based_cursor_model.__class__.__name__}" 1778 ) 1779 1780 cursor_field = self._get_catalog_defined_cursor_field( 1781 stream_name=stream_name, 1782 allow_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1783 or False, 1784 ) 1785 1786 if not cursor_field: 1787 interpolated_cursor_field = InterpolatedString.create( 1788 datetime_based_cursor_model.cursor_field, 1789 # 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: 1790 # * The ComponentDefinition comes from model.__dict__ in which case we have `parameters` 1791 # * The ComponentDefinition comes from the manifest as a dict in which case we have `$parameters` 1792 # 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. 1793 parameters=datetime_based_cursor_model.parameters or {}, 1794 ) 1795 cursor_field = CursorField( 1796 cursor_field_key=interpolated_cursor_field.eval(config=config), 1797 supports_catalog_defined_cursor_field=datetime_based_cursor_model.allow_catalog_defined_cursor_field 1798 or False, 1799 ) 1800 1801 datetime_format = datetime_based_cursor_model.datetime_format 1802 1803 cursor_granularity = ( 1804 parse_duration(datetime_based_cursor_model.cursor_granularity) 1805 if datetime_based_cursor_model.cursor_granularity 1806 else None 1807 ) 1808 1809 connector_state_converter: DateTimeStreamStateConverter 1810 connector_state_converter = CustomFormatConcurrentStreamStateConverter( 1811 datetime_format=datetime_format, 1812 input_datetime_formats=datetime_based_cursor_model.cursor_datetime_formats, 1813 is_sequential_state=True, # ConcurrentPerPartitionCursor only works with sequential state 1814 cursor_granularity=cursor_granularity, 1815 ) 1816 1817 # Create the cursor factory 1818 cursor_factory = ConcurrentCursorFactory( 1819 partial( 1820 self.create_concurrent_cursor_from_datetime_based_cursor, 1821 state_manager=state_manager, 1822 model_type=model_type, 1823 component_definition=component_definition, 1824 stream_name=stream_name, 1825 stream_namespace=stream_namespace, 1826 config=config, 1827 message_repository=NoopMessageRepository(), 1828 ) 1829 ) 1830 1831 # Per-partition state doesn't make sense for GroupingPartitionRouter, so force the global state 1832 use_global_cursor = isinstance( 1833 partition_router, GroupingPartitionRouter 1834 ) or component_definition.get("global_substream_cursor", False) 1835 1836 # Return the concurrent cursor and state converter 1837 return ConcurrentPerPartitionCursor( 1838 cursor_factory=cursor_factory, 1839 partition_router=partition_router, 1840 stream_name=stream_name, 1841 stream_namespace=stream_namespace, 1842 stream_state=stream_state, 1843 message_repository=self._message_repository, # type: ignore 1844 connector_state_manager=state_manager, 1845 connector_state_converter=connector_state_converter, 1846 cursor_field=cursor_field, 1847 use_global_cursor=use_global_cursor, 1848 attempt_to_create_cursor_if_not_provided=attempt_to_create_cursor_if_not_provided, 1849 )
1851 @staticmethod 1852 def create_constant_backoff_strategy( 1853 model: ConstantBackoffStrategyModel, config: Config, **kwargs: Any 1854 ) -> ConstantBackoffStrategy: 1855 ModelToComponentFactory._validate_jitter_range(model.jitter_range_in_seconds) 1856 return ConstantBackoffStrategy( 1857 backoff_time_in_seconds=model.backoff_time_in_seconds, 1858 jitter_range_in_seconds=model.jitter_range_in_seconds, 1859 config=config, 1860 parameters=model.parameters or {}, 1861 )
1868 def create_cursor_pagination( 1869 self, 1870 model: CursorPaginationModel, 1871 config: Config, 1872 decoder: Optional[Decoder] = None, 1873 **kwargs: Any, 1874 ) -> CursorPaginationStrategy: 1875 # None when nested under a custom component, which cannot pass a decoder 1876 if decoder is None: 1877 decoder = JsonDecoder(parameters={}) 1878 if isinstance(decoder, PaginationDecoderDecorator): 1879 inner_decoder = decoder.decoder 1880 else: 1881 inner_decoder = decoder 1882 decoder = PaginationDecoderDecorator(decoder=decoder) 1883 1884 if self._is_supported_decoder_for_pagination(inner_decoder): 1885 decoder_to_use = decoder 1886 else: 1887 raise ValueError( 1888 self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(inner_decoder)) 1889 ) 1890 1891 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 1892 # If page_size is a string that represents an integer (not an interpolation), convert it back. 1893 page_size = model.page_size 1894 if isinstance(page_size, str) and page_size.isdigit(): 1895 page_size = int(page_size) 1896 1897 return CursorPaginationStrategy( 1898 cursor_value=model.cursor_value, 1899 decoder=decoder_to_use, 1900 page_size=page_size, 1901 stop_condition=model.stop_condition, 1902 config=config, 1903 parameters=model.parameters or {}, 1904 )
1906 def create_custom_component(self, model: Any, config: Config, **kwargs: Any) -> Any: 1907 """ 1908 Generically creates a custom component based on the model type and a class_name reference to the custom Python class being 1909 instantiated. Only the model's additional properties that match the custom class definition are passed to the constructor 1910 :param model: The Pydantic model of the custom component being created 1911 :param config: The custom defined connector config 1912 :return: The declarative component built from the Pydantic model to be used at runtime 1913 """ 1914 # Instantiating a custom component means importing and executing arbitrary code referenced 1915 # by `class_name`. Manifests supplied by a caller, whether through the config or directly to 1916 # the manifest server, are untrusted input and could point `class_name` at any importable 1917 # callable, so they honor the same `AIRBYTE_ENABLE_UNSAFE_CODE` gate as injected 1918 # `components.py` code. Manifests bundled in a published connector image are trusted and may 1919 # always use their bundled custom components. 1920 manifest_is_untrusted = not self._custom_components_trusted or bool( 1921 config.get(INJECTED_MANIFEST) 1922 ) 1923 if manifest_is_untrusted and not custom_code_execution_permitted(): 1924 raise AirbyteCustomCodeNotPermittedError 1925 1926 custom_component_class = self._get_class_from_fully_qualified_class_name(model.class_name) 1927 component_fields = get_type_hints(custom_component_class) 1928 model_args = model.dict() 1929 model_args["config"] = config 1930 1931 # There are cases where a parent component will pass arguments to a child component via kwargs. When there are field collisions 1932 # we defer to these arguments over the component's definition 1933 for key, arg in kwargs.items(): 1934 model_args[key] = arg 1935 1936 # Pydantic is unable to parse a custom component's fields that are subcomponents into models because their fields and types are not 1937 # defined in the schema. The fields and types are defined within the Python class implementation. Pydantic can only parse down to 1938 # the custom component and this code performs a second parse to convert the sub-fields first into models, then declarative components 1939 for model_field, model_value in model_args.items(): 1940 # If a custom component field doesn't have a type set, we try to use the type hints to infer the type 1941 if ( 1942 isinstance(model_value, dict) 1943 and "type" not in model_value 1944 and model_field in component_fields 1945 ): 1946 derived_type = self._derive_component_type_from_type_hints( 1947 component_fields.get(model_field) 1948 ) 1949 if derived_type: 1950 model_value["type"] = derived_type 1951 1952 if self._is_component(model_value): 1953 model_args[model_field] = self._create_nested_component( 1954 model, 1955 model_field, 1956 model_value, 1957 config, 1958 **kwargs, 1959 ) 1960 elif isinstance(model_value, list): 1961 vals = [] 1962 for v in model_value: 1963 if isinstance(v, dict) and "type" not in v and model_field in component_fields: 1964 derived_type = self._derive_component_type_from_type_hints( 1965 component_fields.get(model_field) 1966 ) 1967 if derived_type: 1968 v["type"] = derived_type 1969 if self._is_component(v): 1970 vals.append( 1971 self._create_nested_component( 1972 model, 1973 model_field, 1974 v, 1975 config, 1976 **kwargs, 1977 ) 1978 ) 1979 else: 1980 vals.append(v) 1981 model_args[model_field] = vals 1982 1983 kwargs = { 1984 class_field: model_args[class_field] 1985 for class_field in component_fields.keys() 1986 if class_field in model_args 1987 } 1988 1989 if "api_budget" in component_fields and kwargs.get("api_budget") is None: 1990 kwargs["api_budget"] = self._api_budget 1991 1992 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
2127 def create_default_stream( 2128 self, model: DeclarativeStreamModel, config: Config, is_parent: bool = False, **kwargs: Any 2129 ) -> AbstractStream: 2130 primary_key = model.primary_key.__root__ if model.primary_key else None 2131 self._migrate_state(model, config) 2132 self._warn_on_ineffective_incremental_dependency(model) 2133 2134 partition_router = self._build_stream_slicer_from_partition_router( 2135 model.retriever, 2136 config, 2137 stream_name=model.name, 2138 **kwargs, 2139 ) 2140 concurrent_cursor = self._build_concurrent_cursor(model, partition_router, config) 2141 if model.incremental_sync and isinstance(model.incremental_sync, DatetimeBasedCursorModel): 2142 cursor_model: DatetimeBasedCursorModel = model.incremental_sync 2143 2144 end_time_option = ( 2145 self._create_component_from_model( 2146 cursor_model.end_time_option, config, parameters=cursor_model.parameters or {} 2147 ) 2148 if cursor_model.end_time_option 2149 else None 2150 ) 2151 start_time_option = ( 2152 self._create_component_from_model( 2153 cursor_model.start_time_option, config, parameters=cursor_model.parameters or {} 2154 ) 2155 if cursor_model.start_time_option 2156 else None 2157 ) 2158 2159 datetime_request_options_provider = DatetimeBasedRequestOptionsProvider( 2160 start_time_option=start_time_option, 2161 end_time_option=end_time_option, 2162 partition_field_start=cursor_model.partition_field_start, 2163 partition_field_end=cursor_model.partition_field_end, 2164 config=config, 2165 parameters=model.parameters or {}, 2166 ) 2167 request_options_provider = ( 2168 datetime_request_options_provider 2169 if not isinstance(concurrent_cursor, ConcurrentPerPartitionCursor) 2170 else PerPartitionRequestOptionsProvider( 2171 partition_router, datetime_request_options_provider 2172 ) 2173 ) 2174 elif model.incremental_sync and isinstance( 2175 model.incremental_sync, IncrementingCountCursorModel 2176 ): 2177 if isinstance(concurrent_cursor, ConcurrentPerPartitionCursor): 2178 raise ValueError( 2179 "PerPartition does not support per partition states because switching to global state is time based" 2180 ) 2181 2182 cursor_model: IncrementingCountCursorModel = model.incremental_sync # type: ignore 2183 2184 start_time_option = ( 2185 self._create_component_from_model( 2186 cursor_model.start_value_option, # type: ignore # mypy still thinks cursor_model of type DatetimeBasedCursor 2187 config, 2188 parameters=cursor_model.parameters or {}, 2189 ) 2190 if cursor_model.start_value_option # type: ignore # mypy still thinks cursor_model of type DatetimeBasedCursor 2191 else None 2192 ) 2193 2194 # The concurrent engine defaults the start/end fields on the slice to "start" and "end", but 2195 # the default DatetimeBasedRequestOptionsProvider() sets them to start_time/end_time 2196 partition_field_start = "start" 2197 2198 request_options_provider = DatetimeBasedRequestOptionsProvider( 2199 start_time_option=start_time_option, 2200 partition_field_start=partition_field_start, 2201 config=config, 2202 parameters=model.parameters or {}, 2203 ) 2204 else: 2205 request_options_provider = None 2206 2207 transformations = [] 2208 if model.transformations: 2209 for transformation_model in model.transformations: 2210 transformations.append( 2211 self._create_component_from_model(model=transformation_model, config=config) 2212 ) 2213 file_uploader = None 2214 if model.file_uploader: 2215 file_uploader = self._create_component_from_model( 2216 model=model.file_uploader, config=config 2217 ) 2218 2219 stream_slicer: ConcurrentStreamSlicer = ( 2220 partition_router 2221 if isinstance(concurrent_cursor, FinalStateCursor) 2222 else concurrent_cursor 2223 ) 2224 2225 retriever = self._create_component_from_model( 2226 model=model.retriever, 2227 config=config, 2228 name=model.name, 2229 primary_key=primary_key, 2230 request_options_provider=request_options_provider, 2231 stream_slicer=stream_slicer, 2232 partition_router=partition_router, 2233 has_stop_condition_cursor=self._is_stop_condition_on_cursor(model), 2234 is_client_side_incremental_sync=self._is_client_side_filtering_enabled(model), 2235 cursor=concurrent_cursor, 2236 transformations=transformations, 2237 file_uploader=file_uploader, 2238 incremental_sync=model.incremental_sync, 2239 ) 2240 if isinstance(retriever, AsyncRetriever): 2241 stream_slicer = retriever.stream_slicer 2242 2243 schema_loader: SchemaLoader 2244 if model.schema_loader and isinstance(model.schema_loader, list): 2245 nested_schema_loaders = [ 2246 self._create_component_from_model(model=nested_schema_loader, config=config) 2247 for nested_schema_loader in model.schema_loader 2248 ] 2249 schema_loader = CompositeSchemaLoader( 2250 schema_loaders=nested_schema_loaders, parameters={} 2251 ) 2252 elif model.schema_loader: 2253 schema_loader = self._create_component_from_model( 2254 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 2255 config=config, 2256 ) 2257 else: 2258 options = model.parameters or {} 2259 if "name" not in options: 2260 options["name"] = model.name 2261 schema_loader = DefaultSchemaLoader(config=config, parameters=options) 2262 schema_loader = CachingSchemaLoaderDecorator(schema_loader) 2263 2264 stream_name = model.name or "" 2265 return DefaultStream( 2266 partition_generator=StreamSlicerPartitionGenerator( 2267 DeclarativePartitionFactory( 2268 stream_name, 2269 schema_loader, 2270 retriever, 2271 self._message_repository, 2272 ), 2273 stream_slicer, 2274 slice_limit=self._limit_slices_fetched, 2275 ), 2276 name=stream_name, 2277 json_schema=schema_loader.get_json_schema, 2278 primary_key=get_primary_key_from_stream(primary_key), 2279 cursor_field=( 2280 concurrent_cursor.cursor_field 2281 if hasattr(concurrent_cursor, "cursor_field") 2282 else None 2283 ), 2284 logger=logging.getLogger(f"airbyte.{stream_name}"), 2285 cursor=concurrent_cursor, 2286 supports_file_transfer=hasattr(model, "file_uploader") and bool(model.file_uploader), 2287 )
2459 def create_default_error_handler( 2460 self, model: DefaultErrorHandlerModel, config: Config, **kwargs: Any 2461 ) -> DefaultErrorHandler: 2462 backoff_strategies = [] 2463 if model.backoff_strategies: 2464 for backoff_strategy_model in model.backoff_strategies: 2465 backoff_strategies.append( 2466 self._create_component_from_model(model=backoff_strategy_model, config=config) 2467 ) 2468 2469 response_filters = [] 2470 if model.response_filters: 2471 for response_filter_model in model.response_filters: 2472 response_filters.append( 2473 self._create_component_from_model(model=response_filter_model, config=config) 2474 ) 2475 response_filters.append( 2476 HttpResponseFilter(config=config, parameters=model.parameters or {}) 2477 ) 2478 2479 return DefaultErrorHandler( 2480 backoff_strategies=backoff_strategies, 2481 max_retries=model.max_retries, 2482 response_filters=response_filters, 2483 config=config, 2484 parameters=model.parameters or {}, 2485 )
2487 def create_default_paginator( 2488 self, 2489 model: DefaultPaginatorModel, 2490 config: Config, 2491 *, 2492 url_base: str, 2493 extractor_model: Optional[ 2494 Union[CustomRecordExtractorModel, DpathExtractorModel, CombinedExtractorModel] 2495 ] = None, 2496 decoder: Optional[Decoder] = None, 2497 cursor_used_for_stop_condition: Optional[Cursor] = None, 2498 ) -> Union[DefaultPaginator, PaginatorTestReadDecorator]: 2499 if decoder: 2500 if self._is_supported_decoder_for_pagination(decoder): 2501 decoder_to_use = PaginationDecoderDecorator(decoder=decoder) 2502 else: 2503 raise ValueError(self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(decoder))) 2504 else: 2505 decoder_to_use = PaginationDecoderDecorator(decoder=JsonDecoder(parameters={})) 2506 page_size_option = ( 2507 self._create_component_from_model(model=model.page_size_option, config=config) 2508 if model.page_size_option 2509 else None 2510 ) 2511 page_token_option = ( 2512 self._create_component_from_model(model=model.page_token_option, config=config) 2513 if model.page_token_option 2514 else None 2515 ) 2516 pagination_strategy = self._create_component_from_model( 2517 model=model.pagination_strategy, 2518 config=config, 2519 decoder=decoder_to_use, 2520 extractor_model=extractor_model, 2521 ) 2522 if cursor_used_for_stop_condition: 2523 pagination_strategy = StopConditionPaginationStrategyDecorator( 2524 pagination_strategy, CursorStopCondition(cursor_used_for_stop_condition) 2525 ) 2526 paginator = DefaultPaginator( 2527 decoder=decoder_to_use, 2528 page_size_option=page_size_option, 2529 page_token_option=page_token_option, 2530 pagination_strategy=pagination_strategy, 2531 url_base=url_base, 2532 config=config, 2533 parameters=model.parameters or {}, 2534 ) 2535 if self._limit_pages_fetched_per_slice: 2536 return PaginatorTestReadDecorator(paginator, self._limit_pages_fetched_per_slice) 2537 return paginator
2595 def create_combined_extractor( 2596 self, 2597 model: CombinedExtractorModel, 2598 config: Config, 2599 decoder: Optional[Decoder] = None, 2600 **kwargs: Any, 2601 ) -> CombinedExtractor: 2602 self._reject_combined_extractor_over_streaming_decoder(decoder) 2603 extractors = [ 2604 self._create_component_from_model(model=sub_extractor, config=config, decoder=decoder) 2605 for sub_extractor in model.extractors 2606 ] 2607 return CombinedExtractor( 2608 extractors=extractors, 2609 mode=CombineMode(model.mode.value) if model.mode else CombineMode.union, 2610 skip_empty_records=bool(model.skip_empty_records), 2611 parameters=model.parameters or {}, 2612 )
2614 def create_dpath_extractor( 2615 self, 2616 model: DpathExtractorModel, 2617 config: Config, 2618 decoder: Optional[Decoder] = None, 2619 **kwargs: Any, 2620 ) -> DpathExtractor: 2621 if decoder: 2622 decoder_to_use = decoder 2623 else: 2624 decoder_to_use = JsonDecoder(parameters={}) 2625 model_field_path: List[Union[InterpolatedString, str]] = [x for x in model.field_path] 2626 2627 record_expander = None 2628 if model.record_expander: 2629 record_expander = self._create_component_from_model( 2630 model=model.record_expander, 2631 config=config, 2632 ) 2633 2634 return DpathExtractor( 2635 decoder=decoder_to_use, 2636 field_path=model_field_path, 2637 config=config, 2638 parameters=model.parameters or {}, 2639 record_expander=record_expander, 2640 )
2642 def create_record_expander( 2643 self, 2644 model: RecordExpanderModel, 2645 config: Config, 2646 **kwargs: Any, 2647 ) -> RecordExpander: 2648 truncated_list_retriever = None 2649 suppress_incomplete_fetch_warning = False 2650 if model.truncated_list_retriever: 2651 retriever_model = model.truncated_list_retriever 2652 name = "record_expander_truncated_list" 2653 # `CustomRetriever` allows extra fields, so read from the dumped model to cover both types. 2654 retriever_fields = retriever_model.dict() 2655 for unsupported_option in ("partition_router", "pagination_reset"): 2656 if retriever_fields.get(unsupported_option): 2657 raise ValueError( 2658 f"`{unsupported_option}` is not supported on `truncated_list_retriever`." 2659 ) 2660 log_formatter = lambda response: format_http_message( 2661 response, 2662 f"Record expander '{name}' request", 2663 "Request performed in order to fetch the complete nested list of a truncated record.", 2664 name, 2665 is_auxiliary=True, 2666 ) 2667 # `name`/`primary_key`/`transformations` are also what a `CustomRetriever` forwards to its 2668 # nested `requester`/`record_selector`, so they are passed for both retriever types. 2669 truncated_list_retriever = self._create_component_from_model( 2670 model=retriever_model, 2671 config=config, 2672 name=name, 2673 primary_key=None, 2674 transformations=[], 2675 log_formatter=log_formatter, 2676 ) 2677 # Only a capped paginator makes a shortfall expected; `NoPagination` and retrievers 2678 # without a paginator are never capped. 2679 suppress_incomplete_fetch_warning = isinstance( 2680 truncated_list_retriever, SimpleRetriever 2681 ) and isinstance(truncated_list_retriever.paginator, PaginatorTestReadDecorator) 2682 return RecordExpander( 2683 expand_records_from_field=model.expand_records_from_field, 2684 config=config, 2685 parameters=model.parameters or {}, 2686 remain_original_record=model.remain_original_record or False, 2687 parent_fields=[ 2688 self._create_component_from_model(model=parent_field, config=config) 2689 for parent_field in model.parent_fields 2690 ] 2691 if model.parent_fields 2692 else None, 2693 merge_parent=model.merge_parent or False, 2694 on_no_records=OnNoRecords(model.on_no_records.value) 2695 if model.on_no_records 2696 else OnNoRecords.skip, 2697 truncation_indicator_path=model.truncation_indicator_path, 2698 truncated_list_retriever=truncated_list_retriever, 2699 message_repository=self._message_repository, 2700 suppress_incomplete_fetch_warning=suppress_incomplete_fetch_warning, 2701 )
2703 @staticmethod 2704 def create_parent_field_path( 2705 model: ParentFieldPathModel, 2706 config: Config, 2707 **kwargs: Any, 2708 ) -> ParentFieldPath: 2709 return ParentFieldPath( 2710 parent_path=model.parent_path, 2711 record_path=model.record_path, 2712 config=config, 2713 parameters=model.parameters or {}, 2714 )
2716 @staticmethod 2717 def create_response_to_file_extractor( 2718 model: ResponseToFileExtractorModel, 2719 **kwargs: Any, 2720 ) -> ResponseToFileExtractor: 2721 return ResponseToFileExtractor( 2722 parameters=model.parameters or {}, 2723 preserve_na_values=model.preserve_na_values or False, 2724 )
2726 @staticmethod 2727 def create_exponential_backoff_strategy( 2728 model: ExponentialBackoffStrategyModel, config: Config 2729 ) -> ExponentialBackoffStrategy: 2730 ModelToComponentFactory._validate_jitter_range(model.jitter_range_in_seconds) 2731 return ExponentialBackoffStrategy( 2732 factor=model.factor or 5, 2733 jitter_range_in_seconds=model.jitter_range_in_seconds, 2734 parameters=model.parameters or {}, 2735 config=config, 2736 )
2742 def create_http_requester( 2743 self, 2744 model: HttpRequesterModel, 2745 config: Config, 2746 decoder: Decoder = JsonDecoder(parameters={}), 2747 query_properties_key: Optional[str] = None, 2748 use_cache: Optional[bool] = None, 2749 *, 2750 name: str, 2751 ) -> HttpRequester: 2752 authenticator = ( 2753 self._create_component_from_model( 2754 model=model.authenticator, 2755 config=config, 2756 url_base=model.url or model.url_base, 2757 name=name, 2758 decoder=decoder, 2759 ) 2760 if model.authenticator 2761 else None 2762 ) 2763 error_handler = ( 2764 self._create_component_from_model(model=model.error_handler, config=config) 2765 if model.error_handler 2766 else DefaultErrorHandler( 2767 backoff_strategies=[], 2768 response_filters=[], 2769 config=config, 2770 parameters=model.parameters or {}, 2771 ) 2772 ) 2773 2774 api_budget = self._api_budget 2775 2776 request_options_provider = InterpolatedRequestOptionsProvider( 2777 request_body=model.request_body, 2778 request_body_data=model.request_body_data, 2779 request_body_json=model.request_body_json, 2780 request_headers=model.request_headers, 2781 request_parameters=model.request_parameters, # type: ignore # QueryProperties have been removed in `create_simple_retriever` 2782 query_properties_key=query_properties_key, 2783 config=config, 2784 parameters=model.parameters or {}, 2785 ) 2786 2787 assert model.use_cache is not None # for mypy 2788 assert model.http_method is not None # for mypy 2789 2790 should_use_cache = (model.use_cache or bool(use_cache)) and not self._disable_cache 2791 2792 return HttpRequester( 2793 name=name, 2794 url=model.url, 2795 url_base=model.url_base, 2796 path=model.path, 2797 authenticator=authenticator, 2798 error_handler=error_handler, 2799 api_budget=api_budget, 2800 http_method=HttpMethod[model.http_method.value], 2801 request_options_provider=request_options_provider, 2802 config=config, 2803 disable_retries=self._disable_retries, 2804 parameters=model.parameters or {}, 2805 message_repository=self._message_repository, 2806 use_cache=should_use_cache, 2807 connect_timeout_in_seconds=model.connect_timeout_in_seconds, 2808 read_timeout_in_seconds=model.read_timeout_in_seconds, 2809 use_tcp_keepalive=model.use_tcp_keepalive or False, 2810 decoder=decoder, 2811 stream_response=decoder.is_stream_response() if decoder else False, 2812 )
2814 @staticmethod 2815 def create_http_response_filter( 2816 model: HttpResponseFilterModel, config: Config, **kwargs: Any 2817 ) -> HttpResponseFilter: 2818 if model.action: 2819 action = ResponseAction(model.action.value) 2820 else: 2821 action = None 2822 2823 failure_type = FailureType(model.failure_type.value) if model.failure_type else None 2824 2825 http_codes = ( 2826 set(model.http_codes) if model.http_codes else set() 2827 ) # JSON schema notation has no set data type. The schema enforces an array of unique elements 2828 2829 return HttpResponseFilter( 2830 action=action, 2831 failure_type=failure_type, 2832 error_message=model.error_message or "", 2833 error_message_contains=model.error_message_contains or "", 2834 http_codes=http_codes, 2835 predicate=model.predicate or "", 2836 config=config, 2837 parameters=model.parameters or {}, 2838 )
2846 def create_complex_field_type( 2847 self, model: ComplexFieldTypeModel, config: Config, **kwargs: Any 2848 ) -> ComplexFieldType: 2849 items = ( 2850 self._create_component_from_model(model=model.items, config=config) 2851 if isinstance(model.items, ComplexFieldTypeModel) 2852 else model.items 2853 ) 2854 2855 return ComplexFieldType(field_type=model.field_type, items=items)
2857 def create_types_map(self, model: TypesMapModel, config: Config, **kwargs: Any) -> TypesMap: 2858 target_type = ( 2859 self._create_component_from_model(model=model.target_type, config=config) 2860 if isinstance(model.target_type, ComplexFieldTypeModel) 2861 else model.target_type 2862 ) 2863 2864 return TypesMap( 2865 target_type=target_type, 2866 current_type=model.current_type, 2867 condition=model.condition if model.condition is not None else "True", 2868 )
2870 def create_schema_type_identifier( 2871 self, model: SchemaTypeIdentifierModel, config: Config, **kwargs: Any 2872 ) -> SchemaTypeIdentifier: 2873 types_mapping = [] 2874 if model.types_mapping: 2875 types_mapping.extend( 2876 [ 2877 self._create_component_from_model(types_map, config=config) 2878 for types_map in model.types_mapping 2879 ] 2880 ) 2881 model_schema_pointer: List[Union[InterpolatedString, str]] = ( 2882 [x for x in model.schema_pointer] if model.schema_pointer else [] 2883 ) 2884 model_key_pointer: List[Union[InterpolatedString, str]] = [x for x in model.key_pointer] 2885 model_type_pointer: Optional[List[Union[InterpolatedString, str]]] = ( 2886 [x for x in model.type_pointer] if model.type_pointer else None 2887 ) 2888 2889 return SchemaTypeIdentifier( 2890 schema_pointer=model_schema_pointer, 2891 key_pointer=model_key_pointer, 2892 type_pointer=model_type_pointer, 2893 types_mapping=types_mapping, 2894 parameters=model.parameters or {}, 2895 )
2897 def create_dynamic_schema_loader( 2898 self, model: DynamicSchemaLoaderModel, config: Config, **kwargs: Any 2899 ) -> DynamicSchemaLoader: 2900 schema_transformations = [] 2901 if model.schema_transformations: 2902 for transformation_model in model.schema_transformations: 2903 schema_transformations.append( 2904 self._create_component_from_model(model=transformation_model, config=config) 2905 ) 2906 name = "dynamic_properties" 2907 partition_router = self._build_stream_slicer_from_partition_router(model.retriever, config) 2908 retriever_kwargs: Dict[str, Any] = {} 2909 if isinstance(model.retriever, AsyncRetrieverModel): 2910 if model.retriever.partition_router: 2911 raise ValueError( 2912 "DynamicSchemaLoader does not support an AsyncRetriever with a partition_router: only the first record is read, so jobs for other partitions would be orphaned. Remove the partition_router from the schema loader's AsyncRetriever." 2913 ) 2914 # create_async_retriever requires stream_slicer; passing it to a CustomRetriever would override its own 2915 retriever_kwargs["stream_slicer"] = partition_router 2916 retriever = self._create_component_from_model( 2917 model=model.retriever, 2918 config=config, 2919 name=name, 2920 primary_key=None, 2921 partition_router=partition_router, 2922 **retriever_kwargs, 2923 transformations=[], 2924 use_cache=True, 2925 log_formatter=( 2926 lambda response: format_http_message( 2927 response, 2928 f"Schema loader '{name}' request", 2929 f"Request performed in order to extract schema.", 2930 name, 2931 is_auxiliary=True, 2932 ) 2933 ), 2934 ) 2935 schema_type_identifier = self._create_component_from_model( 2936 model.schema_type_identifier, config=config, parameters=model.parameters or {} 2937 ) 2938 schema_filter = ( 2939 self._create_component_from_model( 2940 model.schema_filter, config=config, parameters=model.parameters or {} 2941 ) 2942 if model.schema_filter is not None 2943 else None 2944 ) 2945 2946 return DynamicSchemaLoader( 2947 retriever=retriever, 2948 config=config, 2949 schema_transformations=schema_transformations, 2950 schema_filter=schema_filter, 2951 schema_type_identifier=schema_type_identifier, 2952 parameters=model.parameters or {}, 2953 )
2973 def create_json_items_decoder( 2974 self, model: JsonItemsDecoderModel, config: Config, **kwargs: Any 2975 ) -> Decoder: 2976 return CompositeRawDecoder( 2977 parser=ModelToComponentFactory._get_parser(model, config), 2978 stream_response=False if self._emit_connector_builder_messages else True, 2979 )
2981 def create_gzip_decoder( 2982 self, model: GzipDecoderModel, config: Config, **kwargs: Any 2983 ) -> Decoder: 2984 _compressed_response_types = { 2985 "gzip", 2986 "x-gzip", 2987 "gzip, deflate", 2988 "x-gzip, deflate", 2989 "application/zip", 2990 "application/gzip", 2991 "application/x-gzip", 2992 "application/x-zip-compressed", 2993 } 2994 2995 gzip_parser: GzipParser = ModelToComponentFactory._get_parser(model, config) # type: ignore # based on the model, we know this will be a GzipParser 2996 2997 if self._emit_connector_builder_messages: 2998 return CompositeRawDecoder(gzip_parser, False) 2999 3000 transport_gzip_parser = GzipParser(inner_parser=gzip_parser) 3001 return CompositeRawDecoder.by_headers( 3002 [ 3003 ({"Content-Encoding"}, {"gzip"}, transport_gzip_parser), 3004 ({"Content-Type"}, _compressed_response_types, gzip_parser), 3005 ], 3006 stream_response=True, 3007 fallback_parser=gzip_parser, 3008 )
3062 def create_jwt_authenticator( 3063 self, model: JwtAuthenticatorModel, config: Config, **kwargs: Any 3064 ) -> JwtAuthenticator: 3065 jwt_headers = model.jwt_headers or JwtHeadersModel(kid=None, typ="JWT", cty=None) 3066 jwt_payload = model.jwt_payload or JwtPayloadModel(iss=None, sub=None, aud=None) 3067 request_option = ( 3068 self._create_component_from_model(model.request_option, config) 3069 if model.request_option 3070 else None 3071 ) 3072 return JwtAuthenticator( 3073 config=config, 3074 parameters=model.parameters or {}, 3075 algorithm=JwtAlgorithm(model.algorithm.value), 3076 secret_key=model.secret_key, 3077 base64_encode_secret_key=model.base64_encode_secret_key, 3078 token_duration=model.token_duration, 3079 header_prefix=model.header_prefix, 3080 kid=jwt_headers.kid, 3081 typ=jwt_headers.typ, 3082 cty=jwt_headers.cty, 3083 iss=jwt_payload.iss, 3084 sub=jwt_payload.sub, 3085 aud=jwt_payload.aud, 3086 additional_jwt_headers=model.additional_jwt_headers, 3087 additional_jwt_payload=model.additional_jwt_payload, 3088 passphrase=model.passphrase, 3089 request_option=request_option, 3090 )
3092 def create_list_partition_router( 3093 self, model: ListPartitionRouterModel, config: Config, **kwargs: Any 3094 ) -> ListPartitionRouter: 3095 request_option = ( 3096 self._create_component_from_model(model.request_option, config) 3097 if model.request_option 3098 else None 3099 ) 3100 return ListPartitionRouter( 3101 cursor_field=model.cursor_field, 3102 request_option=request_option, 3103 values=model.values, 3104 config=config, 3105 parameters=model.parameters or {}, 3106 )
3108 @staticmethod 3109 def create_min_max_datetime( 3110 model: MinMaxDatetimeModel, config: Config, **kwargs: Any 3111 ) -> MinMaxDatetime: 3112 return MinMaxDatetime( 3113 datetime=model.datetime, 3114 datetime_format=model.datetime_format or "", 3115 max_datetime=model.max_datetime or "", 3116 min_datetime=model.min_datetime or "", 3117 parameters=model.parameters or {}, 3118 )
3130 def create_oauth_authenticator( 3131 self, model: OAuthAuthenticatorModel, config: Config, **kwargs: Any 3132 ) -> DeclarativeOauth2Authenticator: 3133 profile_assertion = ( 3134 self._create_component_from_model(model.profile_assertion, config=config) 3135 if model.profile_assertion 3136 else None 3137 ) 3138 3139 refresh_token_error_status_codes, refresh_token_error_key, refresh_token_error_values = ( 3140 self._get_refresh_token_error_information(model) 3141 ) 3142 if model.refresh_token_updater: 3143 # ignore type error because fixing it would have a lot of dependencies, revisit later 3144 return DeclarativeSingleUseRefreshTokenOauth2Authenticator( # type: ignore 3145 config, 3146 InterpolatedString.create( 3147 model.token_refresh_endpoint, # type: ignore 3148 parameters=model.parameters or {}, 3149 ).eval(config), 3150 access_token_name=InterpolatedString.create( 3151 model.access_token_name or "access_token", parameters=model.parameters or {} 3152 ).eval(config), 3153 refresh_token_name=model.refresh_token_updater.refresh_token_name, 3154 expires_in_name=InterpolatedString.create( 3155 model.expires_in_name or "expires_in", parameters=model.parameters or {} 3156 ).eval(config), 3157 client_id_name=InterpolatedString.create( 3158 model.client_id_name or "client_id", parameters=model.parameters or {} 3159 ).eval(config), 3160 client_id=InterpolatedString.create( 3161 model.client_id, parameters=model.parameters or {} 3162 ).eval(config) 3163 if model.client_id 3164 else model.client_id, 3165 client_secret_name=InterpolatedString.create( 3166 model.client_secret_name or "client_secret", parameters=model.parameters or {} 3167 ).eval(config), 3168 client_secret=InterpolatedString.create( 3169 model.client_secret, parameters=model.parameters or {} 3170 ).eval(config) 3171 if model.client_secret 3172 else model.client_secret, 3173 access_token_config_path=model.refresh_token_updater.access_token_config_path, 3174 refresh_token_config_path=model.refresh_token_updater.refresh_token_config_path, 3175 token_expiry_date_config_path=model.refresh_token_updater.token_expiry_date_config_path, 3176 grant_type_name=InterpolatedString.create( 3177 model.grant_type_name or "grant_type", parameters=model.parameters or {} 3178 ).eval(config), 3179 grant_type=InterpolatedString.create( 3180 model.grant_type or "refresh_token", parameters=model.parameters or {} 3181 ).eval(config), 3182 refresh_request_body=InterpolatedMapping( 3183 model.refresh_request_body or {}, parameters=model.parameters or {} 3184 ).eval(config), 3185 refresh_request_headers=InterpolatedMapping( 3186 model.refresh_request_headers or {}, parameters=model.parameters or {} 3187 ).eval(config), 3188 send_refresh_request_as_query_params=bool( 3189 model.send_refresh_request_as_query_params 3190 ), 3191 scopes=model.scopes, 3192 token_expiry_date_format=model.token_expiry_date_format, 3193 token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), 3194 message_repository=self._message_repository, 3195 refresh_token_error_status_codes=refresh_token_error_status_codes, 3196 refresh_token_error_key=refresh_token_error_key, 3197 refresh_token_error_values=refresh_token_error_values, 3198 ) 3199 # ignore type error because fixing it would have a lot of dependencies, revisit later 3200 return DeclarativeOauth2Authenticator( # type: ignore 3201 access_token_name=model.access_token_name or "access_token", 3202 access_token_value=model.access_token_value, 3203 client_id_name=model.client_id_name or "client_id", 3204 client_id=model.client_id, 3205 client_secret_name=model.client_secret_name or "client_secret", 3206 client_secret=model.client_secret, 3207 expires_in_name=model.expires_in_name or "expires_in", 3208 grant_type_name=model.grant_type_name or "grant_type", 3209 grant_type=model.grant_type or "refresh_token", 3210 refresh_request_body=model.refresh_request_body, 3211 refresh_request_headers=model.refresh_request_headers, 3212 send_refresh_request_as_query_params=bool(model.send_refresh_request_as_query_params), 3213 refresh_token_name=model.refresh_token_name or "refresh_token", 3214 refresh_token=model.refresh_token, 3215 scopes=model.scopes, 3216 token_expiry_date=model.token_expiry_date, 3217 token_expiry_date_format=model.token_expiry_date_format, 3218 token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), 3219 token_refresh_endpoint=model.token_refresh_endpoint, 3220 config=config, 3221 parameters=model.parameters or {}, 3222 message_repository=self._message_repository, 3223 profile_assertion=profile_assertion, 3224 use_profile_assertion=model.use_profile_assertion, 3225 refresh_token_error_status_codes=refresh_token_error_status_codes, 3226 refresh_token_error_key=refresh_token_error_key, 3227 refresh_token_error_values=refresh_token_error_values, 3228 )
3337 def create_offset_increment( 3338 self, 3339 model: OffsetIncrementModel, 3340 config: Config, 3341 decoder: Optional[Decoder] = None, 3342 extractor_model: Optional[ 3343 Union[CustomRecordExtractorModel, DpathExtractorModel, CombinedExtractorModel] 3344 ] = None, 3345 **kwargs: Any, 3346 ) -> OffsetIncrement: 3347 # None when nested under a custom component, which cannot pass a decoder 3348 if decoder is None: 3349 decoder = JsonDecoder(parameters={}) 3350 if isinstance(decoder, PaginationDecoderDecorator): 3351 inner_decoder = decoder.decoder 3352 else: 3353 inner_decoder = decoder 3354 decoder = PaginationDecoderDecorator(decoder=decoder) 3355 3356 if self._is_supported_decoder_for_pagination(inner_decoder): 3357 decoder_to_use = decoder 3358 else: 3359 raise ValueError( 3360 self._UNSUPPORTED_DECODER_ERROR.format(decoder_type=type(inner_decoder)) 3361 ) 3362 3363 self._reject_union_combined_extractor_for_offset_increment(extractor_model) 3364 3365 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 3366 # If page_size is a string that represents an integer (not an interpolation), convert it back. 3367 page_size = model.page_size 3368 if isinstance(page_size, str) and page_size.isdigit(): 3369 page_size = int(page_size) 3370 3371 return OffsetIncrement( 3372 page_size=page_size, 3373 config=config, 3374 decoder=decoder_to_use, 3375 inject_on_first_request=model.inject_on_first_request or False, 3376 parameters=model.parameters or {}, 3377 )
3379 @staticmethod 3380 def create_page_increment( 3381 model: PageIncrementModel, config: Config, **kwargs: Any 3382 ) -> PageIncrement: 3383 # Pydantic v1 Union type coercion can convert int to string depending on Union order. 3384 # If page_size is a string that represents an integer (not an interpolation), convert it back. 3385 page_size = model.page_size 3386 if isinstance(page_size, str) and page_size.isdigit(): 3387 page_size = int(page_size) 3388 3389 return PageIncrement( 3390 page_size=page_size, 3391 config=config, 3392 start_from_page=model.start_from_page or 0, 3393 inject_on_first_request=model.inject_on_first_request or False, 3394 parameters=model.parameters or {}, 3395 )
3397 def create_parent_stream_config( 3398 self, model: ParentStreamConfigModel, config: Config, *, stream_name: str, **kwargs: Any 3399 ) -> ParentStreamConfig: 3400 declarative_stream = self._create_component_from_model( 3401 model.stream, 3402 config=config, 3403 is_parent=True, 3404 **kwargs, 3405 ) 3406 request_option = ( 3407 self._create_component_from_model(model.request_option, config=config) 3408 if model.request_option 3409 else None 3410 ) 3411 3412 if model.lazy_read_pointer and any("*" in pointer for pointer in model.lazy_read_pointer): 3413 raise ValueError( 3414 "The '*' wildcard in 'lazy_read_pointer' is not supported — only direct paths are allowed." 3415 ) 3416 3417 model_lazy_read_pointer: List[Union[InterpolatedString, str]] = ( 3418 [x for x in model.lazy_read_pointer] if model.lazy_read_pointer else [] 3419 ) 3420 3421 return ParentStreamConfig( 3422 parent_key=model.parent_key, 3423 request_option=request_option, 3424 stream=declarative_stream, 3425 partition_field=model.partition_field, 3426 config=config, 3427 incremental_dependency=model.incremental_dependency or False, 3428 parameters=model.parameters or {}, 3429 extra_fields=model.extra_fields, 3430 lazy_read_pointer=model_lazy_read_pointer, 3431 )
3433 def create_properties_from_endpoint( 3434 self, model: PropertiesFromEndpointModel, config: Config, **kwargs: Any 3435 ) -> PropertiesFromEndpoint: 3436 retriever = self._create_component_from_model( 3437 model=model.retriever, 3438 config=config, 3439 name="dynamic_properties", 3440 primary_key=None, 3441 stream_slicer=None, 3442 transformations=[], 3443 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 3444 ) 3445 return PropertiesFromEndpoint( 3446 property_field_path=model.property_field_path, 3447 retriever=retriever, 3448 config=config, 3449 parameters=model.parameters or {}, 3450 )
3452 def create_property_chunking( 3453 self, model: PropertyChunkingModel, config: Config, **kwargs: Any 3454 ) -> PropertyChunking: 3455 record_merge_strategy = ( 3456 self._create_component_from_model( 3457 model=model.record_merge_strategy, config=config, **kwargs 3458 ) 3459 if model.record_merge_strategy 3460 else None 3461 ) 3462 3463 property_limit_type: PropertyLimitType 3464 match model.property_limit_type: 3465 case PropertyLimitTypeModel.property_count: 3466 property_limit_type = PropertyLimitType.property_count 3467 case PropertyLimitTypeModel.characters: 3468 property_limit_type = PropertyLimitType.characters 3469 case _: 3470 raise ValueError(f"Invalid PropertyLimitType {property_limit_type}") 3471 3472 return PropertyChunking( 3473 property_limit_type=property_limit_type, 3474 property_limit=model.property_limit, 3475 record_merge_strategy=record_merge_strategy, 3476 config=config, 3477 parameters=model.parameters or {}, 3478 )
3480 def create_query_properties( 3481 self, model: QueryPropertiesModel, config: Config, *, stream_name: str, **kwargs: Any 3482 ) -> QueryProperties: 3483 if isinstance(model.property_list, list): 3484 property_list = model.property_list 3485 else: 3486 property_list = self._create_component_from_model( 3487 model=model.property_list, config=config, **kwargs 3488 ) 3489 3490 property_chunking = ( 3491 self._create_component_from_model( 3492 model=model.property_chunking, config=config, **kwargs 3493 ) 3494 if model.property_chunking 3495 else None 3496 ) 3497 3498 property_selector = ( 3499 self._create_component_from_model( 3500 model=model.property_selector, config=config, stream_name=stream_name, **kwargs 3501 ) 3502 if model.property_selector 3503 else None 3504 ) 3505 3506 return QueryProperties( 3507 property_list=property_list, 3508 always_include_properties=model.always_include_properties, 3509 property_chunking=property_chunking, 3510 property_selector=property_selector, 3511 config=config, 3512 parameters=model.parameters or {}, 3513 )
3515 def create_json_schema_property_selector( 3516 self, 3517 model: JsonSchemaPropertySelectorModel, 3518 config: Config, 3519 *, 3520 stream_name: str, 3521 **kwargs: Any, 3522 ) -> JsonSchemaPropertySelector: 3523 configured_stream = self._stream_name_to_configured_stream.get(stream_name) 3524 3525 transformations = [] 3526 if model.transformations: 3527 for transformation_model in model.transformations: 3528 transformations.append( 3529 self._create_component_from_model(model=transformation_model, config=config) 3530 ) 3531 3532 return JsonSchemaPropertySelector( 3533 configured_stream=configured_stream, 3534 properties_transformations=transformations, 3535 config=config, 3536 parameters=model.parameters or {}, 3537 )
3551 @staticmethod 3552 def create_request_option( 3553 model: RequestOptionModel, config: Config, **kwargs: Any 3554 ) -> RequestOption: 3555 inject_into = RequestOptionType(model.inject_into.value) 3556 field_path: Optional[List[Union[InterpolatedString, str]]] = ( 3557 [ 3558 InterpolatedString.create(segment, parameters=kwargs.get("parameters", {})) 3559 for segment in model.field_path 3560 ] 3561 if model.field_path 3562 else None 3563 ) 3564 field_name = ( 3565 InterpolatedString.create(model.field_name, parameters=kwargs.get("parameters", {})) 3566 if model.field_name 3567 else None 3568 ) 3569 return RequestOption( 3570 field_name=field_name, 3571 field_path=field_path, 3572 inject_into=inject_into, 3573 parameters=kwargs.get("parameters", {}), 3574 )
3576 def create_record_selector( 3577 self, 3578 model: RecordSelectorModel, 3579 config: Config, 3580 *, 3581 name: str, 3582 transformations: List[RecordTransformation] | None = None, 3583 decoder: Decoder | None = None, 3584 client_side_incremental_sync_cursor: Optional[Cursor] = None, 3585 is_client_side_incremental_sync: bool = False, 3586 file_uploader: Optional[DefaultFileUploader] = None, 3587 **kwargs: Any, 3588 ) -> RecordSelector: 3589 extractor = self._create_component_from_model( 3590 model=model.extractor, decoder=decoder, config=config 3591 ) 3592 self._count_dropped_empty_records(extractor) 3593 record_filter = ( 3594 self._create_component_from_model(model.record_filter, config=config) 3595 if model.record_filter 3596 else None 3597 ) 3598 3599 # A client-side incremental stream transforms before filtering by default. That default belongs to the flag, 3600 # not to the component that ends up doing the cursor comparison: a data feed does it in the retriever and 3601 # receives no cursor here, but its `record_filter` condition must keep running after the transformations. 3602 default_transform_before_filtering = bool( 3603 client_side_incremental_sync_cursor or is_client_side_incremental_sync 3604 ) 3605 transform_before_filtering = ( 3606 default_transform_before_filtering 3607 if model.transform_before_filtering is None 3608 else model.transform_before_filtering 3609 ) 3610 if client_side_incremental_sync_cursor: 3611 record_filter = ClientSideIncrementalRecordFilterDecorator( 3612 config=config, 3613 parameters=model.parameters, 3614 condition=model.record_filter.condition 3615 if (model.record_filter and hasattr(model.record_filter, "condition")) 3616 else None, 3617 cursor=client_side_incremental_sync_cursor, 3618 ) 3619 3620 if model.schema_normalization is None: 3621 # default to no schema normalization if not set 3622 model.schema_normalization = SchemaNormalizationModel.None_ 3623 3624 schema_normalization = ( 3625 TypeTransformer(SCHEMA_TRANSFORMER_TYPE_MAPPING[model.schema_normalization]) 3626 if isinstance(model.schema_normalization, SchemaNormalizationModel) 3627 else self._create_component_from_model(model.schema_normalization, config=config) # type: ignore[arg-type] # custom normalization model expected here 3628 ) 3629 3630 return RecordSelector( 3631 extractor=extractor, 3632 name=name, 3633 config=config, 3634 record_filter=record_filter, 3635 transformations=transformations or [], 3636 file_uploader=file_uploader, 3637 schema_normalization=schema_normalization, 3638 parameters=model.parameters or {}, 3639 transform_before_filtering=transform_before_filtering, 3640 )
3650 def create_selective_authenticator( 3651 self, 3652 model: SelectiveAuthenticatorModel, 3653 config: Config, 3654 *, 3655 name: Optional[str] = None, 3656 url_base: Optional[str] = None, 3657 **kwargs: Any, 3658 ) -> DeclarativeAuthenticator: 3659 # Keyword-only so that _create_nested_component also fills them under a custom component, 3660 # from the parent's kwargs or $parameters. None is not forwarded, so a missing required one 3661 # keeps its hint. 3662 nested_kwargs = { 3663 key: value 3664 for key, value in {"name": name, "url_base": url_base}.items() 3665 if value is not None 3666 } 3667 authenticators = { 3668 key: self._create_component_from_model( 3669 model=auth, config=config, **nested_kwargs, **kwargs 3670 ) 3671 for key, auth in model.authenticators.items() 3672 } 3673 # SelectiveAuthenticator will return instance of DeclarativeAuthenticator or raise ValueError error 3674 return SelectiveAuthenticator( # type: ignore[abstract] 3675 config=config, 3676 authenticators=authenticators, 3677 authenticator_selection_path=model.authenticator_selection_path, 3678 **kwargs, 3679 )
3681 @staticmethod 3682 def create_legacy_session_token_authenticator( 3683 model: LegacySessionTokenAuthenticatorModel, config: Config, *, url_base: str, **kwargs: Any 3684 ) -> LegacySessionTokenAuthenticator: 3685 return LegacySessionTokenAuthenticator( 3686 api_url=url_base, 3687 header=model.header, 3688 login_url=model.login_url, 3689 password=model.password or "", 3690 session_token=model.session_token or "", 3691 session_token_response_key=model.session_token_response_key or "", 3692 username=model.username or "", 3693 validate_session_url=model.validate_session_url, 3694 config=config, 3695 parameters=model.parameters or {}, 3696 )
3698 def create_simple_retriever( 3699 self, 3700 model: SimpleRetrieverModel, 3701 config: Config, 3702 *, 3703 name: str, 3704 primary_key: Optional[Union[str, List[str], List[List[str]]]], 3705 request_options_provider: Optional[RequestOptionsProvider] = None, 3706 cursor: Optional[Cursor] = None, 3707 has_stop_condition_cursor: bool = False, 3708 is_client_side_incremental_sync: bool = False, 3709 transformations: List[RecordTransformation], 3710 file_uploader: Optional[DefaultFileUploader] = None, 3711 incremental_sync: Optional[ 3712 Union[IncrementingCountCursorModel, DatetimeBasedCursorModel] 3713 ] = None, 3714 use_cache: Optional[bool] = None, 3715 log_formatter: Optional[Callable[[Response], Any]] = None, 3716 partition_router: Optional[PartitionRouter] = None, 3717 **kwargs: Any, 3718 ) -> SimpleRetriever: 3719 def _get_url(req: Requester) -> str: 3720 """ 3721 Closure to get the URL from the requester. This is used to get the URL in the case of a lazy retriever. 3722 This is needed because the URL is not set until the requester is created. 3723 """ 3724 3725 _url: str = ( 3726 model.requester.url 3727 if hasattr(model.requester, "url") and model.requester.url is not None 3728 else req.get_url(stream_state=None, stream_slice=None, next_page_token=None) 3729 ) 3730 _url_base: str = ( 3731 model.requester.url_base 3732 if hasattr(model.requester, "url_base") and model.requester.url_base is not None 3733 else req.get_url_base(stream_state=None, stream_slice=None, next_page_token=None) 3734 ) 3735 3736 return _url or _url_base 3737 3738 if cursor is None: 3739 cursor = FinalStateCursor(name, None, self._message_repository) 3740 3741 # A data feed drops the records the cursor considers already synced in the retriever, which sits downstream of 3742 # the paginator. Letting the record selector drop them as well would be redundant and would hide them from the 3743 # pagination stop condition, so a data feed never delegates that filtering to the record selector, whether 3744 # `is_client_side_incremental` is set or not. The `condition` from `record_filter` is intentionally left out of 3745 # the post-pagination filter and stays in the record selector, which preserves the existing behaviour: the 3746 # selector runs inside the page loop, so the records the condition rejects never reach the paginator's 3747 # accounting. Moving it downstream would start counting them. 3748 post_pagination_filter = ( 3749 ClientSideIncrementalRecordFilterDecorator( 3750 config=config, 3751 parameters=model.parameters or {}, 3752 condition=None, 3753 cursor=cursor, 3754 ) 3755 if has_stop_condition_cursor 3756 else None 3757 ) 3758 client_side_incremental_cursor = ( 3759 cursor if is_client_side_incremental_sync and not post_pagination_filter else None 3760 ) 3761 if post_pagination_filter and is_client_side_incremental_sync: 3762 LOGGER.warning( 3763 f"Stream {name}: `is_client_side_incremental` adds no record filtering when `is_data_feed` is set, " 3764 "as a data feed already filters on the cursor value. It still makes the record selector apply the " 3765 "transformations before the `record_filter` condition." 3766 ) 3767 3768 decoder = ( 3769 self._create_component_from_model(model=model.decoder, config=config) 3770 if model.decoder 3771 else JsonDecoder(parameters={}) 3772 ) 3773 record_selector = self._create_component_from_model( 3774 model=model.record_selector, 3775 name=name, 3776 config=config, 3777 decoder=decoder, 3778 transformations=transformations, 3779 client_side_incremental_sync_cursor=client_side_incremental_cursor, 3780 is_client_side_incremental_sync=is_client_side_incremental_sync, 3781 file_uploader=file_uploader, 3782 ) 3783 3784 query_properties: Optional[QueryProperties] = None 3785 query_properties_key: Optional[str] = None 3786 self._ensure_query_properties_to_model(model.requester) 3787 if self._has_query_properties_in_request_parameters(model.requester): 3788 # It is better to be explicit about an error if PropertiesFromEndpoint is defined in multiple 3789 # places instead of default to request_parameters which isn't clearly documented 3790 if ( 3791 hasattr(model.requester, "fetch_properties_from_endpoint") 3792 and model.requester.fetch_properties_from_endpoint 3793 ): 3794 raise ValueError( 3795 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" 3796 ) 3797 3798 query_properties_definitions = [] 3799 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() 3800 if isinstance(request_parameter, QueryPropertiesModel): 3801 query_properties_key = key 3802 query_properties_definitions.append(request_parameter) 3803 3804 if len(query_properties_definitions) > 1: 3805 raise ValueError( 3806 f"request_parameters only supports defining one QueryProperties field, but found {len(query_properties_definitions)} usages" 3807 ) 3808 3809 if len(query_properties_definitions) == 1: 3810 query_properties = self._create_component_from_model( 3811 model=query_properties_definitions[0], stream_name=name, config=config 3812 ) 3813 3814 # Removes QueryProperties components from the interpolated mappings because it has been designed 3815 # to be used by the SimpleRetriever and will be resolved from the provider from the slice directly 3816 # instead of through jinja interpolation 3817 if hasattr(model.requester, "request_parameters") and isinstance( 3818 model.requester.request_parameters, Mapping 3819 ): 3820 model.requester.request_parameters = self._remove_query_properties( 3821 model.requester.request_parameters 3822 ) 3823 elif ( 3824 hasattr(model.requester, "fetch_properties_from_endpoint") 3825 and model.requester.fetch_properties_from_endpoint 3826 ): 3827 # todo: Deprecate this condition once dependent connectors migrate to query_properties 3828 query_properties_definition = QueryPropertiesModel( 3829 type="QueryProperties", 3830 property_list=model.requester.fetch_properties_from_endpoint, 3831 always_include_properties=None, 3832 property_chunking=None, 3833 ) # type: ignore # $parameters has a default value 3834 3835 query_properties = self.create_query_properties( 3836 model=query_properties_definition, 3837 stream_name=name, 3838 config=config, 3839 ) 3840 elif hasattr(model.requester, "query_properties") and model.requester.query_properties: 3841 query_properties = self.create_query_properties( 3842 model=model.requester.query_properties, 3843 stream_name=name, 3844 config=config, 3845 ) 3846 3847 requester = self._create_component_from_model( 3848 model=model.requester, 3849 decoder=decoder, 3850 name=name, 3851 query_properties_key=query_properties_key, 3852 use_cache=use_cache, 3853 config=config, 3854 ) 3855 3856 if not request_options_provider: 3857 request_options_provider = DefaultRequestOptionsProvider(parameters={}) 3858 if isinstance(request_options_provider, DefaultRequestOptionsProvider) and isinstance( 3859 partition_router, PartitionRouter 3860 ): 3861 request_options_provider = partition_router 3862 3863 paginator = ( 3864 self._create_component_from_model( 3865 model=model.paginator, 3866 config=config, 3867 url_base=_get_url(requester), 3868 extractor_model=model.record_selector.extractor, 3869 decoder=decoder, 3870 cursor_used_for_stop_condition=cursor if has_stop_condition_cursor else None, 3871 ) 3872 if model.paginator 3873 else NoPagination(parameters={}) 3874 ) 3875 3876 ignore_stream_slicer_parameters_on_paginated_requests = ( 3877 model.ignore_stream_slicer_parameters_on_paginated_requests or False 3878 ) 3879 3880 reads_parent_stream_lazily = bool( 3881 model.partition_router 3882 and isinstance(model.partition_router, SubstreamPartitionRouterModel) 3883 and any( 3884 parent_stream_config.lazy_read_pointer 3885 for parent_stream_config in model.partition_router.parent_stream_configs 3886 ) 3887 ) 3888 if reads_parent_stream_lazily and ( 3889 model.page_size_reduction 3890 or self._uses_reduce_page_size_action(getattr(model.requester, "error_handler", None)) 3891 ): 3892 # Checked outside of the LazySimpleRetriever branch below, which only applies on the first 3893 # sync of a stream: gating it on the absence of state would accept the same manifest from the 3894 # second sync onwards. LazySimpleRetriever paginates the parent's embedded pages, so there is 3895 # no page of its own to re-issue with a smaller page size. 3896 raise ValueError( 3897 f"`page_size_reduction` and the REDUCE_PAGE_SIZE response action are not supported when " 3898 f"reading a parent stream lazily. Remove either the page size reduction or the parent " 3899 f"stream's `lazy_read_pointer` for stream {name}." 3900 ) 3901 3902 if reads_parent_stream_lazily and ( 3903 model.request_window_splitting 3904 or self._uses_split_request_window_action( 3905 getattr(model.requester, "error_handler", None) 3906 ) 3907 ): 3908 # Same reasoning as the page_size_reduction check above: LazySimpleRetriever reads records 3909 # embedded in the parent's own pages rather than requesting a window of its own, so there is 3910 # nothing here for a split child window to be read from. 3911 raise ValueError( 3912 f"`request_window_splitting` and the SPLIT_REQUEST_WINDOW response action are not " 3913 f"supported when reading a parent stream lazily. Remove either the request window " 3914 f"splitting or the parent stream's `lazy_read_pointer` for stream {name}." 3915 ) 3916 3917 if reads_parent_stream_lazily and not bool( 3918 self._connector_state_manager.get_stream_state(name, None) 3919 ): 3920 if incremental_sync: 3921 if incremental_sync.type != "DatetimeBasedCursor": 3922 raise ValueError( 3923 f"LazySimpleRetriever only supports DatetimeBasedCursor. Found: {incremental_sync.type}." 3924 ) 3925 3926 elif incremental_sync.step or incremental_sync.cursor_granularity: 3927 raise ValueError( 3928 f"Found more that one slice per parent. LazySimpleRetriever only supports single slice read for stream - {name}." 3929 ) 3930 3931 if model.decoder and model.decoder.type != "JsonDecoder": 3932 raise ValueError( 3933 f"LazySimpleRetriever only supports JsonDecoder. Found: {model.decoder.type}." 3934 ) 3935 3936 return LazySimpleRetriever( 3937 name=name, 3938 paginator=paginator, 3939 primary_key=primary_key, 3940 requester=requester, 3941 record_selector=record_selector, 3942 stream_slicer=_NO_STREAM_SLICING, 3943 request_option_provider=request_options_provider, 3944 config=config, 3945 ignore_stream_slicer_parameters_on_paginated_requests=ignore_stream_slicer_parameters_on_paginated_requests, 3946 post_pagination_filter=post_pagination_filter, 3947 parameters=model.parameters or {}, 3948 ) 3949 3950 if ( 3951 model.record_selector.record_filter 3952 and model.pagination_reset 3953 and model.pagination_reset.limits 3954 ): 3955 raise ValueError("PaginationResetLimits are not supported while having record filter.") 3956 3957 request_window_splitting = self._create_request_window_splitting( 3958 model, name, cursor, incremental_sync, query_properties, file_uploader 3959 ) 3960 return SimpleRetriever( 3961 name=name, 3962 paginator=paginator, 3963 primary_key=primary_key, 3964 requester=requester, 3965 record_selector=record_selector, 3966 stream_slicer=_NO_STREAM_SLICING, 3967 request_option_provider=request_options_provider, 3968 config=config, 3969 ignore_stream_slicer_parameters_on_paginated_requests=ignore_stream_slicer_parameters_on_paginated_requests, 3970 additional_query_properties=query_properties, 3971 log_formatter=self._get_log_formatter(log_formatter, name), 3972 pagination_tracker_factory=self._create_pagination_tracker_factory( 3973 model.pagination_reset, cursor 3974 ), 3975 page_size_reduction=self._create_page_size_reduction( 3976 model, name, query_properties, file_uploader 3977 ), 3978 request_window_splitting=request_window_splitting, 3979 request_window_splitter=( 3980 cursor.split_request_window 3981 if request_window_splitting and hasattr(cursor, "split_request_window") 3982 else None 3983 ), 3984 post_pagination_filter=post_pagination_filter, 3985 parameters=model.parameters or {}, 3986 )
4567 def create_state_delegating_stream( 4568 self, 4569 model: StateDelegatingStreamModel, 4570 config: Config, 4571 **kwargs: Any, 4572 ) -> DefaultStream: 4573 if ( 4574 model.full_refresh_stream.name != model.name 4575 or model.name != model.incremental_stream.name 4576 ): 4577 raise ValueError( 4578 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}." 4579 ) 4580 4581 # Resolve api_retention_period with config context (supports Jinja2 interpolation) 4582 resolved_retention_period: Optional[str] = None 4583 if model.api_retention_period: 4584 interpolated_retention = InterpolatedString.create( 4585 model.api_retention_period, parameters=model.parameters or {} 4586 ) 4587 resolved_value = interpolated_retention.eval(config=config) 4588 if resolved_value: 4589 resolved_retention_period = str(resolved_value) 4590 4591 if resolved_retention_period: 4592 for stream_model in (model.full_refresh_stream, model.incremental_stream): 4593 if isinstance(stream_model.incremental_sync, IncrementingCountCursorModel): 4594 raise ValueError( 4595 f"Stream '{model.name}' uses IncrementingCountCursor which is not supported " 4596 f"with api_retention_period. IncrementingCountCursor does not use datetime-based " 4597 f"cursors, so cursor age validation cannot be performed." 4598 ) 4599 4600 stream_state = self._connector_state_manager.get_stream_state(model.name, None) 4601 4602 if not stream_state: 4603 return self._create_component_from_model( # type: ignore[no-any-return] 4604 model.full_refresh_stream, config=config, **kwargs 4605 ) 4606 4607 incremental_stream: DefaultStream = self._create_component_from_model( 4608 model.incremental_stream, config=config, **kwargs 4609 ) # type: ignore[assignment] 4610 4611 # Only run cursor age validation for streams that are in the configured 4612 # catalog (or when no catalog was provided, e.g. during discover / connector 4613 # builder). Streams not selected by the user but instantiated as parent-stream 4614 # dependencies must not go through this path because it emits state messages 4615 # that the destination does not know about, causing "Stream not found" crashes. 4616 stream_is_in_catalog = ( 4617 not self._stream_name_to_configured_stream # no catalog → validate by default 4618 or model.name in self._stream_name_to_configured_stream 4619 ) 4620 if resolved_retention_period and stream_is_in_catalog: 4621 full_refresh_stream: DefaultStream = self._create_component_from_model( 4622 model.full_refresh_stream, config=config, **kwargs 4623 ) # type: ignore[assignment] 4624 if self._is_cursor_older_than_retention_period( 4625 stream_state, 4626 full_refresh_stream.cursor, 4627 incremental_stream.cursor, 4628 resolved_retention_period, 4629 model.name, 4630 ): 4631 # Clear state BEFORE constructing the full_refresh_stream so that 4632 # its cursor starts from start_date instead of the stale cursor. 4633 self._connector_state_manager.update_state_for_stream(model.name, None, {}) 4634 state_message = self._connector_state_manager.create_state_message(model.name, None) 4635 self._message_repository.emit_message(state_message) 4636 return self._create_component_from_model( # type: ignore[no-any-return] 4637 model.full_refresh_stream, config=config, **kwargs 4638 ) 4639 4640 return incremental_stream
4739 def create_async_retriever( 4740 self, 4741 model: AsyncRetrieverModel, 4742 config: Config, 4743 *, 4744 name: str, 4745 primary_key: Optional[ 4746 Union[str, List[str], List[List[str]]] 4747 ], # this seems to be needed to match create_simple_retriever 4748 stream_slicer: Optional[StreamSlicer], 4749 client_side_incremental_sync: Optional[Dict[str, Any]] = None, 4750 transformations: List[RecordTransformation], 4751 **kwargs: Any, 4752 ) -> AsyncRetriever: 4753 if model.download_target_requester and not model.download_target_extractor: 4754 raise ValueError( 4755 f"`download_target_extractor` required if using a `download_target_requester`" 4756 ) 4757 4758 for requester_field in ( 4759 "creation_requester", 4760 "polling_requester", 4761 "download_requester", 4762 "download_target_requester", 4763 "abort_requester", 4764 "delete_requester", 4765 ): 4766 self._reject_reduce_page_size_action( 4767 getattr(model, requester_field, None), 4768 f"`{requester_field}` of the AsyncRetriever of stream {name}", 4769 ) 4770 self._reject_split_request_window_action( 4771 getattr(model, requester_field, None), 4772 f"`{requester_field}` of the AsyncRetriever of stream {name}", 4773 ) 4774 4775 def _get_download_retriever( 4776 requester: Requester, extractor: RecordExtractor, _decoder: Decoder 4777 ) -> SimpleRetriever: 4778 self._count_dropped_empty_records(extractor) 4779 # We create a record selector for the download retriever 4780 # with no schema normalization and no transformations, neither record filter 4781 # as all this occurs in the record_selector of the AsyncRetriever 4782 record_selector = RecordSelector( 4783 extractor=extractor, 4784 name=name, 4785 record_filter=None, 4786 transformations=[], 4787 schema_normalization=TypeTransformer(TransformConfig.NoTransform), 4788 config=config, 4789 parameters={}, 4790 ) 4791 paginator = ( 4792 self._create_component_from_model( 4793 model=model.download_paginator, 4794 decoder=_decoder, 4795 config=config, 4796 url_base="", 4797 # Only read by the `union` rejection of `OffsetIncrement`. 4798 extractor_model=( 4799 model.download_extractor 4800 if isinstance(model.download_extractor, CombinedExtractorModel) 4801 else None 4802 ), 4803 ) 4804 if model.download_paginator 4805 else NoPagination(parameters={}) 4806 ) 4807 4808 return SimpleRetriever( 4809 requester=requester, 4810 record_selector=record_selector, 4811 primary_key=None, 4812 name=name, 4813 paginator=paginator, 4814 config=config, 4815 parameters={}, 4816 log_formatter=self._get_log_formatter(None, name), 4817 ) 4818 4819 def _get_job_timeout() -> datetime.timedelta: 4820 user_defined_timeout: Optional[int] = ( 4821 int( 4822 InterpolatedString.create( 4823 str(model.polling_job_timeout), 4824 parameters={}, 4825 ).eval(config) 4826 ) 4827 if model.polling_job_timeout 4828 else None 4829 ) 4830 4831 # check for user defined timeout during the test read or 15 minutes 4832 test_read_timeout = datetime.timedelta(minutes=user_defined_timeout or 15) 4833 # default value for non-connector builder is 60 minutes. 4834 default_sync_timeout = datetime.timedelta(minutes=user_defined_timeout or 60) 4835 4836 return ( 4837 test_read_timeout if self._emit_connector_builder_messages else default_sync_timeout 4838 ) 4839 4840 decoder = ( 4841 self._create_component_from_model(model=model.decoder, config=config) 4842 if model.decoder 4843 else JsonDecoder(parameters={}) 4844 ) 4845 record_selector = self._create_component_from_model( 4846 model=model.record_selector, 4847 config=config, 4848 decoder=decoder, 4849 name=name, 4850 transformations=transformations, 4851 client_side_incremental_sync=client_side_incremental_sync, 4852 ) 4853 4854 stream_slicer = stream_slicer or SinglePartitionRouter(parameters={}) 4855 if self._should_limit_slices_fetched(): 4856 stream_slicer = cast( 4857 StreamSlicer, 4858 StreamSlicerTestReadDecorator( 4859 wrapped_slicer=stream_slicer, 4860 maximum_number_of_slices=self._limit_slices_fetched or 5, 4861 ), 4862 ) 4863 4864 creation_requester = self._create_component_from_model( 4865 model=model.creation_requester, 4866 decoder=decoder, 4867 config=config, 4868 name=f"job creation - {name}", 4869 ) 4870 polling_requester = self._create_component_from_model( 4871 model=model.polling_requester, 4872 decoder=decoder, 4873 config=config, 4874 name=f"job polling - {name}", 4875 ) 4876 job_download_components_name = f"job download - {name}" 4877 download_decoder = ( 4878 self._create_component_from_model(model=model.download_decoder, config=config) 4879 if model.download_decoder 4880 else JsonDecoder(parameters={}) 4881 ) 4882 download_extractor = ( 4883 self._create_component_from_model( 4884 model=model.download_extractor, 4885 config=config, 4886 decoder=download_decoder, 4887 parameters=model.parameters, 4888 ) 4889 if model.download_extractor 4890 else DpathExtractor( 4891 [], 4892 config=config, 4893 decoder=download_decoder, 4894 parameters=model.parameters or {}, 4895 ) 4896 ) 4897 download_requester = self._create_component_from_model( 4898 model=model.download_requester, 4899 decoder=download_decoder, 4900 config=config, 4901 name=job_download_components_name, 4902 ) 4903 download_retriever = _get_download_retriever( 4904 download_requester, download_extractor, download_decoder 4905 ) 4906 abort_requester = ( 4907 self._create_component_from_model( 4908 model=model.abort_requester, 4909 decoder=decoder, 4910 config=config, 4911 name=f"job abort - {name}", 4912 ) 4913 if model.abort_requester 4914 else None 4915 ) 4916 delete_requester = ( 4917 self._create_component_from_model( 4918 model=model.delete_requester, 4919 decoder=decoder, 4920 config=config, 4921 name=f"job delete - {name}", 4922 ) 4923 if model.delete_requester 4924 else None 4925 ) 4926 download_target_requester = ( 4927 self._create_component_from_model( 4928 model=model.download_target_requester, 4929 decoder=decoder, 4930 config=config, 4931 name=f"job extract_url - {name}", 4932 ) 4933 if model.download_target_requester 4934 else None 4935 ) 4936 status_extractor = self._create_component_from_model( 4937 model=model.status_extractor, decoder=decoder, config=config, name=name 4938 ) 4939 download_target_extractor = ( 4940 self._create_component_from_model( 4941 model=model.download_target_extractor, 4942 decoder=decoder, 4943 config=config, 4944 name=name, 4945 ) 4946 if model.download_target_extractor 4947 else None 4948 ) 4949 4950 job_repository: AsyncJobRepository = AsyncHttpJobRepository( 4951 creation_requester=creation_requester, 4952 polling_requester=polling_requester, 4953 download_retriever=download_retriever, 4954 download_target_requester=download_target_requester, 4955 abort_requester=abort_requester, 4956 delete_requester=delete_requester, 4957 status_extractor=status_extractor, 4958 status_mapping=self._create_async_job_status_mapping(model.status_mapping, config), 4959 download_target_extractor=download_target_extractor, 4960 job_timeout=_get_job_timeout(), 4961 ) 4962 4963 failed_retry_wait_time_in_seconds: Optional[int] = ( 4964 int( 4965 InterpolatedString.create( 4966 str(model.failed_retry_wait_time_in_seconds), 4967 parameters={}, 4968 ).eval(config) 4969 ) 4970 if model.failed_retry_wait_time_in_seconds 4971 else None 4972 ) 4973 4974 async_job_partition_router = AsyncJobPartitionRouter( 4975 job_orchestrator_factory=lambda stream_slices: AsyncJobOrchestrator( 4976 job_repository, 4977 stream_slices, 4978 self._job_tracker, 4979 self._message_repository, 4980 # FIXME work would need to be done here in order to detect if a stream as a parent stream that is bulk 4981 has_bulk_parent=False, 4982 # set the `job_max_retry` to 1 for the `Connector Builder`` use-case. 4983 # `None` == default retry is set to 3 attempts, under the hood. 4984 job_max_retry=1 if self._emit_connector_builder_messages else None, 4985 failed_retry_wait_time_in_seconds=failed_retry_wait_time_in_seconds, 4986 ), 4987 stream_slicer=stream_slicer, 4988 config=config, 4989 parameters=model.parameters or {}, 4990 ) 4991 4992 return AsyncRetriever( 4993 record_selector=record_selector, 4994 stream_slicer=async_job_partition_router, 4995 config=config, 4996 parameters=model.parameters or {}, 4997 )
4999 def create_spec(self, model: SpecModel, config: Config, **kwargs: Any) -> Spec: 5000 config_migrations = [ 5001 self._create_component_from_model(migration, config) 5002 for migration in ( 5003 model.config_normalization_rules.config_migrations 5004 if ( 5005 model.config_normalization_rules 5006 and model.config_normalization_rules.config_migrations 5007 ) 5008 else [] 5009 ) 5010 ] 5011 config_transformations = [ 5012 self._create_component_from_model(transformation, config) 5013 for transformation in ( 5014 model.config_normalization_rules.transformations 5015 if ( 5016 model.config_normalization_rules 5017 and model.config_normalization_rules.transformations 5018 ) 5019 else [] 5020 ) 5021 ] 5022 config_validations = [ 5023 self._create_component_from_model(validation, config) 5024 for validation in ( 5025 model.config_normalization_rules.validations 5026 if ( 5027 model.config_normalization_rules 5028 and model.config_normalization_rules.validations 5029 ) 5030 else [] 5031 ) 5032 ] 5033 5034 return Spec( 5035 connection_specification=model.connection_specification, 5036 documentation_url=model.documentation_url, 5037 advanced_auth=model.advanced_auth, 5038 parameters={}, 5039 config_migrations=config_migrations, 5040 config_transformations=config_transformations, 5041 config_validations=config_validations, 5042 )
5044 def create_substream_partition_router( 5045 self, 5046 model: SubstreamPartitionRouterModel, 5047 config: Config, 5048 *, 5049 stream_name: str, 5050 **kwargs: Any, 5051 ) -> SubstreamPartitionRouter: 5052 parent_stream_configs = [] 5053 if model.parent_stream_configs: 5054 parent_stream_configs.extend( 5055 [ 5056 self.create_parent_stream_config_with_substream_wrapper( 5057 model=parent_stream_config, config=config, stream_name=stream_name, **kwargs 5058 ) 5059 for parent_stream_config in model.parent_stream_configs 5060 ] 5061 ) 5062 5063 return SubstreamPartitionRouter( 5064 parent_stream_configs=parent_stream_configs, 5065 parameters=model.parameters or {}, 5066 config=config, 5067 )
5069 def create_parent_stream_config_with_substream_wrapper( 5070 self, model: ParentStreamConfigModel, config: Config, *, stream_name: str, **kwargs: Any 5071 ) -> Any: 5072 child_state = self._connector_state_manager.get_stream_state(stream_name, None) 5073 if NO_CURSOR_STATE_KEY in child_state: 5074 # Full refresh streams checkpoint a `{NO_CURSOR_STATE_KEY: true}` sentinel. When such a 5075 # stream is later converted to incremental with an incremental_dependency parent, 5076 # `_instantiate_parent_stream_state_manager` would treat the sentinel's boolean as a legacy 5077 # cursor value and re-key it under the parent's cursor field, crashing cursor initialization. 5078 child_state = { 5079 key: value for key, value in child_state.items() if key != NO_CURSOR_STATE_KEY 5080 } 5081 5082 parent_state: Optional[Mapping[str, Any]] = ( 5083 child_state if model.incremental_dependency and child_state else None 5084 ) 5085 connector_state_manager = self._instantiate_parent_stream_state_manager( 5086 child_state, config, model, parent_state 5087 ) 5088 5089 substream_factory = ModelToComponentFactory( 5090 custom_components_trusted=self._custom_components_trusted, 5091 connector_state_manager=connector_state_manager, 5092 limit_pages_fetched_per_slice=self._limit_pages_fetched_per_slice, 5093 limit_slices_fetched=self._limit_slices_fetched, 5094 emit_connector_builder_messages=self._emit_connector_builder_messages, 5095 disable_retries=self._disable_retries, 5096 disable_cache=self._disable_cache, 5097 message_repository=StateFilteringMessageRepository( 5098 LogAppenderMessageRepositoryDecorator( 5099 { 5100 "airbyte_cdk": {"stream": {"is_substream": True}}, 5101 "http": {"is_auxiliary": True}, 5102 }, 5103 self._message_repository, 5104 self._evaluate_log_level(self._emit_connector_builder_messages), 5105 ), 5106 ), 5107 api_budget=self._api_budget, 5108 # Share the authenticator registry so parent and child streams draw from the 5109 # same token quota counters 5110 rate_limited_authenticators=self._rate_limited_authenticators, 5111 ) 5112 5113 return substream_factory.create_parent_stream_config( 5114 model=model, config=config, stream_name=stream_name, **kwargs 5115 )
5180 @staticmethod 5181 def create_wait_time_from_header( 5182 model: WaitTimeFromHeaderModel, config: Config, **kwargs: Any 5183 ) -> WaitTimeFromHeaderBackoffStrategy: 5184 return WaitTimeFromHeaderBackoffStrategy( 5185 header=model.header, 5186 parameters=model.parameters or {}, 5187 config=config, 5188 regex=model.regex, 5189 max_waiting_time_in_seconds=model.max_waiting_time_in_seconds, 5190 )
5192 @staticmethod 5193 def create_wait_until_time_from_header( 5194 model: WaitUntilTimeFromHeaderModel, config: Config, **kwargs: Any 5195 ) -> WaitUntilTimeFromHeaderBackoffStrategy: 5196 return WaitUntilTimeFromHeaderBackoffStrategy( 5197 header=model.header, 5198 parameters=model.parameters or {}, 5199 config=config, 5200 min_wait=model.min_wait, 5201 regex=model.regex, 5202 max_waiting_time_in_seconds=model.max_waiting_time_in_seconds, 5203 )
5211 @staticmethod 5212 def create_components_mapping_definition( 5213 model: ComponentMappingDefinitionModel, config: Config, **kwargs: Any 5214 ) -> ComponentMappingDefinition: 5215 interpolated_value = InterpolatedString.create( 5216 model.value, parameters=model.parameters or {} 5217 ) 5218 field_path = [ 5219 InterpolatedString.create(path, parameters=model.parameters or {}) 5220 for path in model.field_path 5221 ] 5222 return ComponentMappingDefinition( 5223 field_path=field_path, # type: ignore[arg-type] # field_path can be str and InterpolatedString 5224 value=interpolated_value, 5225 value_type=ModelToComponentFactory._json_schema_type_name_to_type(model.value_type), 5226 create_or_update=model.create_or_update, 5227 condition=model.condition, 5228 parameters=model.parameters or {}, 5229 )
5231 def create_http_components_resolver( 5232 self, model: HttpComponentsResolverModel, config: Config, stream_name: Optional[str] = None 5233 ) -> Any: 5234 partition_router = self._build_stream_slicer_from_partition_router(model.retriever, config) 5235 retriever = self._create_component_from_model( 5236 model=model.retriever, 5237 config=config, 5238 name=f"{stream_name if stream_name else '__http_components_resolver'}", 5239 primary_key=None, 5240 stream_slicer=partition_router, 5241 partition_router=partition_router, 5242 transformations=[], 5243 ) 5244 5245 components_mapping = [] 5246 for component_mapping_definition_model in model.components_mapping: 5247 if component_mapping_definition_model.condition: 5248 raise ValueError("`condition` is only supported for `ConfigComponentsResolver`") 5249 components_mapping.append( 5250 self._create_component_from_model( 5251 model=component_mapping_definition_model, 5252 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 5253 component_mapping_definition_model.value_type 5254 ), 5255 config=config, 5256 ) 5257 ) 5258 5259 return HttpComponentsResolver( 5260 retriever=retriever, 5261 # AsyncRetriever reads records only from the job slices its own slicer yields 5262 stream_slicer=retriever.stream_slicer 5263 if isinstance(retriever, AsyncRetriever) 5264 else partition_router, 5265 config=config, 5266 components_mapping=components_mapping, 5267 parameters=model.parameters or {}, 5268 )
5270 @staticmethod 5271 def create_stream_config( 5272 model: StreamConfigModel, config: Config, **kwargs: Any 5273 ) -> StreamConfig: 5274 model_configs_pointer: List[Union[InterpolatedString, str]] = ( 5275 [x for x in model.configs_pointer] if model.configs_pointer else [] 5276 ) 5277 5278 return StreamConfig( 5279 configs_pointer=model_configs_pointer, 5280 default_values=model.default_values, 5281 parameters=model.parameters or {}, 5282 )
5284 def create_config_components_resolver( 5285 self, 5286 model: ConfigComponentsResolverModel, 5287 config: Config, 5288 ) -> Any: 5289 model_stream_configs = ( 5290 model.stream_config if isinstance(model.stream_config, list) else [model.stream_config] 5291 ) 5292 5293 stream_configs = [ 5294 self._create_component_from_model( 5295 stream_config, config=config, parameters=model.parameters or {} 5296 ) 5297 for stream_config in model_stream_configs 5298 ] 5299 5300 components_mapping = [ 5301 self._create_component_from_model( 5302 model=components_mapping_definition_model, 5303 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 5304 components_mapping_definition_model.value_type 5305 ), 5306 config=config, 5307 parameters=model.parameters, 5308 ) 5309 for components_mapping_definition_model in model.components_mapping 5310 ] 5311 5312 return ConfigComponentsResolver( 5313 stream_configs=stream_configs, 5314 config=config, 5315 components_mapping=components_mapping, 5316 parameters=model.parameters or {}, 5317 )
5319 def create_parametrized_components_resolver( 5320 self, 5321 model: ParametrizedComponentsResolverModel, 5322 config: Config, 5323 ) -> ParametrizedComponentsResolver: 5324 stream_parameters = StreamParametersDefinition( 5325 list_of_parameters_for_stream=model.stream_parameters.list_of_parameters_for_stream 5326 ) 5327 5328 components_mapping = [] 5329 for components_mapping_definition_model in model.components_mapping: 5330 if components_mapping_definition_model.condition: 5331 raise ValueError("`condition` is only supported for `ConfigComponentsResolver`") 5332 components_mapping.append( 5333 self._create_component_from_model( 5334 model=components_mapping_definition_model, 5335 value_type=ModelToComponentFactory._json_schema_type_name_to_type( 5336 components_mapping_definition_model.value_type 5337 ), 5338 config=config, 5339 ) 5340 ) 5341 return ParametrizedComponentsResolver( 5342 stream_parameters=stream_parameters, 5343 config=config, 5344 components_mapping=components_mapping, 5345 parameters=model.parameters or {}, 5346 )
5370 def create_http_api_budget( 5371 self, model: HTTPAPIBudgetModel, config: Config, **kwargs: Any 5372 ) -> HttpAPIBudget: 5373 policies = [ 5374 self._create_component_from_model(model=policy, config=config) 5375 for policy in model.policies 5376 ] 5377 5378 return HttpAPIBudget( 5379 policies=policies, 5380 ratelimit_reset_header=model.ratelimit_reset_header or "ratelimit-reset", 5381 ratelimit_remaining_header=model.ratelimit_remaining_header or "ratelimit-remaining", 5382 status_codes_for_ratelimit_hit=model.status_codes_for_ratelimit_hit or [429], 5383 )
5385 def create_fixed_window_call_rate_policy( 5386 self, model: FixedWindowCallRatePolicyModel, config: Config, **kwargs: Any 5387 ) -> FixedWindowCallRatePolicy: 5388 matchers = [ 5389 self._create_component_from_model(model=matcher, config=config) 5390 for matcher in model.matchers 5391 ] 5392 5393 # Set the initial reset timestamp to 10 days from now. 5394 # This value will be updated by the first request. 5395 return FixedWindowCallRatePolicy( 5396 next_reset_ts=datetime.datetime.now() + datetime.timedelta(days=10), 5397 period=parse_duration(model.period), 5398 call_limit=model.call_limit, 5399 matchers=matchers, 5400 )
5402 def create_file_uploader( 5403 self, model: FileUploaderModel, config: Config, **kwargs: Any 5404 ) -> FileUploader: 5405 self._reject_reduce_page_size_action(model.requester, "requester of a `file_uploader`") 5406 self._reject_split_request_window_action(model.requester, "requester of a `file_uploader`") 5407 name = "File Uploader" 5408 requester = self._create_component_from_model( 5409 model=model.requester, 5410 config=config, 5411 name=name, 5412 **kwargs, 5413 ) 5414 download_target_extractor = self._create_component_from_model( 5415 model=model.download_target_extractor, 5416 config=config, 5417 name=name, 5418 **kwargs, 5419 ) 5420 emit_connector_builder_messages = self._emit_connector_builder_messages 5421 file_uploader = DefaultFileUploader( 5422 requester=requester, 5423 download_target_extractor=download_target_extractor, 5424 config=config, 5425 file_writer=NoopFileWriter() 5426 if emit_connector_builder_messages 5427 else LocalFileSystemFileWriter(), 5428 parameters=model.parameters or {}, 5429 filename_extractor=model.filename_extractor if model.filename_extractor else None, 5430 ) 5431 5432 return ( 5433 ConnectorBuilderFileUploader(file_uploader) 5434 if emit_connector_builder_messages 5435 else file_uploader 5436 )
5438 def create_moving_window_call_rate_policy( 5439 self, model: MovingWindowCallRatePolicyModel, config: Config, **kwargs: Any 5440 ) -> MovingWindowCallRatePolicy: 5441 rates = [ 5442 self._create_component_from_model(model=rate, config=config) for rate in model.rates 5443 ] 5444 matchers = [ 5445 self._create_component_from_model(model=matcher, config=config) 5446 for matcher in model.matchers 5447 ] 5448 return MovingWindowCallRatePolicy( 5449 rates=rates, 5450 matchers=matchers, 5451 )
5453 def create_unlimited_call_rate_policy( 5454 self, model: UnlimitedCallRatePolicyModel, config: Config, **kwargs: Any 5455 ) -> UnlimitedCallRatePolicy: 5456 matchers = [ 5457 self._create_component_from_model(model=matcher, config=config) 5458 for matcher in model.matchers 5459 ] 5460 5461 return UnlimitedCallRatePolicy( 5462 matchers=matchers, 5463 )
5472 def create_http_request_matcher( 5473 self, model: HttpRequestRegexMatcherModel, config: Config, **kwargs: Any 5474 ) -> HttpRequestRegexMatcher: 5475 weight = model.weight 5476 if weight is not None: 5477 if isinstance(weight, str): 5478 weight = int(InterpolatedString.create(weight, parameters={}).eval(config)) 5479 else: 5480 weight = int(weight) 5481 if weight < 1: 5482 raise ValueError(f"weight must be >= 1, got {weight}") 5483 return HttpRequestRegexMatcher( 5484 method=model.method, 5485 url_base=model.url_base, 5486 url_path_pattern=model.url_path_pattern, 5487 params=model.params, 5488 headers=model.headers, 5489 weight=weight, 5490 )
5492 def create_rate_limited_multiple_token_authenticator( 5493 self, 5494 model: RateLimitedMultipleTokenAuthenticatorModel, 5495 config: Config, 5496 **kwargs: Any, 5497 ) -> RateLimitedMultipleTokenAuthenticator: 5498 if isinstance(model.tokens, str): 5499 tokens_value = InterpolatedString.create(model.tokens, parameters={}).eval(config) 5500 delimiter = model.token_delimiter or "," 5501 tokens = [ 5502 token.strip() for token in str(tokens_value).split(delimiter) if token.strip() 5503 ] 5504 else: 5505 tokens = [ 5506 token_value 5507 for token in model.tokens 5508 if ( 5509 token_value := str( 5510 InterpolatedString.create(token, parameters={}).eval(config) 5511 ).strip() 5512 ) 5513 ] 5514 5515 quota_specs = [ 5516 { 5517 "name": quota_model.name, 5518 "remaining_path": quota_model.remaining_path, 5519 "reset_path": quota_model.reset_path, 5520 "limit_path": quota_model.limit_path, 5521 "remaining_header": quota_model.remaining_header, 5522 "reset_header": quota_model.reset_header, 5523 "limit_header": quota_model.limit_header, 5524 # Normalize the same way as the runtime TokenQuota below, so an omitted field 5525 # and an explicit `[]` key identically and keep sharing one set of counters. 5526 "exhaustion_status_codes": quota_model.exhaustion_status_codes or [], 5527 "matchers": [ 5528 { 5529 "method": matcher_model.method, 5530 "url_base": matcher_model.url_base, 5531 "url_path_pattern": matcher_model.url_path_pattern, 5532 "params": matcher_model.params, 5533 "headers": matcher_model.headers, 5534 "weight": matcher_model.weight, 5535 } 5536 for matcher_model in quota_model.matchers or [] 5537 ], 5538 } 5539 for quota_model in model.quotas 5540 ] 5541 5542 quota_status_url = str( 5543 InterpolatedString.create(model.quota_status_source.url, parameters={}).eval(config) 5544 ) 5545 quota_status_http_method = ( 5546 model.quota_status_source.http_method.value 5547 if model.quota_status_source.http_method 5548 else "GET" 5549 ) 5550 quota_status_headers = { 5551 key: str(InterpolatedString.create(value, parameters={}).eval(config)) 5552 for key, value in (model.quota_status_source.request_headers or {}).items() 5553 } 5554 # Normalize the same way as the quota specs above, so an omitted field and an explicit 5555 # `[]` key identically and keep sharing one set of counters. Deduplicated as well as 5556 # sorted, because the runtime turns this into a set: without it `[404]` and `[404, 404]` 5557 # would key differently and stop sharing counters while behaving identically. 5558 quota_status_unavailable_status_codes = sorted( 5559 set(model.quota_status_source.unavailable_status_codes or []) 5560 ) 5561 auth_method = model.auth_method or "Bearer" 5562 header = model.header or "Authorization" 5563 max_wait_time_str = str( 5564 InterpolatedString.create(model.max_wait_time or "PT2H", parameters={}).eval(config) 5565 ) 5566 max_wait_time = parse_duration(max_wait_time_str) 5567 if not isinstance(max_wait_time, datetime.timedelta): 5568 raise ValueError( 5569 f"max_wait_time must be a fixed-length ISO 8601 duration (e.g. 'PT2H'); " 5570 f"calendar-unit durations like '{max_wait_time_str}' are not supported" 5571 ) 5572 budget_reserve_fraction = ( 5573 model.budget_reserve_fraction if model.budget_reserve_fraction is not None else 0.1 5574 ) 5575 budget_min_reserve = ( 5576 model.budget_min_reserve if model.budget_min_reserve is not None else 50 5577 ) 5578 5579 # Reuse the same instance for identical definitions so that all streams share the 5580 # same token quota counters (similar to how api_budget is shared). The key is built 5581 # from the resolved constructor arguments rather than the raw model so that 5582 # stream-specific `$parameters` propagated onto the model (and its nested components) 5583 # cannot break instance sharing. 5584 cache_key = json.dumps( 5585 { 5586 "tokens": tokens, 5587 "quotas": quota_specs, 5588 "quota_status_url": quota_status_url, 5589 "quota_status_http_method": quota_status_http_method, 5590 "quota_status_headers": quota_status_headers, 5591 "quota_status_unavailable_status_codes": quota_status_unavailable_status_codes, 5592 "auth_method": auth_method, 5593 "header": header, 5594 "max_wait_time": max_wait_time.total_seconds(), 5595 "budget_reserve_fraction": budget_reserve_fraction, 5596 "budget_min_reserve": budget_min_reserve, 5597 }, 5598 sort_keys=True, 5599 ) 5600 if cache_key in self._rate_limited_authenticators: 5601 return self._rate_limited_authenticators[cache_key] 5602 5603 quotas = [ 5604 TokenQuota( 5605 name=quota_model.name, 5606 remaining_path=quota_model.remaining_path, 5607 reset_path=quota_model.reset_path, 5608 limit_path=quota_model.limit_path, 5609 remaining_header=quota_model.remaining_header, 5610 reset_header=quota_model.reset_header, 5611 limit_header=quota_model.limit_header, 5612 exhaustion_status_codes=quota_model.exhaustion_status_codes or [], 5613 matchers=[ 5614 self.create_http_request_matcher(matcher_model, config) 5615 for matcher_model in quota_model.matchers or [] 5616 ], 5617 ) 5618 for quota_model in model.quotas 5619 ] 5620 5621 authenticator = RateLimitedMultipleTokenAuthenticator( 5622 tokens=tokens, 5623 quotas=quotas, 5624 quota_status_url=quota_status_url, 5625 quota_status_http_method=quota_status_http_method, 5626 quota_status_headers=quota_status_headers, 5627 quota_status_unavailable_status_codes=quota_status_unavailable_status_codes, 5628 auth_method=auth_method, 5629 header=header, 5630 max_wait_time=max_wait_time, 5631 budget_reserve_fraction=budget_reserve_fraction, 5632 budget_min_reserve=budget_min_reserve, 5633 ) 5634 self._rate_limited_authenticators[cache_key] = authenticator 5635 return authenticator
5642 def create_grouping_partition_router( 5643 self, 5644 model: GroupingPartitionRouterModel, 5645 config: Config, 5646 *, 5647 stream_name: str, 5648 **kwargs: Any, 5649 ) -> GroupingPartitionRouter: 5650 underlying_router = self._create_component_from_model( 5651 model=model.underlying_partition_router, 5652 config=config, 5653 stream_name=stream_name, 5654 **kwargs, 5655 ) 5656 if model.group_size < 1: 5657 raise ValueError(f"Group size must be greater than 0, got {model.group_size}") 5658 5659 # Request options in underlying partition routers are not supported for GroupingPartitionRouter 5660 # because they are specific to individual partitions and cannot be aggregated or handled 5661 # when grouping, potentially leading to incorrect API calls. Any request customization 5662 # should be managed at the stream level through the requester's configuration. 5663 if isinstance(underlying_router, SubstreamPartitionRouter): 5664 if any( 5665 parent_config.request_option 5666 for parent_config in underlying_router.parent_stream_configs 5667 ): 5668 raise ValueError("Request options are not supported for GroupingPartitionRouter.") 5669 5670 if isinstance(underlying_router, ListPartitionRouter): 5671 if underlying_router.request_option: 5672 raise ValueError("Request options are not supported for GroupingPartitionRouter.") 5673 5674 return GroupingPartitionRouter( 5675 group_size=model.group_size, 5676 underlying_partition_router=underlying_router, 5677 deduplicate=model.deduplicate if model.deduplicate is not None else True, 5678 config=config, 5679 )
5681 def create_union_partition_router( 5682 self, 5683 model: UnionPartitionRouterModel, 5684 config: Config, 5685 *, 5686 stream_name: str, 5687 **kwargs: Any, 5688 ) -> UnionPartitionRouter: 5689 # The schema enforces minItems: 2 for manifests; this guard covers construction paths 5690 # that bypass JSON-schema validation (the generated model carries no min_items constraint). 5691 if len(model.partition_routers) < 2: 5692 raise ValueError( 5693 f"UnionPartitionRouter for stream {stream_name} needs at least 2 child partition routers" 5694 ) 5695 5696 partition_routers = [ 5697 self._create_component_from_model( 5698 model=child, 5699 config=config, 5700 stream_name=stream_name, 5701 **kwargs, 5702 ) 5703 for child in model.partition_routers 5704 ] 5705 5706 # partition_field depends only on config/parameters, so it is evaluated once at build 5707 # time; the runtime component always receives a plain string. 5708 partition_field = InterpolatedString.create( 5709 model.partition_field, parameters=model.parameters or {} 5710 ).eval(config) 5711 5712 # Fail fast at build time when a built-in child router is statically known to emit a 5713 # partition field different from the union's. CustomPartitionRouter children are opaque 5714 # and can only be validated at runtime. 5715 for child_model in model.partition_routers: 5716 child_partition_fields: List[str] = [] 5717 if isinstance(child_model, ListPartitionRouterModel): 5718 child_partition_fields.append( 5719 InterpolatedString.create( 5720 child_model.cursor_field, parameters=child_model.parameters or {} 5721 ).eval(config) 5722 ) 5723 elif isinstance(child_model, SubstreamPartitionRouterModel): 5724 for parent_stream_config in child_model.parent_stream_configs: 5725 child_partition_fields.append( 5726 InterpolatedString.create( 5727 parent_stream_config.partition_field, 5728 parameters=parent_stream_config.parameters 5729 or child_model.parameters 5730 or {}, 5731 ).eval(config) 5732 ) 5733 elif isinstance(child_model, UnionPartitionRouterModel): 5734 child_partition_fields.append( 5735 InterpolatedString.create( 5736 child_model.partition_field, parameters=child_model.parameters or {} 5737 ).eval(config) 5738 ) 5739 for child_partition_field in child_partition_fields: 5740 if child_partition_field != partition_field: 5741 raise ValueError( 5742 f"UnionPartitionRouter expects all child partition routers to emit the " 5743 f"partition field '{partition_field}', but a " 5744 f"{child_model.type} child emits '{child_partition_field}'." 5745 ) 5746 5747 # A union slice comes from exactly one child partition router, so request options 5748 # declared on children cannot be applied consistently to requests built from the 5749 # normalized union slices. Partition values should be consumed via interpolation 5750 # (e.g. stream_partition) instead. Note that this validation only covers built-in 5751 # router types; CustomPartitionRouter children are opaque, so any request options 5752 # they implement internally cannot be detected or rejected here. 5753 for router in partition_routers: 5754 if isinstance(router, SubstreamPartitionRouter): 5755 if any( 5756 parent_config.request_option for parent_config in router.parent_stream_configs 5757 ): 5758 raise ValueError("Request options are not supported for UnionPartitionRouter.") 5759 if isinstance(router, ListPartitionRouter) and router.request_option: 5760 raise ValueError("Request options are not supported for UnionPartitionRouter.") 5761 5762 return UnionPartitionRouter( 5763 partition_routers=partition_routers, 5764 partition_field=partition_field, 5765 parameters=model.parameters or {}, 5766 )