Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.


Expand Down
Empty file.
95 changes: 95 additions & 0 deletions sinch/core/models/internal/base_model_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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.
"""
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.
"""
return re.sub(r"(?<!^)(?=[A-Z])", "_", camel_str).lower()


def _camelize_keys(value: Any) -> Any:
"""Recursively camelize dict keys, walking into nested dicts and lists.

: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``.
"""

@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):
"""Base model that allows any extra attributes.

Use for models that do not need automatic case normalization.
"""

model_config = ConfigDict(populate_by_name=True, extra="allow")


class SnakeCaseExtrasModel(_SnakifyExtrasOnInit, BaseConfigModel):
"""Base model that normalizes extra attributes to ``snake_case`` at validation time.

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):
"""Base model that recursively rewrites ``snake_case`` keys to ``camelCase`` in
the serialized output when ``by_alias=True``.

Use for request models targeting ``camelCase`` APIs, so that extra
attributes are emitted as ``camelCase`` in the outgoing request payload.
"""

Original file line number Diff line number Diff line change
@@ -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"(?<!^)(?=[A-Z])", "_", camel_str).lower()

def model_post_init(self, __context: Any) -> 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
Original file line number Diff line number Diff line change
@@ -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"(?<!^)(?=[A-Z])", "_", camel_str).lower()

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 model_post_init(self, __context: Any) -> 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
7 changes: 1 addition & 6 deletions sinch/domains/numbers/models/v1/errors/not_found_error.py
Original file line number Diff line number Diff line change
@@ -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,
)
Expand All @@ -13,8 +13,3 @@ class NotFoundError(BaseModelConfigurationResponse):
message: Optional[StrictStr] = Field(default=None)
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,
)
Original file line number Diff line number Diff line change
@@ -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"(?<!^)(?=[A-Z])", "_", camel_str).lower()

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 model_post_init(self, __context: Any) -> 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
Original file line number Diff line number Diff line change
@@ -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,
)


Expand All @@ -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
Loading
Loading