Skip to content

Commit d6429c6

Browse files
authored
feat(core): DEVEXP-1178 Refactor-Base-Models (#165)
* feat(core): refactor basemodels moving them to core * update CHANGELOG
1 parent e15b6ae commit d6429c6

14 files changed

Lines changed: 398 additions & 368 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ All notable changes to the **Sinch Python SDK** are documented in this file.
2727
- **[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.
2828
- **[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()`.
2929
- **[tech]** Removed unused GitHub environment secrets from CI workflow and simplified test fixtures to use hardcoded test values.
30+
- **[refactor]** Consolidated the duplicated per-domain `BaseModelConfiguration` classes into three shared base classes in `sinch.core.models.internal` (`BaseConfigModel`, `SnakeCaseExtrasModel`, `CamelCaseDumpModel`).
3031
- **[doc]** Improve README structure and content.
3132

3233

sinch/core/models/internal/__init__.py

Whitespace-only changes.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import re
2+
from typing import Any
3+
4+
from pydantic import BaseModel, ConfigDict, SerializationInfo, model_serializer
5+
from pydantic.functional_serializers import SerializerFunctionWrapHandler
6+
7+
8+
def _to_camel_case(snake_str: str) -> str:
9+
"""Convert ``snake_case`` to ``camelCase`` preserving consecutive underscores.
10+
11+
:param snake_str: The snake_case input string.
12+
:returns: The camelCase form, or the input unchanged when it contains no
13+
underscore.
14+
"""
15+
if not snake_str or "_" not in snake_str:
16+
return snake_str
17+
components = snake_str.split("_")
18+
return components[0].lower() + "".join(
19+
(x.capitalize() if x else "_") for x in components[1:]
20+
)
21+
22+
23+
def _to_snake_case(camel_str: str) -> str:
24+
"""Convert ``camelCase`` to ``snake_case``.
25+
26+
:param camel_str: The camelCase input string.
27+
:returns: The snake_case form.
28+
"""
29+
return re.sub(r"(?<!^)(?=[A-Z])", "_", camel_str).lower()
30+
31+
32+
def _camelize_keys(value: Any) -> Any:
33+
"""Recursively camelize dict keys, walking into nested dicts and lists.
34+
35+
:param value: An arbitrary JSON-like value.
36+
:returns: The same structure with every ``snake_case`` dict key converted
37+
to ``camelCase``.
38+
"""
39+
if isinstance(value, dict):
40+
return {_to_camel_case(k): _camelize_keys(v) for k, v in value.items()}
41+
if isinstance(value, list):
42+
return [_camelize_keys(item) for item in value]
43+
return value
44+
45+
46+
class _SnakifyExtrasOnInit:
47+
"""Normalize ``__pydantic_extra__`` keys to ``snake_case`` at validation time."""
48+
49+
def model_post_init(self, __context: Any) -> None:
50+
extra = self.__pydantic_extra__
51+
if extra:
52+
self.__pydantic_extra__ = {_to_snake_case(k): v for k, v in extra.items()}
53+
54+
55+
class _CamelizeKeysOnDump:
56+
"""Recursively rewrite every dict key in the serialized output to
57+
``camelCase`` when ``by_alias=True``.
58+
"""
59+
60+
@model_serializer(mode="wrap")
61+
def _serialize_with_camel_extras(
62+
self, handler: SerializerFunctionWrapHandler, info: SerializationInfo
63+
) -> dict:
64+
data = handler(self)
65+
if info.by_alias:
66+
data = _camelize_keys(data)
67+
return data
68+
69+
70+
class BaseConfigModel(BaseModel):
71+
"""Base model that allows any extra attributes.
72+
73+
Use for models that do not need automatic case normalization.
74+
"""
75+
76+
model_config = ConfigDict(populate_by_name=True, extra="allow")
77+
78+
79+
class SnakeCaseExtrasModel(_SnakifyExtrasOnInit, BaseConfigModel):
80+
"""Base model that normalizes extra attributes to ``snake_case`` at validation time.
81+
82+
Use for response models where extra attributes received from the API
83+
should be normalized to ``snake_case`` regardless of the format returned
84+
by the server.
85+
"""
86+
87+
88+
class CamelCaseDumpModel(_CamelizeKeysOnDump, BaseConfigModel):
89+
"""Base model that recursively rewrites ``snake_case`` keys to ``camelCase`` in
90+
the serialized output when ``by_alias=True``.
91+
92+
Use for request models targeting ``camelCase`` APIs, so that extra
93+
attributes are emitted as ``camelCase`` in the outgoing request payload.
94+
"""
95+
Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,3 @@
1-
import re
2-
from typing import Any
3-
from pydantic import BaseModel, ConfigDict
1+
from sinch.core.models.internal.base_model_config import SnakeCaseExtrasModel
42

5-
6-
class BaseModelConfiguration(BaseModel):
7-
"""
8-
Base model for all conversation message models.
9-
Both request and response use snake_case in the Conversation API.
10-
"""
11-
12-
model_config = ConfigDict(
13-
# Allows using both alias (camelCase) and field name (snake_case)
14-
populate_by_name=True,
15-
# Allows extra values in input
16-
extra="allow",
17-
)
18-
19-
@staticmethod
20-
def _to_snake_case(camel_str: str) -> str:
21-
"""Helper to convert camelCase string to snake_case."""
22-
return re.sub(r"(?<!^)(?=[A-Z])", "_", camel_str).lower()
23-
24-
def model_post_init(self, __context: Any) -> None:
25-
"""Converts unknown fields from camelCase to snake_case."""
26-
if self.__pydantic_extra__:
27-
converted_extra = {
28-
self._to_snake_case(key): value
29-
for key, value in self.__pydantic_extra__.items()
30-
}
31-
self.__pydantic_extra__.clear()
32-
self.__pydantic_extra__.update(converted_extra)
3+
BaseModelConfiguration = SnakeCaseExtrasModel
Lines changed: 6 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,7 @@
1-
import re
2-
from typing import Any
3-
from pydantic import BaseModel, ConfigDict
1+
from sinch.core.models.internal.base_model_config import (
2+
BaseConfigModel,
3+
SnakeCaseExtrasModel,
4+
)
45

5-
6-
class BaseModelConfigurationRequest(BaseModel):
7-
"""
8-
A base model that allows extra fields and converts snake_case to camelCase.
9-
"""
10-
11-
model_config = ConfigDict(
12-
# Allows using both alias (camelCase) and field name (snake_case)
13-
populate_by_name=True,
14-
# Allows extra values in input
15-
extra="allow",
16-
)
17-
18-
19-
class BaseModelConfigurationResponse(BaseModel):
20-
"""
21-
A base model that allows extra fields and converts camelCase to snake_case
22-
"""
23-
24-
@staticmethod
25-
def _to_snake_case(camel_str: str) -> str:
26-
"""Helper to convert camelCase string to snake_case."""
27-
return re.sub(r"(?<!^)(?=[A-Z])", "_", camel_str).lower()
28-
29-
model_config = ConfigDict(
30-
# Allows using both alias (camelCase) and field name (snake_case)
31-
populate_by_name=True,
32-
# Allows extra values in input
33-
extra="allow",
34-
)
35-
36-
def model_post_init(self, __context: Any) -> None:
37-
"""Converts unknown fields from camelCase to snake_case."""
38-
if self.__pydantic_extra__:
39-
converted_extra = {
40-
self._to_snake_case(key): value
41-
for key, value in self.__pydantic_extra__.items()
42-
}
43-
self.__pydantic_extra__.clear()
44-
self.__pydantic_extra__.update(converted_extra)
6+
BaseModelConfigurationRequest = BaseConfigModel
7+
BaseModelConfigurationResponse = SnakeCaseExtrasModel
Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from typing import Optional
2-
from pydantic import ConfigDict, conlist, Field, StrictInt, StrictStr
2+
from pydantic import conlist, Field, StrictInt, StrictStr
33
from sinch.domains.numbers.models.v1.internal.base import (
44
BaseModelConfigurationResponse,
55
)
@@ -13,8 +13,3 @@ class NotFoundError(BaseModelConfigurationResponse):
1313
message: Optional[StrictStr] = Field(default=None)
1414
status: Optional[StrictStr] = Field(default=None)
1515
details: Optional[conlist(NotFoundErrorDetails)] = Field(default=None)
16-
17-
model_config = ConfigDict(
18-
populate_by_name=True,
19-
alias_generator=BaseModelConfigurationResponse._to_snake_case,
20-
)
Lines changed: 6 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,7 @@
1-
import re
2-
from typing import Any
3-
from pydantic import BaseModel, ConfigDict
1+
from sinch.core.models.internal.base_model_config import (
2+
CamelCaseDumpModel,
3+
SnakeCaseExtrasModel,
4+
)
45

5-
6-
class BaseModelConfigurationRequest(BaseModel):
7-
"""
8-
A base model that allows extra fields and converts snake_case to camelCase.
9-
"""
10-
11-
@staticmethod
12-
def _to_camel_case(snake_str: str) -> str:
13-
"""Converts snake_case to camelCase while preserving multiple underscores."""
14-
if not snake_str or "_" not in snake_str:
15-
return snake_str
16-
components = snake_str.split("_")
17-
return components[0].lower() + "".join(
18-
(x.capitalize() if x else "_") for x in components[1:]
19-
)
20-
21-
@classmethod
22-
def _convert_dict_keys(cls, obj):
23-
"""Recursively convert dictionary keys to camelCase."""
24-
if isinstance(obj, dict):
25-
new_dict = {}
26-
for key, value in obj.items():
27-
# Convert dict key to camelCase
28-
camel_key = cls._to_camel_case(key)
29-
# Recurse on the value
30-
new_dict[camel_key] = cls._convert_dict_keys(value)
31-
return new_dict
32-
elif isinstance(obj, list):
33-
# Recurse through any list elements (they might be dicts too)
34-
return [cls._convert_dict_keys(item) for item in obj]
35-
else:
36-
return obj
37-
38-
model_config = ConfigDict(
39-
# Allows using both alias (camelCase) and field name (snake_case)
40-
populate_by_name=True,
41-
# Allows extra values in input
42-
extra="allow",
43-
)
44-
45-
def _convert_dict_to_camel_case(self, data):
46-
if isinstance(data, dict):
47-
return {
48-
self._to_camel_case(k): self._convert_dict_to_camel_case(v)
49-
for k, v in data.items()
50-
}
51-
elif isinstance(data, list):
52-
return [self._convert_dict_to_camel_case(i) for i in data]
53-
return data
54-
55-
def model_dump(self, **kwargs) -> dict:
56-
"""Converts extra fields from snake_case to camelCase when dumping the model in endpoint."""
57-
# Get the standard model dump.
58-
data = super().model_dump(**kwargs)
59-
if not kwargs or kwargs["by_alias"]:
60-
data = self._convert_dict_to_camel_case(data)
61-
62-
# Get extra fields
63-
extra_data = self.__pydantic_extra__ or {}
64-
65-
# Merge known + unknown into one dictionary first
66-
combined = {**data, **extra_data}
67-
68-
final_dict = {}
69-
70-
for key, value in combined.items():
71-
if key in extra_data:
72-
# This is an unknown field to be converted
73-
new_key = self._to_camel_case(key)
74-
else:
75-
# Known field - keep the top-level key as given
76-
new_key = key
77-
78-
# Recursively convert any nested dict keys
79-
converted_value = self._convert_dict_keys(value)
80-
81-
# Add to final dictionary
82-
final_dict[new_key] = converted_value
83-
84-
return final_dict
85-
86-
87-
class BaseModelConfigurationResponse(BaseModel):
88-
"""
89-
A base model that allows extra fields and converts camelCase to snake_case
90-
"""
91-
92-
@staticmethod
93-
def _to_snake_case(camel_str: str) -> str:
94-
"""Helper to convert camelCase string to snake_case."""
95-
return re.sub(r"(?<!^)(?=[A-Z])", "_", camel_str).lower()
96-
97-
model_config = ConfigDict(
98-
# Allows using both alias (camelCase) and field name (snake_case)
99-
populate_by_name=True,
100-
# Allows extra values in input
101-
extra="allow",
102-
)
103-
104-
def model_post_init(self, __context: Any) -> None:
105-
"""Converts unknown fields from camelCase to snake_case."""
106-
if self.__pydantic_extra__:
107-
converted_extra = {
108-
self._to_snake_case(key): value
109-
for key, value in self.__pydantic_extra__.items()
110-
}
111-
self.__pydantic_extra__.clear()
112-
self.__pydantic_extra__.update(converted_extra)
6+
BaseModelConfigurationRequest = CamelCaseDumpModel
7+
BaseModelConfigurationResponse = SnakeCaseExtrasModel

sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
from typing import Optional
2-
from pydantic import Field, StrictInt, StrictStr, field_validator, conlist
2+
3+
from pydantic import Field, StrictInt, StrictStr, conlist, field_validator
4+
5+
from sinch.core.models.internal.base_model_config import _to_camel_case
36
from sinch.domains.numbers.models.v1.internal.base import (
47
BaseModelConfigurationRequest,
58
)
69
from sinch.domains.numbers.models.v1.types import (
710
CapabilityType,
8-
OrderByType,
911
NumberSearchPatternType,
1012
NumberType,
13+
OrderByType,
1114
)
1215

1316

@@ -33,5 +36,5 @@ class ListActiveNumbersRequest(BaseModelConfigurationRequest):
3336
@classmethod
3437
def convert_order_by(cls, value):
3538
if isinstance(value, str):
36-
return cls._to_camel_case(value)
39+
return _to_camel_case(value)
3740
return value

0 commit comments

Comments
 (0)