diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index da4a78876..7f32c089e 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -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 @@ -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: @@ -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] diff --git a/src/adcp/types/canonical_creative.py b/src/adcp/types/canonical_creative.py index 09e850165..d1c1e8a3b 100644 --- a/src/adcp/types/canonical_creative.py +++ b/src/adcp/types/canonical_creative.py @@ -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, ) @@ -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], @@ -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) diff --git a/src/adcp/types/canonical_creative.pyi b/src/adcp/types/canonical_creative.pyi index 4c049fe8c..3da56d7ff 100644 --- a/src/adcp/types/canonical_creative.pyi +++ b/src/adcp/types/canonical_creative.pyi @@ -4,6 +4,9 @@ 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) @@ -11,6 +14,30 @@ _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 @@ -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 @@ -93,7 +120,7 @@ class Package(CanonicalBoundaryModel): **data: Any, ) -> None: ... -class GetProductsRequest(CanonicalBoundaryModel): +class GetProductsRequest(AdcpVersionEnvelope, CanonicalBoundaryModel): account: Any filters: ProductFilters | None fields: Any @@ -101,7 +128,8 @@ class GetProductsRequest(CanonicalBoundaryModel): time_budget: Any pagination: Any -class GetProductsResponse(CanonicalBoundaryModel): +class GetProductsResponse(_CanonicalResponseEnvelope): + status: TaskStatus = ... products: list[Product] | None proposals: Any refinement_applied: Any @@ -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. @@ -135,45 +167,56 @@ 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): ... @@ -181,12 +224,15 @@ 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], ...] diff --git a/src/adcp/types/generated_poc/account/get_account_financials_response.py b/src/adcp/types/generated_poc/account/get_account_financials_response.py index 2d5f405d1..f000dd0e3 100644 --- a/src/adcp/types/generated_poc/account/get_account_financials_response.py +++ b/src/adcp/types/generated_poc/account/get_account_financials_response.py @@ -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 @@ -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}$')] @@ -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 diff --git a/src/adcp/types/generated_poc/account/sync_accounts_response.py b/src/adcp/types/generated_poc/account/sync_accounts_response.py index f8f28c406..d75c57dc2 100644 --- a/src/adcp/types/generated_poc/account/sync_accounts_response.py +++ b/src/adcp/types/generated_poc/account/sync_accounts_response.py @@ -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 @@ -68,7 +69,7 @@ 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] @@ -76,7 +77,7 @@ class SyncAccountsResponse1(AdcpVersionEnvelope): 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 diff --git a/src/adcp/types/generated_poc/brand/acquire_rights_response.py b/src/adcp/types/generated_poc/brand/acquire_rights_response.py index a98ea84ab..825371fd7 100644 --- a/src/adcp/types/generated_poc/brand/acquire_rights_response.py +++ b/src/adcp/types/generated_poc/brand/acquire_rights_response.py @@ -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): @@ -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' @@ -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' @@ -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' @@ -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 diff --git a/src/adcp/types/generated_poc/brand/get_brand_identity_response.py b/src/adcp/types/generated_poc/brand/get_brand_identity_response.py index ff165d2d7..69ccf26d0 100644 --- a/src/adcp/types/generated_poc/brand/get_brand_identity_response.py +++ b/src/adcp/types/generated_poc/brand/get_brand_identity_response.py @@ -12,6 +12,7 @@ from ..core import context as context_1 from ..core import error as error_1 from ..core import ext as ext_1 +from ..core.protocol_envelope import ProtocolEnvelope from ..enums import asset_content_type as asset_content_type_1 from ..enums import right_use as right_use_1 @@ -105,7 +106,7 @@ class Rights(AdcpVersionEnvelope): content_restrictions: list[str] | None = None -class GetBrandIdentityResponse1(AdcpVersionEnvelope): +class GetBrandIdentityResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') brand_id: str house: House @@ -127,7 +128,7 @@ class GetBrandIdentityResponse1(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class GetBrandIdentityResponse2(AdcpVersionEnvelope): +class GetBrandIdentityResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') errors: Annotated[list[error_1.Error], Field(min_length=1)] context: context_1.ContextObject | None = None diff --git a/src/adcp/types/generated_poc/brand/get_rights_response.py b/src/adcp/types/generated_poc/brand/get_rights_response.py index 2b01adace..f949927e9 100644 --- a/src/adcp/types/generated_poc/brand/get_rights_response.py +++ b/src/adcp/types/generated_poc/brand/get_rights_response.py @@ -13,6 +13,7 @@ from ..core import context as context_1 from ..core import error as error_1 from ..core import ext as ext_1 +from ..core.protocol_envelope import ProtocolEnvelope from ..enums import right_type as right_type_1 from ..enums import right_use as right_use_1 @@ -55,7 +56,7 @@ class Excluded(AdcpVersionEnvelope): suggestions: list[str] | None = None -class GetRightsResponse1(AdcpVersionEnvelope): +class GetRightsResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') rights: list[Right] excluded: list[Excluded] | None = None @@ -63,7 +64,7 @@ class GetRightsResponse1(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class GetRightsResponse2(AdcpVersionEnvelope): +class GetRightsResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') errors: Annotated[list[error_1.Error], Field(min_length=1)] context: context_1.ContextObject | None = None diff --git a/src/adcp/types/generated_poc/brand/update_rights_response.py b/src/adcp/types/generated_poc/brand/update_rights_response.py index 69ed76006..7678cdef9 100644 --- a/src/adcp/types/generated_poc/brand/update_rights_response.py +++ b/src/adcp/types/generated_poc/brand/update_rights_response.py @@ -14,9 +14,10 @@ from ..core import error as error_1 from ..core import ext as ext_1 from ..core import generation_credential as generation_credential_1 +from ..core.protocol_envelope import ProtocolEnvelope -class UpdateRightsResponse1(AdcpVersionEnvelope): +class UpdateRightsResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') rights_id: str terms: rights_terms_1.RightsTerms @@ -30,7 +31,7 @@ class UpdateRightsResponse1(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class UpdateRightsResponse2(AdcpVersionEnvelope): +class UpdateRightsResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') errors: Annotated[list[error_1.Error], Field(min_length=1)] context: context_1.ContextObject | None = None diff --git a/src/adcp/types/generated_poc/content_standards/calibrate_content_response.py b/src/adcp/types/generated_poc/content_standards/calibrate_content_response.py index 669bfc5f0..c0a3c7623 100644 --- a/src/adcp/types/generated_poc/content_standards/calibrate_content_response.py +++ b/src/adcp/types/generated_poc/content_standards/calibrate_content_response.py @@ -12,6 +12,7 @@ from ..core import context as context_1 from ..core import error as error_1 from ..core import ext as ext_1 +from ..core.protocol_envelope import ProtocolEnvelope from ..enums import binary_verdict as binary_verdict_1 from ..enums import feature_check_status as feature_check_status_1 @@ -25,7 +26,7 @@ class Feature(AdcpVersionEnvelope): confidence: Annotated[float, Field(ge=0, le=1)] | None = None -class CalibrateContentResponse1(AdcpVersionEnvelope): +class CalibrateContentResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') verdict: binary_verdict_1.BinaryVerdict confidence: Annotated[float, Field(ge=0, le=1)] | None = None @@ -35,7 +36,7 @@ class CalibrateContentResponse1(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class CalibrateContentResponse2(AdcpVersionEnvelope): +class CalibrateContentResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') errors: list[error_1.Error] context: context_1.ContextObject | None = None diff --git a/src/adcp/types/generated_poc/content_standards/get_content_standards_response.py b/src/adcp/types/generated_poc/content_standards/get_content_standards_response.py index 06b885e35..51020f83b 100644 --- a/src/adcp/types/generated_poc/content_standards/get_content_standards_response.py +++ b/src/adcp/types/generated_poc/content_standards/get_content_standards_response.py @@ -12,15 +12,16 @@ from ..core import context as context_1 from ..core import error as error_1 from ..core import ext as ext_1 +from ..core.protocol_envelope import ProtocolEnvelope -class GetContentStandardsResponse1(AdcpVersionEnvelope): +class GetContentStandardsResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None -class GetContentStandardsResponse2(AdcpVersionEnvelope): +class GetContentStandardsResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') errors: list[error_1.Error] context: context_1.ContextObject | None = None diff --git a/src/adcp/types/generated_poc/content_standards/get_media_buy_artifacts_response.py b/src/adcp/types/generated_poc/content_standards/get_media_buy_artifacts_response.py index 4639ef1f6..54d0504e5 100644 --- a/src/adcp/types/generated_poc/content_standards/get_media_buy_artifacts_response.py +++ b/src/adcp/types/generated_poc/content_standards/get_media_buy_artifacts_response.py @@ -14,6 +14,7 @@ from ..core import error as error_1 from ..core import ext as ext_1 from ..core import pagination_response as pagination_response_1 +from ..core.protocol_envelope import ProtocolEnvelope class BrandContext(AdcpVersionEnvelope): @@ -42,7 +43,7 @@ class CollectionInfo(AdcpVersionEnvelope): effective_rate: float | None = None -class GetMediaBuyArtifactsResponse1(AdcpVersionEnvelope): +class GetMediaBuyArtifactsResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') media_buy_id: str artifacts: list[Artifact] @@ -52,7 +53,7 @@ class GetMediaBuyArtifactsResponse1(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class GetMediaBuyArtifactsResponse2(AdcpVersionEnvelope): +class GetMediaBuyArtifactsResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') errors: list[error_1.Error] context: context_1.ContextObject | None = None diff --git a/src/adcp/types/generated_poc/content_standards/validate_content_delivery_response.py b/src/adcp/types/generated_poc/content_standards/validate_content_delivery_response.py index 616211201..0c75fb0d5 100644 --- a/src/adcp/types/generated_poc/content_standards/validate_content_delivery_response.py +++ b/src/adcp/types/generated_poc/content_standards/validate_content_delivery_response.py @@ -12,6 +12,7 @@ from ..core import context as context_1 from ..core import error as error_1 from ..core import ext as ext_1 +from ..core.protocol_envelope import ProtocolEnvelope from ..enums import binary_verdict as binary_verdict_1 from ..enums import feature_check_status as feature_check_status_1 @@ -39,7 +40,7 @@ class Result(AdcpVersionEnvelope): features: list[Feature] | None = None -class ValidateContentDeliveryResponse1(AdcpVersionEnvelope): +class ValidateContentDeliveryResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') summary: Summary results: list[Result] @@ -47,7 +48,7 @@ class ValidateContentDeliveryResponse1(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class ValidateContentDeliveryResponse2(AdcpVersionEnvelope): +class ValidateContentDeliveryResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') errors: list[error_1.Error] context: context_1.ContextObject | None = None diff --git a/src/adcp/types/generated_poc/creative/get_creative_features_response.py b/src/adcp/types/generated_poc/creative/get_creative_features_response.py index 1bfe0665c..f4f1c0f68 100644 --- a/src/adcp/types/generated_poc/creative/get_creative_features_response.py +++ b/src/adcp/types/generated_poc/creative/get_creative_features_response.py @@ -15,9 +15,10 @@ from ..core import creative_consumption as creative_consumption_1 from ..core import error as error_1 from ..core import ext as ext_1 +from ..core.protocol_envelope import ProtocolEnvelope -class GetCreativeFeaturesResponse1(AdcpVersionEnvelope): +class GetCreativeFeaturesResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') results: list[creative_feature_result_1.CreativeFeatureResult] detail_url: AnyUrl | None = None @@ -30,7 +31,7 @@ class GetCreativeFeaturesResponse1(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class GetCreativeFeaturesResponse2(AdcpVersionEnvelope): +class GetCreativeFeaturesResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') errors: list[error_1.Error] context: context_1.ContextObject | None = None diff --git a/src/adcp/types/generated_poc/creative/preview_creative_response.py b/src/adcp/types/generated_poc/creative/preview_creative_response.py index 1b5172672..c565a5f68 100644 --- a/src/adcp/types/generated_poc/creative/preview_creative_response.py +++ b/src/adcp/types/generated_poc/creative/preview_creative_response.py @@ -69,7 +69,7 @@ class Preview3(AdcpVersionEnvelope): renders: Annotated[list[preview_render_1.PreviewRender], Field(min_length=1)] -class PreviewCreativeResponse1(AdcpVersionEnvelope): +class PreviewCreativeResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') response_type: Literal['single'] = 'single' previews: Annotated[list[Preview], Field(min_length=1)] @@ -80,7 +80,7 @@ class PreviewCreativeResponse1(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class PreviewCreativeResponse2(AdcpVersionEnvelope): +class PreviewCreativeResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') response_type: Literal['batch'] = 'batch' results: Annotated[list[Result], Field(min_length=1)] @@ -88,7 +88,7 @@ class PreviewCreativeResponse2(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class PreviewCreativeResponse3(AdcpVersionEnvelope): +class PreviewCreativeResponse3(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') response_type: Literal['variant'] = 'variant' variant_id: str diff --git a/src/adcp/types/generated_poc/creative/sync_creatives_response.py b/src/adcp/types/generated_poc/creative/sync_creatives_response.py index c8ac62e48..cebca0004 100644 --- a/src/adcp/types/generated_poc/creative/sync_creatives_response.py +++ b/src/adcp/types/generated_poc/creative/sync_creatives_response.py @@ -41,7 +41,7 @@ class Creative(AdcpVersionEnvelope): assignment_errors: dict[Annotated[str, StringConstraints(pattern='^[a-zA-Z0-9_-]+$')], str] | None = None -class SyncCreativesResponse1(AdcpVersionEnvelope): +class SyncCreativesResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') dry_run: bool | None = None creatives: list[Creative] @@ -50,7 +50,7 @@ class SyncCreativesResponse1(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class SyncCreativesResponse2(AdcpVersionEnvelope): +class SyncCreativesResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') errors: Annotated[list[error_1.Error], Field(min_length=1)] context: context_1.ContextObject | None = None diff --git a/src/adcp/types/generated_poc/media_buy/build_creative_response.py b/src/adcp/types/generated_poc/media_buy/build_creative_response.py index f15b4f518..05c595c51 100644 --- a/src/adcp/types/generated_poc/media_buy/build_creative_response.py +++ b/src/adcp/types/generated_poc/media_buy/build_creative_response.py @@ -132,7 +132,7 @@ class Estimate(AdcpVersionEnvelope): per_leaf: list[PerLeaf] | None = None -class BuildCreativeResponse1(AdcpVersionEnvelope): +class BuildCreativeResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') creative_manifest: creative_manifest_1.CreativeManifest build_variant_id: str | None = None @@ -149,14 +149,14 @@ class BuildCreativeResponse1(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class BuildCreativeResponse2(AdcpVersionEnvelope): +class BuildCreativeResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') errors: Annotated[list[error_1.Error], Field(min_length=1)] context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None -class BuildCreativeResponse3(AdcpVersionEnvelope): +class BuildCreativeResponse3(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') creative_manifests: Annotated[list[creative_manifest_1.CreativeManifest], Field(min_length=1)] sandbox: bool | None = None @@ -171,7 +171,7 @@ class BuildCreativeResponse3(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class BuildCreativeResponse4(AdcpVersionEnvelope): +class BuildCreativeResponse4(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') creatives: Annotated[list[Creative], Field(min_length=1)] items_total: Annotated[int, Field(ge=0)] | None = None @@ -190,7 +190,7 @@ class BuildCreativeResponse4(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class BuildCreativeResponse5(AdcpVersionEnvelope): +class BuildCreativeResponse5(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') mode: Literal['estimate'] = 'estimate' estimate: Estimate diff --git a/src/adcp/types/generated_poc/media_buy/create_media_buy_response.py b/src/adcp/types/generated_poc/media_buy/create_media_buy_response.py index 5ad3e2564..7e94a7f3a 100644 --- a/src/adcp/types/generated_poc/media_buy/create_media_buy_response.py +++ b/src/adcp/types/generated_poc/media_buy/create_media_buy_response.py @@ -26,7 +26,7 @@ from adcp.types.media_buy_status_helpers import MEDIA_BUY_LEGACY_STATUS_VALUES, unwrap_enum_value -class CreateMediaBuyResponse1(AdcpVersionEnvelope): +class CreateMediaBuyResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') status: Literal['completed'] = 'completed' proposal_id: Annotated[str, StringConstraints(min_length=1)] | None = None @@ -77,7 +77,7 @@ def _normalize_legacy_status(cls, data: Any) -> Any: return data -class CreateMediaBuyResponse2(AdcpVersionEnvelope): +class CreateMediaBuyResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') errors: Annotated[list[error_1.Error], Field(min_length=1)] context: context_1.ContextObject | None = None diff --git a/src/adcp/types/generated_poc/media_buy/log_event_response.py b/src/adcp/types/generated_poc/media_buy/log_event_response.py index e2119072d..6733e580f 100644 --- a/src/adcp/types/generated_poc/media_buy/log_event_response.py +++ b/src/adcp/types/generated_poc/media_buy/log_event_response.py @@ -12,6 +12,7 @@ from ..core import context as context_1 from ..core import error as error_1 from ..core import ext as ext_1 +from ..core.protocol_envelope import ProtocolEnvelope class PartialFailure(AdcpVersionEnvelope): @@ -21,7 +22,7 @@ class PartialFailure(AdcpVersionEnvelope): message: str -class LogEventResponse1(AdcpVersionEnvelope): +class LogEventResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') events_received: Annotated[int, Field(ge=0)] events_processed: Annotated[int, Field(ge=0)] @@ -33,7 +34,7 @@ class LogEventResponse1(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class LogEventResponse2(AdcpVersionEnvelope): +class LogEventResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') errors: Annotated[list[error_1.Error], Field(min_length=1)] context: context_1.ContextObject | None = None diff --git a/src/adcp/types/generated_poc/media_buy/provide_performance_feedback_response.py b/src/adcp/types/generated_poc/media_buy/provide_performance_feedback_response.py index fb80edf90..865af640b 100644 --- a/src/adcp/types/generated_poc/media_buy/provide_performance_feedback_response.py +++ b/src/adcp/types/generated_poc/media_buy/provide_performance_feedback_response.py @@ -12,9 +12,10 @@ from ..core import context as context_1 from ..core import error as error_1 from ..core import ext as ext_1 +from ..core.protocol_envelope import ProtocolEnvelope -class ProvidePerformanceFeedbackResponse1(AdcpVersionEnvelope): +class ProvidePerformanceFeedbackResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') success: Literal[True] feedback_id: Annotated[str, StringConstraints(min_length=1)] | None = None @@ -27,7 +28,7 @@ class ProvidePerformanceFeedbackResponse1(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class ProvidePerformanceFeedbackResponse2(AdcpVersionEnvelope): +class ProvidePerformanceFeedbackResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') errors: Annotated[list[error_1.Error], Field(min_length=1)] context: context_1.ContextObject | None = None diff --git a/src/adcp/types/generated_poc/media_buy/sync_audiences_response.py b/src/adcp/types/generated_poc/media_buy/sync_audiences_response.py index a27287e78..1e1f47c8c 100644 --- a/src/adcp/types/generated_poc/media_buy/sync_audiences_response.py +++ b/src/adcp/types/generated_poc/media_buy/sync_audiences_response.py @@ -55,7 +55,7 @@ class Audience(AdcpVersionEnvelope): errors: list[error_1.Error] | None = None -class SyncAudiencesResponse1(AdcpVersionEnvelope): +class SyncAudiencesResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') audiences: list[Audience] sandbox: bool | None = None @@ -63,7 +63,7 @@ class SyncAudiencesResponse1(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class SyncAudiencesResponse2(AdcpVersionEnvelope): +class SyncAudiencesResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') errors: Annotated[list[error_1.Error], Field(min_length=1)] context: context_1.ContextObject | None = None diff --git a/src/adcp/types/generated_poc/media_buy/sync_catalogs_response.py b/src/adcp/types/generated_poc/media_buy/sync_catalogs_response.py index 39fb3ef7c..e9295ef8c 100644 --- a/src/adcp/types/generated_poc/media_buy/sync_catalogs_response.py +++ b/src/adcp/types/generated_poc/media_buy/sync_catalogs_response.py @@ -45,7 +45,7 @@ class Catalog(AdcpVersionEnvelope): warnings: list[str] | None = None -class SyncCatalogsResponse1(AdcpVersionEnvelope): +class SyncCatalogsResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') status: Literal['completed'] | None = None dry_run: bool | None = None @@ -57,7 +57,7 @@ class SyncCatalogsResponse1(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class SyncCatalogsResponse2(AdcpVersionEnvelope): +class SyncCatalogsResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') errors: Annotated[list[Any], Field(min_length=1)] context: context_1.ContextObject | None = None diff --git a/src/adcp/types/generated_poc/media_buy/sync_event_sources_response.py b/src/adcp/types/generated_poc/media_buy/sync_event_sources_response.py index 9331f2691..2a2294556 100644 --- a/src/adcp/types/generated_poc/media_buy/sync_event_sources_response.py +++ b/src/adcp/types/generated_poc/media_buy/sync_event_sources_response.py @@ -14,6 +14,7 @@ from ..core import event_source_health as event_source_health_1 from ..core import event_surface as event_surface_1 from ..core import ext as ext_1 +from ..core.protocol_envelope import ProtocolEnvelope from ..enums import action_source as action_source_1 from ..enums import event_type as event_type_1 @@ -42,7 +43,7 @@ class EventSource(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class SyncEventSourcesResponse1(AdcpVersionEnvelope): +class SyncEventSourcesResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') event_sources: list[EventSource] sandbox: bool | None = None @@ -50,7 +51,7 @@ class SyncEventSourcesResponse1(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class SyncEventSourcesResponse2(AdcpVersionEnvelope): +class SyncEventSourcesResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') errors: Annotated[list[error_1.Error], Field(min_length=1)] context: context_1.ContextObject | None = None diff --git a/src/adcp/types/generated_poc/media_buy/update_media_buy_response.py b/src/adcp/types/generated_poc/media_buy/update_media_buy_response.py index d4e42d09f..4880fea45 100644 --- a/src/adcp/types/generated_poc/media_buy/update_media_buy_response.py +++ b/src/adcp/types/generated_poc/media_buy/update_media_buy_response.py @@ -26,7 +26,7 @@ from adcp.types.media_buy_status_helpers import MEDIA_BUY_LEGACY_STATUS_VALUES, unwrap_enum_value -class UpdateMediaBuyResponse1(AdcpVersionEnvelope): +class UpdateMediaBuyResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') status: Literal['completed'] = 'completed' media_buy_id: str @@ -73,7 +73,7 @@ def _normalize_legacy_status(cls, data: Any) -> Any: return data -class UpdateMediaBuyResponse2(AdcpVersionEnvelope): +class UpdateMediaBuyResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') errors: Annotated[list[error_1.Error], Field(min_length=1)] context: context_1.ContextObject | None = None diff --git a/src/adcp/types/generated_poc/signals/activate_signal_response.py b/src/adcp/types/generated_poc/signals/activate_signal_response.py index 8c6ee7abb..4fc188c45 100644 --- a/src/adcp/types/generated_poc/signals/activate_signal_response.py +++ b/src/adcp/types/generated_poc/signals/activate_signal_response.py @@ -13,9 +13,10 @@ from ..core import deployment as deployment_1 from ..core import error as error_1 from ..core import ext as ext_1 +from ..core.protocol_envelope import ProtocolEnvelope -class ActivateSignalResponse1(AdcpVersionEnvelope): +class ActivateSignalResponse1(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') deployments: list[deployment_1.Deployment] sandbox: bool | None = None @@ -23,7 +24,7 @@ class ActivateSignalResponse1(AdcpVersionEnvelope): ext: ext_1.ExtensionObject | None = None -class ActivateSignalResponse2(AdcpVersionEnvelope): +class ActivateSignalResponse2(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') errors: Annotated[list[error_1.Error], Field(min_length=1)] context: context_1.ContextObject | None = None diff --git a/tests/test_code_generation.py b/tests/test_code_generation.py index 4b31084f4..50acc8cad 100644 --- a/tests/test_code_generation.py +++ b/tests/test_code_generation.py @@ -1634,3 +1634,171 @@ def test_generated_create_media_buy_success_matches_schema_nullability(): ) assert ast.unparse(confirmed_at.annotation) == "AwareDatetime | None" assert confirmed_at.value is None + + +def _write_response_arm_fixture( + tmp_path, + monkeypatch, + *, + root_all_of: list[dict], + arms: list[dict], +): + """Drive the response-arm emitter over a synthetic account response schema. + + Only ``account/sync-accounts-response.json`` is materialized, so every other + entry in ``response_specs`` is skipped for missing schema/target. + """ + import json + + from scripts import post_generate_fixes + + generated_dir = tmp_path / "generated_poc" + target = generated_dir / "account" / "sync_accounts_response.py" + target.parent.mkdir(parents=True) + target.write_text( + "# generated by datamodel-codegen:\n" + "# filename: account/sync_accounts_response.json\n\n" + "from __future__ import annotations\n\n" + "from ..core.version_envelope import AdcpVersionEnvelope\n\n\n" + "class SyncAccountsResponse(AdcpVersionEnvelope):\n" + " pass\n" + ) + + schema_dir = tmp_path / "schemas" + schema_path = schema_dir / "account" / "sync-accounts-response.json" + schema_path.parent.mkdir(parents=True) + schema_path.write_text( + json.dumps( + { + "title": "Sync Accounts Response", + "type": "object", + "allOf": root_all_of, + "oneOf": arms, + } + ) + ) + + monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", generated_dir) + monkeypatch.setattr(post_generate_fixes, "SCHEMA_DIR", schema_dir) + post_generate_fixes.restore_response_variant_aliases() + source = target.read_text() + + # Regenerating from the same schema must be a no-op. + post_generate_fixes.restore_response_variant_aliases() + assert target.read_text() == source + compile(source, str(target), "exec") + return source + + +def _arm_bases(source: str, class_name: str) -> list[str]: + import ast + + node = next( + item + for item in ast.parse(source).body + if isinstance(item, ast.ClassDef) and item.name == class_name + ) + return [ast.unparse(base) for base in node.bases] + + +_SUCCESS_ARM = { + "type": "object", + "properties": {"accounts": {"type": "array", "items": {"type": "string"}}}, + "required": ["accounts"], +} +_ERROR_ARM = { + "type": "object", + "properties": {"errors": {"type": "array", "items": {"type": "string"}}}, + "required": ["errors"], +} +_SUBMITTED_ARM = { + "type": "object", + "properties": { + "status": {"const": "submitted"}, + "task_id": {"type": "string"}, + }, + "required": ["status", "task_id"], +} + + +@pytest.mark.parametrize( + "protocol_envelope_ref", + [ + "../core/protocol-envelope.json", + "/schemas/core/protocol-envelope.json", + "https://adcontextprotocol.org/schemas/3.2.0-rc.1/core/protocol-envelope.json", + ], + ids=["relative", "root-relative", "canonical-url"], +) +def test_root_all_of_protocol_envelope_reaches_every_one_of_arm( + tmp_path, monkeypatch, protocol_envelope_ref +): + """A root ``allOf`` applies to the whole document, so every arm inherits it. + + Regression test for #1136: only the ``status: submitted`` arm used to pick + up ``ProtocolEnvelope``, leaving ``status``/``replayed``/``task_id`` untyped + on the success and error arms of the same response. + """ + source = _write_response_arm_fixture( + tmp_path, + monkeypatch, + root_all_of=[ + {"$ref": "../core/version-envelope.json"}, + {"$ref": protocol_envelope_ref}, + ], + arms=[_SUCCESS_ARM, _ERROR_ARM], + ) + + assert "from ..core.protocol_envelope import ProtocolEnvelope" in source + for class_name in ("SyncAccountsResponse1", "SyncAccountsResponse2"): + assert _arm_bases(source, class_name) == ["AdcpVersionEnvelope", "ProtocolEnvelope"] + + +def test_nested_root_all_of_protocol_envelope_is_detected(tmp_path, monkeypatch): + """The root ``allOf`` walk descends into nested ``allOf`` groupings.""" + source = _write_response_arm_fixture( + tmp_path, + monkeypatch, + root_all_of=[ + {"allOf": [{"$ref": "../core/protocol-envelope.json"}]}, + ], + arms=[_SUCCESS_ARM, _ERROR_ARM], + ) + + for class_name in ("SyncAccountsResponse1", "SyncAccountsResponse2"): + assert _arm_bases(source, class_name) == ["AdcpVersionEnvelope", "ProtocolEnvelope"] + + +def test_arms_keep_version_envelope_only_without_root_protocol_envelope(tmp_path, monkeypatch): + """The base is attached from the root schema, not sprayed unconditionally. + + A response whose root does not compose the protocol envelope keeps the + plain version envelope on its ordinary arms; the ``submitted`` arm still + gets ``ProtocolEnvelope`` from its own task-envelope shape. + """ + source = _write_response_arm_fixture( + tmp_path, + monkeypatch, + root_all_of=[{"$ref": "../core/version-envelope.json"}], + arms=[_SUCCESS_ARM, _ERROR_ARM, _SUBMITTED_ARM], + ) + + assert _arm_bases(source, "SyncAccountsResponse1") == ["AdcpVersionEnvelope"] + assert _arm_bases(source, "SyncAccountsResponse2") == ["AdcpVersionEnvelope"] + assert _arm_bases(source, "SyncAccountsResponse3") == [ + "AdcpVersionEnvelope", + "ProtocolEnvelope", + ] + + +def test_unrelated_root_all_of_ref_does_not_attach_protocol_envelope(tmp_path, monkeypatch): + """Only ``core/protocol-envelope.json`` counts — not any ``core`` ref.""" + source = _write_response_arm_fixture( + tmp_path, + monkeypatch, + root_all_of=[{"$ref": "../core/push-notification-config.json"}], + arms=[_SUCCESS_ARM], + ) + + assert _arm_bases(source, "SyncAccountsResponse1") == ["AdcpVersionEnvelope"] + assert "from ..core.protocol_envelope import ProtocolEnvelope" not in source diff --git a/tests/test_decisioning_specialisms.py b/tests/test_decisioning_specialisms.py index 50aef8b2b..4fadcd313 100644 --- a/tests/test_decisioning_specialisms.py +++ b/tests/test_decisioning_specialisms.py @@ -507,18 +507,34 @@ def test_build_creative_response_includes_submitted_arm() -> None: """The spec now includes the task-submitted arm in build_creative responses.""" import typing - from adcp.types import LegacyBuildCreativeResponse + from adcp.types import GeneratedTaskStatus, LegacyBuildCreativeResponse arms = typing.get_args(LegacyBuildCreativeResponse) assert len(arms) > 0, "LegacyBuildCreativeResponse should be a Union of arms" + + # The root ``allOf`` composes core/protocol-envelope.json, so ``task_id`` + # and ``status`` are present on *every* arm. Mere presence therefore does + # not identify the submitted arm; the pinned ``Literal`` annotation and its + # matching default do. + assert all({"task_id", "status"}.issubset(arm.model_fields) for arm in arms) + submitted_arms = [ arm for arm in arms - if hasattr(arm, "model_fields") - and {"task_id", "status"}.issubset(set(arm.model_fields.keys())) + if arm.model_fields["status"].annotation + == typing.Literal[GeneratedTaskStatus.submitted] # type: ignore[valid-type] + and arm.model_fields["status"].default is GeneratedTaskStatus.submitted ] assert [arm.__name__ for arm in submitted_arms] == ["BuildCreativeResponse6"] + # Only the submitted arm's own schema branch makes ``task_id`` required; + # the inherited envelope field stays optional on the others. + submitted = submitted_arms[0] + assert submitted.model_fields["task_id"].is_required() + assert not any( + arm.model_fields["task_id"].is_required() for arm in arms if arm is not submitted + ) + # ---- CreativeAdServerPlatform ---- diff --git a/tests/test_protocol_envelope_inheritance.py b/tests/test_protocol_envelope_inheritance.py new file mode 100644 index 000000000..4b39b67ab --- /dev/null +++ b/tests/test_protocol_envelope_inheritance.py @@ -0,0 +1,325 @@ +"""Every response arm carries the root ``allOf`` protocol envelope (#1136). + +Each AdCP response schema composes ``core/protocol-envelope.json`` at its root +via ``allOf``, so ``status``, ``task_id``, ``message``, ``context_id``, +``replayed``, ``timestamp``, ``push_notification_config``, +``governance_context``, ``context``, ``payload`` and ``adcp_error`` are part of +every response's contract — on the success arm, the error arm and the submitted +arm alike. The code generator used to attach :class:`ProtocolEnvelope` only to +the ``status: submitted`` arm, leaving those fields untyped on the rest: a +seller that set one wrote a pydantic extra and a buyer that read one got an +``AttributeError``. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Literal, get_args, get_origin + +import pytest +from pydantic import BaseModel + +from adcp._version import _read_packaged_version +from adcp.types import ProtocolEnvelope, canonical_creative +from adcp.types import aliases as aliases_module +from adcp.types.generated_poc.enums.task_status import TaskStatus +from adcp.validation.version import resolve_bundle_key + +_ENVELOPE_FIELDS = frozenset(ProtocolEnvelope.model_fields) + + +def _schema_dir() -> Path: + return Path("schemas") / "cache" / resolve_bundle_key(_read_packaged_version()) + + +def _public_response_classes() -> list[tuple[str, type[BaseModel]]]: + """Public ``*SuccessResponse`` / ``*ErrorResponse`` aliases that are classes. + + Non-discriminated ``oneOf`` responses also expose bare union aliases, which + are ``types.UnionType`` rather than classes; those are out of scope here. + """ + found: list[tuple[str, type[BaseModel]]] = [] + for name in sorted(dir(aliases_module)): + if name.startswith("_") or not name.endswith(("SuccessResponse", "ErrorResponse")): + continue + obj = getattr(aliases_module, name) + if isinstance(obj, type) and issubclass(obj, BaseModel): + found.append((name, obj)) + return found + + +def test_public_response_aliases_inherit_protocol_envelope() -> None: + """The alias surface adopters import must carry the envelope.""" + classes = _public_response_classes() + assert len(classes) > 20, "expected the full public response-alias surface" + + missing = [name for name, obj in classes if not issubclass(obj, ProtocolEnvelope)] + assert missing == [], "response aliases missing the ProtocolEnvelope base: " + ", ".join( + missing + ) + + +def test_public_response_aliases_declare_every_envelope_field() -> None: + """Envelope fields are declared fields, never ``extra`` bags.""" + for name, obj in _public_response_classes(): + assert _ENVELOPE_FIELDS <= set(obj.model_fields), name + + +@pytest.mark.parametrize( + "relative", + [ + "account/get_account_financials_response.py", + "account/sync_accounts_response.py", + "brand/acquire_rights_response.py", + "brand/get_brand_identity_response.py", + "brand/get_rights_response.py", + "brand/update_rights_response.py", + "content_standards/calibrate_content_response.py", + "content_standards/get_content_standards_response.py", + "content_standards/get_media_buy_artifacts_response.py", + "content_standards/validate_content_delivery_response.py", + "creative/get_creative_features_response.py", + "creative/preview_creative_response.py", + "creative/sync_creatives_response.py", + "media_buy/build_creative_response.py", + "media_buy/create_media_buy_response.py", + "media_buy/log_event_response.py", + "media_buy/provide_performance_feedback_response.py", + "media_buy/sync_audiences_response.py", + "media_buy/sync_catalogs_response.py", + "media_buy/sync_event_sources_response.py", + "media_buy/update_media_buy_response.py", + "signals/activate_signal_response.py", + ], +) +def test_generated_arms_match_their_root_schema_composition(relative: str) -> None: + """Every emitted arm mirrors whether its ROOT schema composes the envelope.""" + import importlib + import re + + module_name = "adcp.types.generated_poc." + relative.removesuffix(".py").replace("/", ".") + module = importlib.import_module(module_name) + + schema_path = _schema_dir() / Path(relative).with_suffix(".json").as_posix().replace("_", "-") + schema = json.loads(schema_path.read_text()) + root_refs = [part.get("$ref", "") for part in schema.get("allOf", []) if isinstance(part, dict)] + root_composes_envelope = any(ref.endswith("core/protocol-envelope.json") for ref in root_refs) + assert root_composes_envelope, f"{relative}: fixture assumes a root protocol envelope" + + # ``__all__`` is emitted as ``[union_alias, *numbered_arms, *nested]``; the + # arms are exactly the ```` names, never the nested helper models. + base_name = module.__all__[0] + arm_pattern = re.compile(rf"{re.escape(base_name)}\d+$") + arms = [ + getattr(module, name) for name in module.__all__ if arm_pattern.fullmatch(name) is not None + ] + assert len(arms) == len(schema["oneOf"]), f"{relative}: arm count drifted from the schema" + for arm in arms: + assert issubclass(arm, ProtocolEnvelope), f"{relative}: {arm.__name__}" + + +def test_error_arm_can_carry_envelope_state() -> None: + """The error arm of a ``oneOf`` is an envelope too, not a bare payload.""" + from adcp.types.aliases import CreateMediaBuyErrorResponse + + error = CreateMediaBuyErrorResponse.model_validate( + { + "errors": [{"code": "INVALID_BUDGET", "message": "too low"}], + "status": "rejected", + "task_id": "task_1", + "replayed": True, + } + ) + assert error.status == "rejected" + assert error.task_id == "task_1" + assert error.replayed is True + assert error.model_extra == {} + + +def test_replayed_and_status_round_trip_as_declared_fields() -> None: + """Idempotency rule 4: a replayed response sets ``replayed`` on the envelope.""" + from adcp.types.aliases import SyncCreativesSuccessResponse + + response = SyncCreativesSuccessResponse.model_validate({"creatives": []}) + assert response.replayed is False + assert response.model_extra == {} + + response.replayed = True + dumped = response.model_dump(mode="json") + assert dumped["replayed"] is True + # ``status`` is required on every task response envelope and now serializes + # from a declared field rather than being dropped. + assert dumped["status"] == "completed" + + reparsed = SyncCreativesSuccessResponse.model_validate(dumped) + assert reparsed.replayed is True + assert reparsed.model_extra == {} + + +def test_envelope_state_used_to_land_in_extra() -> None: + """Pin the exact regression: envelope keys are not ``extra`` any more.""" + from adcp.types.aliases import LogEventSuccessResponse + + response = LogEventSuccessResponse.model_validate( + { + "events_received": 0, + "events_processed": 0, + "status": "completed", + "replayed": True, + "context_id": "ctx_1", + } + ) + assert response.model_extra == {} + assert response.context_id == "ctx_1" + + +# ---- canonical_creative dynamic clones ---- + + +_CANONICAL_RESPONSE_CLONES = ( + "GetProductsResponse", + "CreateMediaBuyResponse1", + "CreateMediaBuyResponse2", + "CreateMediaBuyResponse3", + "UpdateMediaBuyResponse1", + "UpdateMediaBuyResponse2", + "UpdateMediaBuyResponse3", + "ListCreativesResponse", + "GetMediaBuysResponse", + "GetMediaBuyDeliveryResponse", + "GetCreativeDeliveryResponse", +) + + +@pytest.mark.parametrize("name", _CANONICAL_RESPONSE_CLONES) +def test_canonical_clone_preserves_envelope_ancestry(name: str) -> None: + """``_canonical_clone`` rebuilds fields; it must keep the ancestry too.""" + model = getattr(canonical_creative, name) + assert issubclass(model, ProtocolEnvelope), name + assert _ENVELOPE_FIELDS <= set(model.model_fields), name + + +@pytest.mark.parametrize("name", [*_CANONICAL_RESPONSE_CLONES, "GetProductsRequest", "Product"]) +def test_canonical_clone_keeps_the_boundary_extra_policy(name: str) -> None: + """The envelope bases must not override ``CanonicalBoundaryModel``'s config. + + Pydantic merges ``model_config`` left to right across bases, so an envelope + listed after the boundary model would reinstate :class:`AdCPBaseModel`'s + ``extra="ignore"`` and silently drop caller-supplied extension keys. + """ + model = getattr(canonical_creative, name) + assert model.model_config["extra"] == "allow", name + + +def test_canonical_clone_still_round_trips_extension_keys() -> None: + """The config regression above is observable through an unknown key.""" + request = canonical_creative.GetProductsRequest.model_validate( + {"promoted_offering": "test", "buying_mode": "brief"} + ) + assert request.promoted_offering == "test" + + +def test_canonical_response_keeps_its_schema_pinned_status() -> None: + """Adding the envelope base must not relax an arm's pinned ``status``.""" + response = canonical_creative.UpdateMediaBuyResponse1.model_validate( + {"media_buy_id": "mb_1", "revision": 2} + ) + assert response.status == "completed" + assert ( + canonical_creative.UpdateMediaBuyResponse1.model_fields["status"].annotation + == Literal["completed"] + ) + + +def test_canonical_responses_construct_without_status() -> None: + """``status`` is defaulted at runtime, so construction must not demand it. + + The static half of this — that the stub's synthesized ``__init__`` agrees — + is pinned in ``tests/type_checks/response_envelope_fields.py``. + """ + listed = canonical_creative.ListCreativesResponse( + creatives=[], + query_summary={"total_matching": 0, "returned": 0}, + pagination={"has_more": False}, + ) + assert listed.status == TaskStatus.completed + + buys = canonical_creative.GetMediaBuysResponse(media_buys=[]) + assert buys.status == TaskStatus.completed + + accepted = canonical_creative.UpdateMediaBuyResponse3(task_id="task_2") + assert accepted.status == TaskStatus.submitted + + +def _stub_status_annotation(runtime_annotation: object) -> str: + """Spell a runtime ``status`` annotation the way the stub must declare it.""" + if runtime_annotation is TaskStatus: + return "TaskStatus" + if get_origin(runtime_annotation) is Literal: + (member,) = get_args(runtime_annotation) + if isinstance(member, TaskStatus): + return f"Literal[TaskStatus.{member.name}]" + return f"Literal['{member}']" + raise AssertionError(f"unhandled runtime status annotation: {runtime_annotation!r}") + + +def test_canonical_response_stub_status_matches_runtime() -> None: + """Every concrete canonical response re-declares its exact ``status``. + + ``_CanonicalResponseEnvelope`` relaxes ``status`` to ``Any`` so the arms' + ``Literal`` pins do not trip the Liskov check. That relaxation is a bridge, + not a public type: a concrete response that forgets to re-declare ``status`` + silently hands adopters ``Any``, which type-checks against anything and + hides the very narrowing this module is about. Pin stub and runtime + together so the bridge cannot leak. + """ + import ast + + stub_path = Path(canonical_creative.__file__).with_suffix(".pyi") + stub = ast.parse(stub_path.read_text()) + + declared: dict[str, str] = {} + for node in stub.body: + if not isinstance(node, ast.ClassDef): + continue + if not any( + isinstance(base, ast.Name) and base.id == "_CanonicalResponseEnvelope" + for base in node.bases + ): + continue + status = next( + ( + item + for item in node.body + if isinstance(item, ast.AnnAssign) + and isinstance(item.target, ast.Name) + and item.target.id == "status" + ), + None, + ) + assert status is not None, ( + f"{node.name} inherits the relaxed ``status: Any`` bridge without " + f"re-declaring its own — adopters would read ``Any``" + ) + declared[node.name] = ast.unparse(status.annotation) + + # Guard the other direction too: a newly added canonical response must show + # up in the stub rather than quietly skipping this check. + runtime_responses = { + name + for name in dir(canonical_creative) + if not name.startswith("_") + and isinstance(getattr(canonical_creative, name), type) + and issubclass(getattr(canonical_creative, name), ProtocolEnvelope) + # ``ProtocolEnvelope`` itself is imported into this namespace as a base; + # only the canonical clones built on it are in scope here. + and issubclass(getattr(canonical_creative, name), canonical_creative.CanonicalBoundaryModel) + } + assert declared.keys() == runtime_responses + + for name, stub_annotation in sorted(declared.items()): + runtime = getattr(canonical_creative, name).model_fields["status"] + assert stub_annotation == _stub_status_annotation(runtime.annotation), name + # The stub marks the field defaulted (``= ...``); the runtime agrees. + assert not runtime.is_required(), name diff --git a/tests/type_checks/response_envelope_fields.py b/tests/type_checks/response_envelope_fields.py new file mode 100644 index 000000000..c05b25a03 --- /dev/null +++ b/tests/type_checks/response_envelope_fields.py @@ -0,0 +1,119 @@ +"""Adopter pattern: read and write protocol-envelope state on a response arm. + +Every AdCP response schema composes ``core/protocol-envelope.json`` at its +root, so ``status``, ``task_id``, ``replayed`` and friends belong to every +response arm — the success arm, the error arm and the submitted arm alike. +Before #1136 the generator attached the envelope base only to the submitted +arm, so a seller setting ``response.replayed = True`` on any of the other 19 +success shapes wrote a pydantic *extra*, and a buyer reading it got an +``AttributeError``. Statically the attribute did not exist at all. + +This file pins the typed surface: the envelope fields must be visible to a type +checker on ordinary success and error arms, on the canonical-boundary clones, +and through ``ProtocolEnvelope`` as a common ancestor of the arms of one +``oneOf`` — which previously had no shared base below ``AdcpVersionEnvelope``. +""" + +from __future__ import annotations + +from typing import Literal + +from typing_extensions import assert_type + +from adcp.types import ( + CreateMediaBuyErrorResponse, + CreateMediaBuyResponse1, + GeneratedTaskStatus, + GetMediaBuysResponse, + GetProductsResponse, + ListCreativesResponse, + ProtocolEnvelope, + SyncCreativesResponse1, + UpdateMediaBuyResponse3, +) + + +def echo_envelope(response: ProtocolEnvelope) -> str | None: + """A boundary can take any response arm through the shared ancestor.""" + return response.task_id + + +# --- A plain success arm carries envelope state --- + +synced = SyncCreativesResponse1(creatives=[]) +synced.replayed = True +synced.task_id = "task_1" +synced.context_id = "ctx_1" + +replayed: bool | None = synced.replayed +task_id: str | None = synced.task_id +assert replayed is True +assert task_id == "task_1" +assert echo_envelope(synced) == "task_1" + +# --- The canonical-boundary clones keep their generated source's ancestry --- + +created = CreateMediaBuyResponse1( + media_buy_id="mb_1", + status="completed", + confirmed_at=None, + revision=1, + packages=[], +) +assert echo_envelope(created) is None + +# The error arm of the same ``oneOf`` is an envelope too, so both arms share a +# base a seller can annotate against. +rejected = CreateMediaBuyErrorResponse.model_validate( + {"errors": [{"code": "INVALID_BUDGET", "message": "too low"}]} +) +rejected.replayed = True +assert echo_envelope(rejected) is None + +# --- ``status`` keeps its exact runtime type on every concrete response --- +# +# The stub ancestor relaxes ``status`` to ``Any`` purely to make the arms' +# narrowing legal (see ``_CanonicalResponseEnvelope``). ``assert_type`` is what +# proves that relaxation does not leak: an annotation like ``pinned: str = +# created.status`` would pass silently from ``Any`` and prove nothing. + +# Pinned to the single synchronous outcome by the 3.2 schema arm. +assert_type(created.status, Literal["completed"]) + +# Ordinary arm — the wide envelope enum, no narrowing in the schema. +products = GetProductsResponse() +assert_type(products.status, GeneratedTaskStatus) + +# Async task-envelope arm — pinned to the submitted member. +submitted = UpdateMediaBuyResponse3(task_id="task_1") +assert_type(submitted.status, Literal[GeneratedTaskStatus.submitted]) + + +# The envelope's own view stays wide, which is what makes the arms' narrowing +# a genuine refinement rather than a redefinition. +def read_envelope_status(response: ProtocolEnvelope) -> GeneratedTaskStatus: + return response.status + + +assert read_envelope_status(created) == "completed" + +# --- ``status`` is defaulted, so construction must not demand it --- +# +# ``status`` has a default on every runtime response. If the stub declared it +# without ``= ...`` the synthesized ``__init__`` would require it, and these +# three plain constructions would fail to type-check even though the runtime +# model defaults the field. (``ListCreativesResponse`` also has required +# ``query_summary``/``pagination`` fields the stub does not enumerate, so only +# the ``status`` half of its signature is asserted here; the runtime +# construction is covered in tests/test_protocol_envelope_inheritance.py.) + +listed = ListCreativesResponse(creatives=[]) +assert_type(listed.status, GeneratedTaskStatus) + +buys = GetMediaBuysResponse(media_buys=[]) +assert_type(buys.status, GeneratedTaskStatus) +assert buys.status == "completed" + +accepted = UpdateMediaBuyResponse3(task_id="task_2") +assert_type(accepted.status, Literal[GeneratedTaskStatus.submitted]) +assert accepted.status == "submitted"