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
21 changes: 21 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,27 @@ a domain path when the name it needs is not bound there.
- Add specific `type: ignore` comments (e.g., `# type: ignore[no-any-return]`) rather than blanket ignores
- Test type checking in CI across multiple Python versions (3.10+)

## Import-time model patches must defer their rebuild (#1694)

`_ergonomic`, `_forward_compat`, `canonical_creative` and `aliases` edit generated
models' `model_fields` at import. Never follow such an edit with
`model_rebuild(force=True)`: pydantic does not share nested `defer_build`
schemas between parents, so each forced rebuild of a request/response root
walks its whole subtree into pydantic-core — two dozen of them cost ~400 MiB
and 10 s at import on 9.3.0. Call `adcp.types.base.defer_model_rebuild(model)`
instead; the patched fields take effect on the model's first use.

A generated `class X(RootModel[A | B])` builds at the `RootModel[...]`
expression, before any config `X` declares. Generated composing roots
therefore parametrize `adcp.types.base.AdCPRootModel` (deferred); the
post-generate fixer does the rewrite. Every deferred SDK model also carries
`_DeferredValSer` placeholders (installed by `__pydantic_init_subclass__`) that
build on *instance* attribute access, which is how pydantic-core reaches a
serializer when it dumps by inference (`Any` field, `serialize_as_any`); a
subclass that overrides `__pydantic_init_subclass__` must call `super()`.
`tests/test_import_memory.py` imports
each entry module in a subprocess and fails when peak RSS exceeds its budget.

## ctx_metadata: write-only credentials prohibited

`RequestContext.metadata` is for non-secret request hints, not credentials. The
Expand Down
26 changes: 15 additions & 11 deletions scripts/generate_ergonomic_coercion.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,15 +369,18 @@ def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None:
'"""Apply type coercion to generated types for better ergonomics.',
"",
"This module patches the generated types to accept more flexible input types",
"while maintaining type safety. It uses Pydantic's model_rebuild() to add",
"BeforeValidator annotations to fields.",
"while maintaining type safety. It edits the generated field metadata and then",
"invalidates each patched model with ``defer_model_rebuild`` so pydantic",
"re-derives its core schema, with the coercion validators included, on first use.",
"",
"Why import-time patching?",
" We apply coercion at module load time rather than lazily because:",
" 1. Pydantic validation runs during __init__, before any lazy access",
" 2. model_rebuild() is the standard Pydantic pattern for post-hoc changes",
" 3. The cost is minimal (~10-20ms for all types, once at import)",
" 4. After import, there is zero runtime overhead",
" 2. Editing field metadata and rebuilding is the standard Pydantic pattern",
" for post-hoc changes",
" 3. The patch itself is cheap; the rebuild it needs is deferred to the",
" model's first use rather than paid at import for every model (#1694)",
" 4. After the first use of a model, there is zero runtime overhead",
" 5. This approach maintains full type checker compatibility",
"",
"Coercion rules applied:",
Expand All @@ -401,6 +404,7 @@ def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None:
"",
"from pydantic import BeforeValidator, WrapValidator",
"",
"from adcp.types.base import defer_model_rebuild",
"from adcp.types.coercion import (",
" coerce_subclass_list,",
" coerce_to_enum,",
Expand Down Expand Up @@ -546,14 +550,14 @@ def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None:
for c in coercions:
if c["type"] == "enum":
target = get_symbol_name(c["target_class"])
field_comments.append(f'{c["field"]}: {target} | str | None')
field_comments.append(f"{c['field']}: {target} | str | None")
elif c["type"] in {"enum_list", "unique_enum_list"}:
target = get_symbol_name(c["target_class"])
field_comments.append(f'{c["field"]}: list[{target} | str] | None')
field_comments.append(f"{c['field']}: list[{target} | str] | None")
elif c["type"] == "context":
field_comments.append(f'{c["field"]}: ContextObject | dict | None')
field_comments.append(f"{c['field']}: ContextObject | dict | None")
elif c["type"] == "ext":
field_comments.append(f'{c["field"]}: ExtensionObject | dict | None')
field_comments.append(f"{c['field']}: ExtensionObject | dict | None")
elif c["type"] == "subclass_list":
from collections.abc import Sequence as AbcSequence

Expand All @@ -563,7 +567,7 @@ def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None:
is_seq = get_origin(base_ann if base_ann is not None else ann) is AbcSequence
container = "Sequence" if is_seq else "list"
field_comments.append(
f'{c["field"]}: {container}[{get_symbol_name(c["target_class"])}] '
f"{c['field']}: {container}[{get_symbol_name(c['target_class'])}] "
"(accepts subclass instances)"
)

