airbyte-cdk 6.33.0.dev0__py3-none-any.whl → 6.33.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. airbyte_cdk/sources/declarative/auth/token.py +3 -8
  2. airbyte_cdk/sources/declarative/concurrent_declarative_source.py +13 -2
  3. airbyte_cdk/sources/declarative/declarative_component_schema.yaml +15 -212
  4. airbyte_cdk/sources/declarative/incremental/datetime_based_cursor.py +7 -6
  5. airbyte_cdk/sources/declarative/manifest_declarative_source.py +0 -4
  6. airbyte_cdk/sources/declarative/models/declarative_component_schema.py +10 -169
  7. airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py +34 -171
  8. airbyte_cdk/sources/declarative/partition_routers/list_partition_router.py +4 -2
  9. airbyte_cdk/sources/declarative/partition_routers/substream_partition_router.py +26 -18
  10. airbyte_cdk/sources/declarative/requesters/http_requester.py +5 -4
  11. airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py +6 -5
  12. airbyte_cdk/sources/declarative/requesters/request_option.py +83 -4
  13. airbyte_cdk/sources/declarative/requesters/request_options/datetime_based_request_options_provider.py +7 -6
  14. airbyte_cdk/sources/declarative/retrievers/simple_retriever.py +4 -1
  15. airbyte_cdk/sources/streams/call_rate.py +71 -84
  16. airbyte_cdk/utils/mapping_helpers.py +86 -27
  17. {airbyte_cdk-6.33.0.dev0.dist-info → airbyte_cdk-6.33.1.dist-info}/METADATA +1 -1
  18. {airbyte_cdk-6.33.0.dev0.dist-info → airbyte_cdk-6.33.1.dist-info}/RECORD +22 -22
  19. {airbyte_cdk-6.33.0.dev0.dist-info → airbyte_cdk-6.33.1.dist-info}/LICENSE.txt +0 -0
  20. {airbyte_cdk-6.33.0.dev0.dist-info → airbyte_cdk-6.33.1.dist-info}/LICENSE_SHORT +0 -0
  21. {airbyte_cdk-6.33.0.dev0.dist-info → airbyte_cdk-6.33.1.dist-info}/WHEEL +0 -0
  22. {airbyte_cdk-6.33.0.dev0.dist-info → airbyte_cdk-6.33.1.dist-info}/entry_points.txt +0 -0
@@ -112,9 +112,6 @@ from airbyte_cdk.sources.declarative.models.declarative_component_schema import
112
112
  from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
113
113
  AddFields as AddFieldsModel,
114
114
  )
115
- from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
116
- APIBudget as APIBudgetModel,
117
- )
118
115
  from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
119
116
  ApiKeyAuthenticator as ApiKeyAuthenticatorModel,
120
117
  )
@@ -229,9 +226,6 @@ from airbyte_cdk.sources.declarative.models.declarative_component_schema import
229
226
  from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
230
227
  ExponentialBackoffStrategy as ExponentialBackoffStrategyModel,
231
228
  )
232
- from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
233
- FixedWindowCallRatePolicy as FixedWindowCallRatePolicyModel,
234
- )
235
229
  from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
236
230
  FlattenFields as FlattenFieldsModel,
237
231
  )
@@ -241,18 +235,12 @@ from airbyte_cdk.sources.declarative.models.declarative_component_schema import
241
235
  from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
242
236
  GzipParser as GzipParserModel,
243
237
  )
244
- from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
245
- HTTPAPIBudget as HTTPAPIBudgetModel,
246
- )
247
238
  from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
248
239
  HttpComponentsResolver as HttpComponentsResolverModel,
249
240
  )
250
241
  from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
251
242
  HttpRequester as HttpRequesterModel,
252
243
  )
253
- from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
254
- HttpRequestMatcher as HttpRequestMatcherModel,
255
- )
256
244
  from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
257
245
  HttpResponseFilter as HttpResponseFilterModel,
258
246
  )
@@ -307,9 +295,6 @@ from airbyte_cdk.sources.declarative.models.declarative_component_schema import
307
295
  from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
308
296
  MinMaxDatetime as MinMaxDatetimeModel,
309
297
  )
310
- from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
311
- MovingWindowCallRatePolicy as MovingWindowCallRatePolicyModel,
312
- )
313
298
  from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
