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
41 changes: 39 additions & 2 deletions scripts/post_generate_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3984,6 +3984,7 @@ def __init__(self, relative: str, base: str, schema_rel: Path):
self.nested_names: set[str] = set()
self.local_ref_types: dict[str, str] = {}
self.root_schema: dict[str, Any] = {}
self.root_composes_protocol_envelope = False
self.needs_protocol_envelope = False
self.needs_media_buy_helpers = False
self.needs_sequence = False
Expand Down Expand Up @@ -4211,16 +4212,51 @@ def emit_nested(self, preferred: str, schema: dict[str, Any]) -> str:
self.nested.append("\n".join(lines))
return class_name

def composes_protocol_envelope(self, schema: dict[str, Any]) -> bool:
"""Return whether ``schema`` composes ``core/protocol-envelope.json``.

The check walks the document's own ``allOf`` chain, including
nested ``allOf`` groupings, and resolves every ``$ref`` through
:func:`_resolve_schema_ref` so canonical ``https://`` refs,
root-relative ``/schemas/`` refs and ``../core/`` relative refs all
land on the same target.
"""

parts = schema.get("allOf")
if not isinstance(parts, list):
return False
for part in parts:
if not isinstance(part, dict):
continue
ref = part.get("$ref")
if isinstance(ref, str):
try:
ref_rel = _resolve_schema_ref(self.schema_rel, ref)
except ValueError:
ref_rel = None
if ref_rel is not None and ref_rel.as_posix() == "core/protocol-envelope.json":
return True
if self.composes_protocol_envelope(part):
return True
return False

def emit_response_class(self, class_name: str, arm: dict[str, Any]) -> str:
props = arm.get("properties") or {}
required = set(arm.get("required") or [])
is_submitted = (
props.get("status", {}).get("const") == "submitted" and "task_id" in props
)
# A root-level ``allOf`` applies to the whole document, so every
# ``oneOf`` arm of a response whose root composes
# ``core/protocol-envelope.json`` carries the protocol envelope:
# success arms, error arms and submitted arms alike.
inherits_protocol_envelope = is_submitted or self.root_composes_protocol_envelope
bases = (
"AdcpVersionEnvelope, ProtocolEnvelope" if is_submitted else "AdcpVersionEnvelope"
"AdcpVersionEnvelope, ProtocolEnvelope"
if inherits_protocol_envelope
else "AdcpVersionEnvelope"
)
if is_submitted:
if inherits_protocol_envelope:
self.needs_protocol_envelope = True
lines = [f"class {class_name}({bases}):"]
if is_submitted:
Expand Down Expand Up @@ -4320,6 +4356,7 @@ def emit_response_class(self, class_name: str, arm: dict[str, Any]) -> str:

def render(self, schema: dict[str, Any]) -> str:
self.root_schema = schema
self.root_composes_protocol_envelope = self.composes_protocol_envelope(schema)
arms = schema.get("oneOf") or schema.get("anyOf") or []
if not arms:
arms = [schema]
Expand Down
30 changes: 29 additions & 1 deletion src/adcp/types/canonical_creative.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
from adcp.types.generated_poc.core.product import Product as _LegacyProduct
from adcp.types.generated_poc.core.product_filters import ProductFilters as _LegacyProductFilters
from adcp.types.generated_poc.core.product_format_declaration import SellerPreference
from adcp.types.generated_poc.core.protocol_envelope import ProtocolEnvelope
from adcp.types.generated_poc.core.version_envelope import AdcpVersionEnvelope
from adcp.types.generated_poc.creative.get_creative_delivery_response import (
Creative as _LegacyDeliveryCreative,
)
Expand Down Expand Up @@ -411,6 +413,32 @@ def _serialize_canonical_model(
)


#: Protocol envelopes a generated wire model may compose at its schema root.
#: A canonical clone copies the envelope *fields*, but a clone built on
#: ``CanonicalBoundaryModel`` alone would drop the envelope *ancestry* — so
#: ``issubclass(GetProductsResponse, ProtocolEnvelope)`` would be ``False`` even
#: though the response carries ``status``/``replayed``/``task_id``. Re-declare
#: the envelopes as additional bases so the canonical surface keeps the same
#: ancestry as the generated surface it replaces.
_ENVELOPE_BASES: tuple[type[AdCPBaseModel], ...] = (AdcpVersionEnvelope, ProtocolEnvelope)


