From 5f982eb421989c5b74601499bf5cebe17e056c24 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 26 Oct 2026 00:03:53 +0000 Subject: [PATCH] fix(compat): infer display_tag and every 3.2 asset type from the asset key coerce_legacy_assets() / infer_asset_type() let an exact type-name key win over field presence, but the type names came from a hand-kept list of 14 that predated AdCP 3.2. An untyped asset under `display_tag` was inferred as text, url or image from its other keys. Derive the vocabulary from the generated AssetVariant union instead, so the key list is the 21 asset types the pinned schema declares (adds display_tag, zip, card, published_post, pixel_tracker, vast_tracker, daast_tracker) and a schema bump extends it in the same regeneration. Imported lazily on first use. Fixes #1624 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/adcp/types/_legacy_assets.py | 51 ++++++++++++++++++------------ tests/test_legacy_asset_helpers.py | 23 ++++++++++++++ 2 files changed, 53 insertions(+), 21 deletions(-) diff --git a/src/adcp/types/_legacy_assets.py b/src/adcp/types/_legacy_assets.py index 89631ac3f..f1f5ef43a 100644 --- a/src/adcp/types/_legacy_assets.py +++ b/src/adcp/types/_legacy_assets.py @@ -1,37 +1,46 @@ """Pure, opt-in normalization of pre-v3 creative asset dictionaries.""" from collections.abc import Mapping -from typing import Any +from functools import cache +from typing import Any, get_args -_KNOWN_ASSET_TYPES = frozenset( - { - "image", - "video", - "audio", - "vast", - "text", - "url", - "html", - "javascript", - "webhook", - "css", - "daast", - "markdown", - "brief", - "catalog", - } -) +from pydantic import RootModel + + +@cache +def _known_asset_types() -> frozenset[str]: + """Every ``asset_type`` the pinned schema's canonical asset union routes. + + Derived from the generated ``AssetVariant`` arms rather than listed here, + so a schema bump that adds an asset type (as 3.2 added ``display_tag``) + extends key-name inference in the same regeneration. Imported on first use: + the generated asset graph is not needed to import the legacy helpers. + """ + from adcp.types.domains.core.assets.asset_union import AssetVariant + + union = get_args(AssetVariant)[0] + types: set[str] = set() + for arm in get_args(union): + # A multi-shape asset (``vast``, ``display_tag``, ...) is a RootModel + # over per-shape classes that share one ``asset_type`` literal. + shapes = ( + get_args(arm.model_fields["root"].annotation) if issubclass(arm, RootModel) else (arm,) + ) + for shape in shapes: + types.update(get_args(shape.model_fields["asset_type"].annotation)) + return frozenset(types) def infer_asset_type(asset_key: str, asset: Mapping[str, Any]) -> str | None: """Infer a missing discriminator from an exact key, then field presence. - Type-name keys take precedence over fields. Otherwise URL plus both + Keys naming any asset_type the pinned schema declares (``display_tag``, + ``card``, ...) take precedence over fields. Otherwise URL plus both dimensions implies image, URL alone implies url, and content implies text. Unknown keys without those fields return None for validation to diagnose. This function does not inspect or replace an existing discriminator. """ - if asset_key in _KNOWN_ASSET_TYPES: + if asset_key in _known_asset_types(): return asset_key if "url" in asset: return "image" if "width" in asset and "height" in asset else "url" diff --git a/tests/test_legacy_asset_helpers.py b/tests/test_legacy_asset_helpers.py index b6b4c13bd..8c488d47d 100644 --- a/tests/test_legacy_asset_helpers.py +++ b/tests/test_legacy_asset_helpers.py @@ -66,6 +66,29 @@ def test_rules_match_both_adapters(key, asset, inferred, normalized): assert asset == original +def test_display_tag_key_is_inferred_as_display_tag(): + # #1624: a 3.2 display_tag asset was inferred as text from its content. + tag = "" + assert coerce_legacy_assets({"display_tag": {"content": tag}}) == { + "display_tag": {"content": tag, "asset_type": "display_tag"} + } + assert coerce_legacy_assets({"display_tag": {"url": "https://ads.example/tag"}}) == { + "display_tag": {"url": "https://ads.example/tag", "asset_type": "display_tag"} + } + + +def test_key_inference_covers_every_asset_type_the_schema_declares(): + from adcp.types.domains.core import format as format_module + + # The format slot union derives its own vocabulary from the same pin; the + # creative-asset vocabulary key inference uses must not lag behind it. + declared = format_module._KNOWN_BASE_INDIVIDUAL_ASSET_TYPES + assert "display_tag" in declared + for asset_type in declared: + assert infer_asset_type(asset_type, {"content": "x"}) == asset_type + assert infer_asset_type(asset_type, {"url": "https://cdn.example/a"}) == asset_type + + def test_public_reexports_and_mapping_immutability(): assert legacy.infer_asset_type is infer_asset_type assert legacy.coerce_legacy_asset is coerce_legacy_asset