Expand Down Expand Up @@ -648,7 +652,7 @@ def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None:
lines.append(" ],")
lines.append(" )")

lines.append(f" {type_name}.model_rebuild(force=True)")
lines.append(f" defer_model_rebuild({type_name})")
lines.append("")

# PackageUpdate is now a single class (flattened from validation-only oneOf)
Expand Down
42 changes: 23 additions & 19 deletions src/adcp/types/_ergonomic.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@
"""Apply type coercion to generated types for better ergonomics.

This module patches the generated types to accept more flexible input types
while maintaining type safety. It uses Pydantic's model_rebuild() to add
BeforeValidator annotations to fields.
while maintaining type safety. It edits the generated field metadata and then
invalidates each patched model with ``defer_model_rebuild`` so pydantic
re-derives its core schema, with the coercion validators included, on first use.

Why import-time patching?
We apply coercion at module load time rather than lazily because:
1. Pydantic validation runs during __init__, before any lazy access
2. model_rebuild() is the standard Pydantic pattern for post-hoc changes
3. The cost is minimal (~10-20ms for all types, once at import)
4. After import, there is zero runtime overhead
2. Editing field metadata and rebuilding is the standard Pydantic pattern
for post-hoc changes
3. The patch itself is cheap; the rebuild it needs is deferred to the
model's first use rather than paid at import for every model (#1694)
4. After the first use of a model, there is zero runtime overhead
5. This approach maintains full type checker compatibility

Coercion rules applied:
Expand All @@ -38,6 +41,7 @@

from pydantic import BeforeValidator, WrapValidator

from adcp.types.base import defer_model_rebuild
from adcp.types.coercion import (
coerce_subclass_list,
coerce_to_enum,
Expand Down Expand Up @@ -159,7 +163,7 @@ def _apply_coercion() -> None:
"ext",
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
)
ListCreativeFormatsRequest.model_rebuild(force=True)
defer_model_rebuild(ListCreativeFormatsRequest)

# Apply coercion to ListCreativeFormatsRequestCreativeAgent
# - type: Type | str | None
Expand Down Expand Up @@ -207,7 +211,7 @@ def _apply_coercion() -> None:
"ext",
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
)
ListCreativeFormatsRequestCreativeAgent.model_rebuild(force=True)
defer_model_rebuild(ListCreativeFormatsRequestCreativeAgent)

# Apply coercion to ListCreativesRequest
# - assignment_projection: AssignmentProjection | str | None
Expand Down Expand Up @@ -239,7 +243,7 @@ def _apply_coercion() -> None:
"ext",
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
)
ListCreativesRequest.model_rebuild(force=True)
defer_model_rebuild(ListCreativesRequest)

# Apply coercion to Sort
# - field: CreativeSortField | str | None
Expand All @@ -254,7 +258,7 @@ def _apply_coercion() -> None:
"direction",
Annotated[SortDirection | None, BeforeValidator(coerce_to_enum(SortDirection))],
)
Sort.model_rebuild(force=True)
defer_model_rebuild(Sort)

# Apply coercion to GetProductsRequest
# - buying_mode: BuyingMode | str | None
Expand Down Expand Up @@ -284,7 +288,7 @@ def _apply_coercion() -> None:
"ext",
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
)
GetProductsRequest.model_rebuild(force=True)
defer_model_rebuild(GetProductsRequest)

# Apply coercion to PackageRequest
# - pacing: Pacing | str | None
Expand Down Expand Up @@ -323,7 +327,7 @@ def _apply_coercion() -> None:
"ext",
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
)
PackageRequest.model_rebuild(force=True)
defer_model_rebuild(PackageRequest)

# Apply coercion to CreateMediaBuyRequest
# - packages: Sequence[PackageRequest] (accepts subclass instances)
Expand Down Expand Up @@ -367,7 +371,7 @@ def _apply_coercion() -> None:
"ext",
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
)
CreateMediaBuyRequest.model_rebuild(force=True)
defer_model_rebuild(CreateMediaBuyRequest)

# Apply coercion to PackageUpdate
# - pacing: Pacing | str | None
Expand Down Expand Up @@ -406,7 +410,7 @@ def _apply_coercion() -> None:
"ext",
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
)
PackageUpdate.model_rebuild(force=True)
defer_model_rebuild(PackageUpdate)

# Apply coercion to GetProductsResponse
# - context: ContextObject | dict | None
Expand Down Expand Up @@ -451,7 +455,7 @@ def _apply_coercion() -> None:
"ext",
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
)
GetProductsResponse.model_rebuild(force=True)
defer_model_rebuild(GetProductsResponse)

# Apply coercion to ListCreativesResponse
# - context: ContextObject | dict | None
Expand Down Expand Up @@ -490,7 +494,7 @@ def _apply_coercion() -> None:
"ext",
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
)
ListCreativesResponse.model_rebuild(force=True)
defer_model_rebuild(ListCreativesResponse)

# Apply coercion to ListCreativeFormatsResponse
# - context: ContextObject | dict | None
Expand Down Expand Up @@ -544,7 +548,7 @@ def _apply_coercion() -> None:
"ext",
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
)
ListCreativeFormatsResponse.model_rebuild(force=True)
defer_model_rebuild(ListCreativeFormatsResponse)

# Apply coercion to CreateMediaBuyResponse1
# - context: ContextObject | dict | None
Expand Down Expand Up @@ -589,7 +593,7 @@ def _apply_coercion() -> None:
"ext",
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
)
CreateMediaBuyResponse1.model_rebuild(force=True)
defer_model_rebuild(CreateMediaBuyResponse1)

# Apply coercion to UpdateMediaBuyResponse1
# - context: ContextObject | dict | None
Expand Down Expand Up @@ -634,7 +638,7 @@ def _apply_coercion() -> None:
"ext",
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
)
UpdateMediaBuyResponse1.model_rebuild(force=True)
defer_model_rebuild(UpdateMediaBuyResponse1)

# Apply coercion to GetMediaBuyDeliveryResponse
# - context: ContextObject | dict | None
Expand Down Expand Up @@ -679,7 +683,7 @@ def _apply_coercion() -> None:
"ext",
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
)
GetMediaBuyDeliveryResponse.model_rebuild(force=True)
defer_model_rebuild(GetMediaBuyDeliveryResponse)


def _patch_unique_enum_list(
Expand Down
59 changes: 32 additions & 27 deletions src/adcp/types/_forward_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
from pydantic_core import CoreSchema, InitErrorDetails, PydanticCustomError, core_schema
from pydantic_core.core_schema import ErrorType

from adcp.types.base import AdCPBaseModel
from adcp.types.base import AdCPBaseModel, defer_model_rebuild
from adcp.types.canonical_creative import CreateMediaBuyRequest as _PublicCreateMediaBuyRequest
from adcp.types.canonical_creative import GetProductsRequest as _PublicGetProductsRequest
from adcp.types.canonical_creative import PackageRequest as PublicPackageRequest
Expand Down Expand Up @@ -244,8 +244,10 @@ def _patch_model_field(model: type[BaseModel], field_name: str, new_annotation:
"""Replace a Pydantic model field's annotation in-place.

