Skip to content

Commit 4912af9

Browse files
bokelleyclaude
andauthored
docs(types): document Field(exclude=True) and @model_serializer for nested wire isolation (#630)
* docs(types): document Field(exclude=True) and @model_serializer for nested wire isolation Pydantic v2's Rust-backed serializer does not call Python model_dump() overrides on nested child instances — a gap that was driving adopters to write mechanical parent-level dispatch boilerplate (~59 overrides in at least one downstream integration). Neither Field(exclude=True) nor @model_serializer(mode='wrap') require parent-level workarounds; both work correctly at every nesting depth via Pydantic's own pipeline. This commit: - Adds a top-level serialization note to extending-types.md explaining the Pydantic v2 nesting behavior - Adds a "Field(exclude=True) — Recommended" section with a working nested example - Adds a "@model_serializer" section for custom Python-level logic - Adds a migration guide replacing the 59-style parent-dispatch pattern - Adds a serialize_as_any note for runtime-type field inclusion - Updates Best Practices #1 to prefer Field(exclude=True) over call-site exclude={} - Adds a one-line comment on AdCPBaseModel.model_dump() cross-referencing the doc so source readers hit the explanation at the declaration https://claude.ai/code/session_01P7MQW9tW7z4rYm13zghrVC * docs(types): fix pre-PR review blockers in extending-types.md - @model_serializer: correct nesting claim — serializer only fires when serialized directly or when parent calls model_dump(serialize_as_any=True); update example to use user-defined source_label field (no non-existent render_url), add serialize_as_any=True demonstration - Field(exclude=True): fix import (adcp.types.base not adcp.types), switch example to Creative/CreativePayload (verifiable fields), remove invalid Product constructor that would raise ValidationError - Migration section: rename GetCreativesResponse (non-existent) to MyCreativePayload - Best Practices #1: fix CreateMediaBuySuccess → CreateMediaBuySuccessResponse (correct export name), add imports https://claude.ai/code/session_01P7MQW9tW7z4rYm13zghrVC * docs(types): fix pre-existing wrong type names throughout extending-types.md CreateMediaBuySuccess is not exported from adcp — the correct name is CreateMediaBuySuccessResponse. WebhookPayload is also not exported — McpWebhookPayload is the correct replacement. All 10+ import and usage sites in the pre-existing patterns corrected; class name suffixes (CreateMediaBuySuccessExtended) preserved. https://claude.ai/code/session_01P7MQW9tW7z4rYm13zghrVC --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fdd4053 commit 4912af9

2 files changed

Lines changed: 195 additions & 32 deletions

File tree

docs/extending-types.md

Lines changed: 189 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,161 @@ 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).
14+
15+
## Field-Level Exclusion with `Field(exclude=True)` — Recommended
16+
17+
The simplest and most reliable way to keep internal fields off the wire. Fields annotated with
18+
`Field(exclude=True)` are excluded by Pydantic's own serializer at **every nesting depth** — no
19+
call-site `exclude={}` plumbing, no parent-model override required.
20+
21+
```python
22+
from typing import Any
23+
from pydantic import Field
24+
from adcp import Creative
25+
from adcp.types.base import AdCPBaseModel
26+
27+
28+
class InternalCreative(Creative):
29+
"""Creative extended with seller-internal fields."""
30+
internal_approval_id: str | None = Field(default=None, exclude=True)
31+
seller_notes: str | None = Field(default=None, exclude=True)
32+
33+
34+
class CreativePayload(AdCPBaseModel):
35+
"""User-defined payload — creatives declared as base type."""
36+
creatives: list[Creative]
37+
38+
39+
resp = CreativePayload(
40+
creatives=[
41+
InternalCreative(
42+
creative_id="c-1",
43+
variants=[],
44+
internal_approval_id="approv-42",
45+
seller_notes="approved by legal",
46+
)
47+
]
48+
)
49+
50+
wire = resp.model_dump()
51+
# {"creatives": [{"creative_id": "c-1", "variants": []}]}
52+
# internal_approval_id and seller_notes are absent — no parent override needed.
53+
```
54+
55+
`Field(exclude=True)` works with `model_dump()`, `model_dump_json()`, and all standard Pydantic
56+
serialization options including `exclude_none=True`.
57+
58+
## Custom Serialization Logic with `@model_serializer`
59+
60+
For cases where you need Python-level transformation logic beyond field exclusion — reshaping
61+
output, conditional inclusion, derived computed fields — use Pydantic's
62+
`@model_serializer(mode='wrap')`.
63+
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.
69+
70+
```python
71+
from typing import Any
72+
from pydantic import SerializationInfo, model_serializer
73+
from adcp import Creative
74+
from adcp.types.base import AdCPBaseModel
75+
76+
77+
class InternalCreative(Creative):
78+
"""Creative with a normalized source_label field."""
79+
source_label: str | None = None
80+
81+
@model_serializer(mode="wrap")
82+
def _serialize(self, handler: Any, info: SerializationInfo) -> dict[str, Any]:
83+
result = handler(self, info)
84+
# Normalize source_label to lowercase before it hits the wire.
85+
if result.get("source_label"):
86+
result["source_label"] = result["source_label"].lower()
87+
return result
88+
89+
90+
# Direct serialization: serializer fires.
91+
c = InternalCreative(creative_id="c-1", variants=[], source_label="HD_VIDEO")
92+
c.model_dump() # {"creative_id": "c-1", "variants": [], "source_label": "hd_video"}
93+
94+
# Nested in a parent with a base-type annotation:
95+
class CreativePayload(AdCPBaseModel):
96+
creatives: list[Creative] # declared as base type
97+
98+
payload = CreativePayload(creatives=[c])
99+
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)
104+
# {"creatives": [{"creative_id": "c-1", "variants": [], "source_label": "hd_video"}]}
105+
# serializer fired, source_label present and normalized.
106+
```
107+
108+
## Migrating from Manual `model_dump()` Dispatch Overrides
109+
110+
A common pattern in early SDK integrations is writing a parent override that manually re-calls
111+
`model_dump()` on each child list:
112+
113+
```python
114+
# ❌ Fragile: every new response type needs this boilerplate, and missing one is silent.
115+
class MyCreativePayload(AdCPBaseModel):
116+
creatives: list[Creative]
117+
118+
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
119+
result = super().model_dump(**kwargs)
120+
if "creatives" in result and self.creatives:
121+
result["creatives"] = [c.model_dump(**kwargs) for c in self.creatives]
122+
return result
123+
```
124+
125+
This requires ~10 lines per response type, must be written for every parent, and silently
126+
produces wrong output if a new child list field is added without updating the override.
127+
128+
**Migration — field exclusion only:**
129+
130+
```python
131+
# ✅ Delete the parent override entirely. Move exclusion to the child via Field(exclude=True).
132+
class InternalCreative(Creative):
133+
internal_approval_id: str | None = Field(default=None, exclude=True)
134+
135+
# MyCreativePayload needs no model_dump() override — Pydantic handles it at all depths.
136+
```
137+
138+
**Migration — custom Python logic:**
139+
140+
```python
141+
# ✅ Move the logic to the child via @model_serializer.
142+
# Call model_dump(serialize_as_any=True) on the parent to apply it.
143+
class InternalCreative(Creative):
144+
@model_serializer(mode="wrap")
145+
def _serialize(self, handler: Any, info: SerializationInfo) -> dict[str, Any]:
146+
result = handler(self, info)
147+
# ... custom logic here ...
148+
return result
149+
150+
# Parent: no model_dump() override; caller passes serialize_as_any=True.
151+
payload = MyCreativePayload(creatives=[InternalCreative(creative_id="c-1", variants=[])])
152+
wire = payload.model_dump(serialize_as_any=True)
153+
```
154+
7155
## Basic Pattern: Subclassing Response Types
8156

