airbyte-cdk 6.34.1.dev0__py3-none-any.whl → 6.34.1.dev1__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.
- airbyte_cdk/connector_builder/connector_builder_handler.py +16 -12
- airbyte_cdk/connector_builder/test_reader/__init__.py +7 -0
- airbyte_cdk/connector_builder/test_reader/helpers.py +591 -0
- airbyte_cdk/connector_builder/test_reader/message_grouper.py +160 -0
- airbyte_cdk/connector_builder/test_reader/reader.py +441 -0
- airbyte_cdk/connector_builder/test_reader/types.py +75 -0
- airbyte_cdk/sources/declarative/async_job/job_orchestrator.py +7 -7
- airbyte_cdk/sources/declarative/auth/jwt.py +17 -11
- airbyte_cdk/sources/declarative/auth/oauth.py +6 -1
- airbyte_cdk/sources/declarative/auth/token.py +3 -8
- airbyte_cdk/sources/declarative/concurrent_declarative_source.py +30 -79
- airbyte_cdk/sources/declarative/declarative_component_schema.yaml +203 -100
- airbyte_cdk/sources/declarative/declarative_stream.py +3 -1
- airbyte_cdk/sources/declarative/decoders/__init__.py +0 -4
- airbyte_cdk/sources/declarative/decoders/composite_raw_decoder.py +7 -2
- airbyte_cdk/sources/declarative/decoders/json_decoder.py +12 -58
- airbyte_cdk/sources/declarative/extractors/record_selector.py +12 -3
- airbyte_cdk/sources/declarative/incremental/concurrent_partition_cursor.py +56 -25
- airbyte_cdk/sources/declarative/incremental/datetime_based_cursor.py +12 -6
- airbyte_cdk/sources/declarative/incremental/global_substream_cursor.py +6 -2
- airbyte_cdk/sources/declarative/interpolation/jinja.py +13 -0
- airbyte_cdk/sources/declarative/manifest_declarative_source.py +9 -0
- airbyte_cdk/sources/declarative/models/declarative_component_schema.py +150 -41
- airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py +234 -84
- airbyte_cdk/sources/declarative/partition_routers/async_job_partition_router.py +5 -5
- airbyte_cdk/sources/declarative/partition_routers/list_partition_router.py +4 -2
- airbyte_cdk/sources/declarative/partition_routers/substream_partition_router.py +26 -18
- airbyte_cdk/sources/declarative/requesters/http_requester.py +8 -2
- airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py +16 -5
- airbyte_cdk/sources/declarative/requesters/request_option.py +83 -4
- airbyte_cdk/sources/declarative/requesters/request_options/datetime_based_request_options_provider.py +7 -6
- airbyte_cdk/sources/declarative/requesters/request_options/interpolated_nested_request_input_provider.py +1 -4
- airbyte_cdk/sources/declarative/requesters/request_options/interpolated_request_input_provider.py +0 -3
- airbyte_cdk/sources/declarative/requesters/request_options/interpolated_request_options_provider.py +2 -47
- airbyte_cdk/sources/declarative/retrievers/async_retriever.py +6 -12
- airbyte_cdk/sources/declarative/retrievers/simple_retriever.py +4 -3
- airbyte_cdk/sources/declarative/transformations/add_fields.py +4 -4
- airbyte_cdk/sources/file_based/config/abstract_file_based_spec.py +2 -1
- airbyte_cdk/sources/file_based/config/validate_config_transfer_modes.py +81 -0
- airbyte_cdk/sources/file_based/file_based_source.py +70 -37
- airbyte_cdk/sources/file_based/file_based_stream_reader.py +107 -12
- airbyte_cdk/sources/file_based/stream/__init__.py +10 -1
- airbyte_cdk/sources/file_based/stream/identities_stream.py +47 -0
- airbyte_cdk/sources/file_based/stream/permissions_file_based_stream.py +85 -0
- airbyte_cdk/sources/specs/transfer_modes.py +26 -0
- airbyte_cdk/sources/streams/call_rate.py +185 -47
- airbyte_cdk/sources/streams/http/http.py +1 -2
- airbyte_cdk/sources/streams/http/requests_native_auth/abstract_oauth.py +217 -56
- airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py +144 -73
- airbyte_cdk/sources/streams/permissions/identities_stream.py +75 -0
- airbyte_cdk/test/mock_http/mocker.py +9 -1
- airbyte_cdk/test/mock_http/response.py +6 -3
- airbyte_cdk/utils/datetime_helpers.py +48 -66
- airbyte_cdk/utils/mapping_helpers.py +126 -26
- {airbyte_cdk-6.34.1.dev0.dist-info → airbyte_cdk-6.34.1.dev1.dist-info}/METADATA +1 -1
- {airbyte_cdk-6.34.1.dev0.dist-info → airbyte_cdk-6.34.1.dev1.dist-info}/RECORD +60 -51
- airbyte_cdk/connector_builder/message_grouper.py +0 -448
- {airbyte_cdk-6.34.1.dev0.dist-info → airbyte_cdk-6.34.1.dev1.dist-info}/LICENSE.txt +0 -0
- {airbyte_cdk-6.34.1.dev0.dist-info → airbyte_cdk-6.34.1.dev1.dist-info}/LICENSE_SHORT +0 -0
- {airbyte_cdk-6.34.1.dev0.dist-info → airbyte_cdk-6.34.1.dev1.dist-info}/WHEEL +0 -0
- {airbyte_cdk-6.34.1.dev0.dist-info → airbyte_cdk-6.34.1.dev1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,75 @@
|
|
1
|
+
#
|
2
|
+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
|
3
|
+
#
|
4
|
+
|
5
|
+
import traceback
|
6
|
+
from abc import ABC, abstractmethod
|
7
|
+
from typing import Any, Dict, Iterable, List, Mapping, MutableMapping, Optional
|
8
|
+
|
9
|
+
from airbyte_protocol_dataclasses.models import SyncMode
|
10
|
+
|
11
|
+
from airbyte_cdk.models import AirbyteLogMessage, AirbyteMessage, Level
|
12
|
+
from airbyte_cdk.models import Type as MessageType
|
13
|
+
from airbyte_cdk.sources.streams import Stream
|
14
|
+
from airbyte_cdk.sources.streams.checkpoint import Cursor
|
15
|
+
from airbyte_cdk.sources.utils.record_helper import stream_data_to_airbyte_message
|
16
|
+
from airbyte_cdk.utils.traced_exception import AirbyteTracedException
|
17
|
+
|
18
|
+
|
19
|
+
class IdentitiesStream(Stream, ABC):
|
20
|
+
"""
|
21
|
+
The identities stream. A full refresh stream to sync identities from a certain domain.
|
22
|
+
The load_identity_groups method manage the logic to get such data.
|
23
|
+
"""
|
24
|
+
|
25
|
+
IDENTITIES_STREAM_NAME = "identities"
|
26
|
+
|
27
|
+
is_resumable = False
|
28
|
+
|
29
|
+
def __init__(self) -> None:
|
30
|
+
super().__init__()
|
31
|
+
self._cursor: MutableMapping[str, Any] = {}
|
32
|
+
|
33
|
+
@property
|
34
|
+
def state(self) -> MutableMapping[str, Any]:
|
35
|
+
return self._cursor
|
36
|
+
|
37
|
+
@state.setter
|
38
|
+
def state(self, value: MutableMapping[str, Any]) -> None:
|
39
|
+
"""State setter, accept state serialized by state getter."""
|
40
|
+
self._cursor = value
|
41
|
+
|
42
|
+
def read_records(
|
43
|
+
self,
|
44
|
+
sync_mode: SyncMode,
|
45
|
+
cursor_field: Optional[List[str]] = None,
|
46
|
+
stream_slice: Optional[Mapping[str, Any]] = None,
|
47
|
+
stream_state: Optional[Mapping[str, Any]] = None,
|
48
|
+
) -> Iterable[Mapping[str, Any] | AirbyteMessage]:
|
49
|
+
try:
|
50
|
+
identity_groups = self.load_identity_groups()
|
51
|
+
for record in identity_groups:
|
52
|
+
yield stream_data_to_airbyte_message(self.name, record)
|
53
|
+
except AirbyteTracedException as exc:
|
54
|
+
# Re-raise the exception to stop the whole sync immediately as this is a fatal error
|
55
|
+
raise exc
|
56
|
+
except Exception as e:
|
57
|
+
yield AirbyteMessage(
|
58
|
+
type=MessageType.LOG,
|
59
|
+
log=AirbyteLogMessage(
|
60
|
+
level=Level.ERROR,
|
61
|
+
message=f"Error trying to read identities: {e} stream={self.name}",
|
62
|
+
stack_trace=traceback.format_exc(),
|
63
|
+
),
|
64
|
+
)
|
65
|
+
|
66
|
+
@abstractmethod
|
67
|
+
def load_identity_groups(self) -> Iterable[Dict[str, Any]]:
|
68
|
+
raise NotImplementedError("Implement this method to read identity records")
|
69
|
+
|
70
|
+
@property
|
71
|
+
def name(self) -> str:
|
72
|
+
return self.IDENTITIES_STREAM_NAME
|
73
|
+
|
74
|
+
def get_cursor(self) -> Optional[Cursor]:
|
75
|
+
return None
|
@@ -17,6 +17,7 @@ class SupportedHttpMethods(str, Enum):
|
|
17
17
|
GET = "get"
|
18
18
|
PATCH = "patch"
|
19
19
|
POST = "post"
|
20
|
+
PUT = "put"
|
20
21
|
DELETE = "delete"
|
21
22
|
|
22
23
|
|
@@ -77,7 +78,7 @@ class HttpMocker(contextlib.ContextDecorator):
|
|
77
78
|
additional_matcher=self._matches_wrapper(matcher),
|
78
79
|
response_list=[
|
79
80
|
{
|
80
|
-
|
81
|
+
self._get_body_field(response): response.body,
|
81
82
|
"status_code": response.status_code,
|
82
83
|
"headers": response.headers,
|
83
84
|
}
|
@@ -85,6 +86,10 @@ class HttpMocker(contextlib.ContextDecorator):
|
|
85
86
|
],
|
86
87
|
)
|
87
88
|
|
89
|
+
@staticmethod
|
90
|
+
def _get_body_field(response: HttpResponse) -> str:
|
91
|
+
return "text" if isinstance(response.body, str) else "content"
|
92
|
+
|
88
93
|
def get(self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]) -> None:
|
89
94
|
self._mock_request_method(SupportedHttpMethods.GET, request, responses)
|
90
95
|
|
@@ -98,6 +103,9 @@ class HttpMocker(contextlib.ContextDecorator):
|
|
98
103
|
) -> None:
|
99
104
|
self._mock_request_method(SupportedHttpMethods.POST, request, responses)
|
100
105
|
|
106
|
+
def put(self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]) -> None:
|
107
|
+
self._mock_request_method(SupportedHttpMethods.PUT, request, responses)
|
108
|
+
|
101
109
|
def delete(
|
102
110
|
self, request: HttpRequest, responses: Union[HttpResponse, List[HttpResponse]]
|
103
111
|
) -> None:
|
@@ -1,19 +1,22 @@
|
|
1
1
|
# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
|
2
2
|
|
3
3
|
from types import MappingProxyType
|
4
|
-
from typing import Mapping
|
4
|
+
from typing import Mapping, Union
|
5
5
|
|
6
6
|
|
7
7
|
class HttpResponse:
|
8
8
|
def __init__(
|
9
|
-
self,
|
9
|
+
self,
|
10
|
+
body: Union[str, bytes],
|
11
|
+
status_code: int = 200,
|
12
|
+
headers: Mapping[str, str] = MappingProxyType({}),
|
10
13
|
):
|
11
14
|
self._body = body
|
12
15
|
self._status_code = status_code
|
13
16
|
self._headers = headers
|
14
17
|
|
15
18
|
@property
|
16
|
-
def body(self) -> str:
|
19
|
+
def body(self) -> Union[str, bytes]:
|
17
20
|
return self._body
|
18
21
|
|
19
22
|
@property
|
@@ -76,8 +76,8 @@ from airbyte_cdk.utils.datetime_helpers import ab_datetime_try_parse
|
|
76
76
|
assert ab_datetime_try_parse("2023-03-14T15:09:26Z") # Basic UTC format
|
77
77
|
assert ab_datetime_try_parse("2023-03-14T15:09:26-04:00") # With timezone offset
|
78
78
|
assert ab_datetime_try_parse("2023-03-14T15:09:26+00:00") # With explicit UTC offset
|
79
|
-
assert
|
80
|
-
assert not ab_datetime_try_parse("foo")
|
79
|
+
assert ab_datetime_try_parse("2023-03-14 15:09:26Z") # Missing T delimiter but still parsable
|
80
|
+
assert not ab_datetime_try_parse("foo") # Invalid: not parsable, returns `None`
|
81
81
|
```
|
82
82
|
"""
|
83
83
|
|
@@ -138,6 +138,14 @@ class AirbyteDateTime(datetime):
|
|
138
138
|
dt.tzinfo or timezone.utc,
|
139
139
|
)
|
140
140
|
|
141
|
+
def to_datetime(self) -> datetime:
|
142
|
+
"""Converts this AirbyteDateTime to a standard datetime object.
|
143
|
+
|
144
|
+
Today, this just returns `self` because AirbyteDateTime is a subclass of `datetime`.
|
145
|
+
In the future, we may modify our internal representation to use a different base class.
|
146
|
+
"""
|
147
|
+
return self
|
148
|
+
|
141
149
|
def __str__(self) -> str:
|
142
150
|
"""Returns the datetime in ISO8601/RFC3339 format with 'T' delimiter.
|
143
151
|
|
@@ -148,12 +156,7 @@ class AirbyteDateTime(datetime):
|
|
148
156
|
str: ISO8601/RFC3339 formatted string.
|
149
157
|
"""
|
150
158
|
aware_self = self if self.tzinfo else self.replace(tzinfo=timezone.utc)
|
151
|
-
|
152
|
-
if self.microsecond:
|
153
|
-
base = f"{base}.{self.microsecond:06d}"
|
154
|
-
# Format timezone as ±HH:MM
|
155
|
-
offset = aware_self.strftime("%z")
|
156
|
-
return f"{base}{offset[:3]}:{offset[3:]}"
|
159
|
+
return aware_self.isoformat(sep="T", timespec="auto")
|
157
160
|
|
158
161
|
def __repr__(self) -> str:
|
159
162
|
"""Returns the same string representation as __str__ for consistency.
|
@@ -358,15 +361,15 @@ def ab_datetime_now() -> AirbyteDateTime:
|
|
358
361
|
def ab_datetime_parse(dt_str: str | int) -> AirbyteDateTime:
|
359
362
|
"""Parses a datetime string or timestamp into an AirbyteDateTime with timezone awareness.
|
360
363
|
|
361
|
-
|
364
|
+
This implementation is as flexible as possible to handle various datetime formats.
|
365
|
+
Always returns a timezone-aware datetime (defaults to UTC if no timezone specified).
|
362
366
|
|
363
367
|
Handles:
|
364
|
-
- ISO8601/RFC3339 format strings (with 'T' delimiter)
|
368
|
+
- ISO8601/RFC3339 format strings (with ' ' or 'T' delimiter)
|
365
369
|
- Unix timestamps (as integers or strings)
|
366
370
|
- Date-only strings (YYYY-MM-DD)
|
367
371
|
- Timezone-aware formats (+00:00 for UTC, or ±HH:MM offset)
|
368
|
-
|
369
|
-
Always returns a timezone-aware datetime (defaults to UTC if no timezone specified).
|
372
|
+
- Anything that can be parsed by `dateutil.parser.parse()`
|
370
373
|
|
371
374
|
Args:
|
372
375
|
dt_str: A datetime string in ISO8601/RFC3339 format, Unix timestamp (int/str),
|
@@ -416,15 +419,16 @@ def ab_datetime_parse(dt_str: str | int) -> AirbyteDateTime:
|
|
416
419
|
except (ValueError, TypeError):
|
417
420
|
raise ValueError(f"Invalid date format: {dt_str}")
|
418
421
|
|
419
|
-
#
|
420
|
-
if "
|
421
|
-
raise ValueError(f"
|
422
|
+
# Reject time-only strings without date
|
423
|
+
if ":" in dt_str and dt_str.count("-") < 2 and dt_str.count("/") < 2:
|
424
|
+
raise ValueError(f"Missing date part in datetime string: {dt_str}")
|
422
425
|
|
423
426
|
# Try parsing with dateutil for timezone handling
|
424
427
|
try:
|
425
428
|
parsed = parser.parse(dt_str)
|
426
429
|
if parsed.tzinfo is None:
|
427
430
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
431
|
+
|
428
432
|
return AirbyteDateTime.from_datetime(parsed)
|
429
433
|
except (ValueError, TypeError):
|
430
434
|
raise ValueError(f"Could not parse datetime string: {dt_str}")
|
@@ -438,7 +442,29 @@ def ab_datetime_parse(dt_str: str | int) -> AirbyteDateTime:
|
|
438
442
|
raise ValueError(f"Could not parse datetime string: {dt_str}")
|
439
443
|
|
440
444
|
|
441
|
-
def
|
445
|
+
def ab_datetime_try_parse(dt_str: str) -> AirbyteDateTime | None:
|
446
|
+
"""Try to parse the input as a datetime, failing gracefully instead of raising an exception.
|
447
|
+
|
448
|
+
This is a thin wrapper around `ab_datetime_parse()` that catches parsing errors and
|
449
|
+
returns `None` instead of raising an exception.
|
450
|
+
The implementation is as flexible as possible to handle various datetime formats.
|
451
|
+
Always returns a timezone-aware datetime (defaults to `UTC` if no timezone specified).
|
452
|
+
|
453
|
+
Example:
|
454
|
+
>>> ab_datetime_try_parse("2023-03-14T15:09:26Z") # Returns AirbyteDateTime
|
455
|
+
>>> ab_datetime_try_parse("2023-03-14 15:09:26Z") # Missing 'T' delimiter still parsable
|
456
|
+
>>> ab_datetime_try_parse("2023-03-14") # Returns midnight UTC time
|
457
|
+
"""
|
458
|
+
try:
|
459
|
+
return ab_datetime_parse(dt_str)
|
460
|
+
except (ValueError, TypeError):
|
461
|
+
return None
|
462
|
+
|
463
|
+
|
464
|
+
def ab_datetime_format(
|
465
|
+
dt: Union[datetime, AirbyteDateTime],
|
466
|
+
format: str | None = None,
|
467
|
+
) -> str:
|
442
468
|
"""Formats a datetime object as an ISO8601/RFC3339 string with 'T' delimiter and timezone.
|
443
469
|
|
444
470
|
Previously named: format()
|
@@ -449,6 +475,8 @@ def ab_datetime_format(dt: Union[datetime, AirbyteDateTime]) -> str:
|
|
449
475
|
|
450
476
|
Args:
|
451
477
|
dt: Any datetime object to format.
|
478
|
+
format: Optional format string. If provided, calls `strftime()` with this format.
|
479
|
+
Otherwise, uses the default ISO8601/RFC3339 format, adapted for available precision.
|
452
480
|
|
453
481
|
Returns:
|
454
482
|
str: ISO8601/RFC3339 formatted datetime string.
|
@@ -464,54 +492,8 @@ def ab_datetime_format(dt: Union[datetime, AirbyteDateTime]) -> str:
|
|
464
492
|
if dt.tzinfo is None:
|
465
493
|
dt = dt.replace(tzinfo=timezone.utc)
|
466
494
|
|
467
|
-
|
468
|
-
|
469
|
-
if dt.microsecond:
|
470
|
-
base = f"{base}.{dt.microsecond:06d}"
|
471
|
-
offset = dt.strftime("%z")
|
472
|
-
return f"{base}{offset[:3]}:{offset[3:]}"
|
473
|
-
|
474
|
-
|
475
|
-
def ab_datetime_try_parse(dt_str: str) -> AirbyteDateTime | None:
|
476
|
-
"""Try to parse the input string as an ISO8601/RFC3339 datetime, failing gracefully instead of raising an exception.
|
477
|
-
|
478
|
-
Requires strict ISO8601/RFC3339 format with:
|
479
|
-
- 'T' delimiter between date and time components
|
480
|
-
- Valid timezone (Z for UTC or ±HH:MM offset)
|
481
|
-
- Complete datetime representation (date and time)
|
495
|
+
if format:
|
496
|
+
return dt.strftime(format)
|
482
497
|
|
483
|
-
|
484
|
-
|
485
|
-
- Date-only strings
|
486
|
-
- Missing timezone
|
487
|
-
- Invalid timezone format
|
488
|
-
- Wrong date/time separators
|
489
|
-
|
490
|
-
Example:
|
491
|
-
>>> ab_datetime_try_parse("2023-03-14T15:09:26Z") # Returns AirbyteDateTime
|
492
|
-
>>> ab_datetime_try_parse("2023-03-14 15:09:26Z") # Returns None (invalid format)
|
493
|
-
>>> ab_datetime_try_parse("2023-03-14") # Returns None (missing time and timezone)
|
494
|
-
"""
|
495
|
-
if not isinstance(dt_str, str):
|
496
|
-
return None
|
497
|
-
try:
|
498
|
-
# Validate format before parsing
|
499
|
-
if "T" not in dt_str:
|
500
|
-
return None
|
501
|
-
if not any(x in dt_str for x in ["Z", "+", "-"]):
|
502
|
-
return None
|
503
|
-
if "/" in dt_str or " " in dt_str or "GMT" in dt_str:
|
504
|
-
return None
|
505
|
-
|
506
|
-
# Try parsing with dateutil
|
507
|
-
parsed = parser.parse(dt_str)
|
508
|
-
if parsed.tzinfo is None:
|
509
|
-
return None
|
510
|
-
|
511
|
-
# Validate time components
|
512
|
-
if not (0 <= parsed.hour <= 23 and 0 <= parsed.minute <= 59 and 0 <= parsed.second <= 59):
|
513
|
-
return None
|
514
|
-
|
515
|
-
return AirbyteDateTime.from_datetime(parsed)
|
516
|
-
except (ValueError, TypeError):
|
517
|
-
return None
|
498
|
+
# Format with consistent timezone representation and "T" delimiter
|
499
|
+
return dt.isoformat(sep="T", timespec="auto")
|
@@ -3,43 +3,143 @@
|
|
3
3
|
#
|
4
4
|
|
5
5
|
|
6
|
-
|
6
|
+
import copy
|
7
|
+
from typing import Any, Dict, List, Mapping, Optional, Union
|
8
|
+
|
9
|
+
from airbyte_cdk.sources.declarative.requesters.request_option import (
|
10
|
+
RequestOption,
|
11
|
+
RequestOptionType,
|
12
|
+
)
|
13
|
+
from airbyte_cdk.sources.types import Config
|
14
|
+
|
15
|
+
|
16
|
+
def _merge_mappings(
|
17
|
+
target: Dict[str, Any],
|
18
|
+
source: Mapping[str, Any],
|
19
|
+
path: Optional[List[str]] = None,
|
20
|
+
allow_same_value_merge: bool = False,
|
21
|
+
) -> None:
|
22
|
+
"""
|
23
|
+
Recursively merge two dictionaries, raising an error if there are any conflicts.
|
24
|
+
For body_json requests (allow_same_value_merge=True), a conflict occurs only when the same path has different values.
|
25
|
+
For other request types (allow_same_value_merge=False), any duplicate key is a conflict, regardless of value.
|
26
|
+
|
27
|
+
Args:
|
28
|
+
target: The dictionary to merge into
|
29
|
+
source: The dictionary to merge from
|
30
|
+
path: The current path in the nested structure (for error messages)
|
31
|
+
allow_same_value_merge: Whether to allow merging the same value into the same key. Set to false by default, should only be true for body_json injections
|
32
|
+
"""
|
33
|
+
path = path or []
|
34
|
+
for key, source_value in source.items():
|
35
|
+
current_path = path + [str(key)]
|
36
|
+
|
37
|
+
if key in target:
|
38
|
+
target_value = target[key]
|
39
|
+
if isinstance(target_value, dict) and isinstance(source_value, dict):
|
40
|
+
# Only body_json supports nested_structures
|
41
|
+
if not allow_same_value_merge:
|
42
|
+
raise ValueError(
|
43
|
+
f"Request body collision, duplicate keys detected at key path: {'.'.join(current_path)}. Please ensure that all keys in the request are unique."
|
44
|
+
)
|
45
|
+
# If both are dictionaries, recursively merge them
|
46
|
+
_merge_mappings(target_value, source_value, current_path, allow_same_value_merge)
|
47
|
+
|
48
|
+
elif not allow_same_value_merge or target_value != source_value:
|
49
|
+
# If same key has different values, that's a conflict
|
50
|
+
raise ValueError(
|
51
|
+
f"Request body collision, duplicate keys detected at key path: {'.'.join(current_path)}. Please ensure that all keys in the request are unique."
|
52
|
+
)
|
53
|
+
else:
|
54
|
+
# No conflict, just copy the value (using deepcopy for nested structures)
|
55
|
+
target[key] = copy.deepcopy(source_value)
|
7
56
|
|
8
57
|
|
9
58
|
def combine_mappings(
|
10
59
|
mappings: List[Optional[Union[Mapping[str, Any], str]]],
|
60
|
+
allow_same_value_merge: bool = False,
|
11
61
|
) -> Union[Mapping[str, Any], str]:
|
12
62
|
"""
|
13
|
-
Combine multiple mappings into a single mapping.
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
63
|
+
Combine multiple mappings into a single mapping.
|
64
|
+
|
65
|
+
For body_json requests (allow_same_value_merge=True):
|
66
|
+
- Supports nested structures (e.g., {"data": {"user": {"id": 1}}})
|
67
|
+
- Allows duplicate keys if their values match
|
68
|
+
- Raises error if same path has different values
|
69
|
+
|
70
|
+
For other request types (allow_same_value_merge=False):
|
71
|
+
- Only supports flat structures
|
72
|
+
- Any duplicate key raises an error, regardless of value
|
73
|
+
|
74
|
+
Args:
|
75
|
+
mappings: List of mappings to combine
|
76
|
+
allow_same_value_merge: Whether to allow duplicate keys with matching values.
|
77
|
+
Should only be True for body_json requests.
|
78
|
+
|
79
|
+
Returns:
|
80
|
+
A single mapping combining all inputs, or a string if there is exactly one
|
81
|
+
string mapping and no other non-empty mappings.
|
82
|
+
|
83
|
+
Raises:
|
84
|
+
ValueError: If there are:
|
85
|
+
- Multiple string mappings
|
86
|
+
- Both a string mapping and non-empty dictionary mappings
|
87
|
+
- Conflicting keys/paths based on allow_same_value_merge setting
|
18
88
|
"""
|
19
|
-
|
20
|
-
|
21
|
-
if part is None:
|
22
|
-
continue
|
23
|
-
keys = set(part.keys()) if not isinstance(part, str) else set()
|
24
|
-
all_keys.append(keys)
|
89
|
+
if not mappings:
|
90
|
+
return {}
|
25
91
|
|
26
|
-
|
27
|
-
|
92
|
+
# Count how many string options we have, ignoring None values
|
93
|
+
string_options = sum(isinstance(mapping, str) for mapping in mappings if mapping is not None)
|
28
94
|
if string_options > 1:
|
29
95
|
raise ValueError("Cannot combine multiple string options")
|
30
96
|
|
31
|
-
|
32
|
-
|
97
|
+
# Filter out None values and empty mappings
|
98
|
+
non_empty_mappings = [
|
99
|
+
m for m in mappings if m is not None and not (isinstance(m, Mapping) and not m)
|
100
|
+
]
|
101
|
+
|
102
|
+
# If there is only one string option and no other non-empty mappings, return it
|
103
|
+
if string_options == 1:
|
104
|
+
if len(non_empty_mappings) > 1:
|
105
|
+
raise ValueError("Cannot combine multiple options if one is a string")
|
106
|
+
return next(m for m in non_empty_mappings if isinstance(m, str))
|
107
|
+
|
108
|
+
# Start with an empty result and merge each mapping into it
|
109
|
+
result: Dict[str, Any] = {}
|
110
|
+
for mapping in non_empty_mappings:
|
111
|
+
if mapping and isinstance(mapping, Mapping):
|
112
|
+
_merge_mappings(result, mapping, allow_same_value_merge=allow_same_value_merge)
|
113
|
+
|
114
|
+
return result
|
33
115
|
|
34
|
-
# If any mapping is a string, return it
|
35
|
-
for mapping in mappings:
|
36
|
-
if isinstance(mapping, str):
|
37
|
-
return mapping
|
38
116
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
117
|
+
def _validate_component_request_option_paths(
|
118
|
+
config: Config, *request_options: Optional[RequestOption]
|
119
|
+
) -> None:
|
120
|
+
"""
|
121
|
+
Validates that a component with multiple request options does not have conflicting paths.
|
122
|
+
Uses dummy values for validation since actual values might not be available at init time.
|
123
|
+
"""
|
124
|
+
grouped_options: Dict[RequestOptionType, List[RequestOption]] = {}
|
125
|
+
for option in request_options:
|
126
|
+
if option:
|
127
|
+
grouped_options.setdefault(option.inject_into, []).append(option)
|
128
|
+
|
129
|
+
for inject_type, options in grouped_options.items():
|
130
|
+
if len(options) <= 1:
|
131
|
+
continue
|
132
|
+
|
133
|
+
option_dicts: List[Optional[Union[Mapping[str, Any], str]]] = []
|
134
|
+
for i, option in enumerate(options):
|
135
|
+
option_dict: Dict[str, Any] = {}
|
136
|
+
# Use indexed dummy values to ensure we catch conflicts
|
137
|
+
option.inject_into_request(option_dict, f"dummy_value_{i}", config)
|
138
|
+
option_dicts.append(option_dict)
|
43
139
|
|
44
|
-
|
45
|
-
|
140
|
+
try:
|
141
|
+
combine_mappings(
|
142
|
+
option_dicts, allow_same_value_merge=(inject_type == RequestOptionType.body_json)
|
143
|
+
)
|
144
|
+
except ValueError as error:
|
145
|
+
raise ValueError(error)
|