314
299
  NoAuth as NoAuthModel,
315
300
  )
@@ -328,9 +313,6 @@ from airbyte_cdk.sources.declarative.models.declarative_component_schema import
328
313
  from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
329
314
  ParentStreamConfig as ParentStreamConfigModel,
330
315
  )
331
- from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
332
- Rate as RateModel,
333
- )
334
316
  from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
335
317
  RecordFilter as RecordFilterModel,
336
318
  )
@@ -374,9 +356,6 @@ from airbyte_cdk.sources.declarative.models.declarative_component_schema import
374
356
  from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
375
357
  TypesMap as TypesMapModel,
376
358
  )
377
- from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
378
- UnlimitedCallRatePolicy as UnlimitedCallRatePolicyModel,
379
- )
380
359
  from airbyte_cdk.sources.declarative.models.declarative_component_schema import ValueType
381
360
  from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
382
361
  WaitTimeFromHeader as WaitTimeFromHeaderModel,
@@ -490,15 +469,6 @@ from airbyte_cdk.sources.message import (
490
469
  MessageRepository,
491
470
  NoopMessageRepository,
492
471
  )
493
- from airbyte_cdk.sources.streams.call_rate import (
494
- APIBudget,
495
- FixedWindowCallRatePolicy,
496
- HttpAPIBudget,
497
- HttpRequestMatcher,
498
- MovingWindowCallRatePolicy,
499
- Rate,
500
- UnlimitedCallRatePolicy,
501
- )
502
472
  from airbyte_cdk.sources.streams.concurrent.clamping import (
503
473
  ClampingEndProvider,
504
474
  ClampingStrategy,
@@ -550,7 +520,6 @@ class ModelToComponentFactory:
550
520
  self._evaluate_log_level(emit_connector_builder_messages)
551
521
  )
552
522
  self._connector_state_manager = connector_state_manager or ConnectorStateManager()
553
- self._api_budget: Optional[Union[APIBudget, HttpAPIBudget]] = None
554
523
 
555
524
  def _init_mappings(self) -> None:
556
525
  self.PYDANTIC_MODEL_TO_CONSTRUCTOR: Mapping[Type[BaseModel], Callable[..., Any]] = {
@@ -638,13 +607,6 @@ class ModelToComponentFactory:
638
607
  StreamConfigModel: self.create_stream_config,
639
608
  ComponentMappingDefinitionModel: self.create_components_mapping_definition,
640
609
  ZipfileDecoderModel: self.create_zipfile_decoder,
641
- APIBudgetModel: self.create_api_budget,
642
- HTTPAPIBudgetModel: self.create_http_api_budget,
643
- FixedWindowCallRatePolicyModel: self.create_fixed_window_call_rate_policy,
644
- MovingWindowCallRatePolicyModel: self.create_moving_window_call_rate_policy,
645
- UnlimitedCallRatePolicyModel: self.create_unlimited_call_rate_policy,
646
- RateModel: self.create_rate,
647
- HttpRequestMatcherModel: self.create_http_request_matcher,
648
610
  }
649
611
 
650
612
  # Needed for the case where we need to perform a second parse on the fields of a custom component
@@ -771,8 +733,8 @@ class ModelToComponentFactory:
771
733
  }
772
734
  return names_to_types[value_type]
773
735
 
774
- @staticmethod
775
736
  def create_api_key_authenticator(
737
+ self,
776
738
  model: ApiKeyAuthenticatorModel,
777
739
  config: Config,
778
740
  token_provider: Optional[TokenProvider] = None,
@@ -794,10 +756,8 @@ class ModelToComponentFactory:
794
756
  )
795
757
 
