From c6052cc5a3bd4f219853d91830131c886def07aa Mon Sep 17 00:00:00 2001 From: ChristoGrab Date: Mon, 10 Feb 2025 13:17:38 -0800 Subject: [PATCH 1/7] feat: add key conflict validation for component request options --- .../paginators/default_paginator.py | 8 ++++ airbyte_cdk/utils/mapping_helpers.py | 40 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py b/airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py index 6fb412cd9..6ffe4a0f3 100644 --- a/airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py +++ b/airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py @@ -23,6 +23,7 @@ ) from airbyte_cdk.sources.declarative.requesters.request_path import RequestPath from airbyte_cdk.sources.types import Config, Record, StreamSlice, StreamState +from airbyte_cdk.utils.mapping_helpers import combine_mappings, _validate_multiple_request_options @dataclass @@ -112,6 +113,13 @@ def __post_init__(self, parameters: Mapping[str, Any]) -> None: ) if isinstance(self.url_base, str): self.url_base = InterpolatedString(string=self.url_base, parameters=parameters) + + if self.page_token_option and not isinstance(self.page_token_option, RequestPath): + _validate_multiple_request_options( + self.config, + self.page_size_option, + self.page_token_option, + ) def get_initial_token(self) -> Optional[Any]: """ diff --git a/airbyte_cdk/utils/mapping_helpers.py b/airbyte_cdk/utils/mapping_helpers.py index c5682c288..9032e6489 100644 --- a/airbyte_cdk/utils/mapping_helpers.py +++ b/airbyte_cdk/utils/mapping_helpers.py @@ -6,6 +6,8 @@ import copy from typing import Any, Dict, List, Mapping, Optional, Union +from airbyte_cdk.sources.declarative.requesters.request_option import RequestOption, RequestOptionType +from airbyte_cdk.sources.types import Config def _merge_mappings( target: Dict[str, Any], @@ -33,13 +35,13 @@ def _merge_mappings( if isinstance(target_value, dict) and isinstance(source_value, dict): # Only body_json supports nested_structures if not allow_same_value_merge: - raise ValueError(f"Duplicate keys found: {'.'.join(current_path)}") + raise ValueError(f"Request body collision, duplicate keys detected at: {'.'.join(current_path)}. Please ensure that all keys in request are unique.") # If both are dictionaries, recursively merge them _merge_mappings(target_value, source_value, current_path, allow_same_value_merge) elif not allow_same_value_merge or target_value != source_value: # If same key has different values, that's a conflict - raise ValueError(f"Duplicate keys found: {'.'.join(current_path)}") + raise ValueError(f"Request body collision, duplicate keys detected at: {'.'.join(current_path)}. Please ensure that all keys in request are unique.") else: # No conflict, just copy the value (using deepcopy for nested structures) target[key] = copy.deepcopy(source_value) @@ -102,3 +104,37 @@ def combine_mappings( _merge_mappings(result, mapping, allow_same_value_merge=allow_same_value_merge) return result + +def _validate_multiple_request_options( + config: Config, + *request_options: Optional[RequestOption] +) -> None: + """ + Validates that a component with multiple request options does not have conflicting paths. + Uses dummy values for validation since actual values might not be available at init time. + """ + grouped_options: Dict[RequestOptionType, List[RequestOption]] = {} + for option in request_options: + if option: + grouped_options.setdefault(option.inject_into, []).append(option) + + for inject_type, options in grouped_options.items(): + if len(options) <= 1: + continue + + option_dicts: List[Optional[Union[Mapping[str, Any], str]]] = [] + for i, option in enumerate(options): + option_dict: Dict[str, Any] = {} + # Use indexed dummy values to ensure we catch conflicts + option.inject_into_request(option_dict, f"dummy_value_{i}", config) + option_dicts.append(option_dict) + + try: + combine_mappings( + option_dicts, + allow_same_value_merge=(inject_type == RequestOptionType.body_json) + ) + except ValueError as e: + print(e) + raise ValueError(f"Conflict mapping request options: {e}") from e + \ No newline at end of file From 5614a91e1d0171cc88dcebcb1e3e0ac961547f27 Mon Sep 17 00:00:00 2001 From: ChristoGrab Date: Mon, 10 Feb 2025 13:58:48 -0800 Subject: [PATCH 2/7] task: add validation to DatetimeBasedCursor --- .../incremental/datetime_based_cursor.py | 3 + unit_tests/utils/test_mapping_helpers.py | 75 +++++++++++++++++-- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/airbyte_cdk/sources/declarative/incremental/datetime_based_cursor.py b/airbyte_cdk/sources/declarative/incremental/datetime_based_cursor.py index 8ef1c89a4..9422a4450 100644 --- a/airbyte_cdk/sources/declarative/incremental/datetime_based_cursor.py +++ b/airbyte_cdk/sources/declarative/incremental/datetime_based_cursor.py @@ -21,6 +21,7 @@ ) from airbyte_cdk.sources.message import MessageRepository from airbyte_cdk.sources.types import Config, Record, StreamSlice, StreamState +from airbyte_cdk.utils.mapping_helpers import _validate_multiple_request_options @dataclass @@ -121,6 +122,8 @@ def __post_init__(self, parameters: Mapping[str, Any]) -> None: if not self.cursor_datetime_formats: self.cursor_datetime_formats = [self.datetime_format] + + _validate_multiple_request_options(self.config, self.start_time_option, self.end_time_option) def get_stream_state(self) -> StreamState: return {self.cursor_field.eval(self.config): self._cursor} if self._cursor else {} # type: ignore # cursor_field is converted to an InterpolatedString in __post_init__ diff --git a/unit_tests/utils/test_mapping_helpers.py b/unit_tests/utils/test_mapping_helpers.py index 124bf4565..56fa5e0c3 100644 --- a/unit_tests/utils/test_mapping_helpers.py +++ b/unit_tests/utils/test_mapping_helpers.py @@ -1,6 +1,6 @@ import pytest -from airbyte_cdk.utils.mapping_helpers import combine_mappings +from airbyte_cdk.utils.mapping_helpers import combine_mappings, _validate_multiple_request_options, RequestOption, RequestOptionType @pytest.mark.parametrize( @@ -46,14 +46,14 @@ def test_string_handling(test_name, mappings, expected_result, expected_error): @pytest.mark.parametrize( "test_name, mappings, expected_error", [ - ("duplicate_keys_same_value", [{"a": 1}, {"a": 1}], "Duplicate keys found"), - ("duplicate_keys_different_value", [{"a": 1}, {"a": 2}], "Duplicate keys found"), + ("duplicate_keys_same_value", [{"a": 1}, {"a": 1}], "duplicate keys detected"), + ("duplicate_keys_different_value", [{"a": 1}, {"a": 2}], "duplicate keys detected"), ( "nested_structure_not_allowed", [{"a": {"b": 1}}, {"a": {"c": 2}}], - "Duplicate keys found", + "duplicate keys detected", ), - ("any_nesting_not_allowed", [{"a": {"b": 1}}, {"a": {"d": 2}}], "Duplicate keys found"), + ("any_nesting_not_allowed", [{"a": {"b": 1}}, {"a": {"d": 2}}], "duplicate keys detected"), ], ) def test_non_body_json_requests(test_name, mappings, expected_error): @@ -96,13 +96,13 @@ def test_non_body_json_requests(test_name, mappings, expected_error): "nested_conflict", [{"a": {"b": 1}}, {"a": {"b": 2}}], None, - "Duplicate keys found", + "duplicate keys detected", ), ( "type_conflict", [{"a": 1}, {"a": {"b": 2}}], None, - "Duplicate keys found", + "duplicate keys detected", ), ], ) @@ -113,3 +113,64 @@ def test_body_json_requests(test_name, mappings, expected_result, expected_error combine_mappings(mappings, allow_same_value_merge=True) else: assert combine_mappings(mappings, allow_same_value_merge=True) == expected_result + + +@pytest.fixture +def mock_config() -> dict[str, str]: + return {"test": "config"} + +@pytest.mark.parametrize( + "test_name, option1, option2, should_raise", + [ + ( + "different_fields", + RequestOption(field_name="field1", inject_into=RequestOptionType.body_json, parameters={}), + RequestOption(field_name="field2", inject_into=RequestOptionType.body_json, parameters={}), + False, + ), + ( + "same_field_name_header", + RequestOption(field_name="field", inject_into=RequestOptionType.header, parameters={}), + RequestOption(field_name="field", inject_into=RequestOptionType.header, parameters={}), + True, + ), + ( + "different_nested_paths", + RequestOption(field_path=["data", "query1", "limit"], inject_into=RequestOptionType.body_json, parameters={}), + RequestOption(field_path=["data", "query2", "limit"], inject_into=RequestOptionType.body_json, parameters={}), + False, + ), + ( + "same_nested_paths", + RequestOption(field_path=["data", "query", "limit"], inject_into=RequestOptionType.body_json, parameters={}), + RequestOption(field_path=["data", "query", "limit"], inject_into=RequestOptionType.body_json, parameters={}), + True, + ), + ( + "different_inject_types", + RequestOption(field_name="field", inject_into=RequestOptionType.header, parameters={}), + RequestOption(field_name="field", inject_into=RequestOptionType.body_json, parameters={}), + False, + ), + ] +) +def test_request_option_validation(test_name, option1, option2, should_raise, mock_config): + """Test various combinations of request option validation""" + if should_raise: + with pytest.raises(ValueError, match="duplicate keys detected"): + _validate_multiple_request_options(mock_config, option1, option2) + else: + _validate_multiple_request_options(mock_config, option1, option2) + +@pytest.mark.parametrize( + "test_name, options", + [ + ("none_options", [None, RequestOption(field_name="field", inject_into=RequestOptionType.header, parameters={}), None]), + ("single_option", [RequestOption(field_name="field", inject_into=RequestOptionType.header, parameters={})]), + ("all_none", [None, None, None]), + ("empty_list", []), + ] +) +def test_edge_cases(test_name, options, mock_config): + """Test edge cases like None values and single options""" + _validate_multiple_request_options(mock_config, *options) \ No newline at end of file From fdfd154b10ecbdbe62a5d7fb79da39be1bb79472 Mon Sep 17 00:00:00 2001 From: ChristoGrab Date: Mon, 10 Feb 2025 14:06:04 -0800 Subject: [PATCH 3/7] chore: update method name --- .../incremental/datetime_based_cursor.py | 8 +- .../paginators/default_paginator.py | 9 ++- unit_tests/utils/test_mapping_helpers.py | 75 +++++++++++++++---- 3 files changed, 71 insertions(+), 21 deletions(-) diff --git a/airbyte_cdk/sources/declarative/incremental/datetime_based_cursor.py b/airbyte_cdk/sources/declarative/incremental/datetime_based_cursor.py index 9422a4450..cb39f56ba 100644 --- a/airbyte_cdk/sources/declarative/incremental/datetime_based_cursor.py +++ b/airbyte_cdk/sources/declarative/incremental/datetime_based_cursor.py @@ -21,7 +21,7 @@ ) from airbyte_cdk.sources.message import MessageRepository from airbyte_cdk.sources.types import Config, Record, StreamSlice, StreamState -from airbyte_cdk.utils.mapping_helpers import _validate_multiple_request_options +from airbyte_cdk.utils.mapping_helpers import _validate_component_request_option_paths @dataclass @@ -122,8 +122,10 @@ def __post_init__(self, parameters: Mapping[str, Any]) -> None: if not self.cursor_datetime_formats: self.cursor_datetime_formats = [self.datetime_format] - - _validate_multiple_request_options(self.config, self.start_time_option, self.end_time_option) + + _validate_component_request_option_paths( + self.config, self.start_time_option, self.end_time_option + ) def get_stream_state(self) -> StreamState: return {self.cursor_field.eval(self.config): self._cursor} if self._cursor else {} # type: ignore # cursor_field is converted to an InterpolatedString in __post_init__ diff --git a/airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py b/airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py index 6ffe4a0f3..8016838d9 100644 --- a/airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py +++ b/airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py @@ -23,7 +23,10 @@ ) from airbyte_cdk.sources.declarative.requesters.request_path import RequestPath from airbyte_cdk.sources.types import Config, Record, StreamSlice, StreamState -from airbyte_cdk.utils.mapping_helpers import combine_mappings, _validate_multiple_request_options +from airbyte_cdk.utils.mapping_helpers import ( + combine_mappings, + _validate_component_request_option_paths, +) @dataclass @@ -113,9 +116,9 @@ def __post_init__(self, parameters: Mapping[str, Any]) -> None: ) if isinstance(self.url_base, str): self.url_base = InterpolatedString(string=self.url_base, parameters=parameters) - + if self.page_token_option and not isinstance(self.page_token_option, RequestPath): - _validate_multiple_request_options( + _validate_component_request_option_paths( self.config, self.page_size_option, self.page_token_option, diff --git a/unit_tests/utils/test_mapping_helpers.py b/unit_tests/utils/test_mapping_helpers.py index 56fa5e0c3..b6f26efb6 100644 --- a/unit_tests/utils/test_mapping_helpers.py +++ b/unit_tests/utils/test_mapping_helpers.py @@ -1,6 +1,11 @@ import pytest -from airbyte_cdk.utils.mapping_helpers import combine_mappings, _validate_multiple_request_options, RequestOption, RequestOptionType +from airbyte_cdk.utils.mapping_helpers import ( + combine_mappings, + _validate_component_request_option_paths, + RequestOption, + RequestOptionType, +) @pytest.mark.parametrize( @@ -119,13 +124,18 @@ def test_body_json_requests(test_name, mappings, expected_result, expected_error def mock_config() -> dict[str, str]: return {"test": "config"} + @pytest.mark.parametrize( "test_name, option1, option2, should_raise", [ ( "different_fields", - RequestOption(field_name="field1", inject_into=RequestOptionType.body_json, parameters={}), - RequestOption(field_name="field2", inject_into=RequestOptionType.body_json, parameters={}), + RequestOption( + field_name="field1", inject_into=RequestOptionType.body_json, parameters={} + ), + RequestOption( + field_name="field2", inject_into=RequestOptionType.body_json, parameters={} + ), False, ), ( @@ -136,41 +146,76 @@ def mock_config() -> dict[str, str]: ), ( "different_nested_paths", - RequestOption(field_path=["data", "query1", "limit"], inject_into=RequestOptionType.body_json, parameters={}), - RequestOption(field_path=["data", "query2", "limit"], inject_into=RequestOptionType.body_json, parameters={}), + RequestOption( + field_path=["data", "query1", "limit"], + inject_into=RequestOptionType.body_json, + parameters={}, + ), + RequestOption( + field_path=["data", "query2", "limit"], + inject_into=RequestOptionType.body_json, + parameters={}, + ), False, ), ( "same_nested_paths", - RequestOption(field_path=["data", "query", "limit"], inject_into=RequestOptionType.body_json, parameters={}), - RequestOption(field_path=["data", "query", "limit"], inject_into=RequestOptionType.body_json, parameters={}), + RequestOption( + field_path=["data", "query", "limit"], + inject_into=RequestOptionType.body_json, + parameters={}, + ), + RequestOption( + field_path=["data", "query", "limit"], + inject_into=RequestOptionType.body_json, + parameters={}, + ), True, ), ( "different_inject_types", RequestOption(field_name="field", inject_into=RequestOptionType.header, parameters={}), - RequestOption(field_name="field", inject_into=RequestOptionType.body_json, parameters={}), + RequestOption( + field_name="field", inject_into=RequestOptionType.body_json, parameters={} + ), False, ), - ] + ], ) def test_request_option_validation(test_name, option1, option2, should_raise, mock_config): """Test various combinations of request option validation""" if should_raise: with pytest.raises(ValueError, match="duplicate keys detected"): - _validate_multiple_request_options(mock_config, option1, option2) + _validate_component_request_option_paths(mock_config, option1, option2) else: - _validate_multiple_request_options(mock_config, option1, option2) + _validate_component_request_option_paths(mock_config, option1, option2) + @pytest.mark.parametrize( "test_name, options", [ - ("none_options", [None, RequestOption(field_name="field", inject_into=RequestOptionType.header, parameters={}), None]), - ("single_option", [RequestOption(field_name="field", inject_into=RequestOptionType.header, parameters={})]), + ( + "none_options", + [ + None, + RequestOption( + field_name="field", inject_into=RequestOptionType.header, parameters={} + ), + None, + ], + ), + ( + "single_option", + [ + RequestOption( + field_name="field", inject_into=RequestOptionType.header, parameters={} + ) + ], + ), ("all_none", [None, None, None]), ("empty_list", []), - ] + ], ) def test_edge_cases(test_name, options, mock_config): """Test edge cases like None values and single options""" - _validate_multiple_request_options(mock_config, *options) \ No newline at end of file + _validate_component_request_option_paths(mock_config, *options) From 03c5e58d21077d429bb5b2c2905a49da17c2a381 Mon Sep 17 00:00:00 2001 From: ChristoGrab Date: Mon, 10 Feb 2025 14:18:24 -0800 Subject: [PATCH 4/7] chore: format --- airbyte_cdk/utils/mapping_helpers.py | 35 ++++++++++++++++------------ 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/airbyte_cdk/utils/mapping_helpers.py b/airbyte_cdk/utils/mapping_helpers.py index 9032e6489..bce3a849b 100644 --- a/airbyte_cdk/utils/mapping_helpers.py +++ b/airbyte_cdk/utils/mapping_helpers.py @@ -6,9 +6,13 @@ import copy from typing import Any, Dict, List, Mapping, Optional, Union -from airbyte_cdk.sources.declarative.requesters.request_option import RequestOption, RequestOptionType +from airbyte_cdk.sources.declarative.requesters.request_option import ( + RequestOption, + RequestOptionType, +) from airbyte_cdk.sources.types import Config + def _merge_mappings( target: Dict[str, Any], source: Mapping[str, Any], @@ -35,13 +39,17 @@ def _merge_mappings( if isinstance(target_value, dict) and isinstance(source_value, dict): # Only body_json supports nested_structures if not allow_same_value_merge: - raise ValueError(f"Request body collision, duplicate keys detected at: {'.'.join(current_path)}. Please ensure that all keys in request are unique.") + raise ValueError( + f"Request body collision, duplicate keys detected at key path: {'.'.join(current_path)}. Please ensure that all keys in the request are unique." + ) # If both are dictionaries, recursively merge them _merge_mappings(target_value, source_value, current_path, allow_same_value_merge) elif not allow_same_value_merge or target_value != source_value: # If same key has different values, that's a conflict - raise ValueError(f"Request body collision, duplicate keys detected at: {'.'.join(current_path)}. Please ensure that all keys in request are unique.") + raise ValueError( + f"Request body collision, duplicate keys detected at key path: {'.'.join(current_path)}. Please ensure that all keys in the request are unique." + ) else: # No conflict, just copy the value (using deepcopy for nested structures) target[key] = copy.deepcopy(source_value) @@ -105,9 +113,9 @@ def combine_mappings( return result -def _validate_multiple_request_options( - config: Config, - *request_options: Optional[RequestOption] + +def _validate_component_request_option_paths( + config: Config, *request_options: Optional[RequestOption] ) -> None: """ Validates that a component with multiple request options does not have conflicting paths. @@ -117,24 +125,21 @@ def _validate_multiple_request_options( for option in request_options: if option: grouped_options.setdefault(option.inject_into, []).append(option) - + for inject_type, options in grouped_options.items(): if len(options) <= 1: continue - + option_dicts: List[Optional[Union[Mapping[str, Any], str]]] = [] for i, option in enumerate(options): option_dict: Dict[str, Any] = {} # Use indexed dummy values to ensure we catch conflicts option.inject_into_request(option_dict, f"dummy_value_{i}", config) option_dicts.append(option_dict) - + try: combine_mappings( - option_dicts, - allow_same_value_merge=(inject_type == RequestOptionType.body_json) + option_dicts, allow_same_value_merge=(inject_type == RequestOptionType.body_json) ) - except ValueError as e: - print(e) - raise ValueError(f"Conflict mapping request options: {e}") from e - \ No newline at end of file + except ValueError as error: + raise ValueError(error) From e00aed2bcdf846b0080eb2ee9fbdf92457726f2f Mon Sep 17 00:00:00 2001 From: ChristoGrab Date: Mon, 10 Feb 2025 14:22:23 -0800 Subject: [PATCH 5/7] chore: lint --- .../declarative/requesters/paginators/default_paginator.py | 2 +- unit_tests/utils/test_mapping_helpers.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py b/airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py index 8016838d9..91b9ba031 100644 --- a/airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py +++ b/airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py @@ -24,8 +24,8 @@ from airbyte_cdk.sources.declarative.requesters.request_path import RequestPath from airbyte_cdk.sources.types import Config, Record, StreamSlice, StreamState from airbyte_cdk.utils.mapping_helpers import ( - combine_mappings, _validate_component_request_option_paths, + combine_mappings, ) diff --git a/unit_tests/utils/test_mapping_helpers.py b/unit_tests/utils/test_mapping_helpers.py index b6f26efb6..f0370ba86 100644 --- a/unit_tests/utils/test_mapping_helpers.py +++ b/unit_tests/utils/test_mapping_helpers.py @@ -1,10 +1,10 @@ import pytest from airbyte_cdk.utils.mapping_helpers import ( - combine_mappings, - _validate_component_request_option_paths, RequestOption, RequestOptionType, + _validate_component_request_option_paths, + combine_mappings, ) From 4c13d077829e8378faceca3b2c3d83bd7383ef49 Mon Sep 17 00:00:00 2001 From: ChristoGrab Date: Wed, 12 Feb 2025 14:03:34 -0800 Subject: [PATCH 6/7] chore: remove unused import --- .../declarative/requesters/paginators/default_paginator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py b/airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py index 91b9ba031..bd640ad19 100644 --- a/airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py +++ b/airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py @@ -25,7 +25,6 @@ from airbyte_cdk.sources.types import Config, Record, StreamSlice, StreamState from airbyte_cdk.utils.mapping_helpers import ( _validate_component_request_option_paths, - combine_mappings, ) From 0ef0efc6cf0a07a357dc54857a7f367a7937201e Mon Sep 17 00:00:00 2001 From: ChristoGrab Date: Fri, 14 Feb 2025 10:45:54 -0800 Subject: [PATCH 7/7] chore: add paginator test --- .../paginators/test_default_paginator.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/unit_tests/sources/declarative/requesters/paginators/test_default_paginator.py b/unit_tests/sources/declarative/requesters/paginators/test_default_paginator.py index 57b6d9d34..944a0eda9 100644 --- a/unit_tests/sources/declarative/requesters/paginators/test_default_paginator.py +++ b/unit_tests/sources/declarative/requesters/paginators/test_default_paginator.py @@ -447,3 +447,29 @@ def test_paginator_with_page_option_no_page_size(): parameters={}, ), ) + + +def test_request_option_mapping_validator(): + pagination_strategy = PageIncrement( + config={}, page_size=1, start_from_page=0, parameters={}, inject_on_first_request=True + ) + + with pytest.raises(ValueError): + ( + DefaultPaginator( + page_size_option=RequestOption( + field_path=["variables", "limit"], + inject_into=RequestOptionType.body_json, + parameters={}, + ), + page_token_option=RequestOption( + field_path=["variables", "limit"], + inject_into=RequestOptionType.body_json, + parameters={}, + ), + pagination_strategy=pagination_strategy, + config=MagicMock(), + url_base=MagicMock(), + parameters={}, + ), + )