diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 403bbdd7..2965aaaf 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 diff --git a/src/adcp/types/base.py b/src/adcp/types/base.py index 005bc6bc..0a7b09fe 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 a69094e2..c5be23b3 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."""