Skip to content

Commit d79ca69

Browse files
authored
Feature/devexp 1482 python manage additional properties as passthrough (#3)
* feat(core) add legacy_extra_fields_normalization boolean * feat(core): implement legacy extra fields normalization as a passthrough mechanism
1 parent a6949e0 commit d79ca69

49 files changed

Lines changed: 612 additions & 290 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ All notable changes to the **Sinch Python SDK** are documented in this file.
1818

1919
## v2.2.0 – unreleased
2020

21+
### SDK
22+
23+
- **[design]** Extra fields on request and response models now pass through unchanged by default (no camelCase/snake_case rewriting). A new `Configuration(legacy_extra_fields_normalization=True)` flag restores the previous auto-conversion behavior for callers who depend on it; the flag is transitional and will be removed in 3.0.
24+
25+
### Numbers
26+
27+
- **[refactor]** `voice_configuration`, `sms_configuration`, and `number_pattern` request fields are now typed Pydantic models instead of raw dicts with validators. Added `VoiceConfigurationCustom`/`ScheduledVoiceProvisioningCustom` response variants for unrecognized voice configuration types.
28+
2129
### Conversation
2230

2331
- **[feature]** Conversation Apps API: `create`, `get`, `list`, `update`, and `delete` operations, with full model, endpoints and unit/e2e test coverage.

sinch/core/clients/sinch_client_configuration.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import warnings
23
from logging import Logger
34

45
from sinch.core.ports.http_transport import HTTPTransport
@@ -8,6 +9,16 @@
89
class Configuration:
910
"""
1011
Sinch client configuration object.
12+
13+
:param legacy_extra_fields_normalization: When
14+
``True``, restores the behavior before 2.2.0 where extra fields on request/response models were
15+
auto-converted to the api convention ``snake_case`` or ``camelCase``. When
16+
``False`` (default), extra fields pass through unchanged in both
17+
directions.
18+
19+
.. deprecated:: 2.2
20+
This flag is transitional and will be removed in 3.0, along with
21+
the legacy auto-conversion behavior it restores.
1122
"""
1223
def __init__(
1324
self,
@@ -23,14 +34,16 @@ def __init__(
2334
sms_api_token: str = None,
2435
sms_region: str = None,
2536
conversation_region: str = None,
37+
legacy_extra_fields_normalization: bool = False,
2638
):
2739
self.key_id = key_id
2840
self.key_secret = key_secret
2941
self.project_id = project_id
3042
self.connection_timeout = connection_timeout
3143
self.sms_api_token = sms_api_token
3244
self.service_plan_id = service_plan_id
33-
45+
self.legacy_extra_fields_normalization = legacy_extra_fields_normalization
46+
3447
# Determine authentication method based on provided parameters
3548
self._authentication_method = self._determine_authentication_method()
3649
self.auth_origin = "https://auth.sinch.com"

sinch/core/clients/sinch_client_sync.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ class SinchClient:
1414
Synchronous implementation of the Sinch Client
1515
By default this implementation uses HTTPTransportRequests based on Requests library
1616
Custom Sync HTTPTransport implementation can be provided via `transport` argument
17+
18+
:param legacy_extra_fields_normalization: When
19+
``True``, restores the behavior before 2.2.0 where extra fields on request/response models were
20+
auto-converted to the api convention ``snake_case`` or ``camelCase``. When
21+
``False`` (default), extra fields pass through unchanged in both
22+
directions.
23+
24+
.. deprecated:: 2.2
25+
This flag is transitional and will be removed in 3.0, along with
26+
the legacy auto-conversion behavior it restores.
1727
"""
1828
def __init__(
1929
self,
@@ -26,6 +36,7 @@ def __init__(
2636
sms_api_token: str = None,
2737
sms_region: str = None,
2838
conversation_region: str = None,
39+
legacy_extra_fields_normalization: bool = False,
2940
):
3041
self.configuration = Configuration(
3142
key_id=key_id,
@@ -39,6 +50,7 @@ def __init__(
3950
sms_api_token=sms_api_token,
4051
sms_region=sms_region,
4152
conversation_region=conversation_region,
53+
legacy_extra_fields_normalization=legacy_extra_fields_normalization,
4254
)
4355

4456
self.authentication = Authentication(self)

sinch/core/models/internal/base_model_config.py

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,45 @@
11
import re
2-
from typing import Any
2+
from contextlib import contextmanager
3+
from contextvars import ContextVar
4+
from typing import Any, Generator
35

46
from pydantic import BaseModel, ConfigDict, SerializationInfo, model_serializer
57
from pydantic.functional_serializers import SerializerFunctionWrapHandler
68

9+
# Request-scoped normalization policy for extra fields.
10+
#
11+
# Default (False) is passthrough: extra fields are sent/exposed exactly as
12+
# given by the caller/server. Setting this to True for the duration of a
13+
# scope restores the legacy auto-conversion (camelCase on the wire,
14+
# snake_case on Python attributes) that this SDK used before passthrough
15+
# became the default.
16+
#
17+
# This is deliberately a transitional mechanism: `legacy_extra_fields_normalization`
18+
# is expected to be removed in 3.0, once passthrough is the only behavior.
19+
# Keeping it confined to a ContextVar plus the two hooks below.
20+
#
21+
# The scope is opened once per request, around `HTTPTransport.request()`,
22+
# which is the one place that knows which client's `Configuration` a given
23+
# call belongs to.
24+
_legacy_extra_fields_normalization: ContextVar[bool] = ContextVar(
25+
"legacy_extra_fields_normalization", default=False
26+
)
27+
28+
29+
@contextmanager
30+
def legacy_extra_fields_normalization_scope(enabled: bool) -> Generator[None, None, None]:
31+
"""Scope the extra-field normalization policy for the duration of the block.
32+
33+
:param enabled: When True, extra fields are auto-converted matching the
34+
legacy behavior. When False, extra fields pass
35+
through untouched in both directions.
36+
"""
37+
token = _legacy_extra_fields_normalization.set(enabled)
38+
try:
39+
yield
40+
finally:
41+
_legacy_extra_fields_normalization.reset(token)
42+
743

844
def _to_camel_case(snake_str: str) -> str:
945
"""Convert ``snake_case`` to ``camelCase`` preserving consecutive underscores.
@@ -44,25 +80,62 @@ def _camelize_keys(value: Any) -> Any:
4480

4581

4682
class _SnakifyExtrasOnInit:
47-
"""Normalize ``__pydantic_extra__`` keys to ``snake_case`` at validation time."""
83+
"""Normalize extra keys to ``snake_case``, at validation time and at dump time.
84+
85+
Only applies when the legacy scope (:func:`legacy_extra_fields_normalization_scope`)
86+
is active; otherwise extra keys pass through exactly as given.
87+
88+
Two hooks are needed because it is used as a base for both
89+
response and request models:
90+
91+
- As a response base, extras are normalized in ``model_post_init``
92+
(construction time), so that attribute access (``response.extra_field``)
93+
reflects the policy.
94+
- As a request base, the
95+
model is constructed earlier, in the public API method, before that
96+
scope starts, so ``model_post_init`` alone would always see the
97+
ambient default. The ``model_serializer`` below re-checks the policy
98+
at dump time instead, when it's invoked from ``request_body()``
99+
"""
48100

49101
def model_post_init(self, __context: Any) -> None:
102+
if not _legacy_extra_fields_normalization.get():
103+
return
50104
extra = self.__pydantic_extra__
51105
if extra:
52106
self.__pydantic_extra__ = {_to_snake_case(k): v for k, v in extra.items()}
53107

108+
@model_serializer(mode="wrap")
109+
def _serialize_with_snake_extras(
110+
self, handler: SerializerFunctionWrapHandler, info: SerializationInfo
111+
) -> dict:
112+
data = handler(self)
113+
if not _legacy_extra_fields_normalization.get():
114+
return data
115+
116+
extra_keys = set(getattr(self, "__pydantic_extra__", None) or {})
117+
if not extra_keys:
118+
return data
119+
return {
120+
(_to_snake_case(k) if k in extra_keys else k): v
121+
for k, v in data.items()
122+
}
123+
54124

55125
class _CamelizeKeysOnDump:
56126
"""Recursively rewrite every dict key in the serialized output to
57127
``camelCase`` when ``by_alias=True``.
128+
129+
Only applies when the legacy scope (:func:`legacy_extra_fields_normalization_scope`)
130+
is active; otherwise the serialized output passes through unchanged.
58131
"""
59132

60133
@model_serializer(mode="wrap")
61134
def _serialize_with_camel_extras(
62135
self, handler: SerializerFunctionWrapHandler, info: SerializationInfo
63136
) -> dict:
64137
data = handler(self)
65-
if info.by_alias:
138+
if info.by_alias and _legacy_extra_fields_normalization.get():
66139
data = _camelize_keys(data)
67140
return data
68141

sinch/core/ports/http_transport.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from sinch.core.endpoint import HTTPEndpoint
1212
from sinch.core.models.http_request import HttpRequest
1313
from sinch.core.models.http_response import HTTPResponse
14+
from sinch.core.models.internal.base_model_config import legacy_extra_fields_normalization_scope
1415
from sinch.core.exceptions import ValidationException, SinchException
1516
from sinch.core.enums import HTTPAuthentication
1617
from sinch import __version__ as sdk_version
@@ -96,21 +97,24 @@ def request(self, endpoint: HTTPEndpoint) -> HTTPResponse:
9697
:returns: The handled HTTP response.
9798
:rtype: HTTPResponse
9899
"""
99-
if self._legacy_send:
100-
return self._legacy_request(endpoint)
101-
102-
request_data = self.prepare_request(endpoint)
103-
request_data = self.authenticate(endpoint, request_data)
100+
with legacy_extra_fields_normalization_scope(
101+
self.sinch.configuration.legacy_extra_fields_normalization
102+
):
103+
if self._legacy_send:
104+
return self._legacy_request(endpoint)
104105

105-
http_response = self._send_with_retries(endpoint, request_data)
106+
request_data = self.prepare_request(endpoint)
107+
request_data = self.authenticate(endpoint, request_data)
106108

107-
if self._should_refresh_token(endpoint, http_response):
108-
used_token = self._get_bearer_token_from_request(request_data)
109-
new_token = self.sinch.configuration.token_manager.refresh_auth_token(used_token)
110-
self._set_bearer_token(request_data, new_token.access_token)
111109
http_response = self._send_with_retries(endpoint, request_data)
112110

113-
return endpoint.handle_response(http_response)
111+
if self._should_refresh_token(endpoint, http_response):
112+
used_token = self._get_bearer_token_from_request(request_data)
113+
new_token = self.sinch.configuration.token_manager.refresh_auth_token(used_token)
114+
self._set_bearer_token(request_data, new_token.access_token)
115+
http_response = self._send_with_retries(endpoint, request_data)
116+
117+
return endpoint.handle_response(http_response)
114118

115119

116120
def _send_with_retries(

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
VoiceConfigurationEST,
4040
VoiceConfigurationFAX,
4141
VoiceConfigurationRTC,
42+
VoiceConfigurationRequestUnion,
4243
)
4344

4445
__all__ = [
@@ -58,4 +59,5 @@
5859
"VoiceConfigurationEST",
5960
"VoiceConfigurationFAX",
6061
"VoiceConfigurationRTC",
62+
"VoiceConfigurationRequestUnion",
6163
]
Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
from typing import Optional, Dict, Any
1+
from typing import Optional
22
from pydantic import Field, StrictStr, conlist
33
from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType
4-
from sinch.domains.numbers.models.v1.utils.validators import (
5-
validate_sms_voice_configuration,
6-
validate_number_pattern,
4+
from sinch.domains.numbers.models.v1.internal.sms_configuration_request import (
5+
SmsConfigurationRequest,
76
)
7+
from sinch.domains.numbers.models.v1.internal.voice_configuration_request import (
8+
VoiceConfigurationRequestUnion,
9+
)
10+
from sinch.domains.numbers.models.v1.shared.number_pattern import NumberPattern
811
from sinch.domains.numbers.models.v1.internal.base import (
912
BaseModelConfigurationRequest,
1013
)
@@ -16,24 +19,16 @@ class RentAnyNumberRequest(BaseModelConfigurationRequest):
1619
description="ISO 3166-1 alpha-2 country code. Example: US, GB or SE.",
1720
)
1821
number_type: NumberType = Field(alias="type")
19-
number_pattern: Optional[Dict[str, Any]] = Field(
22+
number_pattern: Optional[NumberPattern] = Field(
2023
default=None, alias="numberPattern"
2124
)
2225
capabilities: Optional[conlist(CapabilityType)] = Field(default=None)
23-
sms_configuration: Optional[Dict[str, Any]] = Field(
26+
sms_configuration: Optional[SmsConfigurationRequest] = Field(
2427
default=None, alias="smsConfiguration"
2528
)
26-
voice_configuration: Optional[Dict[str, Any]] = Field(
29+
voice_configuration: Optional[VoiceConfigurationRequestUnion] = Field(
2730
default=None, alias="voiceConfiguration"
2831
)
2932
event_destination_target: Optional[StrictStr] = Field(
3033
default=None, alias="callbackUrl"
3134
)
32-
33-
def __init__(self, **data):
34-
"""
35-
Custom initializer to validate nested dictionaries.
36-
"""
37-
validate_sms_voice_configuration(data)
38-
validate_number_pattern(data)
39-
super().__init__(**data)
Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
from typing import Optional, Dict
1+
from typing import Optional
22
from pydantic import Field, StrictStr
3-
from sinch.domains.numbers.models.v1.utils.validators import (
4-
validate_sms_voice_configuration,
3+
from sinch.domains.numbers.models.v1.internal.sms_configuration_request import (
4+
SmsConfigurationRequest,
5+
)
6+
from sinch.domains.numbers.models.v1.internal.voice_configuration_request import (
7+
VoiceConfigurationRequestUnion,
58
)
69
from sinch.domains.numbers.models.v1.internal.base import (
710
BaseModelConfigurationRequest,
@@ -13,20 +16,12 @@ class RentNumberRequest(BaseModelConfigurationRequest):
1316
alias="phoneNumber",
1417
description="Phone number in E.164 format with leading '+'. Example: '+12025550134'.",
1518
)
16-
# Accepts only dictionary input, not Pydantic models
17-
sms_configuration: Optional[Dict] = Field(
19+
sms_configuration: Optional[SmsConfigurationRequest] = Field(
1820
default=None, alias="smsConfiguration"
1921
)
20-
voice_configuration: Optional[Dict] = Field(
22+
voice_configuration: Optional[VoiceConfigurationRequestUnion] = Field(
2123
default=None, alias="voiceConfiguration"
2224
)
2325
event_destination_target: Optional[StrictStr] = Field(
2426
default=None, alias="callbackUrl"
2527
)
26-
27-
def __init__(self, **data):
28-
"""
29-
Custom initializer to validate nested dictionaries.
30-
"""
31-
validate_sms_voice_configuration(data)
32-
super().__init__(**data)
Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
from typing import Optional, Dict
1+
from typing import Optional
22
from pydantic import Field, StrictStr
33
from sinch.domains.numbers.models.v1.internal.base import (
44
BaseModelConfigurationRequest,
55
)
6-
from sinch.domains.numbers.models.v1.utils.validators import (
7-
validate_sms_voice_configuration,
6+
from sinch.domains.numbers.models.v1.internal.sms_configuration_request import (
7+
SmsConfigurationRequest,
8+
)
9+
from sinch.domains.numbers.models.v1.internal.voice_configuration_request import (
10+
VoiceConfigurationRequestUnion,
811
)
912

1013

@@ -16,20 +19,12 @@ class UpdateNumberConfigurationRequest(BaseModelConfigurationRequest):
1619
display_name: Optional[StrictStr] = Field(
1720
default=None, alias="displayName"
1821
)
19-
sms_configuration: Optional[Dict] = Field(
22+
sms_configuration: Optional[SmsConfigurationRequest] = Field(
2023
default=None, alias="smsConfiguration"
2124
)
22-
voice_configuration: Optional[Dict] = Field(
25+
voice_configuration: Optional[VoiceConfigurationRequestUnion] = Field(
2326
default=None, alias="voiceConfiguration"
2427
)
2528
event_destination_target: Optional[StrictStr] = Field(
2629
default=None, alias="callbackUrl"
2730
)
28-
29-
def __init__(self, **data):
30-
"""
31-
Custom initializer to validate nested dictionaries.
32-
"""
33-
if data.get("sms_configuration") or data.get("voice_configuration"):
34-
validate_sms_voice_configuration(data)
35-
super().__init__(**data)

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

Lines changed: 0 additions & 10 deletions
This file was deleted.

0 commit comments

Comments
 (0)