def _canonical_clone_bases(source: type[AdCPBaseModel]) -> tuple[type[AdCPBaseModel], ...]:
"""Return the clone bases for ``source``: its envelopes, then the boundary.

``CanonicalBoundaryModel`` comes last on purpose. Pydantic merges
``model_config`` across bases left to right, so the right-most base wins;
the envelopes inherit :class:`AdCPBaseModel`'s ``extra`` policy and would
otherwise override the boundary's ``extra="allow"`` and start dropping
caller-supplied extension keys. Method resolution is unaffected — the
envelopes override nothing, so ``CanonicalBoundaryModel`` still supplies
``model_dump``/``model_json_schema`` ahead of :class:`AdCPBaseModel`.
"""

envelopes = tuple(envelope for envelope in _ENVELOPE_BASES if issubclass(source, envelope))
return (*envelopes, CanonicalBoundaryModel)


def _canonical_clone(
name: str,
source: type[AdCPBaseModel],
Expand All @@ -420,7 +448,7 @@ def _canonical_clone(
) -> type[CanonicalBoundaryModel]:
model = create_model( # type: ignore[call-overload]
name,
__base__=CanonicalBoundaryModel,
__base__=_canonical_clone_bases(source),
__module__=__name__,
__validators__={
"_serialize_canonical": model_serializer(mode="wrap")(_serialize_canonical_model)
Expand Down
84 changes: 65 additions & 19 deletions src/adcp/types/canonical_creative.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,40 @@ from typing import Any, ClassVar, Literal, TypeAlias, TypeVar

from adcp.types.base import AdCPBaseModel
from adcp.types.generated_poc.core.canonical_format_kind import CanonicalFormatKind
from adcp.types.generated_poc.core.protocol_envelope import ProtocolEnvelope
from adcp.types.generated_poc.core.version_envelope import AdcpVersionEnvelope
from adcp.types.generated_poc.enums.task_status import TaskStatus
from adcp.types.legacy import LegacyFormatId

_T = TypeVar("_T", bound=AdCPBaseModel)

class CanonicalBoundaryModel(AdCPBaseModel):
__adcp_canonical_creative_model__: ClassVar[bool]

class _CanonicalResponseEnvelope(AdcpVersionEnvelope, ProtocolEnvelope, CanonicalBoundaryModel):
"""Stub-only Liskov bridge for canonical responses; not a runtime class.

The canonical response clones inherit :class:`AdcpVersionEnvelope`,
:class:`ProtocolEnvelope` and :class:`CanonicalBoundaryModel` directly at
runtime, so ``isinstance``/``issubclass`` agree with this stub. Collapsing
them into one private ancestor exists purely for the type checker: several
schema arms pin an envelope field to a ``Literal``, and narrowing an
inherited mutable attribute is a Liskov violation. Relaxing the pinned
fields to ``Any`` once here lets each arm declare its precise literal with
no per-field suppression, and keeps plain-string construction
(``status="completed"``) working for adopters.

This is a bridge, not a public type. Every concrete response below
re-declares ``status`` with the exact annotation its runtime model carries,
so no adopter ever reads ``Any`` off one of them — enforced by
``test_canonical_response_stub_status_matches_runtime``. The ``= ...``
matters as much as the type: ``status`` is defaulted on every runtime
response, so a bare ``status: Any`` would make the synthesized ``__init__``
demand it and reject a plain ``GetMediaBuysResponse(media_buys=[])``.
"""

status: Any = ...

class Format(CanonicalBoundaryModel):
format_option_id: str | None
publisher_domain: str | None
Expand Down Expand Up @@ -70,7 +97,7 @@ class DeliveryCreative(CanonicalBoundaryModel):
class CreativeFilters(CanonicalBoundaryModel): ...
class ProductFilters(CanonicalBoundaryModel): ...

class PackageRequest(CanonicalBoundaryModel):
class PackageRequest(AdcpVersionEnvelope, CanonicalBoundaryModel):
product_id: str
format_option_refs: list[Any] | None
creatives: list[CreativeAsset] | None
Expand All @@ -93,15 +120,16 @@ class Package(CanonicalBoundaryModel):
**data: Any,
) -> None: ...

class GetProductsRequest(CanonicalBoundaryModel):
class GetProductsRequest(AdcpVersionEnvelope, CanonicalBoundaryModel):
account: Any
filters: ProductFilters | None
fields: Any
refine: Any
time_budget: Any
pagination: Any

class GetProductsResponse(CanonicalBoundaryModel):
class GetProductsResponse(_CanonicalResponseEnvelope):
status: TaskStatus = ...
products: list[Product] | None
proposals: Any
refinement_applied: Any
Expand All @@ -114,19 +142,23 @@ class GetProductsResponse(CanonicalBoundaryModel):
**data: Any,
) -> None: ...

class CreateMediaBuyRequest(CanonicalBoundaryModel):
class CreateMediaBuyRequest(AdcpVersionEnvelope, CanonicalBoundaryModel):
account: Any
packages: list[PackageRequest] | None

class UpdateMediaBuyRequest(CanonicalBoundaryModel):
class UpdateMediaBuyRequest(AdcpVersionEnvelope, CanonicalBoundaryModel):
account: Any
media_buy_id: str
packages: list[PackageUpdate] | None
new_packages: list[PackageRequest] | None

class CreateMediaBuyResponse1(CanonicalBoundaryModel):
class CreateMediaBuyResponse1(_CanonicalResponseEnvelope):
media_buy_id: str
packages: list[Package]
# AdCP 3.2 drops the synchronous task-envelope status from this arm, so the
# runtime model pins it to the single outcome and defaults it. Mirror both
# halves: the literal and the default.
status: Literal["completed"] = ...
# Required *and* nullable: the schema lists confirmed_at in the success
# branch's ``required`` while typing it ``["string", "null"]``. A buy
# awaiting seller commitment carries the key with a null value.
Expand All @@ -135,58 +167,72 @@ class CreateMediaBuyResponse1(CanonicalBoundaryModel):
self,
*,
media_buy_id: str,
status: Any,
confirmed_at: datetime | None,
revision: int,
packages: list[Package],
status: Literal["completed"] = ...,
media_buy_status: Any = ...,
**data: Any,
) -> None: ...