9157
```python
10-
from adcp import CreateMediaBuySuccess
158+
from adcp import CreateMediaBuySuccessResponse
11159
from pydantic import ConfigDict, Field
12160

13-
class CreateMediaBuySuccessExtended(CreateMediaBuySuccess):
161+
class CreateMediaBuySuccessExtended(CreateMediaBuySuccessResponse):
14162
"""Extended with internal tracking fields."""
15163
workflow_step_id: str | None = Field(None, description="Internal workflow step ID")
16164
created_at: str | None = Field(None, description="Internal timestamp")
@@ -31,7 +179,7 @@ internal_response = CreateMediaBuySuccessExtended(
31179
)
32180

33181
# Serialize to ADCP spec before sending over wire
34-
adcp_response = CreateMediaBuySuccess.model_validate(
182+
adcp_response = CreateMediaBuySuccessResponse.model_validate(
35183
internal_response.model_dump(exclude={'workflow_step_id', 'created_at', 'internal_notes'})
36184
)
37185
```
@@ -57,10 +205,10 @@ class InternalResponseWrapper(BaseModel, Generic[T]):
57205
model_config = ConfigDict(extra='allow')
58206

59207
# Usage
60-
from adcp import CreateMediaBuySuccess
208+
from adcp import CreateMediaBuySuccessResponse
61209

