From 7a3de31527ca2f544e724d9d6b9772041ca05f53 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Fri, 26 Jun 2026 14:52:04 +0200 Subject: [PATCH 1/4] feat(core): refactor basemodels moving them to core --- sinch/core/models/internal/__init__.py | 0 .../core/models/internal/base_model_config.py | 115 ++++++++ .../internal/base_model_config_originals.py | 279 ++++++++++++++++++ .../internal/base/base_model_configuration.py | 33 +-- .../internal/base/base_model_configuration.py | 49 +-- .../models/v1/errors/not_found_error.py | 6 +- .../internal/base/base_model_configuration.py | 117 +------- .../internal/list_active_numbers_request.py | 9 +- .../internal/base/base_model_configuration.py | 49 +-- .../models/internal/test_base_model_config.py | 236 +++++++++++++++ tests/unit/core/models/test_utils.py | 44 +++ .../internal/base/test_base_model_requests.py | 56 ---- .../internal/base/test_base_model_response.py | 16 - .../base/test_base_model_configuration.py | 59 ---- 14 files changed, 701 insertions(+), 367 deletions(-) create mode 100644 sinch/core/models/internal/__init__.py create mode 100644 sinch/core/models/internal/base_model_config.py create mode 100644 sinch/core/models/internal/base_model_config_originals.py create mode 100644 tests/unit/core/models/internal/test_base_model_config.py create mode 100644 tests/unit/core/models/test_utils.py delete mode 100644 tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py delete mode 100644 tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py delete mode 100644 tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py diff --git a/sinch/core/models/internal/__init__.py b/sinch/core/models/internal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sinch/core/models/internal/base_model_config.py b/sinch/core/models/internal/base_model_config.py new file mode 100644 index 00000000..5be987c1 --- /dev/null +++ b/sinch/core/models/internal/base_model_config.py @@ -0,0 +1,115 @@ +import re +from typing import Any + +from pydantic import BaseModel, ConfigDict, SerializationInfo, model_serializer +from pydantic.functional_serializers import SerializerFunctionWrapHandler + + +def _to_camel_case(snake_str: str) -> str: + """Convert ``snake_case`` to ``camelCase`` preserving consecutive underscores. + + :param snake_str: The snake_case input string. + :returns: The camelCase form, or the input unchanged when it contains no + underscore. + """ + ## Differs from pydantic.alias_generators.to_camel in two ways: + ## - Empty components produced by "__" are kept as "_" instead of being + ## collapsed, so "foo__bar" becomes "foo_Bar" (pydantic returns "fooBar"). + ## - The first component is lowercased verbatim, so "PHONE_NUMBER" becomes + ## "phoneNumber" (pydantic returns "PHONENumber"). + if not snake_str or "_" not in snake_str: + return snake_str + components = snake_str.split("_") + return components[0].lower() + "".join( + (x.capitalize() if x else "_") for x in components[1:] + ) + + +def _to_snake_case(camel_str: str) -> str: + """Convert ``camelCase`` to ``snake_case``. + + :param camel_str: The camelCase input string. + :returns: The snake_case form. + """ + ## Differs from pydantic.alias_generators.to_snake in acronym handling: + ## this implementation inserts an underscore before every uppercase letter, + ## so "XMLDocURL" becomes "x_m_l_doc_u_r_l" (pydantic is acronym-aware and + ## yields "xml_doc_url"). Kept for backwards compatibility with the previous + ## per-domain implementations. + return re.sub(r"(? Any: + """Recursively camelize dict keys, walking into nested dicts and lists. + + Non-container values are returned unchanged. + + :param value: An arbitrary JSON-like value. + :returns: The same structure with every ``snake_case`` dict key converted + to ``camelCase``. + """ + if isinstance(value, dict): + return {_to_camel_case(k): _camelize_keys(v) for k, v in value.items()} + if isinstance(value, list): + return [_camelize_keys(item) for item in value] + return value + + +class _SnakifyExtrasOnInit: + """Normalize ``__pydantic_extra__`` keys to ``snake_case`` at validation time.""" + + def model_post_init(self, __context: Any) -> None: + extra = self.__pydantic_extra__ + if extra: + self.__pydantic_extra__ = {_to_snake_case(k): v for k, v in extra.items()} + + +class _CamelizeKeysOnDump: + """Recursively rewrite every dict key in the serialized output to + ``camelCase`` when ``by_alias=True``. + + Implemented as a Pydantic v2 wrap serializer so the model integrates + with both :meth:`~pydantic.BaseModel.model_dump` and + :meth:`~pydantic.BaseModel.model_dump_json` without overriding either. + Preserves the previous Numbers ``model_dump`` override byte-for-byte + for the cases endpoints exercise (``by_alias=True``). + """ + + @model_serializer(mode="wrap") + def _serialize_with_camel_extras( + self, handler: SerializerFunctionWrapHandler, info: SerializationInfo + ) -> dict: + data = handler(self) + if info.by_alias: + data = _camelize_keys(data) + return data + + +class BaseConfigModel(BaseModel): + """Permissive base: ``populate_by_name=True, extra="allow"`` and nothing else. + + Use for models that do not need automatic case normalization. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +class SnakeCaseExtrasModel(_SnakifyExtrasOnInit, BaseConfigModel): + """Permissive base + ``camelCase``→``snake_case`` normalization of + ``__pydantic_extra__`` at validation time. + + Use for response models (and the Conversation shared base) where the + consumer-facing attribute style stays ``snake_case`` regardless of the + wire format. + """ + + +class CamelCaseDumpModel(_CamelizeKeysOnDump, BaseConfigModel): + """Permissive base + recursive ``snake_case``→``camelCase`` rewrite of + the serialized output when ``by_alias=True``. + + Use for ``camelCase``-API requests that need extras (and free-form + dict values nested under known fields) emitted as ``camelCase`` on the + wire. This is the role Numbers' request base played previously. + """ + diff --git a/sinch/core/models/internal/base_model_config_originals.py b/sinch/core/models/internal/base_model_config_originals.py new file mode 100644 index 00000000..a821098c --- /dev/null +++ b/sinch/core/models/internal/base_model_config_originals.py @@ -0,0 +1,279 @@ + + +## FOR SMS , the API is snake_case both the request and the response allows extra fields +## for the request no conversion is done +## for the response unknown fields are converted from camelCase to snake_case +## No alias needed for the request or the response + + +import re +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class BaseModelConfigurationRequest(BaseModel): + """ + A base model that allows extra fields and converts snake_case to camelCase. + """ + + model_config = ConfigDict( + # Allows using both alias (camelCase) and field name (snake_case) + populate_by_name=True, + # Allows extra values in input + extra="allow", + ) + + +class BaseModelConfigurationResponse(BaseModel): + """ + A base model that allows extra fields and converts camelCase to snake_case + """ + + @staticmethod + def _to_snake_case(camel_str: str) -> str: + """Helper to convert camelCase string to snake_case.""" + return re.sub(r"(? None: + """Converts unknown fields from camelCase to snake_case.""" + if self.__pydantic_extra__: + converted_extra = { + self._to_snake_case(key): value + for key, value in self.__pydantic_extra__.items() + } + self.__pydantic_extra__.clear() + self.__pydantic_extra__.update(converted_extra) + + + +## Numbers - the API is camelCase for both the request and the response +## Uses alias for the request and the response +## Extra fields are converted from snake_case to camelCase for the request and from camelCase to snake_case for the response + + +import re +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class BaseModelConfigurationRequest(BaseModel): + """ + A base model that allows extra fields and converts snake_case to camelCase. + """ + + @staticmethod + def _to_camel_case(snake_str: str) -> str: + """Converts snake_case to camelCase while preserving multiple underscores.""" + if not snake_str or "_" not in snake_str: + return snake_str + components = snake_str.split("_") + return components[0].lower() + "".join( + (x.capitalize() if x else "_") for x in components[1:] + ) + + @classmethod + def _convert_dict_keys(cls, obj): + """Recursively convert dictionary keys to camelCase.""" + if isinstance(obj, dict): + new_dict = {} + for key, value in obj.items(): + # Convert dict key to camelCase + camel_key = cls._to_camel_case(key) + # Recurse on the value + new_dict[camel_key] = cls._convert_dict_keys(value) + return new_dict + elif isinstance(obj, list): + # Recurse through any list elements (they might be dicts too) + return [cls._convert_dict_keys(item) for item in obj] + else: + return obj + + model_config = ConfigDict( + # Allows using both alias (camelCase) and field name (snake_case) + populate_by_name=True, + # Allows extra values in input + extra="allow", + ) + + def _convert_dict_to_camel_case(self, data): + if isinstance(data, dict): + return { + self._to_camel_case(k): self._convert_dict_to_camel_case(v) + for k, v in data.items() + } + elif isinstance(data, list): + return [self._convert_dict_to_camel_case(i) for i in data] + return data + + def model_dump(self, **kwargs) -> dict: + """Converts extra fields from snake_case to camelCase when dumping the model in endpoint.""" + # Get the standard model dump. + data = super().model_dump(**kwargs) + if not kwargs or kwargs["by_alias"]: + data = self._convert_dict_to_camel_case(data) + + # Get extra fields + extra_data = self.__pydantic_extra__ or {} + + # Merge known + unknown into one dictionary first + combined = {**data, **extra_data} + + final_dict = {} + + for key, value in combined.items(): + if key in extra_data: + # This is an unknown field to be converted + new_key = self._to_camel_case(key) + else: + # Known field - keep the top-level key as given + new_key = key + + # Recursively convert any nested dict keys + converted_value = self._convert_dict_keys(value) + + # Add to final dictionary + final_dict[new_key] = converted_value + + return final_dict + + +class BaseModelConfigurationResponse(BaseModel): + """ + A base model that allows extra fields and converts camelCase to snake_case + """ + + @staticmethod + def _to_snake_case(camel_str: str) -> str: + """Helper to convert camelCase string to snake_case.""" + return re.sub(r"(? None: + """Converts unknown fields from camelCase to snake_case.""" + if self.__pydantic_extra__: + converted_extra = { + self._to_snake_case(key): value + for key, value in self.__pydantic_extra__.items() + } + self.__pydantic_extra__.clear() + self.__pydantic_extra__.update(converted_extra) + + +## NUMBER LOOKUP - the API is camelCase for both the request and the response +## Uses alias for the request and the response +## Extra fields are converted from camelCase to snake case for the response +import re +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class BaseModelConfigurationRequest(BaseModel): + """ + A base model that allows extra fields and converts snake_case to camelCase. + """ + + model_config = ConfigDict( + # Allows using both alias (camelCase) and field name (snake_case) + populate_by_name=True, + # Allows extra values in input + extra="allow", + ) + + +class BaseModelConfigurationResponse(BaseModel): + """ + A base model that allows extra fields and converts camelCase to snake_case + """ + + @staticmethod + def _to_snake_case(camel_str: str) -> str: + """Helper to convert camelCase string to snake_case.""" + return re.sub(r"(? None: + """Converts unknown fields from camelCase to snake_case.""" + if self.__pydantic_extra__: + converted_extra = { + self._to_snake_case(key): value + for key, value in self.__pydantic_extra__.items() + } + self.__pydantic_extra__.clear() + self.__pydantic_extra__.update(converted_extra) + + +## CONVERSATION - the API is snake_case for both the request and the response +## Extra fields are converted from camelCase to snake_case for the response and the request + +import re +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class BaseModelConfiguration(BaseModel): + """ + Base model for all conversation message models. + Both request and response use snake_case in the Conversation API. + """ + + model_config = ConfigDict( + # Allows using both alias (camelCase) and field name (snake_case) + populate_by_name=True, + # Allows extra values in input + extra="allow", + ) + + @staticmethod + def _to_snake_case(camel_str: str) -> str: + """Helper to convert camelCase string to snake_case.""" + return re.sub(r"(? None: + """Converts unknown fields from camelCase to snake_case.""" + if self.__pydantic_extra__: + converted_extra = { + self._to_snake_case(key): value + for key, value in self.__pydantic_extra__.items() + } + self.__pydantic_extra__.clear() + self.__pydantic_extra__.update(converted_extra) + + +## AUTHENTICATION + +import json +from dataclasses import asdict, dataclass + + +@dataclass +class SinchBaseModel: + def as_dict(self): + return asdict(self) + + def as_json(self): + return json.dumps(self.as_dict()) + + +@dataclass +class SinchRequestBaseModel(SinchBaseModel): + def as_dict(self): + return {k: v for k, v in asdict(self).items() if v is not None} diff --git a/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py b/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py index 200cf35e..0e174152 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py +++ b/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py @@ -1,32 +1,3 @@ -import re -from typing import Any -from pydantic import BaseModel, ConfigDict +from sinch.core.models.internal.base_model_config import SnakeCaseExtrasModel - -class BaseModelConfiguration(BaseModel): - """ - Base model for all conversation message models. - Both request and response use snake_case in the Conversation API. - """ - - model_config = ConfigDict( - # Allows using both alias (camelCase) and field name (snake_case) - populate_by_name=True, - # Allows extra values in input - extra="allow", - ) - - @staticmethod - def _to_snake_case(camel_str: str) -> str: - """Helper to convert camelCase string to snake_case.""" - return re.sub(r"(? None: - """Converts unknown fields from camelCase to snake_case.""" - if self.__pydantic_extra__: - converted_extra = { - self._to_snake_case(key): value - for key, value in self.__pydantic_extra__.items() - } - self.__pydantic_extra__.clear() - self.__pydantic_extra__.update(converted_extra) +BaseModelConfiguration = SnakeCaseExtrasModel diff --git a/sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py b/sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py index 204ea49d..a3cb8354 100644 --- a/sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py +++ b/sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py @@ -1,44 +1,7 @@ -import re -from typing import Any -from pydantic import BaseModel, ConfigDict +from sinch.core.models.internal.base_model_config import ( + BaseConfigModel, + SnakeCaseExtrasModel, +) - -class BaseModelConfigurationRequest(BaseModel): - """ - A base model that allows extra fields and converts snake_case to camelCase. - """ - - model_config = ConfigDict( - # Allows using both alias (camelCase) and field name (snake_case) - populate_by_name=True, - # Allows extra values in input - extra="allow", - ) - - -class BaseModelConfigurationResponse(BaseModel): - """ - A base model that allows extra fields and converts camelCase to snake_case - """ - - @staticmethod - def _to_snake_case(camel_str: str) -> str: - """Helper to convert camelCase string to snake_case.""" - return re.sub(r"(? None: - """Converts unknown fields from camelCase to snake_case.""" - if self.__pydantic_extra__: - converted_extra = { - self._to_snake_case(key): value - for key, value in self.__pydantic_extra__.items() - } - self.__pydantic_extra__.clear() - self.__pydantic_extra__.update(converted_extra) +BaseModelConfigurationRequest = BaseConfigModel +BaseModelConfigurationResponse = SnakeCaseExtrasModel diff --git a/sinch/domains/numbers/models/v1/errors/not_found_error.py b/sinch/domains/numbers/models/v1/errors/not_found_error.py index da52f032..5ca2ce1d 100644 --- a/sinch/domains/numbers/models/v1/errors/not_found_error.py +++ b/sinch/domains/numbers/models/v1/errors/not_found_error.py @@ -1,5 +1,5 @@ from typing import Optional -from pydantic import ConfigDict, conlist, Field, StrictInt, StrictStr +from pydantic import conlist, Field, StrictInt, StrictStr from sinch.domains.numbers.models.v1.internal.base import ( BaseModelConfigurationResponse, ) @@ -14,7 +14,3 @@ class NotFoundError(BaseModelConfigurationResponse): status: Optional[StrictStr] = Field(default=None) details: Optional[conlist(NotFoundErrorDetails)] = Field(default=None) - model_config = ConfigDict( - populate_by_name=True, - alias_generator=BaseModelConfigurationResponse._to_snake_case, - ) diff --git a/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py b/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py index 8e9d179b..079affda 100644 --- a/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py +++ b/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py @@ -1,112 +1,7 @@ -import re -from typing import Any -from pydantic import BaseModel, ConfigDict +from sinch.core.models.internal.base_model_config import ( + CamelCaseDumpModel, + SnakeCaseExtrasModel, +) - -class BaseModelConfigurationRequest(BaseModel): - """ - A base model that allows extra fields and converts snake_case to camelCase. - """ - - @staticmethod - def _to_camel_case(snake_str: str) -> str: - """Converts snake_case to camelCase while preserving multiple underscores.""" - if not snake_str or "_" not in snake_str: - return snake_str - components = snake_str.split("_") - return components[0].lower() + "".join( - (x.capitalize() if x else "_") for x in components[1:] - ) - - @classmethod - def _convert_dict_keys(cls, obj): - """Recursively convert dictionary keys to camelCase.""" - if isinstance(obj, dict): - new_dict = {} - for key, value in obj.items(): - # Convert dict key to camelCase - camel_key = cls._to_camel_case(key) - # Recurse on the value - new_dict[camel_key] = cls._convert_dict_keys(value) - return new_dict - elif isinstance(obj, list): - # Recurse through any list elements (they might be dicts too) - return [cls._convert_dict_keys(item) for item in obj] - else: - return obj - - model_config = ConfigDict( - # Allows using both alias (camelCase) and field name (snake_case) - populate_by_name=True, - # Allows extra values in input - extra="allow", - ) - - def _convert_dict_to_camel_case(self, data): - if isinstance(data, dict): - return { - self._to_camel_case(k): self._convert_dict_to_camel_case(v) - for k, v in data.items() - } - elif isinstance(data, list): - return [self._convert_dict_to_camel_case(i) for i in data] - return data - - def model_dump(self, **kwargs) -> dict: - """Converts extra fields from snake_case to camelCase when dumping the model in endpoint.""" - # Get the standard model dump. - data = super().model_dump(**kwargs) - if not kwargs or kwargs["by_alias"]: - data = self._convert_dict_to_camel_case(data) - - # Get extra fields - extra_data = self.__pydantic_extra__ or {} - - # Merge known + unknown into one dictionary first - combined = {**data, **extra_data} - - final_dict = {} - - for key, value in combined.items(): - if key in extra_data: - # This is an unknown field to be converted - new_key = self._to_camel_case(key) - else: - # Known field - keep the top-level key as given - new_key = key - - # Recursively convert any nested dict keys - converted_value = self._convert_dict_keys(value) - - # Add to final dictionary - final_dict[new_key] = converted_value - - return final_dict - - -class BaseModelConfigurationResponse(BaseModel): - """ - A base model that allows extra fields and converts camelCase to snake_case - """ - - @staticmethod - def _to_snake_case(camel_str: str) -> str: - """Helper to convert camelCase string to snake_case.""" - return re.sub(r"(? None: - """Converts unknown fields from camelCase to snake_case.""" - if self.__pydantic_extra__: - converted_extra = { - self._to_snake_case(key): value - for key, value in self.__pydantic_extra__.items() - } - self.__pydantic_extra__.clear() - self.__pydantic_extra__.update(converted_extra) +BaseModelConfigurationRequest = CamelCaseDumpModel +BaseModelConfigurationResponse = SnakeCaseExtrasModel diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py index f832beb5..4b877009 100644 --- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py @@ -1,13 +1,16 @@ from typing import Optional -from pydantic import Field, StrictInt, StrictStr, field_validator, conlist + +from pydantic import Field, StrictInt, StrictStr, conlist, field_validator + +from sinch.core.models.internal.base_model_config import _to_camel_case from sinch.domains.numbers.models.v1.internal.base import ( BaseModelConfigurationRequest, ) from sinch.domains.numbers.models.v1.types import ( CapabilityType, - OrderByType, NumberSearchPatternType, NumberType, + OrderByType, ) @@ -33,5 +36,5 @@ class ListActiveNumbersRequest(BaseModelConfigurationRequest): @classmethod def convert_order_by(cls, value): if isinstance(value, str): - return cls._to_camel_case(value) + return _to_camel_case(value) return value diff --git a/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py b/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py index 204ea49d..5fd1eaef 100644 --- a/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py +++ b/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py @@ -1,44 +1,7 @@ -import re -from typing import Any -from pydantic import BaseModel, ConfigDict +from sinch.core.models.internal.base_model_config import ( + BaseConfigModel, + SnakeCaseExtrasModel, +) - -class BaseModelConfigurationRequest(BaseModel): - """ - A base model that allows extra fields and converts snake_case to camelCase. - """ - - model_config = ConfigDict( - # Allows using both alias (camelCase) and field name (snake_case) - populate_by_name=True, - # Allows extra values in input - extra="allow", - ) - - -class BaseModelConfigurationResponse(BaseModel): - """ - A base model that allows extra fields and converts camelCase to snake_case - """ - - @staticmethod - def _to_snake_case(camel_str: str) -> str: - """Helper to convert camelCase string to snake_case.""" - return re.sub(r"(? None: - """Converts unknown fields from camelCase to snake_case.""" - if self.__pydantic_extra__: - converted_extra = { - self._to_snake_case(key): value - for key, value in self.__pydantic_extra__.items() - } - self.__pydantic_extra__.clear() - self.__pydantic_extra__.update(converted_extra) +BaseModelConfigurationRequest = BaseConfigModel +BaseModelConfigurationResponse = SnakeCaseExtrasModel \ No newline at end of file diff --git a/tests/unit/core/models/internal/test_base_model_config.py b/tests/unit/core/models/internal/test_base_model_config.py new file mode 100644 index 00000000..eff3968f --- /dev/null +++ b/tests/unit/core/models/internal/test_base_model_config.py @@ -0,0 +1,236 @@ +"""Tests for the core base model configuration. + +Covers the module-level case helpers, the private mixins and the three +public model classes (BaseConfigModel, SnakeCaseExtrasModel, +CamelCaseDumpModel) including composition behavior. +""" + +import json +from typing import Any, Dict, Optional + +import pytest +from pydantic import Field + +from sinch.core.models.internal.base_model_config import ( + BaseConfigModel, + CamelCaseDumpModel, + SnakeCaseExtrasModel, + _camelize_keys, + _to_camel_case, + _to_snake_case, +) + + +# --------------------------------------------------------------------------- +# Module-level helpers +# --------------------------------------------------------------------------- + + +class TestToCamelCase: + @pytest.mark.parametrize( + "snake, camel", + [ + ("foo_bar", "fooBar"), + ("hello_world", "helloWorld"), + ("this_is_a_test", "thisIsATest"), + ("PHONE_NUMBER", "phoneNumber"), + ("appId", "appId"), + ], + ) + def test_standard_cases(self, snake, camel): + assert _to_camel_case(snake) == camel + + @pytest.mark.parametrize( + "snake, camel", + [ + # Documented deviation from pydantic.alias_generators.to_camel: + # consecutive underscores are preserved rather than collapsed. + ("foo__bar", "foo_Bar"), + ("foo___bar", "foo__Bar"), + ("trailing_", "trailing_"), + ], + ) + def test_edge_cases_preserve_consecutive_underscores(self, snake, camel): + assert _to_camel_case(snake) == camel + + def test_empty_string(self): + assert _to_camel_case("") == "" + + @pytest.mark.parametrize("word", ["word", "single"]) + def test_single_word_returns_unchanged(self, word): + assert _to_camel_case(word) == word + + +class TestToSnakeCase: + @pytest.mark.parametrize( + "camel, snake", + [ + ("camelCase", "camel_case"), + ("fooBar", "foo_bar"), + ("simpleField", "simple_field"), + ("already_snake", "already_snake"), + ("lowercase", "lowercase"), + ], + ) + def test_standard_cases(self, camel, snake): + assert _to_snake_case(camel) == snake + + +class TestCamelizeKeys: + def test_flat_dict(self): + assert _camelize_keys({"foo_bar": 1, "baz_qux": 2}) == { + "fooBar": 1, + "bazQux": 2, + } + + def test_nested_dict(self): + assert _camelize_keys({"outer_field": {"inner_field": 42}}) == { + "outerField": {"innerField": 42} + } + + def test_list_of_dicts(self): + assert _camelize_keys([{"foo_bar": 1}, {"baz_qux": 2}]) == [ + {"fooBar": 1}, + {"bazQux": 2}, + ] + + def test_scalars_unchanged(self): + assert _camelize_keys(42) == 42 + assert _camelize_keys("foo_bar") == "foo_bar" + assert _camelize_keys(None) is None + + +# --------------------------------------------------------------------------- +# BaseConfigModel — permissive base, no normalization +# --------------------------------------------------------------------------- + + +class TestBaseConfigModel: + def test_extras_pass_through_unchanged_in_either_case(self): + model = BaseConfigModel(extraField="x", another_extra="y") + assert getattr(model, "extraField") == "x" + assert model.another_extra == "y" + + def test_dump_emits_extras_unchanged(self): + model = BaseConfigModel(camelKey="a", snake_key="b") + assert model.model_dump(by_alias=True) == { + "camelKey": "a", + "snake_key": "b", + } + + +# --------------------------------------------------------------------------- +# SnakeCaseExtrasModel — normalize extras on validation +# --------------------------------------------------------------------------- + + +class TestSnakeCaseExtrasModel: + def test_extras_normalized_camel_to_snake_on_init(self): + model = SnakeCaseExtrasModel(extraField="x", anotherCamel=42) + assert model.extra_field == "x" + assert model.another_camel == 42 + + def test_snake_extras_are_idempotent(self): + model = SnakeCaseExtrasModel(already_snake="value") + assert model.already_snake == "value" + + def test_dump_emits_normalized_snake_keys(self): + model = SnakeCaseExtrasModel(extraField="x") + assert model.model_dump(by_alias=True) == {"extra_field": "x"} + + +# --------------------------------------------------------------------------- +# CamelCaseDumpModel — recursive camelize on dump (by_alias only) +# --------------------------------------------------------------------------- + + +class TestCamelCaseDumpModel: + def test_extras_emit_as_camel_with_by_alias_true(self): + model = CamelCaseDumpModel(snake_extra="value", another_snake=42) + assert model.model_dump(by_alias=True) == { + "snakeExtra": "value", + "anotherSnake": 42, + } + + def test_extras_kept_as_snake_when_by_alias_false(self): + # Mirrors the previous Numbers override: conversion is gated on by_alias. + model = CamelCaseDumpModel(snake_extra="value") + assert model.model_dump(by_alias=False) == {"snake_extra": "value"} + + def test_nested_dict_keys_camelized_recursively(self): + class Outer(CamelCaseDumpModel): + sms_configuration: Optional[Dict[str, Any]] = Field( + default=None, alias="smsConfiguration" + ) + voice_configuration: Optional[Dict[str, Any]] = Field( + default=None, alias="voiceConfiguration" + ) + + model = Outer( + sms_configuration={"service_plan_id": "X"}, + voice_configuration={"appId": "Y", "type": "RTC"}, + ) + assert model.model_dump(by_alias=True) == { + "smsConfiguration": {"servicePlanId": "X"}, + "voiceConfiguration": {"appId": "Y", "type": "RTC"}, + } + + def test_model_dump_json_also_camelizes(self): + # Improvement over the previous model_dump override: the wrap + # serializer applies to JSON serialization too. + model = CamelCaseDumpModel(snake_extra="value") + assert json.loads(model.model_dump_json(by_alias=True)) == { + "snakeExtra": "value", + } + + +# --------------------------------------------------------------------------- +# Composition — each model handles its own extras independently +# --------------------------------------------------------------------------- + + +class TestComposition: + def test_nested_snake_extras_models_normalize_at_every_level(self): + class Inner(SnakeCaseExtrasModel): + foo_bar: int + + class Outer(SnakeCaseExtrasModel): + inner: Inner + name: int + + outer = Outer( + inner={"foo_bar": 12, "extraField": 123}, + name=112, + extraAtRoot="extra", + ) + assert outer.inner.foo_bar == 12 + assert outer.inner.extra_field == 123 + assert outer.extra_at_root == "extra" + + def test_snake_extras_inner_in_base_config_outer_keeps_outer_extras_raw(self): + # Reproduces the historical SMS pattern: a Response-derived inner + # model used inside a permissive outer model. The inner normalizes + # its extras, the outer forwards its own as-provided. + class Common(SnakeCaseExtrasModel): + foo_bar: int + + class Outer(BaseConfigModel): + common: Common + name: int + + outer = Outer( + common={"foo_bar": 12, "extraField": 123}, + name=112, + extraAtRoot="extra", + ) + assert outer.common.extra_field == 123 + assert getattr(outer, "extraAtRoot") == "extra" + + def test_camel_dump_outer_camelizes_nested_free_form_dict_keys(self): + class Outer(CamelCaseDumpModel): + payload: Dict[str, Any] + + outer = Outer(payload={"user_id": "abc", "nested_obj": {"item_id": 1}}) + assert outer.model_dump(by_alias=True) == { + "payload": {"userId": "abc", "nestedObj": {"itemId": 1}} + } diff --git a/tests/unit/core/models/test_utils.py b/tests/unit/core/models/test_utils.py new file mode 100644 index 00000000..a5456076 --- /dev/null +++ b/tests/unit/core/models/test_utils.py @@ -0,0 +1,44 @@ +"""Tests for sinch.core.models.utils helpers.""" + +from typing import List, Optional + +from pydantic import BaseModel + +from sinch.core.models.utils import model_dump_for_query_params + + +class TestModelDumpForQueryParams: + def test_simple_fields_returned_as_is(self): + class M(BaseModel): + batch_id: str + status: Optional[str] = None + + result = model_dump_for_query_params( + M(batch_id="01FC66621XXXXX119Z8PMV1QPQ", status="delivered") + ) + assert result["batch_id"] == "01FC66621XXXXX119Z8PMV1QPQ" + assert result["status"] == "delivered" + + def test_lists_converted_to_comma_separated_strings(self): + class M(BaseModel): + status: Optional[List[str]] = None + code: Optional[List[int]] = None + + result = model_dump_for_query_params( + M(status=["Delivered", "Failed"], code=[15, 0]) + ) + assert result["status"] == "Delivered,Failed" + assert result["code"] == "15,0" + + def test_empty_values_filtered_out(self): + class M(BaseModel): + batch_id: str + status: str = "" + code: List[int] = [] + + result = model_dump_for_query_params( + M(batch_id="01FC66621XXXXX119Z8PMV1QPQ", status="", code=[]) + ) + assert result["batch_id"] == "01FC66621XXXXX119Z8PMV1QPQ" + assert "status" not in result + assert "code" not in result diff --git a/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py b/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py deleted file mode 100644 index c6cf1fca..00000000 --- a/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py +++ /dev/null @@ -1,56 +0,0 @@ -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest - - -def test_to_camel_case_expects_parsed_standard_cases(): - """ - Test standard snake_case to camelCase conversion. - """ - assert BaseModelConfigurationRequest._to_camel_case("foo_bar") == "fooBar" - assert BaseModelConfigurationRequest._to_camel_case("hello_world") == "helloWorld" - assert BaseModelConfigurationRequest._to_camel_case("this_is_a_test") == "thisIsATest" - assert BaseModelConfigurationRequest._to_camel_case("PHONE_NUMBER") == "phoneNumber" - assert BaseModelConfigurationRequest._to_camel_case("appId") == "appId" - - -def test_to_camel_case_expects_parsed_edge_cases(): - """ - Test edge cases like leading/trailing underscores and multiple underscores. - """ - assert BaseModelConfigurationRequest._to_camel_case("foo__bar") == "foo_Bar" - assert BaseModelConfigurationRequest._to_camel_case("foo___bar") == "foo__Bar" - assert BaseModelConfigurationRequest._to_camel_case("trailing_") == "trailing_" - - -def test_to_camel_case_expects_empty_string(): - """ - Test empty string case. - """ - assert BaseModelConfigurationRequest._to_camel_case("") == "" - - -def test_to_camel_case_expects_single_word(): - """ - Test single-word cases. - """ - assert BaseModelConfigurationRequest._to_camel_case("word") == "word" - assert BaseModelConfigurationRequest._to_camel_case("single") == "single" - - -def test_dict_expects_camel_case_input(): - """ - Test that the model correctly handles camelCase input. - """ - data = { - "sms_configuration": {"service_plan_id": "YOUR_SMS_servicePlanId"}, - "voice_configuration": { - "appId": "YOUR_voice_appID", - "type": "RTC" - } - } - request = BaseModelConfigurationRequest(**data) - response = request.model_dump() - - assert response == { - 'smsConfiguration': {'servicePlanId': 'YOUR_SMS_servicePlanId'}, - 'voiceConfiguration': {'appId': 'YOUR_voice_appID', 'type': 'RTC'} - } diff --git a/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py b/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py deleted file mode 100644 index 2b2767cf..00000000 --- a/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py +++ /dev/null @@ -1,16 +0,0 @@ -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse - - -def test_base_model_response_expects_unrecognized_fields_snake_case(): - """ - Expects unrecognized fields to be dynamically added as snake_case attributes. - """ - data = { - "unexpectedField": "unexpectedValue", - "anotherExtraField": 42, - } - response = BaseModelConfigurationResponse(**data) - - # Assert unrecognized fields are dynamically added - assert response.unexpected_field == "unexpectedValue" - assert response.another_extra_field == 42 diff --git a/tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py b/tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py deleted file mode 100644 index 2ead9c16..00000000 --- a/tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py +++ /dev/null @@ -1,59 +0,0 @@ -from sinch.core.models.utils import model_dump_for_query_params -from sinch.domains.sms.models.v1.internal.base import ( - BaseModelConfigurationRequest, -) - - -def test_model_dump_for_query_params_expects_simple_fields(): - """ - Test that simple fields are returned as-is. - """ - - class TestModel(BaseModelConfigurationRequest): - batch_id: str - status: str = None - - model = TestModel( - batch_id="01FC66621XXXXX119Z8PMV1QPQ", status="delivered" - ) - result = model_dump_for_query_params(model) - - assert result["batch_id"] == "01FC66621XXXXX119Z8PMV1QPQ" - assert result["status"] == "delivered" - - -def test_model_dump_for_query_params_expects_list_to_comma_separated_string(): - """ - Test that lists are converted to comma-separated strings. - """ - - class TestModel(BaseModelConfigurationRequest): - status: list[str] = None - code: list[int] = None - - model = TestModel(status=["Delivered", "Failed"], code=[15, 0]) - result = model_dump_for_query_params(model) - - assert result["status"] == "Delivered,Failed" - assert result["code"] == "15,0" - - -def test_model_dump_for_query_params_expects_empty_values_filtered(): - """ - Test that empty strings and empty lists are filtered out. - """ - - class TestModel(BaseModelConfigurationRequest): - batch_id: str - status: str = "" - code: list[int] = [] - - model = TestModel( - batch_id="01FC66621XXXXX119Z8PMV1QPQ", status="", code=[] - ) - result = model_dump_for_query_params(model) - - assert "batch_id" in result - assert result["batch_id"] == "01FC66621XXXXX119Z8PMV1QPQ" - assert "status" not in result - assert "code" not in result From 4983b41b642e5d3c56c7a56e2e0a7d2650138a8f Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Fri, 26 Jun 2026 15:00:35 +0200 Subject: [PATCH 2/4] feat(core): refactor basemodels moving them to core --- .../internal/base_model_config_originals.py | 279 ------------------ 1 file changed, 279 deletions(-) delete mode 100644 sinch/core/models/internal/base_model_config_originals.py diff --git a/sinch/core/models/internal/base_model_config_originals.py b/sinch/core/models/internal/base_model_config_originals.py deleted file mode 100644 index a821098c..00000000 --- a/sinch/core/models/internal/base_model_config_originals.py +++ /dev/null @@ -1,279 +0,0 @@ - - -## FOR SMS , the API is snake_case both the request and the response allows extra fields -## for the request no conversion is done -## for the response unknown fields are converted from camelCase to snake_case -## No alias needed for the request or the response - - -import re -from typing import Any -from pydantic import BaseModel, ConfigDict - - -class BaseModelConfigurationRequest(BaseModel): - """ - A base model that allows extra fields and converts snake_case to camelCase. - """ - - model_config = ConfigDict( - # Allows using both alias (camelCase) and field name (snake_case) - populate_by_name=True, - # Allows extra values in input - extra="allow", - ) - - -class BaseModelConfigurationResponse(BaseModel): - """ - A base model that allows extra fields and converts camelCase to snake_case - """ - - @staticmethod - def _to_snake_case(camel_str: str) -> str: - """Helper to convert camelCase string to snake_case.""" - return re.sub(r"(? None: - """Converts unknown fields from camelCase to snake_case.""" - if self.__pydantic_extra__: - converted_extra = { - self._to_snake_case(key): value - for key, value in self.__pydantic_extra__.items() - } - self.__pydantic_extra__.clear() - self.__pydantic_extra__.update(converted_extra) - - - -## Numbers - the API is camelCase for both the request and the response -## Uses alias for the request and the response -## Extra fields are converted from snake_case to camelCase for the request and from camelCase to snake_case for the response - - -import re -from typing import Any -from pydantic import BaseModel, ConfigDict - - -class BaseModelConfigurationRequest(BaseModel): - """ - A base model that allows extra fields and converts snake_case to camelCase. - """ - - @staticmethod - def _to_camel_case(snake_str: str) -> str: - """Converts snake_case to camelCase while preserving multiple underscores.""" - if not snake_str or "_" not in snake_str: - return snake_str - components = snake_str.split("_") - return components[0].lower() + "".join( - (x.capitalize() if x else "_") for x in components[1:] - ) - - @classmethod - def _convert_dict_keys(cls, obj): - """Recursively convert dictionary keys to camelCase.""" - if isinstance(obj, dict): - new_dict = {} - for key, value in obj.items(): - # Convert dict key to camelCase - camel_key = cls._to_camel_case(key) - # Recurse on the value - new_dict[camel_key] = cls._convert_dict_keys(value) - return new_dict - elif isinstance(obj, list): - # Recurse through any list elements (they might be dicts too) - return [cls._convert_dict_keys(item) for item in obj] - else: - return obj - - model_config = ConfigDict( - # Allows using both alias (camelCase) and field name (snake_case) - populate_by_name=True, - # Allows extra values in input - extra="allow", - ) - - def _convert_dict_to_camel_case(self, data): - if isinstance(data, dict): - return { - self._to_camel_case(k): self._convert_dict_to_camel_case(v) - for k, v in data.items() - } - elif isinstance(data, list): - return [self._convert_dict_to_camel_case(i) for i in data] - return data - - def model_dump(self, **kwargs) -> dict: - """Converts extra fields from snake_case to camelCase when dumping the model in endpoint.""" - # Get the standard model dump. - data = super().model_dump(**kwargs) - if not kwargs or kwargs["by_alias"]: - data = self._convert_dict_to_camel_case(data) - - # Get extra fields - extra_data = self.__pydantic_extra__ or {} - - # Merge known + unknown into one dictionary first - combined = {**data, **extra_data} - - final_dict = {} - - for key, value in combined.items(): - if key in extra_data: - # This is an unknown field to be converted - new_key = self._to_camel_case(key) - else: - # Known field - keep the top-level key as given - new_key = key - - # Recursively convert any nested dict keys - converted_value = self._convert_dict_keys(value) - - # Add to final dictionary - final_dict[new_key] = converted_value - - return final_dict - - -class BaseModelConfigurationResponse(BaseModel): - """ - A base model that allows extra fields and converts camelCase to snake_case - """ - - @staticmethod - def _to_snake_case(camel_str: str) -> str: - """Helper to convert camelCase string to snake_case.""" - return re.sub(r"(? None: - """Converts unknown fields from camelCase to snake_case.""" - if self.__pydantic_extra__: - converted_extra = { - self._to_snake_case(key): value - for key, value in self.__pydantic_extra__.items() - } - self.__pydantic_extra__.clear() - self.__pydantic_extra__.update(converted_extra) - - -## NUMBER LOOKUP - the API is camelCase for both the request and the response -## Uses alias for the request and the response -## Extra fields are converted from camelCase to snake case for the response -import re -from typing import Any -from pydantic import BaseModel, ConfigDict - - -class BaseModelConfigurationRequest(BaseModel): - """ - A base model that allows extra fields and converts snake_case to camelCase. - """ - - model_config = ConfigDict( - # Allows using both alias (camelCase) and field name (snake_case) - populate_by_name=True, - # Allows extra values in input - extra="allow", - ) - - -class BaseModelConfigurationResponse(BaseModel): - """ - A base model that allows extra fields and converts camelCase to snake_case - """ - - @staticmethod - def _to_snake_case(camel_str: str) -> str: - """Helper to convert camelCase string to snake_case.""" - return re.sub(r"(? None: - """Converts unknown fields from camelCase to snake_case.""" - if self.__pydantic_extra__: - converted_extra = { - self._to_snake_case(key): value - for key, value in self.__pydantic_extra__.items() - } - self.__pydantic_extra__.clear() - self.__pydantic_extra__.update(converted_extra) - - -## CONVERSATION - the API is snake_case for both the request and the response -## Extra fields are converted from camelCase to snake_case for the response and the request - -import re -from typing import Any -from pydantic import BaseModel, ConfigDict - - -class BaseModelConfiguration(BaseModel): - """ - Base model for all conversation message models. - Both request and response use snake_case in the Conversation API. - """ - - model_config = ConfigDict( - # Allows using both alias (camelCase) and field name (snake_case) - populate_by_name=True, - # Allows extra values in input - extra="allow", - ) - - @staticmethod - def _to_snake_case(camel_str: str) -> str: - """Helper to convert camelCase string to snake_case.""" - return re.sub(r"(? None: - """Converts unknown fields from camelCase to snake_case.""" - if self.__pydantic_extra__: - converted_extra = { - self._to_snake_case(key): value - for key, value in self.__pydantic_extra__.items() - } - self.__pydantic_extra__.clear() - self.__pydantic_extra__.update(converted_extra) - - -## AUTHENTICATION - -import json -from dataclasses import asdict, dataclass - - -@dataclass -class SinchBaseModel: - def as_dict(self): - return asdict(self) - - def as_json(self): - return json.dumps(self.as_dict()) - - -@dataclass -class SinchRequestBaseModel(SinchBaseModel): - def as_dict(self): - return {k: v for k, v in asdict(self).items() if v is not None} From 9e2153e05e4f517539dc7e454874967b081b5b43 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Fri, 26 Jun 2026 15:12:16 +0200 Subject: [PATCH 3/4] update CHANGELOG --- CHANGELOG.md | 1 + sinch/domains/numbers/models/v1/errors/not_found_error.py | 1 - .../sms/models/v1/internal/base/base_model_configuration.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d15fc2a7..5754e64b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ All notable changes to the **Sinch Python SDK** are documented in this file. - **[deprecation notice]** `HTTPTransport.send(endpoint)` is deprecated in favour of `send_request(request_data)`; the legacy method still works for backward compatibility, but will be removed in 3.0. - **[deprecation notice]** `TokenManagerBase.invalidate_expired_token()` and `handle_invalid_token()` (and the `TokenState.EXPIRED` value) are deprecated and will be removed in 3.0, as token renewal now goes through `refresh_auth_token()`. - **[tech]** Removed unused GitHub environment secrets from CI workflow and simplified test fixtures to use hardcoded test values. +- **[refactor]** Consolidated the duplicated per-domain `BaseModelConfiguration` classes into three shared base classes in `sinch.core.models.internal` (`BaseConfigModel`, `SnakeCaseExtrasModel`, `CamelCaseDumpModel`). - **[doc]** Improve README structure and content. diff --git a/sinch/domains/numbers/models/v1/errors/not_found_error.py b/sinch/domains/numbers/models/v1/errors/not_found_error.py index 5ca2ce1d..d4ed8a9b 100644 --- a/sinch/domains/numbers/models/v1/errors/not_found_error.py +++ b/sinch/domains/numbers/models/v1/errors/not_found_error.py @@ -13,4 +13,3 @@ class NotFoundError(BaseModelConfigurationResponse): message: Optional[StrictStr] = Field(default=None) status: Optional[StrictStr] = Field(default=None) details: Optional[conlist(NotFoundErrorDetails)] = Field(default=None) - diff --git a/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py b/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py index 5fd1eaef..a3cb8354 100644 --- a/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py +++ b/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py @@ -4,4 +4,4 @@ ) BaseModelConfigurationRequest = BaseConfigModel -BaseModelConfigurationResponse = SnakeCaseExtrasModel \ No newline at end of file +BaseModelConfigurationResponse = SnakeCaseExtrasModel From 8310e5e940bc695f5436d9ab796e76c12aa3ca9b Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Mon, 29 Jun 2026 08:40:55 +0200 Subject: [PATCH 4/4] update base models docstrings --- .../core/models/internal/base_model_config.py | 36 +++++-------------- .../models/internal/test_base_model_config.py | 5 --- 2 files changed, 8 insertions(+), 33 deletions(-) diff --git a/sinch/core/models/internal/base_model_config.py b/sinch/core/models/internal/base_model_config.py index 5be987c1..956352bc 100644 --- a/sinch/core/models/internal/base_model_config.py +++ b/sinch/core/models/internal/base_model_config.py @@ -12,11 +12,6 @@ def _to_camel_case(snake_str: str) -> str: :returns: The camelCase form, or the input unchanged when it contains no underscore. """ - ## Differs from pydantic.alias_generators.to_camel in two ways: - ## - Empty components produced by "__" are kept as "_" instead of being - ## collapsed, so "foo__bar" becomes "foo_Bar" (pydantic returns "fooBar"). - ## - The first component is lowercased verbatim, so "PHONE_NUMBER" becomes - ## "phoneNumber" (pydantic returns "PHONENumber"). if not snake_str or "_" not in snake_str: return snake_str components = snake_str.split("_") @@ -31,19 +26,12 @@ def _to_snake_case(camel_str: str) -> str: :param camel_str: The camelCase input string. :returns: The snake_case form. """ - ## Differs from pydantic.alias_generators.to_snake in acronym handling: - ## this implementation inserts an underscore before every uppercase letter, - ## so "XMLDocURL" becomes "x_m_l_doc_u_r_l" (pydantic is acronym-aware and - ## yields "xml_doc_url"). Kept for backwards compatibility with the previous - ## per-domain implementations. return re.sub(r"(? Any: """Recursively camelize dict keys, walking into nested dicts and lists. - Non-container values are returned unchanged. - :param value: An arbitrary JSON-like value. :returns: The same structure with every ``snake_case`` dict key converted to ``camelCase``. @@ -67,12 +55,6 @@ def model_post_init(self, __context: Any) -> None: class _CamelizeKeysOnDump: """Recursively rewrite every dict key in the serialized output to ``camelCase`` when ``by_alias=True``. - - Implemented as a Pydantic v2 wrap serializer so the model integrates - with both :meth:`~pydantic.BaseModel.model_dump` and - :meth:`~pydantic.BaseModel.model_dump_json` without overriding either. - Preserves the previous Numbers ``model_dump`` override byte-for-byte - for the cases endpoints exercise (``by_alias=True``). """ @model_serializer(mode="wrap") @@ -86,7 +68,7 @@ def _serialize_with_camel_extras( class BaseConfigModel(BaseModel): - """Permissive base: ``populate_by_name=True, extra="allow"`` and nothing else. + """Base model that allows any extra attributes. Use for models that do not need automatic case normalization. """ @@ -95,21 +77,19 @@ class BaseConfigModel(BaseModel): class SnakeCaseExtrasModel(_SnakifyExtrasOnInit, BaseConfigModel): - """Permissive base + ``camelCase``→``snake_case`` normalization of - ``__pydantic_extra__`` at validation time. + """Base model that normalizes extra attributes to ``snake_case`` at validation time. - Use for response models (and the Conversation shared base) where the - consumer-facing attribute style stays ``snake_case`` regardless of the - wire format. + Use for response models where extra attributes received from the API + should be normalized to ``snake_case`` regardless of the format returned + by the server. """ class CamelCaseDumpModel(_CamelizeKeysOnDump, BaseConfigModel): - """Permissive base + recursive ``snake_case``→``camelCase`` rewrite of + """Base model that recursively rewrites ``snake_case`` keys to ``camelCase`` in the serialized output when ``by_alias=True``. - Use for ``camelCase``-API requests that need extras (and free-form - dict values nested under known fields) emitted as ``camelCase`` on the - wire. This is the role Numbers' request base played previously. + Use for request models targeting ``camelCase`` APIs, so that extra + attributes are emitted as ``camelCase`` in the outgoing request payload. """ diff --git a/tests/unit/core/models/internal/test_base_model_config.py b/tests/unit/core/models/internal/test_base_model_config.py index eff3968f..3e2710c7 100644 --- a/tests/unit/core/models/internal/test_base_model_config.py +++ b/tests/unit/core/models/internal/test_base_model_config.py @@ -176,8 +176,6 @@ class Outer(CamelCaseDumpModel): } def test_model_dump_json_also_camelizes(self): - # Improvement over the previous model_dump override: the wrap - # serializer applies to JSON serialization too. model = CamelCaseDumpModel(snake_extra="value") assert json.loads(model.model_dump_json(by_alias=True)) == { "snakeExtra": "value", @@ -208,9 +206,6 @@ class Outer(SnakeCaseExtrasModel): assert outer.extra_at_root == "extra" def test_snake_extras_inner_in_base_config_outer_keeps_outer_extras_raw(self): - # Reproduces the historical SMS pattern: a Response-derived inner - # model used inside a permissive outer model. The inner normalizes - # its extras, the outer forwards its own as-provided. class Common(SnakeCaseExtrasModel): foo_bar: int