class CreateMediaBuyResponse2(CanonicalBoundaryModel): ...
class CreateMediaBuyResponse3(CanonicalBoundaryModel): ...
class CreateMediaBuyResponse2(_CanonicalResponseEnvelope):
status: TaskStatus = ...

class CreateMediaBuyResponse3(_CanonicalResponseEnvelope):
status: Literal[TaskStatus.submitted] = ...

CreateMediaBuyResponse: TypeAlias = (
CreateMediaBuyResponse1 | CreateMediaBuyResponse2 | CreateMediaBuyResponse3
)

class UpdateMediaBuyResponse1(CanonicalBoundaryModel):
class UpdateMediaBuyResponse1(_CanonicalResponseEnvelope):
media_buy_id: str
status: Literal["completed"]
# The 3.x schema arm pins ``status`` to the single synchronous outcome.
# ``_CanonicalResponseEnvelope`` is what makes this precise literal legal
# without a per-field suppression, and it keeps ``status="completed"``
# constructible — see tests/type_checks/extend_response_with_sequence.py.
status: Literal["completed"] = ...
revision: int
media_buy_status: Any = ...
affected_packages: Sequence[Package] | None = ...

class UpdateMediaBuyResponse2(CanonicalBoundaryModel): ...
class UpdateMediaBuyResponse3(CanonicalBoundaryModel): ...
class UpdateMediaBuyResponse2(_CanonicalResponseEnvelope):
status: TaskStatus = ...

class UpdateMediaBuyResponse3(_CanonicalResponseEnvelope):
status: Literal[TaskStatus.submitted] = ...

UpdateMediaBuyResponse: TypeAlias = (
UpdateMediaBuyResponse1 | UpdateMediaBuyResponse2 | UpdateMediaBuyResponse3
)

class SyncCreativesRequest(CanonicalBoundaryModel):
class SyncCreativesRequest(AdcpVersionEnvelope, CanonicalBoundaryModel):
account: Any
creatives: list[CreativeAsset]

class ListCreativesRequest(CanonicalBoundaryModel):
class ListCreativesRequest(AdcpVersionEnvelope, CanonicalBoundaryModel):
account: Any
filters: CreativeFilters | None
fields: Any

class ListCreativesResponse(CanonicalBoundaryModel):
class ListCreativesResponse(_CanonicalResponseEnvelope):
status: TaskStatus = ...
creatives: list[Creative]

class MediaBuyPackage(CanonicalBoundaryModel): ...

class MediaBuy(CanonicalBoundaryModel):
packages: Sequence[MediaBuyPackage]

class GetMediaBuysResponse(CanonicalBoundaryModel):
class GetMediaBuysResponse(_CanonicalResponseEnvelope):
status: TaskStatus = ...
media_buys: Sequence[MediaBuy]

class GetMediaBuyDeliveryResponse(CanonicalBoundaryModel): ...
class GetMediaBuyDeliveryResponse(_CanonicalResponseEnvelope):
status: TaskStatus = ...

class GetCreativeDeliveryResponse(CanonicalBoundaryModel):
class GetCreativeDeliveryResponse(_CanonicalResponseEnvelope):
status: TaskStatus = ...
creatives: Sequence[DeliveryCreative]

