Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down
53 changes: 50 additions & 3 deletions src/adcp/types/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions tests/test_serialize_as_any_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading