From e9204e8bf13b45add974896d6dbe72ca9d484d91 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 11 Sep 2026 01:38:06 +0000 Subject: [PATCH 1/2] fix(types): preserve open canonical format kinds --- src/adcp/types/_forward_compat.py | 31 +++++- src/adcp/types/canonical_creative.py | 24 +++- src/adcp/types/canonical_creative.pyi | 9 +- tests/test_canonical_formats_v2_to_v1.py | 10 ++ tests/test_creative_asset_regression.py | 21 ++++ tests/test_forward_compat_format_kind.py | 116 ++++++++++++++++++++ tests/type_checks/creative_asset_binding.py | 21 ++++ 7 files changed, 222 insertions(+), 10 deletions(-) create mode 100644 tests/test_creative_asset_regression.py create mode 100644 tests/test_forward_compat_format_kind.py create mode 100644 tests/type_checks/creative_asset_binding.py diff --git a/src/adcp/types/_forward_compat.py b/src/adcp/types/_forward_compat.py index 5fe08cabb..2ef80276c 100644 --- a/src/adcp/types/_forward_compat.py +++ b/src/adcp/types/_forward_compat.py @@ -27,9 +27,9 @@ from __future__ import annotations from copy import copy -from typing import Any, cast, get_args +from typing import Annotated, Any, cast, get_args -from pydantic import BaseModel +from pydantic import BaseModel, Field from pydantic.fields import FieldInfo from adcp.types.aliases import FormatAssetUnion, GroupFormatAssetUnion, RepeatableAssetGroup @@ -48,14 +48,24 @@ from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import ( PublisherDomain as BundledPublisherDomain, ) +from adcp.types.generated_poc.core.canonical_format_kind import CanonicalFormatKind from adcp.types.generated_poc.core.canonical_product import PublisherDomain +from adcp.types.generated_poc.core.creative_manifest import CreativeManifest from adcp.types.generated_poc.core.format import Format from adcp.types.generated_poc.core.media_buy_features import MediaBuyFeatures +from adcp.types.generated_poc.creative.get_creative_delivery_response import ( + Creative as DeliveryCreative, +) from adcp.types.generated_poc.protocol.get_adcp_capabilities_response import ( AcceptancePolicyDiscovery, PrimaryCountry, ) +_OpenCanonicalFormatKind = Annotated[ + CanonicalFormatKind | str, + Field(union_mode="left_to_right"), +] + def _patch_model_field(model: type[BaseModel], field_name: str, new_annotation: Any) -> None: """Replace a Pydantic model field's annotation in-place. @@ -103,6 +113,23 @@ def _patch_equivalent_model_field( def _apply_forward_compat() -> None: """Apply open-union, capability, and public-model compatibility patches.""" + # Canonical format kinds are an open enum on consumer boundaries. Preserve + # values introduced by a newer protocol revision instead of rejecting the + # entire creative manifest. Known values still coerce to the StrEnum arm. + _patch_model_field( + CreativeManifest, + "format_kind", + _OpenCanonicalFormatKind | None, + ) + CreativeManifest.model_rebuild(force=True) + + _patch_model_field( + DeliveryCreative, + "format_kind", + _OpenCanonicalFormatKind | None, + ) + DeliveryCreative.model_rebuild(force=True) + _patch_model_field(Format, "assets", list[FormatAssetUnion] | None) Format.model_rebuild(force=True) diff --git a/src/adcp/types/canonical_creative.py b/src/adcp/types/canonical_creative.py index fb61c37fa..09e850165 100644 --- a/src/adcp/types/canonical_creative.py +++ b/src/adcp/types/canonical_creative.py @@ -117,6 +117,11 @@ unwrap_enum_value, ) +_OpenCanonicalFormatKind = Annotated[ + CanonicalFormatKind | str, + Field(union_mode="left_to_right"), +] + _LEGACY_IDENTITY_KEY = re.compile(r"(^|_)(?:format_ids?|v1_format_ref)($|_)") _CREDENTIAL_SHAPED_KEY_SUFFIXES = ( "credential", @@ -518,6 +523,8 @@ def _validate_custom_shape(self) -> Format: if self.format_kind is CanonicalFormatKind.custom: if not self.format_shape: raise ValueError("custom formats require format_shape") + if self.format_schema is None: + raise ValueError("custom formats require format_schema") elif self.format_shape is not None or self.format_schema is not None: raise ValueError("format_shape and format_schema are only valid for custom formats") return self @@ -553,16 +560,25 @@ def _validate_custom_shape(self) -> Format: CreativeAsset = _canonical_clone( "CreativeAsset", _CanonicalCreativeWire, - overrides={"format_kind": (CanonicalFormatKind, Field())}, + overrides={"format_kind": (_OpenCanonicalFormatKind, Field())}, ) Creative = _canonical_clone( "Creative", _CanonicalListedCreative, - overrides={"format_kind": (CanonicalFormatKind, Field())}, + overrides={"format_kind": (_OpenCanonicalFormatKind, Field())}, ) -_CreativeManifestBase = _canonical_clone("_CreativeManifestBase", _CanonicalCreativeManifestWire) +_CreativeManifestBase = _canonical_clone( + "_CreativeManifestBase", + _CanonicalCreativeManifestWire, + overrides={ + "format_kind": ( + _OpenCanonicalFormatKind | None, + copy.deepcopy(_CanonicalCreativeManifestWire.model_fields["format_kind"]), + ) + }, +) class CreativeManifest(_CreativeManifestBase): @@ -604,7 +620,7 @@ def wire_value(value: Any) -> Any: "DeliveryCreative", _LegacyDeliveryCreative, overrides={ - "format_kind": (CanonicalFormatKind | None, Field(default=None)), + "format_kind": (_OpenCanonicalFormatKind | None, Field(default=None)), "variants": (list[CreativeVariant], Field()), }, ) diff --git a/src/adcp/types/canonical_creative.pyi b/src/adcp/types/canonical_creative.pyi index 3c9c75eb2..1661c402c 100644 --- a/src/adcp/types/canonical_creative.pyi +++ b/src/adcp/types/canonical_creative.pyi @@ -46,22 +46,23 @@ class Product(CanonicalBoundaryModel): class CreativeAsset(CanonicalBoundaryModel): creative_id: str - format_kind: CanonicalFormatKind + format_kind: CanonicalFormatKind | str format_option_ref: Any class Creative(CanonicalBoundaryModel): creative_id: str - format_kind: CanonicalFormatKind + format_kind: CanonicalFormatKind | str format_option_ref: Any -class CreativeManifest(CanonicalBoundaryModel): ... +class CreativeManifest(CanonicalBoundaryModel): + format_kind: CanonicalFormatKind | str | None class CreativeVariant(CanonicalBoundaryModel): manifest: CreativeManifest | None class DeliveryCreative(CanonicalBoundaryModel): creative_id: str - format_kind: CanonicalFormatKind | None + format_kind: CanonicalFormatKind | str | None variants: list[CreativeVariant] class CreativeFilters(CanonicalBoundaryModel): ... diff --git a/tests/test_canonical_formats_v2_to_v1.py b/tests/test_canonical_formats_v2_to_v1.py index 9020771bb..0b6117ac4 100644 --- a/tests/test_canonical_formats_v2_to_v1.py +++ b/tests/test_canonical_formats_v2_to_v1.py @@ -27,6 +27,13 @@ def _ref(id_: str = "display_300x250_image") -> FormatId: ) +def _format_schema() -> dict[str, str]: + return { + "uri": "https://example.com/custom-format.json", + "digest": f"sha256:{'0' * 64}", + } + + # --------------------------------------------------------------------------- # Step 1 — explicit v1-unreachability is silent (no refs, no advisories) # --------------------------------------------------------------------------- @@ -57,6 +64,7 @@ def test_custom_without_refs_is_silent() -> None: format_kind=CanonicalFormatKind.custom, params={}, format_shape="multi_placement_takeover", + format_schema=_format_schema(), ) result = project_declaration_to_v1(decl) @@ -72,6 +80,7 @@ def test_custom_with_v1_format_ref_emits_refs() -> None: format_kind=CanonicalFormatKind.custom, params={}, format_shape="multi_placement_takeover", + format_schema=_format_schema(), v1_format_ref=refs, ) @@ -166,6 +175,7 @@ def test_non_translatable_canonicals_are_silent_with_no_ref(kind: CanonicalForma format_kind=kind, params={}, format_shape="test_custom" if kind is CanonicalFormatKind.custom else None, + format_schema=_format_schema() if kind is CanonicalFormatKind.custom else None, ) result = project_declaration_to_v1(decl) diff --git a/tests/test_creative_asset_regression.py b/tests/test_creative_asset_regression.py new file mode 100644 index 000000000..7c28b4cd6 --- /dev/null +++ b/tests/test_creative_asset_regression.py @@ -0,0 +1,21 @@ +"""Regression tests for the public CreativeAsset binding (issue #1141).""" + +from __future__ import annotations + +from pydantic import BaseModel + +import adcp.types +from adcp.types.canonical_creative import CanonicalBoundaryModel + + +def test_creative_asset_is_concrete_canonical_class() -> None: + cls = adcp.types.CreativeAsset + + assert isinstance(cls, type), "CreativeAsset must be a class, not a union" + assert issubclass(cls, CanonicalBoundaryModel) + assert issubclass(cls, BaseModel) + assert cls.__name__ == "CreativeAsset" + assert cls is not adcp.types.LegacyCreativeAsset + assert "format_id" not in cls.model_fields + assert "format_kind" in cls.model_fields + assert cls.model_fields["format_kind"].is_required() diff --git a/tests/test_forward_compat_format_kind.py b/tests/test_forward_compat_format_kind.py new file mode 100644 index 000000000..8985bdbd0 --- /dev/null +++ b/tests/test_forward_compat_format_kind.py @@ -0,0 +1,116 @@ +"""Regression tests for open-enum canonical format kinds (issue #1140).""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from pydantic import ValidationError + +from adcp.types import ( + CanonicalFormatKind, + Creative, + CreativeAsset, + CreativeManifest, + DeliveryCreative, + Format, +) + +FUTURE_FORMAT_KIND = "future_canonical_format" + + +def _creative_asset(format_kind: str) -> CreativeAsset: + return CreativeAsset( + creative_id="creative-1", + name="Creative", + format_kind=format_kind, + assets={}, + ) + + +def _creative(format_kind: str) -> Creative: + now = datetime.now(UTC) + return Creative( + creative_id="creative-1", + name="Creative", + format_kind=format_kind, + status="approved", + created_date=now, + updated_date=now, + ) + + +def _delivery_creative(format_kind: str) -> DeliveryCreative: + return DeliveryCreative( + creative_id="creative-1", + format_kind=format_kind, + variants=[], + ) + + +def _creative_manifest(format_kind: str) -> CreativeManifest: + return CreativeManifest(format_kind=format_kind, assets={}) + + +@pytest.mark.parametrize( + "factory", + [_creative_asset, _creative, _delivery_creative, _creative_manifest], +) +def test_unknown_format_kind_is_preserved(factory) -> None: + model = factory(FUTURE_FORMAT_KIND) + + assert model.format_kind == FUTURE_FORMAT_KIND + assert type(model.format_kind) is str + assert model.model_dump(mode="json")["format_kind"] == FUTURE_FORMAT_KIND + + +@pytest.mark.parametrize( + "factory", + [_creative_asset, _creative, _delivery_creative, _creative_manifest], +) +def test_known_format_kind_still_coerces_to_enum(factory) -> None: + model = factory("image") + + assert model.format_kind is CanonicalFormatKind.image + assert model.model_dump(mode="json")["format_kind"] == "image" + + +def _format_schema() -> dict[str, str]: + return { + "uri": "https://example.com/custom-format.json", + "digest": f"sha256:{'0' * 64}", + } + + +def test_custom_format_requires_shape() -> None: + with pytest.raises(ValidationError, match="custom formats require format_shape"): + Format(format_kind="custom", params={}, format_schema=_format_schema()) + + +def test_custom_format_requires_schema() -> None: + with pytest.raises(ValidationError, match="custom formats require format_schema"): + Format(format_kind="custom", params={}, format_shape="new_shape") + + +def test_custom_format_accepts_shape_and_schema() -> None: + model = Format( + format_kind="custom", + params={}, + format_shape="new_shape", + format_schema=_format_schema(), + ) + + assert model.format_kind is CanonicalFormatKind.custom + assert model.format_shape == "new_shape" + assert model.format_schema is not None + + +@pytest.mark.parametrize("field", ["format_shape", "format_schema"]) +def test_non_custom_format_rejects_custom_fields(field: str) -> None: + value = "new_shape" if field == "format_shape" else _format_schema() + + with pytest.raises( + ValidationError, + match="format_shape and format_schema are only valid for custom formats", + ): + Format(format_kind="image", params={}, **{field: value}) diff --git a/tests/type_checks/creative_asset_binding.py b/tests/type_checks/creative_asset_binding.py new file mode 100644 index 000000000..35b29330c --- /dev/null +++ b/tests/type_checks/creative_asset_binding.py @@ -0,0 +1,21 @@ +"""Static contract for the public canonical CreativeAsset binding (issue #1141).""" + +from adcp.types import CanonicalFormatKind, CreativeAsset +from adcp.types.canonical_creative import CanonicalBoundaryModel + + +def accepts_canonical_class(model: type[CanonicalBoundaryModel]) -> None: + pass + + +accepts_canonical_class(CreativeAsset) + +asset = CreativeAsset.model_validate( + { + "creative_id": "creative-1", + "name": "Creative", + "format_kind": "future_canonical_format", + "assets": {}, + } +) +format_kind: CanonicalFormatKind | str = asset.format_kind From 9fa6feaffe855f0439de2667209d8f5e8310f623 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 11 Sep 2026 01:46:27 +0000 Subject: [PATCH 2/2] fix(types): align delivery creative typing --- scripts/post_generate_fixes.py | 31 +++++++++++++++++++ src/adcp/types/canonical_creative.pyi | 3 +- .../get_creative_delivery_response.py | 7 +++-- tests/test_code_generation.py | 29 +++++++++++++++++ tests/test_forward_compat_format_kind.py | 16 ++++++++-- tests/type_checks/creative_asset_binding.py | 12 ++++++- 6 files changed, 92 insertions(+), 6 deletions(-) diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index af6d271f1..638bc24f0 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -3588,6 +3588,36 @@ def _reject_seller_bound_manifest_fields(cls, data: Any) -> Any: print(" core/creative_representation.py: restored canonical format contract") +def preserve_open_delivery_format_kind() -> None: + """Keep the public delivery-creative consumer boundary forward-compatible.""" + target = OUTPUT_DIR / "creative" / "get_creative_delivery_response.py" + if not target.exists(): + print(" creative/get_creative_delivery_response.py not found (skipping open enum)") + return + + source = target.read_text() + generated = """ format_kind: Annotated[ + canonical_format_kind.CanonicalFormatKind | None, + Field(description='Canonical format kind delivered for this creative.'), + ] = None""" + replacement = """ format_kind: Annotated[ + canonical_format_kind.CanonicalFormatKind | str | None, + Field( + description='Canonical format kind delivered for this creative.', + union_mode='left_to_right', + ), + ] = None""" + if generated in source: + target.write_text(source.replace(generated, replacement, 1)) + print(" creative/get_creative_delivery_response.py: opened format_kind enum") + elif replacement in source: + print(" creative/get_creative_delivery_response.py: format_kind already open") + else: + raise RuntimeError( + "get_creative_delivery_response.py: expected format_kind field not found" + ) + + def enforce_transformer_output_contract() -> None: """Require a transformer to declare canonical or legacy output formats.""" target = OUTPUT_DIR / "core" / "transformer.py" @@ -5742,6 +5772,7 @@ def main(argv: list[str] | None = None): restore_principal_result_aliases, disambiguate_comply_response_arm, restore_flattened_contract_field_types, + preserve_open_delivery_format_kind, enforce_transformer_output_contract, restore_constructible_response_bases, restore_response_variant_aliases, diff --git a/src/adcp/types/canonical_creative.pyi b/src/adcp/types/canonical_creative.pyi index 1661c402c..9ac23fe80 100644 --- a/src/adcp/types/canonical_creative.pyi +++ b/src/adcp/types/canonical_creative.pyi @@ -55,7 +55,8 @@ class Creative(CanonicalBoundaryModel): format_option_ref: Any class CreativeManifest(CanonicalBoundaryModel): - format_kind: CanonicalFormatKind | str | None + format_kind: CanonicalFormatKind | str | None = ... + assets: dict[str, Any] class CreativeVariant(CanonicalBoundaryModel): manifest: CreativeManifest | None diff --git a/src/adcp/types/generated_poc/creative/get_creative_delivery_response.py b/src/adcp/types/generated_poc/creative/get_creative_delivery_response.py index a5258ceec..b68889b18 100644 --- a/src/adcp/types/generated_poc/creative/get_creative_delivery_response.py +++ b/src/adcp/types/generated_poc/creative/get_creative_delivery_response.py @@ -79,8 +79,11 @@ class Creative(AdCPBaseModel): ), ] = None format_kind: Annotated[ - canonical_format_kind.CanonicalFormatKind | None, - Field(description='Canonical format kind delivered for this creative.'), + canonical_format_kind.CanonicalFormatKind | str | None, + Field( + description='Canonical format kind delivered for this creative.', + union_mode='left_to_right', + ), ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, diff --git a/tests/test_code_generation.py b/tests/test_code_generation.py index acffbb43e..faf3a72f5 100644 --- a/tests/test_code_generation.py +++ b/tests/test_code_generation.py @@ -1195,6 +1195,35 @@ def test_schema_derived_response_arms_preserve_nested_validation(): ) +def test_post_generate_preserves_open_delivery_format_kind(tmp_path, monkeypatch) -> None: + """The public delivery alias must stay open after clean code generation.""" + from scripts import post_generate_fixes + + generated_dir = tmp_path / "generated_poc" + target = generated_dir / "creative" / "get_creative_delivery_response.py" + target.parent.mkdir(parents=True) + target.write_text( + "from typing import Annotated\n" + "from pydantic import Field\n" + "from ..core import canonical_format_kind\n\n" + "class Creative:\n" + " format_kind: Annotated[\n" + " canonical_format_kind.CanonicalFormatKind | None,\n" + " Field(description='Canonical format kind delivered for this creative.'),\n" + " ] = None\n" + ) + monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", generated_dir) + + post_generate_fixes.preserve_open_delivery_format_kind() + generated_source = target.read_text() + post_generate_fixes.preserve_open_delivery_format_kind() + + assert target.read_text() == generated_source + assert "CanonicalFormatKind | str | None" in generated_source + assert "union_mode='left_to_right'" in generated_source + compile(generated_source, str(target), "exec") + + def test_post_generate_sync_creatives_response_arms_match_schema_creative_fields( tmp_path, monkeypatch ): diff --git a/tests/test_forward_compat_format_kind.py b/tests/test_forward_compat_format_kind.py index 8985bdbd0..79fe7c57f 100644 --- a/tests/test_forward_compat_format_kind.py +++ b/tests/test_forward_compat_format_kind.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import datetime, timezone import pytest from pydantic import ValidationError @@ -15,6 +15,7 @@ DeliveryCreative, Format, ) +from adcp.types.aliases import DeliveryCreative as AliasDeliveryCreative FUTURE_FORMAT_KIND = "future_canonical_format" @@ -29,7 +30,7 @@ def _creative_asset(format_kind: str) -> CreativeAsset: def _creative(format_kind: str) -> Creative: - now = datetime.now(UTC) + now = datetime.now(timezone.utc) return Creative( creative_id="creative-1", name="Creative", @@ -75,6 +76,17 @@ def test_known_format_kind_still_coerces_to_enum(factory) -> None: assert model.model_dump(mode="json")["format_kind"] == "image" +def test_delivery_creative_alias_is_open_and_keeps_its_identity() -> None: + assert AliasDeliveryCreative is DeliveryCreative + + model = AliasDeliveryCreative( + creative_id="creative-1", + format_kind=FUTURE_FORMAT_KIND, + variants=[], + ) + assert model.format_kind == FUTURE_FORMAT_KIND + + def _format_schema() -> dict[str, str]: return { "uri": "https://example.com/custom-format.json", diff --git a/tests/type_checks/creative_asset_binding.py b/tests/type_checks/creative_asset_binding.py index 35b29330c..6ad41ad3e 100644 --- a/tests/type_checks/creative_asset_binding.py +++ b/tests/type_checks/creative_asset_binding.py @@ -1,6 +1,6 @@ """Static contract for the public canonical CreativeAsset binding (issue #1141).""" -from adcp.types import CanonicalFormatKind, CreativeAsset +from adcp.types import CanonicalFormatKind, CreativeAsset, CreativeManifest, DeliveryCreative from adcp.types.canonical_creative import CanonicalBoundaryModel @@ -19,3 +19,13 @@ def accepts_canonical_class(model: type[CanonicalBoundaryModel]) -> None: } ) format_kind: CanonicalFormatKind | str = asset.format_kind + +manifest = CreativeManifest(assets={}) +manifest_kind: CanonicalFormatKind | str | None = manifest.format_kind + +delivery = DeliveryCreative( + creative_id="creative-1", + format_kind="future_canonical_format", + variants=[], +) +delivery_kind: CanonicalFormatKind | str | None = delivery.format_kind