Skip to content

Commit 67a4d38

Browse files
committed
fix(python): keep two-argument serialize_model for overrides
The generated model serializer took a third `info` argument so it could read `exclude_none`, which broke subclasses that delegate with `super().serialize_model(handler)` and two-argument overrides under type checking. `serialize_model(self, handler)` keeps its previous signature and is no longer the pydantic serializer itself. A new `_serialize_model_with_info` wrap serializer receives `info`, exposes `exclude_none` through a context variable for the duration of the call, and delegates to `self.serialize_model`. Overrides decorated with `@model_serializer` that call the parent get the previous behaviour; undecorated overrides keep the include/exclude filtering.
1 parent 7eca0ea commit 67a4d38

56 files changed

Lines changed: 641 additions & 235 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
11
from .base64fileinput import Base64EncodedString, Base64FileInput
22
from .basemodel import (
33
BaseModel,
4+
current_exclude_none,
45
Nullable,
56
OptionalNullable,
67
UnrecognizedInt,
78
UnrecognizedStr,
89
UNSET,
910
UNSET_SENTINEL,
11+
serialize_with_info,
1012
)
1113

1214
__all__ = [
1315
"Base64EncodedString",
1416
"Base64FileInput",
1517
"BaseModel",
18+
"current_exclude_none",
1619
"Nullable",
1720
"OptionalNullable",
1821
"UnrecognizedInt",
1922
"UnrecognizedStr",
2023
"UNSET",
2124
"UNSET_SENTINEL",
25+
"serialize_with_info",
2226
]

‎templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
from contextvars import ContextVar
12
from pydantic import ConfigDict, model_serializer
23
from pydantic import BaseModel as PydanticBaseModel
34
from pydantic_core import PydanticSerializationUnexpectedValue, core_schema
4-
from typing import TYPE_CHECKING, Any, Literal, Optional, Type, TypeVar, Union
5+
from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Type, TypeVar, Union
56
from typing_extensions import TypeAliasType, TypeAlias
67

78

@@ -35,6 +36,24 @@ def __bool__(self) -> Literal[False]:
3536
)
3637

3738

39+
_exclude_none: ContextVar[Optional[bool]] = ContextVar("exclude_none", default=None)
40+
41+
42+
def serialize_with_info(
43+
serialize: Callable[[Any], Any], handler: Any, info: core_schema.SerializationInfo
44+
) -> Any:
45+
token = _exclude_none.set(info.exclude_none)
46+
try:
47+
return serialize(handler)
48+
finally:
49+
_exclude_none.reset(token)
50+
51+
52+
def current_exclude_none() -> Optional[bool]:
53+
"""Return the exclude_none option of the enclosing serialize_with_info call, or None outside of one."""
54+
return _exclude_none.get()
55+
56+
3857
def _unrecognized_serialization(cls: Type[Union[str, int]]) -> core_schema.PlainSerializerFunctionSerSchema:
3958
def serialize(value: Any, info: core_schema.SerializationInfo) -> Any:
4059
# Decline other values so union serialization tries the remaining members

‎templates/templates/pythonv2/model.py.stmpl‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,14 @@ class {{$className}}(BaseModel):
7575
{{- if or $hasNullableField $hasOptionalField}}
7676
{{- addImport "pydantic" "model_serializer"}}
7777
{{- addImport "types" "UNSET_SENTINEL" true}}
78+
{{- addImport "types" "serialize_with_info" true}}
79+
{{- addImport "types" "current_exclude_none" true}}
80+
{{- $serializeMethodName := sanitizeSerializeMethodName $type}}
7881
@model_serializer(mode="wrap")
79-
def {{sanitizeSerializeMethodName $type}}(self, handler, info):
82+
def _serialize_model_with_info(self, handler, info):
83+
return serialize_with_info(self.{{$serializeMethodName}}, handler, info)
84+
85+
def {{$serializeMethodName}}(self, handler):
8086
{{- $nullDefaultFields := templateNullDefaultFieldList $type}}
8187
{{- $hasNullDefaultField := gt (len $nullDefaultFields) 0}}
8288
{{- if $hasOptionalField}}
@@ -88,12 +94,13 @@ class {{$className}}(BaseModel):
8894
{{- end}}
8995
{{- end}}
9096
{{- end}}
97+
exclude_none = current_exclude_none()
9198
serialized = handler(self)
9299
m = {}
93100

94101
for n, f in type(self).model_fields.items():
95102
k = f.alias if f.alias is not None else n
96-
if k not in serialized and n not in serialized and not info.exclude_none:
103+
if k not in serialized and n not in serialized and exclude_none is False:
97104
continue
98105
val = serialized.get(k, serialized.get(n))
99106
{{- if $hasAdditionalProperties}}

‎templates/templates/pythonv2/tests/primary/test_model_dump_filters_additional.py‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import json
22
from typing import List, Optional
33

4-
from pydantic import TypeAdapter
4+
from pydantic import TypeAdapter, model_serializer
55

66
from openapi import utils
77
from openapi.models import errors, shared
@@ -204,3 +204,29 @@ def test_value_kept_under_empty_api_name(self):
204204
assert model.model_dump()[""] == b"x"
205205
assert "content" not in model.model_dump(by_alias=True)
206206
assert "" not in model.model_dump(by_alias=True, exclude={"content"})
207+
208+
209+
class TestSerializerOverrides:
210+
def test_decorated_override_calls_parent(self):
211+
class Custom(shared.FilteredFields):
212+
@model_serializer(mode="wrap")
213+
def serialize_model(self, handler):
214+
return {**super().serialize_model(handler), "custom": True}
215+
216+
dumped = Custom(display_name="name", required_nullable=None).model_dump()
217+
218+
assert dumped["displayName"] == "name"
219+
assert dumped["requiredNullable"] is None
220+
assert dumped["custom"] is True
221+
222+
def test_undecorated_override_keeps_filters(self):
223+
class Custom(shared.FilteredFields):
224+
def serialize_model(self, handler):
225+
return {**super().serialize_model(handler), "custom": True}
226+
227+
dumped = Custom(display_name="name", required_nullable=None).model_dump(
228+
exclude={"display_name"}
229+
)
230+
231+
assert "displayName" not in dumped
232+
assert dumped["custom"] is True

0 commit comments

Comments
 (0)