Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
{% endif %}
# pylint: disable=protected-access, broad-except

import contextvars
import copy
import calendar
import decimal
Expand Down Expand Up @@ -656,12 +657,58 @@ def _create_value(rf: typing.Optional["_RestField"], value: typing.Any) -> typin
if rf._is_multipart_file_input:
return value
if rf._is_model:
return _deserialize(rf._type, value)
if _is_model(value):
return value
return _deserialize_public(rf._type, value)
if isinstance(value, ET.Element):
value = _deserialize(rf._type, value)
return _serialize(value, rf._format)


def _create_public_value(rf: typing.Optional["_RestField"], value: typing.Any) -> typing.Any:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing your input dictionary unexpectedly changes the model

Consider a nested child with a list:

data = {"labels": ["a"]}
parent = Parent(child=data)

# Later, change the original input:
data["labels"].append("b")

The resulting model differs:

parent.as_dict()
# Before: {"child": {"labels": ["a"]}}
# After:  {"child": {"labels": ["a", "b"]}}

Why: Previously, serialization created a separate list for the model. The new path stores the caller’s list directly, so both reference the same object.

The PR adds copying for positional construction:

Parent({"child": data})  # Protected by the new cloning helper

But not for these routes:

Parent(child=data)      # Keyword construction
parent.child = data     # Property assignment

Impact: Modifying input data can silently change a model you already constructed.

Requested fix: Preserve the previous separation from caller-owned containers on all public input paths. This is distinct from issue 1: copying a datetime does not serialize it, and copying containers solves a different problem.

return _create_value(rf, value)


_WIRE_VALUE = contextvars.ContextVar("_WIRE_VALUE", default=None)
_DESERIALIZING_PUBLIC_VALUE = contextvars.ContextVar("_DESERIALIZING_PUBLIC_VALUE", default=False)


def _construct_from_wire(cls: type, data: typing.Any) -> typing.Any:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Python values stop being converted into JSON-friendly values

Imagine Parent contains a Child, and Child has a datetime field:

parent = Parent(child={
    "timestamp": datetime(2026, 1, 1, tzinfo=timezone.utc)
})

Previously, creating the child converted that datetime into its wire representation:

parent.as_dict()
# Before: {"child": {"timestamp": "2026-01-01T00:00:00Z"}}
# After:  {"child": {"timestamp": datetime(...)}}

Why: The PR routes nested model construction through the new “already serialized” path. That assumption is correct for a server’s JSON response, but not for this caller-provided dictionary.

Impact: json.dumps(parent.as_dict()) now raises an error because JSON cannot directly encode a datetime. The datetime attribute should still be a datetime; the problem is its representation in as_dict().

Requested fix: Distinguish response data from caller input, and keep serializing caller-provided Python values.

token = _WIRE_VALUE.set(data)
try:
return cls(data)
finally:
_WIRE_VALUE.reset(token)


def _construct_model(cls: type, data: typing.Any) -> typing.Any:
return cls(data) if _DESERIALIZING_PUBLIC_VALUE.get() else _construct_from_wire(cls, data)


def _create_value_from_wire(rf: typing.Optional["_RestField"], value: typing.Any) -> typing.Any:
"""Build a stored value from an already-serialized (wire/JSON) payload.

Non-model values are already in wire form and are stored as-is; model fields and
``ET.Element`` values are deserialized into their target type.

:param rf: The rest field describing the target attribute, if known.
:type rf: ~_RestField or None
:param value: The already-serialized value from the response body.
:type value: any
:return: The value to store in the model's backing dict.
:rtype: any
"""
if not rf:
return value
if rf._is_multipart_file_input:
return value
if rf._is_model:
return _deserialize(rf._type, value)
if isinstance(value, ET.Element):
return _deserialize(rf._type, value)
return value


# ============================================================================
# Fast-path scalar deserializer functions for rest_field(deserializer=...)
# These are referenced from rest_field declarations to bypass the generic
Expand Down Expand Up @@ -835,6 +882,12 @@ def _extract_xml_model_type(rf_type):
return None


def _deserialize_xml_model(model_cls, value):
if isinstance(value, list):
return value
return model_cls._deserialize(value, [])


