Skip to content

Commit 3160ace

Browse files
bokelleyclaude
andauthored
feat(types)!: default serialize_as_any=True in AdCPBaseModel.model_dump (#639)
Symmetric with the existing exclude_none=True default. Removes the parent-side model_dump boilerplate that adopters previously needed to write per response type to make subclass @model_serializer overrides fire through base-typed parent fields. Field(exclude=True) remains the wire-isolation contract and continues to suppress internal fields at every nesting depth. BREAKING CHANGE: Subclasses that add fields without Field(exclude=True) will now have those fields appear in model_dump() output where they were previously dropped by Pydantic's declared-schema firewall. Audit each subclass and mark internal fields with Field(exclude=True). To restore the prior behavior at a specific call site, pass serialize_as_any=False explicitly. Closes #615 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 20e496c commit 3160ace

3 files changed

Lines changed: 180 additions & 27 deletions

File tree

docs/extending-types.md

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ ADCP types represent the standardized protocol schema. However, your implementat
44

55
This guide shows how to extend ADCP types safely while maintaining protocol compliance.
66

7-
> **Pydantic v2 serialization note:** Pydantic v2 uses a Rust-backed serializer. When a parent
8-
> model calls `model_dump()`, Pydantic serializes nested child instances using its own compiled
9-
> pipeline — it does **not** call Python-level `model_dump()` overrides on child objects. This
10-
> means that if you override `model_dump()` on a child class, that override will not fire when
11-
> the child is serialized as part of a parent. Use `Field(exclude=True)` for field-level
12-
> exclusion (works automatically at every nesting depth) or `@model_serializer` with
13-
> `serialize_as_any=True` for custom Python logic (covered below).
7+
> **Pydantic v2 serialization note:** Pydantic v2 uses a Rust-backed serializer that
8+
> serializes nested child instances using the declared schema of the parent's field, not the
9+
> child's `model_dump()` override. `AdCPBaseModel.model_dump()` and `model_dump_json()` set
10+
> `serialize_as_any=True` by default so that subclass `@model_serializer` overrides do fire
11+
> through base-typed parent fields, and `Field(exclude=True)` keeps internal fields off the
12+
> wire at every nesting depth. Adopters do **not** need to write parent-side `model_dump`
13+
> overrides to walk children — Pydantic does the walking; this guide covers the two seams
14+
> (`Field(exclude=True)` and `@model_serializer`) that hook into it.
1415
1516
## Field-Level Exclusion with `Field(exclude=True)` — Recommended
1617

@@ -61,11 +62,10 @@ For cases where you need Python-level transformation logic beyond field exclusio
6162
output, conditional inclusion, derived computed fields — use Pydantic's
6263
`@model_serializer(mode='wrap')`.
6364

64-
**Important:** When a parent field is annotated as the base type (`creatives: list[Creative]`),
65-
Pydantic's Rust serializer uses the declared type's schema and the subclass `@model_serializer`
66-
does **not** fire automatically. You must pass `serialize_as_any=True` to the parent's
67-
`model_dump()` call to apply subclass serializers from a parent. If you control the field
68-
annotation you can also declare it as the concrete subclass type.
65+
When the parent extends `AdCPBaseModel` (which all SDK-generated response types do), the
66+
parent's `model_dump()` defaults `serialize_as_any=True`, so subclass `@model_serializer`
67+
overrides fire automatically through base-typed parent fields. No call-site kwarg is
68+
required.
6969

7070
```python
7171
from typing import Any
@@ -91,20 +91,20 @@ class InternalCreative(Creative):
9191
c = InternalCreative(creative_id="c-1", variants=[], source_label="HD_VIDEO")
9292
c.model_dump() # {"creative_id": "c-1", "variants": [], "source_label": "hd_video"}
9393

94-
# Nested in a parent with a base-type annotation:
94+
# Nested under an AdCPBaseModel parent with a base-type annotation:
9595
class CreativePayload(AdCPBaseModel):
9696
creatives: list[Creative] # declared as base type
9797

9898
payload = CreativePayload(creatives=[c])
9999
payload.model_dump()
100-
# {"creatives": [{"creative_id": "c-1", "variants": []}]}
101-
# source_label absent, serializer skipped — Pydantic uses Creative's declared schema.
102-
103-
payload.model_dump(serialize_as_any=True)
104100
# {"creatives": [{"creative_id": "c-1", "variants": [], "source_label": "hd_video"}]}
105-
# serializer fired, source_label present and normalized.
101+
# Subclass serializer fired automatically — AdCPBaseModel.model_dump() defaults
102+
# serialize_as_any=True. Pass serialize_as_any=False explicitly to suppress it.
106103
```
107104

105+
If your parent extends plain `pydantic.BaseModel` (not `AdCPBaseModel`), you must pass
106+
`serialize_as_any=True` yourself — the default kwarg only ships on AdCP types.
107+
108108
## Migrating from Manual `model_dump()` Dispatch Overrides
109109

110110
A common pattern in early SDK integrations is writing a parent override that manually re-calls
@@ -139,19 +139,27 @@ class InternalCreative(Creative):
139139

140140
```python
141141
# ✅ Move the logic to the child via @model_serializer.
142-
# Call model_dump(serialize_as_any=True) on the parent to apply it.
142+
# AdCPBaseModel parents default serialize_as_any=True so the subclass serializer
143+
# fires automatically — no call-site kwarg needed.
143144
class InternalCreative(Creative):
144145
@model_serializer(mode="wrap")
145146
def _serialize(self, handler: Any, info: SerializationInfo) -> dict[str, Any]:
146147
result = handler(self, info)
147148
# ... custom logic here ...
148149
return result
149150

150-
# Parent: no model_dump() override; caller passes serialize_as_any=True.
151+
# Parent: no model_dump() override needed.
151152
payload = MyCreativePayload(creatives=[InternalCreative(creative_id="c-1", variants=[])])
152-
wire = payload.model_dump(serialize_as_any=True)
153+
wire = payload.model_dump()
153154
```
154155

156+
**Adopter migration note (`serialize_as_any` default flip):** If you have subclasses that
157+
add fields *without* `Field(exclude=True)`, those fields previously dropped at the
158+
wire because the parent's base-type annotation acted as an accidental firewall. They will
159+
now appear in `model_dump()` output. Audit each subclass and mark internal fields with
160+
`Field(exclude=True)`; the field is the canonical wire-isolation contract. If you need the
161+
prior behavior at a specific call site, pass `serialize_as_any=False` explicitly.
162+
155163
## Basic Pattern: Subclassing Response Types
156164

157165
```python

src/adcp/types/base.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -232,19 +232,24 @@ class AdCPBaseModel(BaseModel):
232232
model_config = ConfigDict(extra=_EXTRA_POLICY)
233233

234234
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
235-
# NOTE: Pydantic v2 uses a Rust-backed serializer that does NOT call Python-level
236-
# model_dump() overrides on nested child instances. If a child class overrides
237-
# model_dump() for custom serialization logic, that override will not fire when
238-
# the child is serialized as part of a parent model_dump() call. Use
239-
# Field(exclude=True) for field-level exclusion (works at all nesting depths) or
240-
# @model_serializer for custom output logic. See docs/extending-types.md.
235+
# ``serialize_as_any=True`` makes Pydantic dispatch on the runtime type of
236+
# nested values rather than the declared schema, so subclass
237+
# ``@model_serializer`` overrides fire from a base-typed parent field. Combined
238+
# with ``Field(exclude=True)`` on internal fields (which already works at every
239+
# nesting depth), this removes the parent-side ``model_dump`` boilerplate that
240+
# adopters previously needed to write per response type. See
241+
# docs/extending-types.md.
241242
if "exclude_none" not in kwargs:
242243
kwargs["exclude_none"] = True
244+
if "serialize_as_any" not in kwargs:
245+
kwargs["serialize_as_any"] = True
243246
return super().model_dump(**kwargs)
244247

245248
def model_dump_json(self, **kwargs: Any) -> str:
246249
if "exclude_none" not in kwargs:
247250
kwargs["exclude_none"] = True
251+
if "serialize_as_any" not in kwargs:
252+
kwargs["serialize_as_any"] = True
248253
return super().model_dump_json(**kwargs)
249254

250255
def model_summary(self) -> str:
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""AdCPBaseModel defaults ``serialize_as_any=True`` so that subclass
2+
``@model_serializer`` overrides fire when nested under a base-typed parent
3+
field, and ``Field(exclude=True)`` continues to suppress internal fields at
4+
every nesting depth.
5+
6+
Together these two guarantees mean adopters never need to write parent-side
7+
``model_dump`` overrides that manually walk children — Pydantic does the
8+
walking, ``Field(exclude=True)`` is the wire-isolation contract, and
9+
``@model_serializer`` is the custom-logic seam. The previous default
10+
(``serialize_as_any=False``) silently dropped subclass-only fields and
11+
skipped subclass serializers under nesting; that footgun is what these
12+
tests pin closed.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import json
18+
from typing import Any
19+
20+
from pydantic import BaseModel, Field, SerializationInfo, model_serializer
21+
22+
from adcp.types.base import AdCPBaseModel
23+
24+
25+
class _SpecChild(BaseModel):
26+
spec_field: str
27+
28+
29+
class _ExtendedChildWithExtraField(_SpecChild):
30+
"""Subclass that adds a non-excluded field — appears in serialized output
31+
when the parent dispatches via ``serialize_as_any=True``."""
32+
33+
seller_extension: str = "exposed"
34+
35+
36+
class _ExtendedChildWithExcludedField(_SpecChild):
37+
"""Subclass that adds an internal field marked ``exclude=True`` — must
38+
never appear on the wire, regardless of serialize_as_any state."""
39+
40+
internal_id: str = Field(default="internal-42", exclude=True)
41+
42+
43+
class _ExtendedChildWithSerializer(_SpecChild):
44+
"""Subclass with a wrap-mode model serializer — fires under nesting
45+
once serialize_as_any is set."""
46+
47+
@model_serializer(mode="wrap")
48+
def _serialize(self, handler: Any, info: SerializationInfo) -> dict[str, Any]:
49+
result: dict[str, Any] = handler(self, info)
50+
result["normalized_by_subclass"] = True
51+
return result
52+
53+
54+
class _Parent(AdCPBaseModel):
55+
"""Parent declares the field as the spec base type."""
56+
57+
child: _SpecChild
58+
children: list[_SpecChild] = Field(default_factory=list)
59+
60+
61+
def test_subclass_serializer_fires_on_singular_field() -> None:
62+
parent = _Parent(child=_ExtendedChildWithSerializer(spec_field="ok"))
63+
dumped = parent.model_dump()
64+
assert dumped["child"] == {"spec_field": "ok", "normalized_by_subclass": True}
65+
66+
67+
def test_subclass_serializer_fires_on_list_field() -> None:
68+
parent = _Parent(
69+
child=_SpecChild(spec_field="root"),
70+
children=[
71+
_ExtendedChildWithSerializer(spec_field="a"),
72+
_ExtendedChildWithSerializer(spec_field="b"),
73+
],
74+
)
75+
dumped = parent.model_dump()
76+
for entry in dumped["children"]:
77+
assert entry["normalized_by_subclass"] is True
78+
79+
80+
def test_subclass_serializer_fires_in_json_dump() -> None:
81+
"""``model_dump_json`` carries the same default."""
82+
parent = _Parent(child=_ExtendedChildWithSerializer(spec_field="ok"))
83+
dumped = json.loads(parent.model_dump_json())
84+
assert dumped["child"]["normalized_by_subclass"] is True
85+
86+
87+
def test_field_exclude_true_still_suppresses_internal_field() -> None:
88+
"""The wire-isolation contract: ``Field(exclude=True)`` keeps internal
89+
state off the wire even when serialize_as_any honors subclass schemas."""
90+
parent = _Parent(child=_ExtendedChildWithExcludedField(spec_field="ok"))
91+
dumped = parent.model_dump()
92+
assert dumped["child"] == {"spec_field": "ok"}
93+
assert "internal_id" not in dumped["child"]
94+
95+
96+
def test_field_exclude_true_works_in_list_field() -> None:
97+
parent = _Parent(
98+
child=_SpecChild(spec_field="root"),
99+
children=[
100+
_ExtendedChildWithExcludedField(spec_field="a"),
101+
_ExtendedChildWithExcludedField(spec_field="b"),
102+
],
103+
)
104+
dumped = parent.model_dump()
105+
for entry in dumped["children"]:
106+
assert "internal_id" not in entry
107+
108+
109+
def test_subclass_only_field_appears_under_default() -> None:
110+
"""Subclasses that add fields without ``Field(exclude=True)`` will see
111+
those fields appear on the wire under the new default. This pins the
112+
behavior change so adopters who relied on the previous accidental
113+
firewall surface a failing test rather than discovering it in
114+
production."""
115+
parent = _Parent(child=_ExtendedChildWithExtraField(spec_field="ok"))
116+
dumped = parent.model_dump()
117+
assert dumped["child"] == {"spec_field": "ok", "seller_extension": "exposed"}
118+
119+
120+
def test_caller_can_opt_out_with_explicit_kwarg() -> None:
121+
"""Adopters who want the prior firewall back can pass
122+
``serialize_as_any=False`` explicitly — the default only kicks in when
123+
the kwarg is unset."""
124+
parent = _Parent(child=_ExtendedChildWithExtraField(spec_field="ok"))
125+
dumped = parent.model_dump(serialize_as_any=False)
126+
assert dumped["child"] == {"spec_field": "ok"}
127+
assert "seller_extension" not in dumped["child"]
128+
129+
130+
def test_caller_can_still_pass_exclude_none_false() -> None:
131+
"""The two defaults are independent — overriding one doesn't disturb
132+
the other."""
133+
134+
class _ParentWithOptional(AdCPBaseModel):
135+
child: _SpecChild
136+
optional: str | None = None
137+
138+
parent = _ParentWithOptional(child=_SpecChild(spec_field="ok"))
139+
dumped = parent.model_dump(exclude_none=False)
140+
assert dumped["optional"] is None

0 commit comments

Comments
 (0)