Repository navigation
[Python] - Improve Model Deserialization Perf #11705
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4fea7b3
017b92b
54148a2
8e43133
0e43653
3a1483c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
| {% endif %} | ||
| # pylint: disable=protected-access, broad-except | ||
|
|
||
| import contextvars | ||
| import copy | ||
| import calendar | ||
| import decimal | ||
|
|
@@ -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: | ||
| 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: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Python values stop being converted into JSON-friendly values Imagine 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: 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 | ||
|
|
@@ -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: | ||
|
|
@@ -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)) | ||
|
|
@@ -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) | ||
|
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] | ||
|
|
@@ -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) | ||
|
|
@@ -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 | ||
|
|
@@ -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", {}) | ||
|
|
@@ -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: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 <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") → 1The new numeric shortcut instead does: int(element) # Fails: an XML element is not a numberIts 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, | ||
|
|
@@ -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): | ||
|
|
@@ -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( | ||
|
|
@@ -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, | ||
|
|
@@ -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)) | ||
|
|
||
There was a problem hiding this comment.
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:
The resulting model differs:
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:
But not for these routes:
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.