62-
wrapper = InternalResponseWrapper[CreateMediaBuySuccess](
63-
response=CreateMediaBuySuccess(
210+
wrapper = InternalResponseWrapper[CreateMediaBuySuccessResponse](
211+
response=CreateMediaBuySuccessResponse(
64212
media_buy_id="mb_123",
65213
buyer_ref="ref_456",
66214
packages=[]
@@ -70,7 +218,7 @@ wrapper = InternalResponseWrapper[CreateMediaBuySuccess](
70218
)
71219

72220
# Access ADCP response
73-
adcp_response = wrapper.response # Type: CreateMediaBuySuccess
221+
adcp_response = wrapper.response # Type: CreateMediaBuySuccessResponse
74222

75223
# Access internal fields
76224
workflow_id = wrapper.workflow_step_id
@@ -82,7 +230,7 @@ When storing responses in a database with internal metadata:
82230

83231
```python
84232
from datetime import datetime
85-
from adcp import CreateMediaBuySuccess
233+
from adcp import CreateMediaBuySuccessResponse
86234

87235
class MediaBuyRecord(BaseModel):
88236
"""Database record combining ADCP response with internal metadata."""
@@ -94,12 +242,12 @@ class MediaBuyRecord(BaseModel):
94242
workflow_step_id: str
95243

96244
# ADCP response (stored as JSON)
97-
response_data: CreateMediaBuySuccess
245+
response_data: CreateMediaBuySuccessResponse
98246

99247
@classmethod
100248
def from_response(
101249
cls,
102-
response: CreateMediaBuySuccess,
250+
response: CreateMediaBuySuccessResponse,
103251
user_id: str,
104252
workflow_step_id: str
105253
) -> "MediaBuyRecord":
@@ -113,13 +261,13 @@ class MediaBuyRecord(BaseModel):
113261
response_data=response
114262
)
115263

116-
def to_adcp_response(self) -> CreateMediaBuySuccess:
264+
def to_adcp_response(self) -> CreateMediaBuySuccessResponse:
117265
"""Extract ADCP response for wire protocol."""
118266
return self.response_data
119267

120268
# Usage
121269
response = await client.create_media_buy(request)
122-
if isinstance(response, CreateMediaBuySuccess):
270+
if isinstance(response, CreateMediaBuySuccessResponse):
123271
record = MediaBuyRecord.from_response(
124272
response,
125273
user_id="user_123",
@@ -136,10 +284,10 @@ adcp_response = record.to_adcp_response()
136284
When processing webhook payloads with internal routing metadata:
137285

138286
```python
139-
from adcp import WebhookPayload
287+
from adcp import McpMcpWebhookPayload
140288
from pydantic import ConfigDict
141289

142-
class InternalWebhookPayload(WebhookPayload):
290+
class InternalWebhookPayload(McpWebhookPayload):
143291
"""Extended webhook payload with internal routing."""
144292
internal_destination: str | None = None
145293
retry_count: int = 0
@@ -150,7 +298,7 @@ class InternalWebhookPayload(WebhookPayload):
150298
async def process_webhook(payload: dict) -> None:
151299
"""Process webhook with internal tracking."""
152300
# Parse with extensions
153-
internal_payload = InternalWebhookPayload.model_validate(payload)
301+
internal_payload = InternalMcpWebhookPayload.model_validate(payload)
154302

155303
# Add internal routing
156304
internal_payload.internal_destination = determine_destination(internal_payload)
@@ -160,7 +308,7 @@ async def process_webhook(payload: dict) -> None:
160308
await route_to_handler(internal_payload)
161309

162310
# When forwarding to another service, use base type
163-
external_payload = WebhookPayload.model_validate(
311+
external_payload = McpWebhookPayload.model_validate(
164312
internal_payload.model_dump(exclude={'internal_destination', 'retry_count', 'routing_key'})
165313
)
166314
```
@@ -211,29 +359,38 @@ response = await client.create_media_buy(internal_request.to_adcp_request())
211359

212360
### 1. Always Use Field Exclusion for Wire Protocol
213361

214-
**Don't** rely on serialization settings to exclude internal fields automatically:
362+
**Prefer `Field(exclude=True)` over call-site `model_dump(exclude={...})`.** `Field(exclude=True)` is declared once on the field, works at every nesting depth automatically, and cannot be forgotten at a call site.
215363

216364
```python
365+
from adcp import CreateMediaBuySuccessResponse
366+
from pydantic import Field
367+
217368
# ❌ BAD: Relying on field name conventions
218-
class Extended(CreateMediaBuySuccess):
219-
_internal_id: str # Private field - might not serialize correctly
369+
class Extended(CreateMediaBuySuccessResponse):
370+
_internal_id: str # Private field — may or may not serialize correctly
220371

221-
# ✅ GOOD: Explicit exclusion
222-
class Extended(CreateMediaBuySuccess):
372+
# ⚠ OK but fragile: call-site exclusion must be repeated every time model_dump() is called
373+
class Extended(CreateMediaBuySuccessResponse):
223374
internal_id: str
224375

225-
adcp_response = CreateMediaBuySuccess.model_validate(
226-
extended.model_dump(exclude={'internal_id'})
376+
adcp_response = CreateMediaBuySuccessResponse.model_validate(
377+
extended.model_dump(exclude={"internal_id"}) # Easy to forget, silent if omitted
227378
)
379+
380+
# ✅ BEST: Field-level exclusion fires automatically at all nesting depths
381+
class Extended(CreateMediaBuySuccessResponse):
382+
internal_id: str = Field(exclude=True)
383+
384+
adcp_response = extended.model_dump() # internal_id is absent — no extra plumbing
228385
```
229386

230387
### 2. Document Internal Fields
231388

232389
Make it clear which fields are internal:
233390

234391
```python
235-
class Extended(CreateMediaBuySuccess):
236-
"""Extended CreateMediaBuySuccess with internal tracking.
392+
class Extended(CreateMediaBuySuccessResponse):
393+
"""Extended CreateMediaBuySuccessResponse with internal tracking.
237394
238395
Internal fields (not part of ADCP spec):
239396
workflow_step_id: Internal workflow tracking
@@ -257,7 +414,7 @@ def test_internal_fields_excluded():
257414
)
258415

259416
# Convert to wire protocol
260-
adcp_response = CreateMediaBuySuccess.model_validate(
417+
adcp_response = CreateMediaBuySuccessResponse.model_validate(
261418
extended.model_dump(exclude={'workflow_step_id'})
262419
)
263420

@@ -273,7 +430,7 @@ def test_internal_fields_excluded():
273430
from typing import TypeGuard
274431

275432
def is_extended_response(
276-
response: CreateMediaBuySuccess
433+
response: CreateMediaBuySuccessResponse
277434
) -> TypeGuard[CreateMediaBuySuccessExtended]:
278435
"""Check if response has extended internal fields."""
279436
return isinstance(response, CreateMediaBuySuccessExtended)
@@ -291,16 +448,16 @@ Define reusable field sets for exclusion:
291448
```python
292449
from typing import ClassVar
293450

294-
class CreateMediaBuySuccessExtended(CreateMediaBuySuccess):
451+
class CreateMediaBuySuccessExtended(CreateMediaBuySuccessResponse):
295452
workflow_step_id: str | None = None
296453
created_at: str | None = None
297454

298455
# Define internal fields as class variable
299456
INTERNAL_FIELDS: ClassVar[set[str]] = {'workflow_step_id', 'created_at'}
300457

301-
def to_adcp_response(self) -> CreateMediaBuySuccess:
458+
def to_adcp_response(self) -> CreateMediaBuySuccessResponse:
302459
"""Convert to wire protocol, excluding internal fields."""
303-
return CreateMediaBuySuccess.model_validate(
460+
return CreateMediaBuySuccessResponse.model_validate(
304461
self.model_dump(exclude=self.INTERNAL_FIELDS)
305462
)
306463
```
@@ -346,7 +503,7 @@ def test_roundtrip():
346503
)
347504

348505
# Convert to base type
349-
base = CreateMediaBuySuccess.model_validate(
506+
base = CreateMediaBuySuccessResponse.model_validate(
350507
extended.model_dump(exclude={'workflow_step_id'})
351508
)
352509

@@ -356,7 +513,7 @@ def test_roundtrip():
356513

357514
# Verify can parse from wire format
358515
wire_format = base.model_dump_json()
359-
parsed = CreateMediaBuySuccess.model_validate_json(wire_format)
516+
parsed = CreateMediaBuySuccessResponse.model_validate_json(wire_format)
360517
assert parsed.media_buy_id == "mb_123"
361518
```
362519

src/adcp/types/base.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,12 @@ 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.
235241
if "exclude_none" not in kwargs:
236242
kwargs["exclude_none"] = True
237243
return super().model_dump(**kwargs)

0 commit comments

Comments
 (0)