From b8aebf48c0f2e64896135e4c7e73153dbbdbe4a3 Mon Sep 17 00:00:00 2001 From: Andy Bevan Date: Sat, 27 Jun 2026 19:06:00 -0400 Subject: [PATCH 1/2] 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 --- src/adcp/types/base.py | 53 ++++++++++++++++++++++++-- tests/test_serialize_as_any_default.py | 26 +++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/adcp/types/base.py b/src/adcp/types/base.py index 005bc6bc1..0a7b09fea 100644 --- a/src/adcp/types/base.py +++ b/src/adcp/types/base.py @@ -38,6 +38,36 @@ def _resolve_extra_policy() -> Literal["ignore", "forbid"]: _EXTRA_POLICY: Literal["ignore", "forbid"] = _resolve_extra_policy() +def _build_deferred_serializers(value: Any, seen: set[int]) -> None: + """Force lazy core-schema builds for every model instance in a graph. + + With ``defer_build=True`` a model class used only as a nested field keeps a + placeholder serializer until it is first built. ``serialize_as_any=True`` + (set by :meth:`AdCPBaseModel.model_dump`) makes pydantic-core dispatch + serialization to each nested instance's *own* class serializer, a path that + does not trigger the lazy build. This walks the instance graph and rebuilds + any class whose core schema is still deferred, so only the model classes + that actually appear in serialized payloads get built — preserving the + import-time memory saving while keeping serialization correct. + """ + if isinstance(value, BaseModel): + ident = id(value) + if ident in seen: + return + seen.add(ident) + cls = type(value) + if not cls.__pydantic_complete__: + cls.model_rebuild(force=False) + for field_value in value.__dict__.values(): + _build_deferred_serializers(field_value, seen) + elif isinstance(value, (list, tuple, set, frozenset)): + for item in value: + _build_deferred_serializers(item, seen) + elif isinstance(value, dict): + for item in value.values(): + _build_deferred_serializers(item, seen) + + def _pluralize(count: int, singular: str, plural: str | None = None) -> str: """Return singular or plural form based on count.""" if count == 1: @@ -229,7 +259,12 @@ class AdCPBaseModel(BaseModel): ``model_config`` on their subclass. """ - model_config = ConfigDict(extra=_EXTRA_POLICY) + # ``defer_build=True`` skips building each model's pydantic-core + # validator/serializer at class-definition time. With ~700 generated model + # modules, eager builds dominate ``import adcp`` memory; deferring means each + # model's core schema is built lazily on first validate/serialize, so only + # the handful of models actually used are paid for. + model_config = ConfigDict(extra=_EXTRA_POLICY, defer_build=True) def model_dump(self, **kwargs: Any) -> dict[str, Any]: # ``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]: kwargs["exclude_none"] = True if "serialize_as_any" not in kwargs: kwargs["serialize_as_any"] = True - return super().model_dump(**kwargs) + try: + return super().model_dump(**kwargs) + except TypeError as exc: + if "MockValSer" not in str(exc): + raise + _build_deferred_serializers(self, set()) + return super().model_dump(**kwargs) def model_dump_json(self, **kwargs: Any) -> str: if "exclude_none" not in kwargs: kwargs["exclude_none"] = True if "serialize_as_any" not in kwargs: kwargs["serialize_as_any"] = True - return super().model_dump_json(**kwargs) + try: + return super().model_dump_json(**kwargs) + except TypeError as exc: + if "MockValSer" not in str(exc): + raise + _build_deferred_serializers(self, set()) + return super().model_dump_json(**kwargs) def model_summary(self) -> str: """Human-readable summary for protocol responses. diff --git a/tests/test_serialize_as_any_default.py b/tests/test_serialize_as_any_default.py index a69094e22..c5be23b37 100644 --- a/tests/test_serialize_as_any_default.py +++ b/tests/test_serialize_as_any_default.py @@ -127,6 +127,32 @@ def test_caller_can_opt_out_with_explicit_kwarg() -> None: assert "seller_extension" not in dumped["child"] +class _DeferredChild(AdCPBaseModel): + spec_field: str + + +class _DeferredParent(AdCPBaseModel): + child: _DeferredChild + children: list[_DeferredChild] = Field(default_factory=list) + + +def test_serialize_as_any_builds_deferred_nested_serializer() -> None: + """``defer_build=True`` leaves a nested-only model's serializer as a + placeholder until first built. ``serialize_as_any=True`` dispatches to that + placeholder and would raise ``TypeError: 'MockValSer' object is not an + instance of 'SchemaSerializer'``. The model is built from a dict so the + child class is never validated or dumped on its own — exactly the path that + leaves its serializer deferred. ``model_dump``/``model_dump_json`` must + build it on demand.""" + parent = _DeferredParent.model_validate( + {"child": {"spec_field": "ok"}, "children": [{"spec_field": "a"}]} + ) + dumped = parent.model_dump() + assert dumped["child"] == {"spec_field": "ok"} + assert dumped["children"] == [{"spec_field": "a"}] + assert json.loads(parent.model_dump_json())["child"] == {"spec_field": "ok"} + + def test_caller_can_still_pass_exclude_none_false() -> None: """The two defaults are independent — overriding one doesn't disturb the other.""" From 1233c5c587eb35a02be0a7fe3281b9e3159967b1 Mon Sep 17 00:00:00 2001 From: Andy Bevan Date: Sat, 27 Jun 2026 19:35:55 -0400 Subject: [PATCH 2/2] ci: point storyboard runner at @adcp/sdk latest instead of adcp-3.1 The @adcp/sdk publish process changed to publish stable releases under the `latest` dist-tag and no longer emits an `adcp-3.1` tag (AdCP 3.1 is the current stable line, shipped as `latest`). The removed `adcp-3.1` tag made every storyboard runner fail with `npm notarget @adcp/sdk@adcp-3.1`. Run the storyboard matrix against ["adcp-3.0", "latest"]: the sticky adcp-3.0 tag (7.11.7) is a fixed, reproducible backwards-compat floor for the AdCP 3.0 runner line, and `latest` tracks the current AdCP 3.1 stable line. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 403bbdd78..2965aaaf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,10 +6,10 @@ on: pull_request: branches: [main] -# Default @adcp/sdk runner alias for non-matrix storyboard jobs. -# The reference seller storyboard job below runs both sticky 3.0 and 3.1 tags. +# Default @adcp/sdk runner alias for storyboard jobs. Tracks the current +# stable @adcp/sdk release via the ``latest`` npm dist-tag. env: - ADCP_SDK_VERSION: "adcp-3.1" + ADCP_SDK_VERSION: "latest" concurrency: group: ci-${{ github.ref }} @@ -361,15 +361,14 @@ jobs: name: AdCP storyboard runner — examples/seller_agent.py (@adcp/sdk ${{ matrix.adcp-sdk-tag }}) runs-on: ubuntu-latest # Blocking gate: examples/seller_agent.py is the Python-owned - # reference target for bidirectional storyboard interop. The npm - # dist-tags are sticky compatibility gates: ``adcp-3.0`` stays on - # the 3.0 runner line, while ``adcp-3.1`` tracks the current 3.1 - # runner. Downstream SDKs and adopters can assert backwards - # compatibility without depending on moving latest/beta semantics. + # reference target for bidirectional storyboard interop. The matrix + # runs two legs: the sticky ``adcp-3.0`` tag is a fixed, reproducible + # backwards-compat floor (the AdCP 3.0 runner line), while ``latest`` + # tracks the current stable @adcp/sdk release (the AdCP 3.1 line). strategy: fail-fast: false matrix: - adcp-sdk-tag: ["adcp-3.0", "adcp-3.1"] + adcp-sdk-tag: ["adcp-3.0", "latest"] steps: - uses: actions/checkout@v6