PRIMARY_CANONICAL_MODELS: tuple[type[CanonicalBoundaryModel], ...]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from ..core import date_range as date_range_1
from ..core import error as error_1
from ..core import ext as ext_1
from ..core.protocol_envelope import ProtocolEnvelope
from ..enums import payment_terms as payment_terms_1


Expand Down Expand Up @@ -54,7 +55,7 @@ class Invoice(AdcpVersionEnvelope):
paid_date: date | None = None


class GetAccountFinancialsResponse1(AdcpVersionEnvelope):
class GetAccountFinancialsResponse1(AdcpVersionEnvelope, ProtocolEnvelope):
model_config = ConfigDict(extra='allow')
account: account_ref_1.AccountReference
currency: Annotated[str, StringConstraints(pattern='^[A-Z]{3}$')]
Expand All @@ -70,7 +71,7 @@ class GetAccountFinancialsResponse1(AdcpVersionEnvelope):
ext: ext_1.ExtensionObject | None = None


class GetAccountFinancialsResponse2(AdcpVersionEnvelope):
class GetAccountFinancialsResponse2(AdcpVersionEnvelope, ProtocolEnvelope):
model_config = ConfigDict(extra='allow')
errors: Annotated[list[error_1.Error], Field(min_length=1)]
context: context_1.ContextObject | None = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from ..core import notification_config as notification_config_1
from ..core import operator_unit as operator_unit_1
from ..core import reporting_delivery_config_state as reporting_delivery_config_state_1
from ..core.protocol_envelope import ProtocolEnvelope
from ..enums import account_scope as account_scope_1
from ..enums import billing_party as billing_party_1
from ..enums import payment_terms as payment_terms_1
Expand Down Expand Up @@ -68,15 +69,15 @@ class Account(AdcpVersionEnvelope):
authorization: account_authorization_1.AccountAuthorization | None = None


class SyncAccountsResponse1(AdcpVersionEnvelope):
class SyncAccountsResponse1(AdcpVersionEnvelope, ProtocolEnvelope):
model_config = ConfigDict(extra='allow')
dry_run: bool | None = None
accounts: list[Account]
context: context_1.ContextObject | None = None
ext: ext_1.ExtensionObject | None = None


class SyncAccountsResponse2(AdcpVersionEnvelope):
class SyncAccountsResponse2(AdcpVersionEnvelope, ProtocolEnvelope):
model_config = ConfigDict(extra='allow')
errors: Annotated[list[error_1.Error], Field(min_length=1)]
context: context_1.ContextObject | None = None
Expand Down
9 changes: 5 additions & 4 deletions src/adcp/types/generated_poc/brand/acquire_rights_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from ..core import ext as ext_1
from ..core import generation_credential as generation_credential_1
from ..core import push_notification_config as push_notification_config_1
from ..core.protocol_envelope import ProtocolEnvelope


class Disclosure(AdcpVersionEnvelope):
Expand All @@ -23,7 +24,7 @@ class Disclosure(AdcpVersionEnvelope):
text: str | None = None


class AcquireRightsResponse1(AdcpVersionEnvelope):
class AcquireRightsResponse1(AdcpVersionEnvelope, ProtocolEnvelope):
model_config = ConfigDict(extra='allow')
rights_id: str
rights_status: Literal['acquired'] = 'acquired'
Expand All @@ -39,7 +40,7 @@ class AcquireRightsResponse1(AdcpVersionEnvelope):
ext: ext_1.ExtensionObject | None = None


class AcquireRightsResponse2(AdcpVersionEnvelope):
class AcquireRightsResponse2(AdcpVersionEnvelope, ProtocolEnvelope):
model_config = ConfigDict(extra='allow')
rights_id: str
rights_status: Literal['pending_approval'] = 'pending_approval'
Expand All @@ -50,7 +51,7 @@ class AcquireRightsResponse2(AdcpVersionEnvelope):
ext: ext_1.ExtensionObject | None = None


class AcquireRightsResponse3(AdcpVersionEnvelope):
class AcquireRightsResponse3(AdcpVersionEnvelope, ProtocolEnvelope):
model_config = ConfigDict(extra='allow')
rights_id: str
rights_status: Literal['rejected'] = 'rejected'
Expand All @@ -61,7 +62,7 @@ class AcquireRightsResponse3(AdcpVersionEnvelope):
ext: ext_1.ExtensionObject | None = None


class AcquireRightsResponse4(AdcpVersionEnvelope):
class AcquireRightsResponse4(AdcpVersionEnvelope, ProtocolEnvelope):
model_config = ConfigDict(extra='allow')
errors: Annotated[list[error_1.Error], Field(min_length=1)]
context: context_1.ContextObject | None = None
Expand Down
Loading
Loading