Sets both model_fields (the FieldInfo dict Pydantic uses for schema
generation) and __annotations__ (for introspection), then forces a
schema rebuild. Preserves all constraints and metadata from the original field.
generation) and __annotations__ (for introspection). The caller then
invalidates the model with ``defer_model_rebuild`` so its next use builds
from the patched field. Preserves all constraints and metadata from the
original field.
"""
old_fi = model.model_fields.get(field_name)
if old_fi is None:
Expand Down Expand Up @@ -392,7 +394,7 @@ def _patch_targeting_overlay(model: type[BaseModel]) -> None:
),
],
)
model.model_rebuild(force=True)
defer_model_rebuild(model)


def _patch_package_forecast_targeting() -> None:
Expand All @@ -410,9 +412,12 @@ def _patch_package_forecast_targeting() -> None:
forecast_model = forecast_module.PackageForecastInput
_patch_targeting_overlay(forecast_model)
request_module = import_module("adcp.types.domains.media_buy.get_products_request")
# _ergonomic eagerly builds get_products before this patch. Rebuild its
# product-scoped union arm first, then both public request parents. Names
# of generated anonymous union arms change during code generation.
# Invalidate every parent that nests the forecast input -- its
# product-scoped union arm, then both public request parents -- so none
# keeps a schema built before this patch (#1694 defers the builds to first
# use, so at import nothing is built yet; invalidating is what keeps that
# true whatever ran first). Names of generated anonymous union arms change
# during code generation.
for model in vars(request_module).values():
if (
isinstance(model, type)
Expand All @@ -423,9 +428,9 @@ def _patch_package_forecast_targeting() -> None:
for field in model.model_fields.values()
)
):
model.model_rebuild(force=True)
request_module.GetProductsRequest.model_rebuild(force=True)
_PublicGetProductsRequest.model_rebuild(force=True)
defer_model_rebuild(model)
defer_model_rebuild(request_module.GetProductsRequest)
defer_model_rebuild(_PublicGetProductsRequest)


def _apply_forward_compat() -> None:
Expand All @@ -443,9 +448,9 @@ def _apply_forward_compat() -> None:
_patch_targeting_overlay(model)
_patch_package_forecast_targeting()

# _ergonomic eagerly builds this legacy parent before the targeting patch.
# Refresh its cached nested validator as well as the package model itself.
_GeneratedCreateMediaBuyRequest.model_rebuild(force=True)
# _ergonomic touched this legacy parent before the targeting patch.
# Invalidate it too, so its first build nests the patched package input.
defer_model_rebuild(_GeneratedCreateMediaBuyRequest)

# Response manifests retain unknown future kinds because the field is
# ``str`` everywhere. These patches thread the readback NODES through the
Expand All @@ -457,30 +462,30 @@ def _apply_forward_compat() -> None:
# ``format_kind`` needs no patch: it is generated as ``str``, so a response
# retains an unknown kind without one.
_patch_model_field(DeliveryCreative, "variants", GenericAlias(list, _DeliveryVariant))
DeliveryCreative.model_rebuild(force=True)
GetCreativeDeliveryResponse.model_rebuild(force=True)
defer_model_rebuild(DeliveryCreative)
defer_model_rebuild(GetCreativeDeliveryResponse)

_patch_model_field(PreviewCreativeResponse3, "manifest", _ReadbackCreativeManifest | None)
PreviewCreativeResponse3.model_rebuild(force=True)
defer_model_rebuild(PreviewCreativeResponse3)
_patch_model_field(BuildCreativeResponse1, "creative_manifest", _ReadbackCreativeManifest)
BuildCreativeResponse1.model_rebuild(force=True)
defer_model_rebuild(BuildCreativeResponse1)
_patch_model_field(
BuildCreativeResponse3, "creative_manifests", GenericAlias(list, _ReadbackCreativeManifest)
)
BuildCreativeResponse3.model_rebuild(force=True)
defer_model_rebuild(BuildCreativeResponse3)
_patch_model_field(
BuildCreativeResponse4, "creatives", GenericAlias(list, _BuildReadbackCreative)
)
BuildCreativeResponse4.model_rebuild(force=True)
defer_model_rebuild(BuildCreativeResponse4)

for response in (ContextMatchResponseRouterPublisher, ContextMatchResponseProviderRouter):
_patch_model_field(response, "offers", GenericAlias(list, _ReadbackOffer))
response.model_rebuild(force=True)
defer_model_rebuild(response)

# This eager wrapper captured build/preview validators before the patches.
# Rebuilding it re-resolves AdcpAsyncResponseData, a union type alias, so
# completed task callbacks retain typed manifests.
McpWebhookPayload.model_rebuild(force=True)
# This wrapper nests the build/preview responses patched above.
# Invalidating it re-resolves AdcpAsyncResponseData, a union type alias,
# on first use, so completed task callbacks retain typed manifests.
defer_model_rebuild(McpWebhookPayload)

# The 3.1 canonical-creatives capability was published after the bundled
# generated model. Preserve it across code generation until the schema
Expand All @@ -507,7 +512,7 @@ def _apply_forward_compat() -> None:
),
)
features_model.__annotations__["canonical_creatives"] = canonical_creatives_annotation
features_model.model_rebuild(force=True)
defer_model_rebuild(features_model)

# Canonical schemas referenced through the bundled capabilities schema are
# generated a second time as identity-distinct Pydantic classes. Accept the
Expand All @@ -519,7 +524,7 @@ def _apply_forward_compat() -> None:
BundledAcceptancePolicyDiscovery,
AcceptancePolicyDiscovery | None,
)
BundledCapabilitiesMediaBuy.model_rebuild(force=True)
defer_model_rebuild(BundledCapabilitiesMediaBuy)

_patch_equivalent_model_field(
BundledCapabilitiesPortfolio,
Expand All @@ -533,7 +538,7 @@ def _apply_forward_compat() -> None:
BundledPrimaryCountry,
list[PrimaryCountry] | None,
)
BundledCapabilitiesPortfolio.model_rebuild(force=True)
defer_model_rebuild(BundledCapabilitiesPortfolio)


_apply_forward_compat()
Loading
Loading