Skip to content

Commit b8aebf4

Browse files
perf(types): defer pydantic core-schema builds to cut import memory
Add defer_build=True to AdCPBaseModel so each generated model's pydantic-core validator/serializer is built lazily on first use rather than at class definition. With ~1500 generated model classes, eager builds dominate `import adcp` memory; deferring builds only the working set (18 of 1564 classes build at import). Because model_dump forces serialize_as_any=True, pydantic-core dispatches serialization to each nested instance's own class serializer, which under defer_build stays a placeholder and bypasses the lazy-build trigger. Walk the instance graph on that error and rebuild the still-deferred classes, then retry, so only models that appear in real payloads get built. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7c81056 commit b8aebf4

2 files changed

Lines changed: 76 additions & 3 deletions

File tree

src/adcp/types/base.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,36 @@ def _resolve_extra_policy() -> Literal["ignore", "forbid"]:
3838
_EXTRA_POLICY: Literal["ignore", "forbid"] = _resolve_extra_policy()
3939

4040

41+
def _build_deferred_serializers(value: Any, seen: set[int]) -> None:
42+
"""Force lazy core-schema builds for every model instance in a graph.
43+
44+
With ``defer_build=True`` a model class used only as a nested field keeps a
45+
placeholder serializer until it is first built. ``serialize_as_any=True``
46+
(set by :meth:`AdCPBaseModel.model_dump`) makes pydantic-core dispatch
47+
serialization to each nested instance's *own* class serializer, a path that
48+
does not trigger the lazy build. This walks the instance graph and rebuilds
49+
any class whose core schema is still deferred, so only the model classes
50+
that actually appear in serialized payloads get built — preserving the
51+
import-time memory saving while keeping serialization correct.
52+
"""
53+
if isinstance(value, BaseModel):
54+
ident = id(value)
55+
if ident in seen:
56+
return
57+
seen.add(ident)
58+
cls = type(value)
59+
if not cls.__pydantic_complete__:
60+
cls.model_rebuild(force=False)
61+
for field_value in value.__dict__.values():
62+
_build_deferred_serializers(field_value, seen)
63+
elif isinstance(value, (list, tuple, set, frozenset)):
64+
for item in value:
65+
_build_deferred_serializers(item, seen)
66+
elif isinstance(value, dict):
67+
for item in value.values():
68+
_build_deferred_serializers(item, seen)
69+
70+
4171
def _pluralize(count: int, singular: str, plural: str | None = None) -> str:
4272
"""Return singular or plural form based on count."""
4373
if count == 1:
@@ -229,7 +259,12 @@ class AdCPBaseModel(BaseModel):
229259
``model_config`` on their subclass.
230260
"""
231261

232-
model_config = ConfigDict(extra=_EXTRA_POLICY)
262+
# ``defer_build=True`` skips building each model's pydantic-core
263+
# validator/serializer at class-definition time. With ~700 generated model
264+
# modules, eager builds dominate ``import adcp`` memory; deferring means each
265+
# model's core schema is built lazily on first validate/serialize, so only
266+
# the handful of models actually used are paid for.
267+
model_config = ConfigDict(extra=_EXTRA_POLICY, defer_build=True)
233268

234269
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
235270
# ``serialize_as_any=True`` makes Pydantic dispatch on the runtime type of
@@ -243,14 +278,26 @@ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
243278
kwargs["exclude_none"] = True
244279
if "serialize_as_any" not in kwargs:
245280
kwargs["serialize_as_any"] = True
246-
return super().model_dump(**kwargs)
281+
try:
282+
return super().model_dump(**kwargs)
283+
except TypeError as exc:
284+
if "MockValSer" not in str(exc):
285+
raise
286+
_build_deferred_serializers(self, set())
287+
return super().model_dump(**kwargs)
247288

248289
def model_dump_json(self, **kwargs: Any) -> str:
249290
if "exclude_none" not in kwargs:
250291
kwargs["exclude_none"] = True
251292
if "serialize_as_any" not in kwargs:
252293
kwargs["serialize_as_any"] = True
253-
return super().model_dump_json(**kwargs)
294+
try:
295+
return super().model_dump_json(**kwargs)
296+
except TypeError as exc:
297+
if "MockValSer" not in str(exc):
298+
raise
299+
_build_deferred_serializers(self, set())
300+
return super().model_dump_json(**kwargs)
254301

255302
def model_summary(self) -> str:
256303
"""Human-readable summary for protocol responses.

tests/test_serialize_as_any_default.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,32 @@ def test_caller_can_opt_out_with_explicit_kwarg() -> None:
127127
assert "seller_extension" not in dumped["child"]
128128

129129

130+
class _DeferredChild(AdCPBaseModel):
131+
spec_field: str
132+
133+
134+
class _DeferredParent(AdCPBaseModel):
135+
child: _DeferredChild
136+
children: list[_DeferredChild] = Field(default_factory=list)
137+
138+
139+
def test_serialize_as_any_builds_deferred_nested_serializer() -> None:
140+
"""``defer_build=True`` leaves a nested-only model's serializer as a
141+
placeholder until first built. ``serialize_as_any=True`` dispatches to that
142+
placeholder and would raise ``TypeError: 'MockValSer' object is not an
143+
instance of 'SchemaSerializer'``. The model is built from a dict so the
144+
child class is never validated or dumped on its own — exactly the path that
145+
leaves its serializer deferred. ``model_dump``/``model_dump_json`` must
146+
build it on demand."""
147+
parent = _DeferredParent.model_validate(
148+
{"child": {"spec_field": "ok"}, "children": [{"spec_field": "a"}]}
149+
)
150+
dumped = parent.model_dump()
151+
assert dumped["child"] == {"spec_field": "ok"}
152+
assert dumped["children"] == [{"spec_field": "a"}]
153+
assert json.loads(parent.model_dump_json())["child"] == {"spec_field": "ok"}
154+
155+
130156
def test_caller_can_still_pass_exclude_none_false() -> None:
131157
"""The two defaults are independent — overriding one doesn't disturb
132158
the other."""

0 commit comments

Comments
 (0)