def _build_xml_field_plan( # pylint: disable=docstring-missing-return, docstring-missing-rtype, unused-variable
cls, attr_to_rest_field: dict
) -> list:
Expand Down Expand Up @@ -871,7 +924,7 @@ def _build_xml_field_plan( # pylint: disable=docstring-missing-return, docstrin
if deser is None and rf._type is not None:
model_cls = _extract_xml_model_type(rf._type)
if model_cls is not None:
deser = model_cls
deser = functools.partial(_deserialize_xml_model, model_cls)

if prop_meta.get("attribute", False):
plan.append((rf._rest_name, xml_name, 1, deser, rf._type, is_optional, None))
Expand Down Expand Up @@ -910,8 +963,14 @@ class Model(_MyMutableMapping):
if isinstance(args[0], ET.Element):
dict_to_pass.update(self._init_from_xml(args[0]))
else:
rest_field_by_rest_name = self._rest_field_by_rest_name
mapping = args[0]
create_value = _create_value_from_wire if _WIRE_VALUE.get() is mapping else _create_public_value
dict_to_pass.update(
{k: _create_value(_get_rest_field(self._attr_to_rest_field, k), v) for k, v in args[0].items()}
{
k: create_value(rest_field_by_rest_name.get(k), v)
Comment thread
iscai-msft marked this conversation as resolved.
for k, v in mapping.items()
}
)
else:
non_attr_kwargs = [k for k in kwargs if k not in self._attr_to_rest_field]
Expand All @@ -927,9 +986,7 @@ class Model(_MyMutableMapping):
)
# Apply client default values for fields the caller didn't set so that
# defaults are part of `_data` and therefore included during serialization.
for rf in self._attr_to_rest_field.values():
if rf._default is _UNSET:
continue
for rf in self._fields_with_defaults:
if rf._rest_name in dict_to_pass:
continue
dict_to_pass[rf._rest_name] = _create_value(rf, rf._default)
Expand Down Expand Up @@ -1060,6 +1117,14 @@ class Model(_MyMutableMapping):
if not rf._rest_name_input:
rf._rest_name_input = attr
cls._attr_to_rest_field: dict[str, _RestField] = dict(attr_to_rest_field.items())
# Mapping of rest_name -> _RestField, built once per class.
cls._rest_field_by_rest_name: dict[str, _RestField] = {
rf._rest_name: rf for rf in attr_to_rest_field.values()
}
# Subset of fields that declare a client-side default, built once per class.
cls._fields_with_defaults: list[_RestField] = [
rf for rf in attr_to_rest_field.values() if rf._default is not _UNSET
]
{% if code_model.has_padded_model_property %}
cls._backcompat_attr_to_rest_field: dict[str, _RestField] = {
Model._get_backcompat_attribute_name(cls._attr_to_rest_field, attr): rf for attr, rf in cls
Expand Down Expand Up @@ -1100,10 +1165,10 @@ class Model(_MyMutableMapping):
@classmethod
def _deserialize(cls, data, exist_discriminators):
if not hasattr(cls, "__mapping__"):
return cls(data)
return _construct_model(cls, data)
discriminator = cls._get_discriminator(exist_discriminators)
if discriminator is None:
return cls(data)
return _construct_model(cls, data)
exist_discriminators.append(discriminator._rest_name)
if isinstance(data, ET.Element):
model_meta = getattr(cls, "_xml", {})
Expand Down Expand Up @@ -1234,13 +1299,84 @@ def _deserialize_sequence(
return type(obj)(_deserialize(deserializer, entry, module) for entry in obj)


_PRIMITIVE_SEQUENCE_TYPES = (int, float)


def _deserialize_primitive_sequence(
builtin: typing.Callable,
deserializer: typing.Optional[typing.Callable],
module: typing.Optional[str],
obj,
):
"""Deserialize a homogeneous sequence of scalars.

Plain ``list``/``tuple``/``set`` inputs are converted with ``map(builtin, obj)``, falling
back to the generic per-element deserializer when an entry cannot be converted directly.
Any other shape (encoded ``str``, ``ET.Element``, ...) is delegated to
:func:`_deserialize_sequence`.

:param builtin: The scalar constructor to apply (``int`` / ``float``).
:type builtin: callable
:param deserializer: The generic element deserializer used for the delegated path.
:type deserializer: callable or None
:param module: The module name used for forward-ref resolution in the delegated path.
:type module: str or None
:param obj: The already-parsed value.
:type obj: any
:return: The converted sequence.
:rtype: any
"""
if obj is None:
return obj
if isinstance(obj, (list, tuple, set)):
if obj and isinstance(next(iter(obj)), ET.Element):
return _deserialize_sequence(deserializer, module, obj)
try:
return type(obj)(map(builtin, obj))
except (TypeError, ValueError):

def _lenient(entry: typing.Any) -> typing.Any:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

XML numeric lists return XML objects instead of numbers

Suppose an XML model declares an unwrapped List[int] field:

<Numbers>
    <value>1</value>
    <value>2</value>
</Numbers>

The XML parser supplies a list of XML elements, not a list of strings or numbers.

Previously, the element deserializer extracted each element’s text and converted it:

int(element.text)  # int("1") → 1

The new numeric shortcut instead does:

int(element)  # Fails: an XML element is not a number

Its fallback catches that failure and returns the original element.

model.values
# Before: [1, 2]
# After:  [<Element 'value' ...>, <Element 'value' ...>]

Requested fix: When the shortcut cannot convert an entry, use the existing element deserializer, which understands XML, rather than returning the entry unchanged.

if entry is None:
return entry
try:
return builtin(entry)
except (TypeError, ValueError):
return _deserialize(deserializer, entry, module)

return type(obj)(_lenient(entry) for entry in obj)
return _deserialize_sequence(deserializer, module, obj)


def _sorted_annotations(types: list[typing.Any]) -> list[typing.Any]:
return sorted(
types,
key=lambda x: hasattr(x, "__name__") and x.__name__.lower() in ("str", "float", "int", "bool"),
)

def _get_deserialize_callable_from_annotation( # pylint: disable=too-many-return-statements, too-many-statements, too-many-branches
@functools.lru_cache(maxsize=None)
def _deserialize_callable_from_annotation_cached(
annotation: typing.Any,
module: typing.Optional[str],
) -> typing.Optional[typing.Callable[[typing.Any], typing.Any]]:
return _resolve_deserialize_callable_from_annotation(annotation, module, None)


def _get_deserialize_callable_from_annotation(
annotation: typing.Any,
module: typing.Optional[str],
rf: typing.Optional["_RestField"] = None,
) -> typing.Optional[typing.Callable[[typing.Any], typing.Any]]:
# The rf-bound path may mutate rf (e.g. rf._is_model) and is resolved every call.
# The rf-less path is side-effect free, so it is memoized on (annotation, module).
if rf is not None:
return _resolve_deserialize_callable_from_annotation(annotation, module, rf)
try:
return _deserialize_callable_from_annotation_cached(annotation, module)
except TypeError: # annotation can't be hashed, so it can't be used as a cache key
return _resolve_deserialize_callable_from_annotation(annotation, module, None)


def _resolve_deserialize_callable_from_annotation( # pylint: disable=too-many-return-statements, too-many-statements, too-many-branches
annotation: typing.Any,
module: typing.Optional[str],
rf: typing.Optional["_RestField"] = None,
Expand Down Expand Up @@ -1336,6 +1472,11 @@ def _get_deserialize_callable_from_annotation( # pylint: disable=too-many-retur
deserializer = _get_deserialize_callable_from_annotation(
annotation.__args__[0], module, rf # pyright: ignore
)
element_annotation = annotation.__args__[0] # pyright: ignore
if element_annotation in _PRIMITIVE_SEQUENCE_TYPES and not (rf and rf._format):
return functools.partial(
_deserialize_primitive_sequence, element_annotation, deserializer, module
)

return functools.partial(_deserialize_sequence, deserializer, module)
except (TypeError, IndexError, AttributeError, SyntaxError):
Expand All @@ -1353,10 +1494,15 @@ def _get_deserialize_callable_from_annotation( # pylint: disable=too-many-retur
pass
return obj

if get_deserializer(annotation, rf):
return functools.partial(_deserialize_default, get_deserializer(annotation, rf))
deserializer = get_deserializer(annotation, rf)
{% if code_model.has_external_type %}
if deserializer is TYPE_HANDLER_REGISTRY.get_deserializer(annotation):
def _deserialize_external_default(obj):
return _deserialize_default(TYPE_HANDLER_REGISTRY.get_deserializer(annotation) or annotation, obj)

return functools.partial(_deserialize_default, annotation)
return _deserialize_external_default
{% endif %}
return functools.partial(_deserialize_default, deserializer or annotation)


def _deserialize_with_callable(
Expand Down Expand Up @@ -1412,6 +1558,17 @@ def _deserialize(
return _deserialize_with_callable(deserializer, value)


def _deserialize_public(
deserializer: typing.Any,
value: typing.Any,
) -> typing.Any:
token = _DESERIALIZING_PUBLIC_VALUE.set(True)
try:
return _deserialize(deserializer, value)
finally:
_DESERIALIZING_PUBLIC_VALUE.reset(token)


def _failsafe_deserialize(
deserializer: typing.Any,
response: HttpResponse,
Expand Down Expand Up @@ -1541,7 +1698,7 @@ class _RestField:
return
if self._is_model:
if not _is_model(value):
value = _deserialize(self._type, value)
value = _deserialize_public(self._type, value)
obj.__setitem__(self._rest_name, value)
return
obj.__setitem__(self._rest_name, _serialize(value, self._format))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import geojson
from specs.azure.clientgenerator.core.alternatetype import AlternateTypeClient
from specs.azure.clientgenerator.core.alternatetype import models
from specs.azure.clientgenerator.core.alternatetype._utils.model_base import TYPE_HANDLER_REGISTRY, _deserialize

# Shared test data
PROPERTIES = {"name": "A single point of interest", "category": "landmark", "elevation": 100}
Expand Down Expand Up @@ -67,3 +68,23 @@ def test_external_type_put_property(client: AlternateTypeClient, feature_geojson
# Should return None (204/empty response)
result = client.external_type.put_property(body=model_with_feature)
assert result is None


def test_external_type_deserializer_registered_after_first_use():
class LateRegisteredType:
def __init__(self, data):
self.source = "default"
self.data = data

first = _deserialize(LateRegisteredType, {"value": 1})
assert first.source == "default"

@TYPE_HANDLER_REGISTRY.register_deserializer(LateRegisteredType)
def deserialize_late_registered_type(cls, data):
result = cls(data)
result.source = "registered"
return result

second = _deserialize(LateRegisteredType, {"value": 2})
assert second.source == "registered"
assert second.data == {"value": 2}
Loading
Loading