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