796
758
  request_option = (
797
- RequestOption(
798
- inject_into=RequestOptionType(model.inject_into.inject_into.value),
799
- field_name=model.inject_into.field_name,
800
- parameters=model.parameters or {},
759
+ self._create_component_from_model(
760
+ model.inject_into, config, parameters=model.parameters or {}
801
761
  )
802
762
  if model.inject_into
803
763
  else RequestOption(
@@ -806,6 +766,7 @@ class ModelToComponentFactory:
806
766
  parameters=model.parameters or {},
807
767
  )
808
768
  )
769
+
809
770
  return ApiKeyAuthenticator(
810
771
  token_provider=(
811
772
  token_provider
@@ -887,7 +848,7 @@ class ModelToComponentFactory:
887
848
  token_provider=token_provider,
888
849
  )
889
850
  else:
890
- return ModelToComponentFactory.create_api_key_authenticator(
851
+ return self.create_api_key_authenticator(
891
852
  ApiKeyAuthenticatorModel(
892
853
  type="ApiKeyAuthenticator",
893
854
  api_token="",
@@ -1527,19 +1488,15 @@ class ModelToComponentFactory:
1527
1488
  )
1528
1489
 
1529
1490
  end_time_option = (
1530
- RequestOption(
1531
- inject_into=RequestOptionType(model.end_time_option.inject_into.value),
1532
- field_name=model.end_time_option.field_name,
1533
- parameters=model.parameters or {},
1491
+ self._create_component_from_model(
1492
+ model.end_time_option, config, parameters=model.parameters or {}
1534
1493
  )
1535
1494
  if model.end_time_option
1536
1495
  else None
1537
1496
  )
1538
1497
  start_time_option = (
1539
- RequestOption(
1540
- inject_into=RequestOptionType(model.start_time_option.inject_into.value),
1541
- field_name=model.start_time_option.field_name,
1542
- parameters=model.parameters or {},
1498
+ self._create_component_from_model(
1499
+ model.start_time_option, config, parameters=model.parameters or {}
1543
1500
  )
1544
1501
  if model.start_time_option
1545
1502
  else None
@@ -1610,19 +1567,15 @@ class ModelToComponentFactory:
1610
1567
  cursor_model = model.incremental_sync
1611
1568
 
1612
1569
  end_time_option = (
1613
- RequestOption(
1614
- inject_into=RequestOptionType(cursor_model.end_time_option.inject_into.value),
1615
- field_name=cursor_model.end_time_option.field_name,
1616
- parameters=cursor_model.parameters or {},
1570
+ self._create_component_from_model(
1571
+ cursor_model.end_time_option, config, parameters=cursor_model.parameters or {}
1617
1572
  )
1618
1573
  if cursor_model.end_time_option
1619
1574
  else None
1620
1575
  )
1621
1576
  start_time_option = (
1622
- RequestOption(
1623
- inject_into=RequestOptionType(cursor_model.start_time_option.inject_into.value),
1624
- field_name=cursor_model.start_time_option.field_name,
1625
- parameters=cursor_model.parameters or {},
1577
+ self._create_component_from_model(
1578
+ cursor_model.start_time_option, config, parameters=cursor_model.parameters or {}
1626
1579
  )
1627
1580
  if cursor_model.start_time_option
1628
1581
  else None
@@ -1949,8 +1902,6 @@ class ModelToComponentFactory:
1949
1902
  )
1950
1903
  )
1951
1904
 
1952
- api_budget = self._api_budget
1953
-
1954
1905
  request_options_provider = InterpolatedRequestOptionsProvider(
1955
1906
  request_body_data=model.request_body_data,
1956
1907
  request_body_json=model.request_body_json,
@@ -1971,7 +1922,6 @@ class ModelToComponentFactory:
1971
1922
  path=model.path,
1972
1923
  authenticator=authenticator,
1973
1924
  error_handler=error_handler,
1974
- api_budget=api_budget,
1975
1925
  http_method=HttpMethod[model.http_method.value],
1976
1926
  request_options_provider=request_options_provider,
1977
1927
  config=config,
@@ -2191,16 +2141,11 @@ class ModelToComponentFactory:
2191
2141
  additional_jwt_payload=model.additional_jwt_payload,
2192
2142
  )
2193
2143
 
2194
- @staticmethod
2195
2144
  def create_list_partition_router(
2196
- model: ListPartitionRouterModel, config: Config, **kwargs: Any
2145
+ self, model: ListPartitionRouterModel, config: Config, **kwargs: Any
2197
2146
  ) -> ListPartitionRouter:
2198
2147
  request_option = (
2199
- RequestOption(
2200
- inject_into=RequestOptionType(model.request_option.inject_into.value),
2201
- field_name=model.request_option.field_name,
2202
- parameters=model.parameters or {},
2203
- )
2148
+ self._create_component_from_model(model.request_option, config)
2204
2149
  if model.request_option
2205
2150
  else None
2206
2151
  )
@@ -2396,7 +2341,25 @@ class ModelToComponentFactory:
2396
2341
  model: RequestOptionModel, config: Config, **kwargs: Any
2397
2342
  ) -> RequestOption:
2398
2343
  inject_into = RequestOptionType(model.inject_into.value)
2399
- return RequestOption(field_name=model.field_name, inject_into=inject_into, parameters={})
2344
+ field_path: Optional[List[Union[InterpolatedString, str]]] = (
2345
+ [
2346
+ InterpolatedString.create(segment, parameters=kwargs.get("parameters", {}))
2347
+ for segment in model.field_path
2348
+ ]
2349
+ if model.field_path
2350
+ else None
2351
+ )
2352
+ field_name = (
2353
+ InterpolatedString.create(model.field_name, parameters=kwargs.get("parameters", {}))
2354
+ if model.field_name
2355
+ else None
2356
+ )
2357
+ return RequestOption(
2358
+ field_name=field_name,
2359
+ field_path=field_path,
2360
+ inject_into=inject_into,
2361
+ parameters=kwargs.get("parameters", {}),
2362
+ )
2400
2363
 
2401
2364
  def create_record_selector(
2402
2365
  self,
@@ -2960,103 +2923,3 @@ class ModelToComponentFactory:
2960
2923
  return isinstance(parser.inner_parser, JsonParser)
2961
2924
  else:
2962
2925
  return False
2963
-
2964
- def create_api_budget(self, model: APIBudgetModel, config: Config, **kwargs: Any) -> APIBudget:
2965
- policies = [
2966
- self._create_component_from_model(model=policy, config=config)
2967
- for policy in model.policies
2968
- ]
2969
-
2970
- return APIBudget(
2971
- policies=policies,
2972
- maximum_attempts_to_acquire=model.maximum_attempts_to_acquire or 100000,
2973
- )
2974
-
2975
- def create_http_api_budget(
2976
- self, model: HTTPAPIBudgetModel, config: Config, **kwargs: Any
2977
- ) -> HttpAPIBudget:
2978
- policies = [
2979
- self._create_component_from_model(model=policy, config=config)
2980
- for policy in model.policies
2981
- ]
2982
-
2983
- return HttpAPIBudget(
2984
- policies=policies,
2985
- maximum_attempts_to_acquire=model.maximum_attempts_to_acquire or 100000,
2986
- ratelimit_reset_header=model.ratelimit_reset_header or "ratelimit-reset",
2987
- ratelimit_remaining_header=model.ratelimit_remaining_header or "ratelimit-remaining",
2988
- status_codes_for_ratelimit_hit=model.status_codes_for_ratelimit_hit or (429,),
2989
- )
2990
-
2991
- def create_fixed_window_call_rate_policy(
2992
- self, model: FixedWindowCallRatePolicyModel, config: Config, **kwargs: Any
2993
- ) -> FixedWindowCallRatePolicy:
2994
- matchers = [
2995
- self._create_component_from_model(model=matcher, config=config)
2996
- for matcher in model.matchers
2997
- ]
2998
- return FixedWindowCallRatePolicy(
2999
- next_reset_ts=model.next_reset_ts,
3000
- period=parse_duration(model.period),
3001
- call_limit=model.call_limit,
3002
- matchers=matchers,
3003
- )
3004
-
3005
- def create_moving_window_call_rate_policy(
3006
- self, model: MovingWindowCallRatePolicyModel, config: Config, **kwargs: Any
3007
- ) -> MovingWindowCallRatePolicy:
3008
- rates = [
3009
- self._create_component_from_model(model=rate, config=config) for rate in model.rates
3010
- ]
3011
- matchers = [
3012
- self._create_component_from_model(model=matcher, config=config)
3013
- for matcher in model.matchers
3014
- ]
3015
- return MovingWindowCallRatePolicy(
3016
- rates=rates,
3017
- matchers=matchers,
3018
- )
3019
-
3020
- def create_unlimited_call_rate_policy(
3021
- self, model: UnlimitedCallRatePolicyModel, config: Config, **kwargs: Any
3022
- ) -> UnlimitedCallRatePolicy:
3023
- matchers = [
3024
- self._create_component_from_model(model=matcher, config=config)
3025
- for matcher in model.matchers
3026
- ]
3027
-
3028
- return UnlimitedCallRatePolicy(
3029
- matchers=matchers,
3030
- )
3031
-
3032
- def create_rate(self, model: RateModel, config: Config, **kwargs: Any) -> Rate:
3033
- return Rate(
3034
- limit=model.limit,
3035
- interval=model.interval,
3036
- )
3037
-
3038
- def create_http_request_matcher(
3039
- self, model: HttpRequestMatcherModel, config: Config, **kwargs: Any
3040
- ) -> HttpRequestMatcher:
3041
- return HttpRequestMatcher(
3042
- method=model.method,
3043
- url_base=model.url_base,
3044
- url_path_pattern=model.url_path_pattern,
3045
- params=model.params,
3046
- headers=model.headers,
3047
- )
3048
-
3049
- def set_api_budget(self, component_definition: ComponentDefinition, config: Config) -> None:
3050
- model_str = component_definition.get("type")
3051
- if model_str == "APIBudget":
3052
- # Annotate model_type as a type that is a subclass of BaseModel
3053
- model_type: Union[Type[APIBudgetModel], Type[HTTPAPIBudgetModel]] = APIBudgetModel
3054
- elif model_str == "HTTPAPIBudget":
3055
- model_type = HTTPAPIBudgetModel
3056
- else:
3057
- raise ValueError(f"Unknown API Budget type: {model_str}")
3058
-
3059
- # create_component expects a type[BaseModel] and returns an instance of that model.
3060
- self._api_budget = self.create_component(
3061
- model_type=model_type, component_definition=component_definition, config=config
3062
- )
@@ -3,7 +3,7 @@
3
3
  #
4
4
 
5
5
  from dataclasses import InitVar, dataclass
6
- from typing import Any, Iterable, List, Mapping, Optional, Union
6
+ from typing import Any, Iterable, List, Mapping, MutableMapping, Optional, Union
7
7
 
8
8
  from airbyte_cdk.sources.declarative.interpolation.interpolated_string import InterpolatedString
9
9
  from airbyte_cdk.sources.declarative.partition_routers.partition_router import PartitionRouter
@@ -100,7 +100,9 @@ class ListPartitionRouter(PartitionRouter):
100
100
  ):
101
101
  slice_value = stream_slice.get(self._cursor_field.eval(self.config))
102
102
  if slice_value:
103
- return {self.request_option.field_name.eval(self.config): slice_value} # type: ignore # field_name is always casted to InterpolatedString
103
+ options: MutableMapping[str, Any] = {}
104
+ self.request_option.inject_into_request(options, slice_value, self.config)
105
+ return options
104
106
  else:
105
107
  return {}
106
108
  else:
@@ -4,7 +4,7 @@
4
4
  import copy
5
5
  import logging
6
6
  from dataclasses import InitVar, dataclass
7
- from typing import TYPE_CHECKING, Any, Iterable, List, Mapping, Optional, Union
7
+ from typing import TYPE_CHECKING, Any, Iterable, List, Mapping, MutableMapping, Optional, Union
8
8
 
9
9
  import dpath
10
10
 
@@ -118,7 +118,7 @@ class SubstreamPartitionRouter(PartitionRouter):
118
118
  def _get_request_option(
119
119
  self, option_type: RequestOptionType, stream_slice: Optional[StreamSlice]
120
120
  ) -> Mapping[str, Any]:
121
- params = {}
121
+ params: MutableMapping[str, Any] = {}
122
122
  if stream_slice:
123
123
  for parent_config in self.parent_stream_configs:
124
124
  if (
@@ -128,13 +128,7 @@ class SubstreamPartitionRouter(PartitionRouter):
128
128
  key = parent_config.partition_field.eval(self.config) # type: ignore # partition_field is always casted to an interpolated string
129
129
  value = stream_slice.get(key)
130
130
  if value:
131
- params.update(
132
- {
133
- parent_config.request_option.field_name.eval( # type: ignore [union-attr]
134
- config=self.config
135
- ): value
136
- }
137
- )
131
+ parent_config.request_option.inject_into_request(params, value, self.config)
138
132
  return params
139
133
 
140
134
  def stream_slices(self) -> Iterable[StreamSlice]:
@@ -305,23 +299,33 @@ class SubstreamPartitionRouter(PartitionRouter):
305
299
 
306
300
  def _migrate_child_state_to_parent_state(self, stream_state: StreamState) -> StreamState:
307
301
  """
308
- Migrate the child stream state to the parent stream's state format.
302
+ Migrate the child or global stream state into the parent stream's state format.
303
+
304
+ This method converts the child stream state—or, if present, the global state—into a format that is
305
+ compatible with parent streams that use incremental synchronization. The migration occurs only for
306
+ parent streams with incremental dependencies. It filters out per-partition states and retains only the
307
+ global state in the form {cursor_field: cursor_value}.
309
308
 
310
- This method converts the global or child state into a format compatible with parent
311
- streams. The migration occurs only for parent streams with incremental dependencies.
312
- The method filters out per-partition states and retains only the global state in the
313
- format `{cursor_field: cursor_value}`.
309
+ The method supports multiple input formats:
310
+ - A simple global state, e.g.:
311
+ {"updated_at": "2023-05-27T00:00:00Z"}
312
+ - A state object that contains a "state" key (which is assumed to hold the global state), e.g.:
313
+ {"state": {"updated_at": "2023-05-27T00:00:00Z"}, ...}
314
+ In this case, the migration uses the first value from the "state" dictionary.
315
+ - Any per-partition state formats or other non-simple structures are ignored during migration.
314
316
 
315
317
  Args:
316
318
  stream_state (StreamState): The state to migrate. Expected formats include:
317
319
  - {"updated_at": "2023-05-27T00:00:00Z"}
318
- - {"states": [...] } (ignored during migration)
320
+ - {"state": {"updated_at": "2023-05-27T00:00:00Z"}, ...}
321
+ (In this format, only the first global state value is used, and per-partition states are ignored.)
319
322
 
320
323
  Returns:
321
324
  StreamState: A migrated state for parent streams in the format:
322
325
  {
323
326
  "parent_stream_name": {"parent_stream_cursor": "2023-05-27T00:00:00Z"}
324
327
  }
328
+ where each parent stream with an incremental dependency is assigned its corresponding cursor value.
325
329
 
326
330
  Example:
327
331
  Input: {"updated_at": "2023-05-27T00:00:00Z"}
@@ -332,11 +336,15 @@ class SubstreamPartitionRouter(PartitionRouter):
332
336
  substream_state_values = list(stream_state.values())
333
337
  substream_state = substream_state_values[0] if substream_state_values else {}
334
338
 
335
- # Ignore per-partition states or invalid formats
339
+ # Ignore per-partition states or invalid formats.
336
340
  if isinstance(substream_state, (list, dict)) or len(substream_state_values) != 1:
337
- return {}
341
+ # If a global state is present under the key "state", use its first value.
342
+ if "state" in stream_state and isinstance(stream_state["state"], dict):
343
+ substream_state = list(stream_state["state"].values())[0]
344
+ else:
345
+ return {}
338
346
 
339
- # Copy child state to parent streams with incremental dependencies
347
+ # Build the parent state for all parent streams with incremental dependencies.
340
348
  parent_state = {}
341
349
  if substream_state:
342
350
  for parent_config in self.parent_stream_configs:
@@ -22,7 +22,6 @@ from airbyte_cdk.sources.declarative.requesters.request_options.interpolated_req
22
22
  )
23
23
  from airbyte_cdk.sources.declarative.requesters.requester import HttpMethod, Requester
24
24
  from airbyte_cdk.sources.message import MessageRepository, NoopMessageRepository
25
- from airbyte_cdk.sources.streams.call_rate import APIBudget
26
25
  from airbyte_cdk.sources.streams.http import HttpClient
27
26
  from airbyte_cdk.sources.streams.http.error_handlers import ErrorHandler
28
27
  from airbyte_cdk.sources.types import Config, StreamSlice, StreamState
@@ -56,7 +55,6 @@ class HttpRequester(Requester):
56
55
  http_method: Union[str, HttpMethod] = HttpMethod.GET
57
56
  request_options_provider: Optional[InterpolatedRequestOptionsProvider] = None
58
57
  error_handler: Optional[ErrorHandler] = None
59
- api_budget: Optional[APIBudget] = None
60
58
  disable_retries: bool = False
61
59
  message_repository: MessageRepository = NoopMessageRepository()
62
60
  use_cache: bool = False
@@ -93,7 +91,6 @@ class HttpRequester(Requester):
93
91
  name=self.name,
94
92
  logger=self.logger,
95
93
  error_handler=self.error_handler,
96
- api_budget=self.api_budget,
97
94
  authenticator=self._authenticator,
98
95
  use_cache=self.use_cache,
99
96
  backoff_strategy=backoff_strategies,
@@ -202,6 +199,9 @@ class HttpRequester(Requester):
202
199
  Raise a ValueError if there's a key collision
203
200
  Returned merged mapping otherwise
204
201
  """
202
+
203
+ is_body_json = requester_method.__name__ == "get_request_body_json"
204
+
205
205
  return combine_mappings(
206
206
  [
207
207
  requester_method(
@@ -211,7 +211,8 @@ class HttpRequester(Requester):
211
211
  ),
212
212
  auth_options_method(),
213
213
  extra_options,
214
- ]
214
+ ],
215
+ allow_same_value_merge=is_body_json,
215
216
  )
216
217
 
217
218
  def _request_headers(
@@ -187,7 +187,7 @@ class DefaultPaginator(Paginator):
187
187
  def _get_request_options(
188
188
  self, option_type: RequestOptionType, next_page_token: Optional[Mapping[str, Any]]
189
189
  ) -> MutableMapping[str, Any]:
190
- options = {}
190
+ options: MutableMapping[str, Any] = {}
191
191
 
192
192
  token = next_page_token.get("next_page_token") if next_page_token else None
193
193
  if (
@@ -196,15 +196,16 @@ class DefaultPaginator(Paginator):
196
196
  and isinstance(self.page_token_option, RequestOption)
197
197
  and self.page_token_option.inject_into == option_type
198
198
  ):
199
- options[self.page_token_option.field_name.eval(config=self.config)] = token # type: ignore # field_name is always cast to an interpolated string
199
+ self.page_token_option.inject_into_request(options, token, self.config)
200
+
200
201
  if (
201
202
  self.page_size_option
202
203
  and self.pagination_strategy.get_page_size()
203
204
  and self.page_size_option.inject_into == option_type
204
205
  ):
205
- options[self.page_size_option.field_name.eval(config=self.config)] = ( # type: ignore [union-attr]
206
- self.pagination_strategy.get_page_size()
207
- ) # type: ignore # field_name is always cast to an interpolated string
206
+ page_size = self.pagination_strategy.get_page_size()
207
+ self.page_size_option.inject_into_request(options, page_size, self.config)
208
+
208
209
  return options
209
210
 
210
211
 
@@ -4,9 +4,10 @@
4
4
 
5
5
  from dataclasses import InitVar, dataclass
6
6
  from enum import Enum
7
- from typing import Any, Mapping, Union
7
+ from typing import Any, List, Literal, Mapping, MutableMapping, Optional, Union
8
8
 
9
9
  from airbyte_cdk.sources.declarative.interpolation.interpolated_string import InterpolatedString
10
+ from airbyte_cdk.sources.types import Config
10
11
 
11
12
 
12
13
  class RequestOptionType(Enum):
@@ -26,13 +27,91 @@ class RequestOption:
26
27
  Describes an option to set on a request
27
28
 
28
29
  Attributes:
29
- field_name (str): Describes the name of the parameter to inject
30
+ field_name (str): Describes the name of the parameter to inject. Mutually exclusive with field_path.
31
+ field_path (list(str)): Describes the path to a nested field as a list of field names.
32
+ Only valid for body_json injection type, and mutually exclusive with field_name.
30
33
  inject_into (RequestOptionType): Describes where in the HTTP request to inject the parameter
31
34
  """
32
35
 
33
- field_name: Union[InterpolatedString, str]
34
36
  inject_into: RequestOptionType
35
37
  parameters: InitVar[Mapping[str, Any]]
38
+ field_name: Optional[Union[InterpolatedString, str]] = None
39
+ field_path: Optional[List[Union[InterpolatedString, str]]] = None
36
40
 
37
41
  def __post_init__(self, parameters: Mapping[str, Any]) -> None:
38
- self.field_name = InterpolatedString.create(self.field_name, parameters=parameters)
42
+ # Validate inputs. We should expect either field_name or field_path, but not both
43
+ if self.field_name is None and self.field_path is None:
44
+ raise ValueError("RequestOption requires either a field_name or field_path")
45
+
46
+ if self.field_name is not None and self.field_path is not None:
47
+ raise ValueError(
48
+ "Only one of field_name or field_path can be provided to RequestOption"
49
+ )
50
+
51
+ # Nested field injection is only supported for body JSON injection
52
+ if self.field_path is not None and self.inject_into != RequestOptionType.body_json:
53
+ raise ValueError(
54
+ "Nested field injection is only supported for body JSON injection. Please use a top-level field_name for other injection types."
55
+ )
56
+
57
+ # Convert field_name and field_path into InterpolatedString objects if they are strings
58
+ if self.field_name is not None:
59
+ self.field_name = InterpolatedString.create(self.field_name, parameters=parameters)
60
+ elif self.field_path is not None:
61
+ self.field_path = [
62
+ InterpolatedString.create(segment, parameters=parameters)
63
+ for segment in self.field_path
64
+ ]
65
+
66
+ @property
67
+ def _is_field_path(self) -> bool:
68
+ """Returns whether this option is a field path (ie, a nested field)"""
69
+ return self.field_path is not None
70
+
71
+ def inject_into_request(
72
+ self,
73
+ target: MutableMapping[str, Any],
74
+ value: Any,
75
+ config: Config,
76
+ ) -> None:
77
+ """
78
+ Inject a request option value into a target request structure using either field_name or field_path.
79
+ For non-body-json injection, only top-level field names are supported.
80
+ For body-json injection, both field names and nested field paths are supported.
81
+
82
+ Args:
83
+ target: The request structure to inject the value into
84
+ value: The value to inject
85
+ config: The config object to use for interpolation
86
+ """
87
+ if self._is_field_path:
88
+ if self.inject_into != RequestOptionType.body_json:
89
+ raise ValueError(
90
+ "Nested field injection is only supported for body JSON injection. Please use a top-level field_name for other injection types."
91
+ )
92
+
93
+ assert self.field_path is not None # for type checker
94
+ current = target
95
+ # Convert path segments into strings, evaluating any interpolated segments
96
+ # Example: ["data", "{{ config[user_type] }}", "id"] -> ["data", "admin", "id"]
97
+ *path_parts, final_key = [
98
+ str(
99
+ segment.eval(config=config)
100
+ if isinstance(segment, InterpolatedString)
101
+ else segment
102
+ )
103
+ for segment in self.field_path
104
+ ]
105
+
106
+ # Build a nested dictionary structure and set the final value at the deepest level
107
+ for part in path_parts:
108
+ current = current.setdefault(part, {})
109
+ current[final_key] = value
110
+ else:
111
+ # For non-nested fields, evaluate the field name if it's an interpolated string
112
+ key = (
113
+ self.field_name.eval(config=config)
114
+ if isinstance(self.field_name, InterpolatedString)
115
+ else self.field_name
116
+ )
117
+ target[str(key)] = value
@@ -80,12 +80,13 @@ class DatetimeBasedRequestOptionsProvider(RequestOptionsProvider):
80
80
  options: MutableMapping[str, Any] = {}
81
81
  if not stream_slice:
82
82
  return options
83
+
83
84
  if self.start_time_option and self.start_time_option.inject_into == option_type:
84
- options[self.start_time_option.field_name.eval(config=self.config)] = stream_slice.get( # type: ignore # field_name is always casted to an interpolated string
85
- self._partition_field_start.eval(self.config)
86
- )
85
+ start_time_value = stream_slice.get(self._partition_field_start.eval(self.config))
86
+ self.start_time_option.inject_into_request(options, start_time_value, self.config)
87
+
87
88
  if self.end_time_option and self.end_time_option.inject_into == option_type:
88
- options[self.end_time_option.field_name.eval(config=self.config)] = stream_slice.get( # type: ignore [union-attr]
89
- self._partition_field_end.eval(self.config)
90
- )
89
+ end_time_value = stream_slice.get(self._partition_field_end.eval(self.config))
90
+ self.end_time_option.inject_into_request(options, end_time_value, self.config)
91
+
91
92
  return options