diff --git a/MIGRATION_v5_to_v6.md b/MIGRATION_v5_to_v6.md index 438f2631..fd1c1a63 100644 --- a/MIGRATION_v5_to_v6.md +++ b/MIGRATION_v5_to_v6.md @@ -37,6 +37,167 @@ existing `pip install adcp` calls in production continue resolving to ## Per-release entries -(Add sections here as breaking changes land. Format: heading per -released beta increment, bullet list of breaking surfaces with -migration recipe.) +### Canonical-formats public surface (#741) + +The v2 catalog-side canonical-formats vocabulary is now reachable from +`adcp.types`, and a new `adcp.canonical_formats` module ships the v2 → +v1 projection layer + closed-set `format_options[]` validator. + +**New public-API names on `adcp.types`:** + +- Discriminator + projection: `CanonicalFormatKind`, + `ProductFormatDeclaration`, `ProductFormatSellerPreference`, + `CanonicalProjectionReference`, `CanonicalAssetSource`, + `CanonicalSlotOverride`. +- 13 canonical format classes (one per `CanonicalFormatKind` value): + `CanonicalFormatImage`, `CanonicalFormatHtml5Banner`, + `CanonicalFormatDisplayTag`, `CanonicalFormatImageCarousel`, + `CanonicalFormatHostedVideo`, `CanonicalFormatVastVideo`, + `CanonicalFormatHostedAudio`, `CanonicalFormatDaastAudio`, + `CanonicalFormatNativeInFeed`, `CanonicalFormatResponsiveCreative`, + `CanonicalFormatAgentPlacement`, `CanonicalFormatSponsoredPlacement` + (the last two have shorter names than the codegen output — + `CanonicalFormatAgentPlacementAiSurfaceSponsoredPlacement` and + `CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven` are + collapsed). +- Pixel tracker asset: `PixelTrackerAsset`, `PixelTrackerEvent`, + `PixelTrackerMethod`. +- v1↔v2 registry types: `V1V2CanonicalFormatMappingRegistry`, + `V1CanonicalMapping`, `V1CanonicalGlobPattern`, + `V1CanonicalStructuralPattern`, `V1CanonicalStructural`, + `V1CanonicalV2Projection`, `V1CanonicalDimensions`. + +**`ProductFormatDeclaration` is hand-rolled, not codegen.** +`datamodel-code-generator` flattens the upstream schema's discriminated +`oneOf` and drops the `format_kind` / `params` fields entirely. The +public class lives at `adcp.types.canonical_decl.ProductFormatDeclaration` +and carries both discriminator + open `params` dict with `extra='allow'`. +The codegen output is preserved under `adcp.types.canonical_decl._GeneratedProductFormatDeclaration` +for code that needs the raw shared-properties view. + +**New module `adcp.canonical_formats`:** + +```python +from adcp.canonical_formats import ( + project_declaration_to_v1, + project_product_to_v1, + validate_format_kind_in_options, + find_declaration_by_kind, + load_default_registry, + FormatKindNotInClosedSetError, +) +``` + +- `project_declaration_to_v1(declaration, *, field_path, product_id)` + walks one `ProductFormatDeclaration` through the resolution order + documented in `registries/v1-canonical-mapping.json` and returns a + `V2ToV1Projection` carrying the projected `format_ids[]` plus any + SDK-source advisories the resolution emitted + (`FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE`, + `FORMAT_DECLARATION_V1_AMBIGUOUS`). +- `project_product_to_v1(product, *, product_index)` fans out across + the product's `format_options[]`, accumulating refs + advisories with + product-indexed field paths for multi-product responses. +- `validate_format_kind_in_options(format_kind, format_options)` is + the seller-side pre-call guard: raises + `FormatKindNotInClosedSetError` when the kind is outside the + product's published closed set. Sellers MUST pair this with + `UNSUPPORTED_FEATURE` on the wire response. +- `find_declaration_by_kind(format_kind, format_options, *, capability_id)` + looks up the matching declaration, disambiguating by `capability_id` + when the closed set carries multiple declarations of the same kind. + +**Recipe — emit `format_ids[]` from `format_options[]`:** + +```python +from adcp.canonical_formats import project_product_to_v1 + +projection = project_product_to_v1(product, product_index=i) +response.products[i].format_ids = projection.format_ids +response.errors = (response.errors or []) + projection.advisories +``` + +**Recipe — reject out-of-set `format_kind` at `create_media_buy`:** + +```python +from adcp.canonical_formats import ( + FormatKindNotInClosedSetError, + validate_format_kind_in_options, +) + +try: + validate_format_kind_in_options( + manifest.format_kind, + product.format_options, + ) +except FormatKindNotInClosedSetError as e: + return CreateMediaBuyErrorResponse(errors=[e.to_wire_error()]) +``` + +The `e.to_wire_error()` helper builds the wire-correct +`UNSUPPORTED_FEATURE` `Error` with `details.rejected_value` + +`details.accepted_values` per the canonical rejection shape in +`error.json`. Override `field=` when the rejection isn't at the +default `manifest.format_kind` pointer. + +**Recipe — seller-side v1 inbound lookup:** + +When a v1-only buyer's `create_media_buy` arrives with a `format_id` +rather than a v2 `format_kind`, sellers walk the product's +`format_options[]` looking for the declaration that asserted that +v1 ref: + +```python +from adcp.canonical_formats import find_declaration_by_v1_format_id + +decl = find_declaration_by_v1_format_id( + manifest.format_id, + product.format_options, +) +if decl is None: + return CreateMediaBuyErrorResponse(errors=[Error( + code="UNSUPPORTED_FEATURE", + message="v1 format_id not in product format_options[]", + field="manifest.format_id", + )]) +# Use decl.format_kind + decl.params_as(...) from here. +``` + +**Recipe — recover typed canonical body from `params`:** + +```python +from adcp.types import CanonicalFormatImage, CanonicalFormatKind + +# decl.params is dict[str, Any] for cross-kind compatibility — narrow +# it via params_as(...) once you've discriminated on format_kind. +if decl.format_kind is CanonicalFormatKind.image: + img = decl.params_as(CanonicalFormatImage) + for size in img.sizes: + ... # typed: size.width, size.height +``` + +**Wire-shape enforcement landed in `ProductFormatDeclaration`:** + +- `params` is now required (matches `required: ["format_kind", "params"]` on the schema). +- `canonical_formats_only=True` and `v1_format_ref[]` are rejected at + construction when combined (the schema's `allOf.not` clause). +- Credential-shaped keys in `params` or model extras raise at + construction. Same suffix list and rationale as the dispatcher's + `ctx_metadata` gate (`credential`, `token`, `secret`, `api_key`, + `apikey`, `password`, `bearer`). + +**SDK-source advisory provenance:** + +All advisories emitted by the projection layer carry `source="sdk"` and +`sdk_id="adcontextprotocol-adcp-python@"`. The distribution-name +prefix is fixed (independent of `pyproject.toml`'s `[project].name`) +so wheel installs and dev installs emit the same attribution string — +the multi-hop `(code, field, sdk_id)` dedup contract in `core/error.json` +keys on this and would corrupt under drift. Adopters relying on a +particular `sdk_id` for multi-hop dedup should pin to a specific SDK +release rather than parsing the string. + +**Not yet shipped (later beta increments):** v1 → v2 reverse projection, +`pixel_tracker` bidirectional contract, the 14 reference fixtures and +round-trip tests, `FORMAT_DECLARATION_DIVERGENT` narrowing check between +v2 `params` and the referenced v1 format's `requirements`. diff --git a/src/adcp/__init__.py b/src/adcp/__init__.py index dd317755..6345d2e2 100644 --- a/src/adcp/__init__.py +++ b/src/adcp/__init__.py @@ -869,8 +869,8 @@ def get_adcp_version() -> str: # Signal pricing types "SignalPricingOption", # Configuration types - "PushNotificationConfig", "NotificationConfig", + "PushNotificationConfig", "WholesaleFeedEvent", "WholesaleFeedWebhook", # Adagents validation diff --git a/src/adcp/canonical_formats/__init__.py b/src/adcp/canonical_formats/__init__.py new file mode 100644 index 00000000..3536f69b --- /dev/null +++ b/src/adcp/canonical_formats/__init__.py @@ -0,0 +1,86 @@ +"""Pythonic v1↔v2 canonical-formats projection layer. + +AdCP 3.1 introduced canonical formats as the v2 catalog-side vocabulary +that replaces v1's per-publisher format proliferation. A seller publishes +``Product.format_options[]`` carrying ``ProductFormatDeclaration`` entries +(one per accepted canonical kind); buyer agents reason about creative +compatibility against the closed canonical set rather than the open v1 +``format_ids`` namespace. + +During the migration window both wire shapes coexist: 3.0-era buyers +read ``Product.format_ids[]`` (v1) and 3.1-aware buyers read +``Product.format_options[]`` (v2). This module supplies the projection +layer the SDK needs to bridge them without adopters hand-rolling +translation per integration. + +Public surface +============== + +* :func:`project_declaration_to_v1` — single ``ProductFormatDeclaration`` + → ``format_ids[]`` (with advisory ``errors[]`` emission on ambiguity + / multi-size lossy fan-out). +* :func:`project_product_to_v1` — fan-out helper across a product's + ``format_options[]``, accumulating refs and advisories. +* :func:`validate_format_kind_in_options` — closed-set guard: rejects a + ``format_kind`` that isn't published in the seller's ``format_options[]``. + Sellers call this before accepting a ``create_media_buy``. +* :func:`find_declaration_by_kind` — looks up the matching declaration + (with optional ``capability_id`` disambiguation). +* :func:`load_default_registry` — loads the AAO-published v1↔v2 mapping + registry from the bundled schema cache. +* :class:`SdkAdvisory` — typed wrapper around the SDK-source ``Error`` + entries the projection emits on ``errors[]``. + +Resolution-order semantics for v2 → v1 follow ``registries/v1-canonical-mapping.json``: + +1. ``canonical_formats_only=True`` or ``format_kind=custom`` → no v1 emit, no advisory. +2. ``v1_format_ref[]`` set → emit those refs; if ``params.sizes[]`` count exceeds + ``v1_format_ref[]`` count, emit ``FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE``. +3. Canonical's ``v1_translatable=False`` (``agent_placement``, ``sponsored_placement``, + ``responsive_creative``, ``image_carousel``) → no v1 emit, no advisory — the + canonical is structurally v1-unreachable by design. +4. Canonical's ``v1_translatable=True`` but no ``v1_format_ref[]`` → emit + ``FORMAT_DECLARATION_V1_AMBIGUOUS``. SDKs MUST NOT synthesize a v1 ``format_id`` + from registry structural matches; the registry is authoritative for v1→v2 + projection only. +""" + +from __future__ import annotations + +from adcp.canonical_formats.advisory import SDK_ID, SdkAdvisory, make_sdk_advisory +from adcp.canonical_formats.format_options import ( + FormatKindNotInClosedSetError, + find_declaration_by_kind, + find_declaration_by_v1_format_id, + validate_format_kind_in_options, +) +from adcp.canonical_formats.projection import ( + V1_TRANSLATABLE, + V2ToV1Projection, + project_declaration_to_v1, + project_product_to_v1, +) +from adcp.canonical_formats.registry import ( + RegistryLoadError, + glob_match, + load_default_registry, + structural_match, +) + +__all__ = [ + "FormatKindNotInClosedSetError", + "RegistryLoadError", + "SDK_ID", + "SdkAdvisory", + "V1_TRANSLATABLE", + "V2ToV1Projection", + "find_declaration_by_kind", + "find_declaration_by_v1_format_id", + "glob_match", + "load_default_registry", + "make_sdk_advisory", + "project_declaration_to_v1", + "project_product_to_v1", + "structural_match", + "validate_format_kind_in_options", +] diff --git a/src/adcp/canonical_formats/advisory.py b/src/adcp/canonical_formats/advisory.py new file mode 100644 index 00000000..586aeceb --- /dev/null +++ b/src/adcp/canonical_formats/advisory.py @@ -0,0 +1,159 @@ +"""SDK-source ``errors[]`` advisory construction. + +The canonical-formats projection emits non-fatal advisories on the +``errors[]`` array of ``get_products`` / ``list_creative_formats`` +responses. Advisories carry ``source="sdk"`` (vs. seller-emitted +``producer`` entries) and ``sdk_id="adcontextprotocol-adcp-python@"`` +so multi-hop consumers can attribute the entry to this SDK and +deduplicate on ``(code, field)`` per the multi-hop propagation contract +in ``core/error.json``. + +The advisory functions live separately from :mod:`adcp.canonical_formats.projection` +so other SDK paths (e.g., the v1→v2 reverse projection in a future PR, +the closed-set validator's own dispatch path) can emit the same shape +without circular imports. +""" + +from __future__ import annotations + +from functools import lru_cache +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version +from typing import Any, TypeAlias + +from adcp.types import Error, Recovery, Source # all three are public surface + +# Maximum length we'll echo into advisory ``details`` from +# seller-controlled identifiers (``product_id``, ``capability_id``). +# Sellers control these strings and they round-trip into multi-hop +# responses + the idempotency replay cache, so cap to prevent +# log-injection / response-spoofing via newlines or absurd lengths. +_MAX_ECHOED_IDENTIFIER_LEN = 128 + +# ``Error`` is the wire model carried on ``errors[]``; ``SdkAdvisory`` is +# the same shape used for naming intent at API boundaries — alias rather +# than subclass to avoid pydantic schema-rebuild side effects. +SdkAdvisory: TypeAlias = Error + + +# Canonical distribution name for the wire ``sdk_id``. Hardcoded (rather +# than read from ``pyproject.toml``'s ``[project].name``) so installed +# and dev builds emit the same audit-trail attribution. The +# ``core/error.json`` dedup contract keys on ``(code, field, sdk_id)``; +# drift here corrupts multi-hop deduplication. Installed wheels publish +# the PyPI distribution as ``adcp``; the fully-qualified +# ``adcontextprotocol-adcp-python`` form is what the spec example uses +# and what cross-SDK consumers expect. +_SDK_DIST_NAME: str = "adcontextprotocol-adcp-python" + + +@lru_cache(maxsize=1) +def _resolve_sdk_id() -> str: + """Return the wire-format ``sdk_id`` for this SDK build. + + Format per ``core/error.json``: ``@``. + The package-name prefix is fixed (``_SDK_DIST_NAME``) so installed + and dev builds emit the same attribution; only the version + component varies between them. Without this, dev installs would + emit a different ``sdk_id`` from wheel installs and break the + multi-hop dedup contract for the same SDK. + + Cached because the resolution is process-stable: the package + metadata doesn't change at runtime. Lazy (computed on first call) + so setuptools-scm-style late version resolution still works. + Falls back to a ``0.0.0-dev`` version marker when the package + isn't installed. + """ + try: + v = _pkg_version("adcp") + except PackageNotFoundError: + v = "0.0.0-dev" + return f"{_SDK_DIST_NAME}@{v}" + + +def _echo_identifier(value: str | None) -> str | None: + """Cap + scrub seller-controlled identifiers before echoing into advisory details. + + Two defenses applied in order: + + 1. **Control-character scrub** — replaces every C0 control char + (``\\x00``-``\\x1f``), the C1 range (``\\x7f``-``\\x9f``), and + all Unicode line separators with a literal ``"\\u"`` + escape. A seller publishing a ``product_id`` containing ``\\n`` + or ``\\x1b[`` would otherwise round-trip into + ``errors[].details.product_id``, forging log lines or + triggering ANSI escape sequences in operator tooling that + prints one advisory per line. + + 2. **Length cap** — at 128 chars (after escaping), so a malformed + seller identifier cannot grow the multi-hop ``errors[]`` array + unbounded into the idempotency replay cache. + + Returns ``None`` for ``None`` input (the explicit absent-product case). + """ + if value is None: + return None + scrubbed_chars: list[str] = [] + for ch in value: + cp = ord(ch) + # C0 (incl. \t, \n, \r) + DEL + C1 + LS/PS line separators. + if cp < 0x20 or 0x7F <= cp <= 0x9F or ch in ("
", "
"): + scrubbed_chars.append(f"\\u{cp:04x}") + else: + scrubbed_chars.append(ch) + scrubbed = "".join(scrubbed_chars) + if len(scrubbed) <= _MAX_ECHOED_IDENTIFIER_LEN: + return scrubbed + return scrubbed[:_MAX_ECHOED_IDENTIFIER_LEN] + "…[truncated]" + + +SDK_ID: str = _resolve_sdk_id() + + +def make_sdk_advisory( + *, + code: str, + message: str, + field: str | None = None, + details: dict[str, Any] | None = None, + recovery: Recovery = Recovery.correctable, + suggestion: str | None = None, +) -> Error: + """Build an SDK-source advisory entry for ``errors[]`` augmentation. + + Sets ``source=sdk`` and ``sdk_id=@`` per the + multi-hop propagation contract in ``core/error.json``. Consumers + receiving this entry MUST treat it as advisory — the response stays + success on the v1 path; only the v2 projection is degraded. + + Args: + code: AdCP error code (e.g., ``FORMAT_DECLARATION_V1_AMBIGUOUS``). + Must be ≤64 chars per the wire schema. + message: Human-readable description. + field: JSONPath-lite pointer to the offending field + (e.g., ``products[0].format_options[2]``). + details: Code-specific structured payload. + recovery: Recovery classification — defaults to ``correctable`` + because canonical-projection advisories tell the seller what + to fix (add ``v1_format_ref``, file a registry PR, etc.). + suggestion: Optional one-line fix hint surfaced to operators. + """ + return Error( + code=code, + message=message, + field=field, + details=details, + recovery=recovery, + source=Source.sdk, + sdk_id=_resolve_sdk_id(), + suggestion=suggestion, + ) + + +# ``_echo_identifier`` and ``_resolve_sdk_id`` are private helpers; not +# part of ``__all__``. +__all__ = [ + "SDK_ID", + "SdkAdvisory", + "make_sdk_advisory", +] diff --git a/src/adcp/canonical_formats/format_options.py b/src/adcp/canonical_formats/format_options.py new file mode 100644 index 00000000..1f1654ea --- /dev/null +++ b/src/adcp/canonical_formats/format_options.py @@ -0,0 +1,226 @@ +"""Closed-set ``format_options[]`` validation. + +Per AdCP 3.1 ``ProductFormatDeclaration.seller_preference`` (normative): + + `format_options[]` IS the closed set of accepted formats; anything + outside the list is rejected at `create_media_buy` regardless of + preference. + +Sellers MUST reject a ``create_media_buy`` whose creative manifest +declares a ``format_kind`` outside the product's published +``format_options[]``. This module provides the pre-call guard. + +Two helpers: + +* :func:`validate_format_kind_in_options` — raises + :class:`FormatKindNotInClosedSetError` when the kind is absent. + Seller-side check; pair with an ``UNSUPPORTED_FEATURE`` error + emitted on the wire response. +* :func:`find_declaration_by_kind` — looks up the matching declaration + (with optional ``capability_id`` disambiguation when the closed set + carries multiple declarations of the same kind). +""" + +from __future__ import annotations + +from collections.abc import Iterable +from urllib.parse import urlsplit, urlunsplit + +from adcp.types import CanonicalFormatKind, Error, FormatId, ProductFormatDeclaration + +# Default ports per RFC 3986 §3.2.3 — stripped during canonicalization +# so ``https://x.example:443`` matches ``https://x.example``. +_DEFAULT_PORTS: dict[str, int] = {"http": 80, "https": 443} + + +def _canonicalize_agent_url(raw: str) -> str: + """Return ``raw`` with scheme + host lowercased and default port stripped. + + Per ``core/format-id.json`` (normative): callers MUST canonicalize + ``agent_url`` before comparing two ``FormatId`` values for identity. + Pydantic's ``AnyUrl`` does trailing-slash normalization but not + RFC 3986 §6 host-casefolding or default-port stripping — a seller + publishing ``"https://Creative.AdContextProtocol.org"`` would + silently miss-match a buyer's ``"https://creative.adcontextprotocol.org"`` + without this step. + + Non-throwing: malformed inputs round-trip as-is. The lookup is a + closed-set match, not a security check; we don't want to reject + here, just normalize what we can. + """ + try: + parts = urlsplit(raw) + except ValueError: + return raw + if not parts.scheme or not parts.hostname: + return raw + scheme = parts.scheme.lower() + host = parts.hostname.lower() + port = parts.port + if port is not None and port == _DEFAULT_PORTS.get(scheme): + port = None + netloc = host if port is None else f"{host}:{port}" + return urlunsplit((scheme, netloc, parts.path, parts.query, "")) + + +class FormatKindNotInClosedSetError(ValueError): + """Raised when a ``format_kind`` is not in the product's ``format_options[]``. + + Carries the rejected kind plus the closed set on the exception + instance so handlers can surface them on the wire response (e.g., + via ``error.details.accepted_values``). Use :meth:`to_wire_error` + to construct the response ``Error`` directly. + """ + + def __init__( + self, + format_kind: str, + accepted_kinds: list[str], + ) -> None: + self.format_kind = format_kind + self.accepted_kinds = accepted_kinds + super().__init__( + f"format_kind={format_kind!r} is not in the product's format_options[] " + f"closed set (accepted: {sorted(set(accepted_kinds))!r})." + ) + + def to_wire_error( + self, + *, + field: str = "manifest.format_kind", + message: str | None = None, + ) -> Error: + """Build the wire-correct ``UNSUPPORTED_FEATURE`` ``Error`` for the response. + + Per ``error.json``, closed-set rejections SHOULD use + ``details.rejected_value`` + ``details.accepted_values`` so + buyer-side diagnostic tooling can surface the accepted set + without per-seller pattern matching. + + Args: + field: JSONPath-lite pointer to the rejected field on the + buyer's request (default ``"manifest.format_kind"`` — + the typical ``create_media_buy`` location). + message: Override the default human-readable message. + """ + return Error( + code="UNSUPPORTED_FEATURE", + message=message or str(self), + field=field, + details={ + "rejected_value": self.format_kind, + "accepted_values": sorted(set(self.accepted_kinds)), + }, + ) + + +def _coerce_kind(value: str | CanonicalFormatKind) -> str: + """Normalise the input to the wire string the schema uses.""" + if isinstance(value, CanonicalFormatKind): + return value.value + return value + + +def validate_format_kind_in_options( + format_kind: str | CanonicalFormatKind, + format_options: Iterable[ProductFormatDeclaration], +) -> None: + """Raise if ``format_kind`` isn't published in ``format_options[]``. + + Args: + format_kind: The kind a buyer's manifest targets. Accepts both + the wire-string form (``"image"``) and the typed enum form + (``CanonicalFormatKind.image``). + format_options: The product's closed set of accepted format + declarations. + + Raises: + FormatKindNotInClosedSetError: when no declaration in the closed + set carries that ``format_kind``. The seller MUST surface + ``UNSUPPORTED_FEATURE`` on the response. + """ + wanted = _coerce_kind(format_kind) + accepted = [_coerce_kind(d.format_kind) for d in format_options] + if wanted not in accepted: + raise FormatKindNotInClosedSetError(wanted, accepted) + + +def find_declaration_by_kind( + format_kind: str | CanonicalFormatKind, + format_options: Iterable[ProductFormatDeclaration], + *, + capability_id: str | None = None, +) -> ProductFormatDeclaration | None: + """Look up the declaration in ``format_options[]`` matching the kind. + + Disambiguates with ``capability_id`` when the closed set carries + multiple declarations sharing the same ``format_kind`` (the case + where ``capability_id`` is REQUIRED per + ``ProductFormatDeclaration.capability_id``). + + Args: + format_kind: The kind to match. Accepts string or enum. + format_options: The product's ``format_options[]``. + capability_id: When provided, only declarations whose + ``capability_id`` equals this value are considered a match. + When omitted, the first kind match wins; this is unambiguous + only when every declaration of that kind shares the same + ``capability_id``. + + Returns: + The matching declaration, or ``None`` when no declaration in the + closed set satisfies the query. + """ + wanted = _coerce_kind(format_kind) + for d in format_options: + if _coerce_kind(d.format_kind) != wanted: + continue + if capability_id is not None and d.capability_id != capability_id: + continue + return d + return None + + +def find_declaration_by_v1_format_id( + format_id: FormatId, + format_options: Iterable[ProductFormatDeclaration], +) -> ProductFormatDeclaration | None: + """Look up the declaration whose ``v1_format_ref[]`` includes ``format_id``. + + Seller-side helper for processing v1 ``create_media_buy`` requests + against a product publishing v2 ``format_options[]``. A buyer + targeting a v1 ``format_id`` lands here: the SDK walks the closed + set looking for the declaration that asserted this v1 ref. + + Matches on both ``agent_url`` and ``id`` — a v1 format identity is + the ``(agent_url, id)`` pair, not the id alone. Returns the first + declaration whose ``v1_format_ref[]`` contains a structurally equal + entry. + + Args: + format_id: The v1 ``FormatId`` the buyer's manifest targets. + format_options: The product's ``format_options[]`` closed set. + + Returns: + The matching declaration, or ``None`` when no declaration in the + closed set asserts this v1 ref. ``None`` means the request + should be rejected with ``UNSUPPORTED_FEATURE`` — the v1 + ``format_id`` is not a recognised entry for this product. + """ + target_url = _canonicalize_agent_url(str(format_id.agent_url)) + target_id = format_id.id + for decl in format_options: + refs = decl.v1_format_ref or [] + for ref in refs: + ref_url = _canonicalize_agent_url(str(ref.agent_url)) + if ref_url == target_url and ref.id == target_id: + return decl + return None + + +__all__ = [ + "FormatKindNotInClosedSetError", + "find_declaration_by_kind", + "find_declaration_by_v1_format_id", + "validate_format_kind_in_options", +] diff --git a/src/adcp/canonical_formats/projection.py b/src/adcp/canonical_formats/projection.py new file mode 100644 index 00000000..ab192af0 --- /dev/null +++ b/src/adcp/canonical_formats/projection.py @@ -0,0 +1,260 @@ +"""v2 → v1 canonical-format projection. + +Projects ``Product.format_options[]`` (v2) into ``Product.format_ids[]`` +(v1) so v1-only buyers see the product without losing visibility, while +emitting non-fatal advisories on ``errors[]`` for the seller to act on. + +Resolution order per ``registries/v1-canonical-mapping.json`` (the +"direction of truth" section is normative): + +1. ``canonical_formats_only=True`` — no v1 emit and no advisory. The + seller has explicitly opted out of v1 projection. Note that + ``ProductFormatDeclaration`` enforces this is mutually exclusive + with ``v1_format_ref[]`` at construction. +2. ``v1_format_ref[]`` set — emit those refs into ``format_ids[]``. + Applies to every ``format_kind`` including ``custom`` (a custom + format MAY carry seller-asserted v1 refs). If ``params.sizes[]`` + count > ``v1_format_ref[]`` count, emit + ``FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE`` (advisory only; the partial + coverage still ships). +3. ``v1_format_ref[]`` absent AND the canonical's ``v1_translatable`` + default is ``False`` — no v1 emit, no advisory. Canonicals + ``agent_placement``, ``sponsored_placement``, ``responsive_creative``, + ``image_carousel``, and ``custom`` (without seller-asserted refs) + are v1-unreachable by design; warning here would spam the wire. +4. ``v1_format_ref[]`` absent AND the canonical is normally + ``v1_translatable=True`` — emit ``FORMAT_DECLARATION_V1_AMBIGUOUS``. + The SDK explicitly does NOT synthesize a v1 ``format_id`` from a + structural registry match; that would produce inter-SDK divergence + on structurally-equal v2 declarations. + +The registry is intentionally NOT consulted on the v2 → v1 path +(see "Direction of truth (normative)" in +``registries/v1-canonical-mapping.json``). The v1 → v2 reverse path +will consume it in a follow-up PR. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from adcp.canonical_formats.advisory import _echo_identifier, make_sdk_advisory +from adcp.types import ( + CanonicalFormatKind, + Error, + FormatId, + ProductFormatDeclaration, +) + +# Per-canonical ``v1_translatable`` default, mirrored from the schemas +# under ``schemas/cache//formats/canonical/*.json``. Canonicals +# inherit ``v1_translatable=True`` from ``_base.json``; the four explicit +# False entries are overrides on each canonical's own schema file. +# ``custom`` is treated as not-v1-translatable by default — a custom +# format with no seller-asserted ``v1_format_ref[]`` has no projection +# target — but a custom declaration MAY carry ``v1_format_ref`` and +# project via step 2. +V1_TRANSLATABLE: dict[CanonicalFormatKind, bool] = { + CanonicalFormatKind.image: True, + CanonicalFormatKind.html5: True, + CanonicalFormatKind.display_tag: True, + CanonicalFormatKind.image_carousel: False, + CanonicalFormatKind.video_hosted: True, + CanonicalFormatKind.video_vast: True, + CanonicalFormatKind.audio_hosted: True, + CanonicalFormatKind.audio_daast: True, + CanonicalFormatKind.sponsored_placement: False, + CanonicalFormatKind.native_in_feed: True, + CanonicalFormatKind.responsive_creative: False, + CanonicalFormatKind.agent_placement: False, + CanonicalFormatKind.custom: False, +} + + +@dataclass +class V2ToV1Projection: + """Result of projecting one or more ``ProductFormatDeclaration``s to v1. + + Attributes: + format_ids: v1 ``format_ids[]`` entries to dual-emit alongside the + v2 ``format_options[]``. Empty when the declarations are all + v1-unreachable (custom / canonical_formats_only / non-translatable + canonicals). + advisories: SDK-source ``errors[]`` entries to augment the response + with. Each carries ``source="sdk"`` and ``sdk_id=``. + """ + + format_ids: list[FormatId] = field(default_factory=list) + advisories: list[Error] = field(default_factory=list) + + +def _params_sizes_count(declaration: ProductFormatDeclaration) -> int: + """Return the number of distinct sizes declared in ``params.sizes[]``. + + ``params`` is an open ``dict[str, Any]`` on the declaration (the + canonical's own schema narrows it on construction; the wire-level + type is permissive). ``sizes`` is canonical-image-specific but may + appear on any multi-size canonical. Returns ``0`` when absent. + """ + params = getattr(declaration, "params", None) + if params is None: + return 0 + if hasattr(params, "model_dump"): + params_dict = params.model_dump(exclude_none=True) + elif isinstance(params, dict): + params_dict = params + else: + return 0 + sizes = params_dict.get("sizes") + if not isinstance(sizes, list): + return 0 + return len(sizes) + + +def project_declaration_to_v1( + declaration: ProductFormatDeclaration, + *, + field_path: str = "format_options[]", + product_id: str | None = None, +) -> V2ToV1Projection: + """Project a single declaration to v1, emitting advisories per the + resolution order documented at module level. + + Args: + declaration: The v2 ``ProductFormatDeclaration`` to project. + field_path: JSONPath-lite pointer surfaced on emitted advisories + (e.g., ``products[0].format_options[2]``). The default points + at the seller-published declaration without product context; + callers wrapping a ``Product`` should pass the indexed form. + product_id: Optional product identifier — surfaced in advisory + ``details.product_id`` for buyer-side correlation. + + Returns: + :class:`V2ToV1Projection` with the projected refs and any + advisories the resolution order emitted. + """ + kind = declaration.format_kind + refs = list(declaration.v1_format_ref or []) + + # Step 1: seller has explicitly opted out of v1 projection. + # ``ProductFormatDeclaration`` enforces this is mutually exclusive + # with ``v1_format_ref[]``, so we can't reach step 2 from here. + if declaration.canonical_formats_only: + return V2ToV1Projection() + + # Step 2: seller-asserted v1 link — emit refs, check multi-size fan-out. + if refs: + advisories: list[Error] = [] + sizes_n = _params_sizes_count(declaration) + if sizes_n > len(refs): + details: dict[str, Any] = { + "format_kind": kind.value, + "v1_format_ref_count": len(refs), + "sizes_count": sizes_n, + } + if product_id is not None: + details["product_id"] = _echo_identifier(product_id) + advisories.append( + make_sdk_advisory( + code="FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE", + message=( + f"v1_format_ref[] has {len(refs)} entries but params.sizes[] " + f"declares {sizes_n} sizes — the partial v1 emission covers " + f"only the referenced sizes. Seller SHOULD author one " + f"v1_format_ref entry per size." + ), + field=field_path, + details=details, + suggestion=( + "Add per-size v1_format_ref[] entries (one per params.sizes " + "entry) to give v1-only buyers full size coverage." + ), + ) + ) + return V2ToV1Projection(format_ids=refs, advisories=advisories) + + # Step 3: canonical is not v1-translatable — silent. + if not V1_TRANSLATABLE.get(kind, True): + return V2ToV1Projection() + + # Step 4: canonical IS v1-translatable but seller didn't author refs. + details = { + "format_kind": kind.value, + "reason": "no_v1_format_ref", + } + if product_id is not None: + details["product_id"] = _echo_identifier(product_id) + return V2ToV1Projection( + advisories=[ + make_sdk_advisory( + code="FORMAT_DECLARATION_V1_AMBIGUOUS", + message=( + f"Canonical '{kind.value}' is normally v1-translatable but the " + f"declaration carries no v1_format_ref[] — SDK cannot synthesize " + f"a v1 format_id without seller assertion." + ), + field=field_path, + details=details, + suggestion=( + "Add v1_format_ref[] pointing at the v1 named format(s) this " + "declaration projects to (e.g., AAO-hosted formats at " + "https://creative.adcontextprotocol.org or a platform-published " + "adagents.json formats[] entry)." + ), + ) + ] + ) + + +def project_product_to_v1( + product: Any, + *, + product_index: int | None = None, +) -> V2ToV1Projection: + """Project every ``format_options[]`` entry on a ``Product`` to v1. + + Walks the product's declarations, applies + :func:`project_declaration_to_v1` to each, and accumulates the + aggregated refs + advisories. The product's existing v1 ``format_ids`` + field is preserved by the caller — this helper produces the *additive* + set that the seller publishes alongside seller-declared v1 ids. + + Args: + product: A ``Product`` instance carrying ``format_options[]``. + Duck-typed so the helper works against the wire response, + adopter-typed wrappers, or in-progress builders. + product_index: Optional zero-based index of the product within the + enclosing ``Products[]`` array. When provided, advisories + carry the indexed field path (``products[N].format_options[K]``) + so multi-product responses don't collapse to ambiguous pointers. + + Returns: + :class:`V2ToV1Projection` with the union of per-declaration results. + """ + declarations = getattr(product, "format_options", None) or [] + product_id = getattr(product, "product_id", None) or getattr(product, "id", None) + + out = V2ToV1Projection() + for i, decl in enumerate(declarations): + prefix = ( + f"products[{product_index}].format_options[{i}]" + if product_index is not None + else f"format_options[{i}]" + ) + result = project_declaration_to_v1( + decl, + field_path=prefix, + product_id=product_id, + ) + out.format_ids.extend(result.format_ids) + out.advisories.extend(result.advisories) + return out + + +__all__ = [ + "V1_TRANSLATABLE", + "V2ToV1Projection", + "project_declaration_to_v1", + "project_product_to_v1", +] diff --git a/src/adcp/canonical_formats/registry.py b/src/adcp/canonical_formats/registry.py new file mode 100644 index 00000000..c4f902f6 --- /dev/null +++ b/src/adcp/canonical_formats/registry.py @@ -0,0 +1,350 @@ +"""v1↔v2 canonical mapping registry loader + matchers. + +Implements the registry contract from +``registries/v1-canonical-mapping.json``. Two match modes: + +* **Glob** — exact / wildcard match against a v1 ``format_id.id`` value. + As of 3.1 the registry carries zero literal entries; the AAO-published + IAB-standard formats project via catalog ``canonical:`` annotations + (resolution-order step 2). The matcher is implemented to handle + ``*`` wildcards anywhere in the pattern so future literal entries + work without further code change. +* **Structural** — match against the v1 format's slot shape, asset types, + and VAST/DAAST version constraints. The primary fallback for v1 wire + traffic. + +Directional invariant: this registry is authoritative for **v1 → v2 projection +only**. The v2 → v1 path in :mod:`adcp.canonical_formats.projection` does NOT +consult the registry; it relies on the seller-asserted ``v1_format_ref[]`` on +the v2 declaration. +""" + +from __future__ import annotations + +import json +import re +from functools import lru_cache +from importlib.resources import as_file, files +from pathlib import Path +from typing import Any + +from adcp.types import V1V2CanonicalFormatMappingRegistry + +_REGISTRY_RELATIVE = Path("registries") / "v1-canonical-mapping.json" + + +def _read_registry_json() -> str: + """Return the raw JSON for the registry from packaged or dev-checkout layout. + + Mirrors :mod:`adcp.validation.schema_loader`'s resolution order: + + 1. Packaged: ``importlib.resources.files("adcp") / "_schemas" / / …`` + (populated by ``scripts/bundle_schemas.py`` before wheel build). + 2. Dev checkout: ``/schemas/cache//…`` walking up from + this module, used by editable installs that haven't bundled yet. + + Raises :class:`FileNotFoundError` when neither layout exposes the registry — + the canonical-formats projection cannot operate without the registry, so + fail fast rather than degrade silently. + """ + adcp_version = (files("adcp") / "ADCP_VERSION").read_text().strip() + + try: + packaged = files("adcp") / "_schemas" / adcp_version / str(_REGISTRY_RELATIVE) + with as_file(packaged) as p: + packaged_path = Path(p) + if packaged_path.is_file(): + return packaged_path.read_text() + except (ModuleNotFoundError, FileNotFoundError, OSError): + # Packaged-bundle lookup is best-effort. Editable/dev installs + # (the common case during development) won't have a populated + # ``_schemas/`` tree; fall through to the dev-checkout walk-up + # below. Other resolution failures (missing version file, + # filesystem race) also defer to the fallback — the eventual + # ``FileNotFoundError`` raise below carries the diagnostic. + pass + + here = Path(__file__).resolve() + for ancestor in here.parents: + candidate = ancestor / "schemas" / "cache" / adcp_version / _REGISTRY_RELATIVE + if candidate.is_file(): + return candidate.read_text() + if ancestor.parent == ancestor: + break + + raise FileNotFoundError( + f"v1-canonical-mapping registry not found for ADCP_VERSION={adcp_version} " + f"in either packaged (_schemas/) or dev-checkout (schemas/cache/) layout." + ) + + +class RegistryLoadError(RuntimeError): + """Raised when the bundled v1↔v2 registry cannot be loaded or parsed. + + Wraps the underlying :class:`FileNotFoundError`, + :class:`json.JSONDecodeError`, or :class:`pydantic.ValidationError` + with a contextual message naming the registry path + ADCP version so + adopters can diagnose a corrupt bundle. + """ + + +@lru_cache(maxsize=1) +def _load_registry_uncopied() -> V1V2CanonicalFormatMappingRegistry: + """Cached parsed registry — DO NOT call directly; use :func:`load_default_registry`. + + Wraps all read/parse failures in :class:`RegistryLoadError` with the + bundle context. The cache stores the parsed model once per process; + :func:`load_default_registry` returns a deep copy so multi-tenant + callers cannot mutate each other's view. + """ + adcp_version = (files("adcp") / "ADCP_VERSION").read_text().strip() + try: + raw = _read_registry_json() + except FileNotFoundError as exc: + raise RegistryLoadError( + f"v1-canonical-mapping registry not found " f"(ADCP_VERSION={adcp_version!r}): {exc}" + ) from exc + + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise RegistryLoadError( + f"v1-canonical-mapping registry has invalid JSON " + f"(ADCP_VERSION={adcp_version!r}, position {exc.pos}): {exc.msg}" + ) from exc + + try: + return V1V2CanonicalFormatMappingRegistry.model_validate(parsed) + except Exception as exc: + raise RegistryLoadError( + f"v1-canonical-mapping registry failed schema validation " + f"(ADCP_VERSION={adcp_version!r}): {exc}" + ) from exc + + +def load_default_registry() -> V1V2CanonicalFormatMappingRegistry: + """Load and parse the AAO-published v1↔v2 mapping registry. + + Returns a fresh deep copy of the cached parsed registry — callers + can safely mutate the returned instance without affecting other + callers in the same process. The underlying parsed registry is + cached per process (the registry is immutable for a given SDK + build, keyed by ``ADCP_VERSION``). + + .. note:: + **Experimental.** The registry is consulted only on the v1 → v2 + inbound path (lands in #741 part 2). Adopters using this helper + to inspect the bundled mappings SHOULD pin to the SDK release + they integrate against; the return shape may sharpen when the + inbound consumer lands. + + Raises: + RegistryLoadError: when the bundle is missing, malformed JSON, + or fails schema validation. + """ + return _load_registry_uncopied().model_copy(deep=True) + + +def glob_match(value: str, pattern: str) -> bool: + """Glob-match ``value`` against a registry ``format_id_glob`` pattern. + + Per the registry schema: ``*`` matches any segment. Patterns are + compared against the v1 ``format_id.id`` (NOT the ``{agent_url, id}`` + pair — the registry mantra is family identification, not full + namespace resolution). + + Treats ``*`` as a permissive wildcard (any chars including ``_``). + Other regex metacharacters are escaped — the pattern language is + glob, not regex. + + .. note:: + **Experimental.** This helper is unused on the v2 → v1 path (the + registry is consulted only on the v1 → v2 inbound path, which + lands in #741's second PR). The signature may shift when that + path consumes it; adopters SHOULD pin to the SDK release they + integrate against. + """ + if pattern == "*": + return True + regex = "^" + re.escape(pattern).replace(r"\*", ".*") + "$" + return re.fullmatch(regex, value) is not None + + +# Constraint operator prefixes the registry's version DSL recognises. +# A constraint that does not match any of these AND does not end in +# ``.x`` is treated as a bare exact version (e.g., ``"4.2"``). Order +# matters: longer prefixes must come before shorter (``">="`` before +# ``">"``) so the dispatch finds the right operator first. +_VERSION_OPERATORS: tuple[str, ...] = (">=", "<=", ">", "<", "==", "!=") + + +def _versions_overlap(have: str, want_constraints: list[str]) -> bool: + """True iff a single concrete version satisfies any of the constraints. + + The registry's ``vast_versions`` / ``daast_versions`` constraints use + a small DSL: ``"4.2"`` (exact), ``"4.x"`` (any 4-major), ``">=4.0"`` + (semver-style range, supports ``<``, ``<=``, ``>``, ``>=``, ``==``, + ``!=``). Constraints are matched against a single concrete version + like ``"3.0"`` or ``"4.2"``. + + Constraints are OR-joined — any matching entry returns ``True``. + + Raises: + ValueError: when a constraint string starts with an unrecognised + operator prefix (e.g., ``"~>4.0"``). Silently ignoring would + mask a registry-publishing mistake — the registry MUST stick + to the documented DSL. + """ + try: + have_major, have_minor = _parse_version_pair(have) + except ValueError: + return False + + for constraint in want_constraints: + c = constraint.strip() + + if c.endswith(".x"): + try: + want_major = int(c[:-2]) + except ValueError as exc: + raise ValueError(f"Unparseable .x version constraint: {constraint!r}") from exc + if have_major == want_major: + return True + continue + + op: str | None = None + for candidate in _VERSION_OPERATORS: + if c.startswith(candidate): + op = candidate + break + + # Looks like an operator (starts with one of ``<>=!~^``) but + # didn't match the recognised set — fail loudly so a typo or + # unsupported DSL extension in a registry entry surfaces during + # loading rather than silently never matching. + if op is None and c and c[0] in "<>=!~^": + raise ValueError( + f"Unrecognised version-constraint operator in {constraint!r}; " + f"supported operators are {_VERSION_OPERATORS!r} and the ``.x`` " + f"suffix." + ) + + rest = c[len(op) :].strip() if op else c + try: + want_major, want_minor = _parse_version_pair(rest) + except ValueError: + continue + + have_pair = (have_major, have_minor) + want_pair = (want_major, want_minor) + if op is None or op == "==": + if have_pair == want_pair: + return True + elif op == "!=": + if have_pair != want_pair: + return True + elif op == ">=": + if have_pair >= want_pair: + return True + elif op == "<=": + if have_pair <= want_pair: + return True + elif op == ">": + if have_pair > want_pair: + return True + elif op == "<": + if have_pair < want_pair: + return True + return False + + +def _parse_version_pair(s: str) -> tuple[int, int]: + """Parse ``"4.2"`` / ``"3"`` / ``"3.0"`` into a ``(major, minor)`` int pair. + + Leading/trailing whitespace stripped; a missing minor component + defaults to ``0``. Raises :class:`ValueError` on unparseable input. + """ + parts = s.strip().split(".") + if not parts or not parts[0]: + raise ValueError(s) + major = int(parts[0]) + minor = int(parts[1]) if len(parts) >= 2 and parts[1] else 0 + return major, minor + + +def structural_match( + *, + asset_types: list[str], + vast_versions: list[str] | None = None, + daast_versions: list[str] | None = None, + width: int | None = None, + height: int | None = None, + pattern: Any, +) -> bool: + """Check whether a v1 format's structural shape matches a registry entry. + + ``pattern`` is the registry entry's ``structural`` block (a + :class:`adcp.types.V1CanonicalStructural` or equivalent dict). All + constraints declared on the pattern MUST match; constraints absent + from the pattern do not narrow the match. + + Args: + asset_types: Asset types appearing in the v1 format's slots. + The pattern's ``asset_types`` is a *subset* requirement — + every type the pattern lists must be present in ``asset_types``. + vast_versions: VAST version(s) declared on the v1 format (typically + a single value like ``"4.2"``). Each must satisfy at least one + constraint in the pattern's ``vast_versions`` list. + daast_versions: DAAST version(s); same matching semantics as VAST. + width: Slot dimension width (pixels), if applicable. + height: Slot dimension height (pixels), if applicable. + pattern: The registry entry's ``structural`` block. + + Returns: + ``True`` iff every constraint declared on ``pattern`` is satisfied + by the v1 format's structural shape. + + .. note:: + **Experimental.** Same caveat as :func:`glob_match` — the v1 → v2 + inbound consumer lands in the second half of #741. + """ + if hasattr(pattern, "model_dump"): + p = pattern.model_dump(exclude_none=True) + else: + p = dict(pattern) if pattern else {} + + want_types = p.get("asset_types") + if want_types: + for t in want_types: + if t not in asset_types: + return False + + want_vast = p.get("vast_versions") + if want_vast: + if not vast_versions: + return False + if not any(_versions_overlap(v, want_vast) for v in vast_versions): + return False + + want_daast = p.get("daast_versions") + if want_daast: + if not daast_versions: + return False + if not any(_versions_overlap(v, want_daast) for v in daast_versions): + return False + + want_dims = p.get("dimensions") or {} + if want_dims.get("width") is not None and want_dims["width"] != width: + return False + if want_dims.get("height") is not None and want_dims["height"] != height: + return False + + return True + + +__all__ = [ + "RegistryLoadError", + "glob_match", + "load_default_registry", + "structural_match", +] diff --git a/src/adcp/types/__init__.py b/src/adcp/types/__init__.py index f5254f10..66471591 100644 --- a/src/adcp/types/__init__.py +++ b/src/adcp/types/__init__.py @@ -464,6 +464,24 @@ CalibrateContentErrorResponse, CalibrateContentResponse1, CalibrateContentSuccessResponse, + CanonicalAssetSource, + CanonicalCompositionModel, + CanonicalFormatAgentPlacement, + CanonicalFormatBase, + CanonicalFormatDaastAudio, + CanonicalFormatDisplayTag, + CanonicalFormatHostedAudio, + CanonicalFormatHostedVideo, + CanonicalFormatHtml5Banner, + CanonicalFormatImage, + CanonicalFormatImageCarousel, + CanonicalFormatKind, + CanonicalFormatNativeInFeed, + CanonicalFormatResponsiveCreative, + CanonicalFormatSponsoredPlacement, + CanonicalFormatVastVideo, + CanonicalProjectionReference, + CanonicalSlotOverride, CatalogFormatAsset, CatalogGroupBinding, ComplyErrorResponse, @@ -535,6 +553,9 @@ MarkdownFormatAsset, MarkdownFormatGroupAsset, MediaBuyDeliveryStatus, + PixelTrackerAsset, + PixelTrackerEvent, + PixelTrackerMethod, PlatformDeployment, PlatformDestination, PreviewCreativeBatchResponse, @@ -542,6 +563,8 @@ PreviewCreativeSingleResponse, PreviewCreativeVariantResponse, PricingOption, + ProductFormatDeclaration, + ProductFormatSellerPreference, PropertyId, PropertyTag, ProvidePerformanceFeedbackByBuyerRefRequest, @@ -553,10 +576,12 @@ PublisherPropertiesAll, PublisherPropertiesById, PublisherPropertiesByTag, + Recovery, RepeatableAssetGroup, SegmentIdActivationKey, SiSendActionResponseRequest, SiSendTextMessageRequest, + Source, SyncAccountsErrorResponse, SyncAccountsResponse1, SyncAccountsSuccessResponse, @@ -592,6 +617,13 @@ UrlFormatGroupAsset, UrlPreviewRender, UrlVastAsset, + V1CanonicalDimensions, + V1CanonicalGlobPattern, + V1CanonicalMapping, + V1CanonicalStructural, + V1CanonicalStructuralPattern, + V1CanonicalV2Projection, + V1V2CanonicalFormatMappingRegistry, ValidateContentDeliveryErrorResponse, ValidateContentDeliveryResponse1, ValidateContentDeliverySuccessResponse, @@ -1016,8 +1048,41 @@ def __init__(self, *args: object, **kwargs: object) -> None: "Format", "FormatCard", "FormatCardDetailed", + # Canonical-formats v2 surface (AdCP 3.1) + "CanonicalAssetSource", + "CanonicalCompositionModel", + "CanonicalFormatAgentPlacement", + "CanonicalFormatBase", + "CanonicalFormatDaastAudio", + "CanonicalFormatDisplayTag", + "CanonicalFormatHostedAudio", + "CanonicalFormatHostedVideo", + "CanonicalFormatHtml5Banner", + "CanonicalFormatImage", + "CanonicalFormatImageCarousel", + "CanonicalFormatKind", + "CanonicalFormatNativeInFeed", + "CanonicalFormatResponsiveCreative", + "CanonicalFormatSponsoredPlacement", + "CanonicalFormatVastVideo", + "CanonicalProjectionReference", + "CanonicalSlotOverride", "FormatId", "FormatIdParameter", + "PixelTrackerAsset", + "PixelTrackerEvent", + "PixelTrackerMethod", + "Recovery", + "Source", + "ProductFormatDeclaration", + "ProductFormatSellerPreference", + "V1CanonicalDimensions", + "V1CanonicalGlobPattern", + "V1CanonicalMapping", + "V1CanonicalStructural", + "V1CanonicalStructuralPattern", + "V1CanonicalV2Projection", + "V1V2CanonicalFormatMappingRegistry", "FormatReferenceStructuredObject", "Identifier", "Input", @@ -1130,9 +1195,11 @@ def __init__(self, *args: object, **kwargs: object) -> None: "Authentication", "AuthorizedAgents", "AvailableMetric", - "PushNotificationConfig", "NotificationConfig", + "PushNotificationConfig", "ReportingCapabilities", + "WholesaleFeedEvent", + "WholesaleFeedWebhook", "ReportingFrequency", "ReportingPeriod", "ReportingWebhook", diff --git a/src/adcp/types/aliases.py b/src/adcp/types/aliases.py index 8968790f..5aa37299 100644 --- a/src/adcp/types/aliases.py +++ b/src/adcp/types/aliases.py @@ -92,6 +92,10 @@ VastAsset2, VcpmPricingOption, ) +from adcp.types.generated_poc.core.error import ( + Recovery, + Source, +) from adcp.types.generated_poc.media_buy.create_media_buy_response import ( CreateMediaBuyResponse1, CreateMediaBuyResponse2, @@ -214,6 +218,50 @@ # Import Package from _generated (still uses qualified name for internal reasons) from adcp.types._generated import _PackageFromPackage as Package +# ``ProductFormatDeclaration`` comes from ``adcp.types.canonical_decl`` +# (a hand-rolled class) rather than ``generated_poc`` because the codegen +# can't represent the discriminated oneOf — see canonical_decl.py. +from adcp.types.canonical_decl import ProductFormatDeclaration +from adcp.types.generated_poc.core.assets.pixel_tracker_asset import ( + Event as PixelTrackerEvent, +) +from adcp.types.generated_poc.core.assets.pixel_tracker_asset import ( + Method as PixelTrackerMethod, +) +from adcp.types.generated_poc.core.assets.pixel_tracker_asset import ( + PixelTrackerAsset, +) + +# ---------------------------------------------------------------------------- +# Canonical-formats public surface (AdCP 3.1) +# ---------------------------------------------------------------------------- +# The v2 catalog-side canonical-formats vocabulary lives across several +# generated_poc paths. Re-export the public-facing classes under clean names +# so adopters import them from ``adcp.types`` without having to reach into +# ``generated_poc/``. Two renames clean up codegen-derived class names that +# include the schema title's parenthetical descriptor: +# +# * ``CanonicalFormatAgentPlacementAiSurfaceSponsoredPlacement`` → +# ``CanonicalFormatAgentPlacement`` +# * ``CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven`` → +# ``CanonicalFormatSponsoredPlacement`` +# +# All other canonical format classes keep their generated names. Registry +# types are renamed from the generic codegen forms (``Mapping``, ``V1Pattern``, +# ``V2``) to scoped names that make sense once imported into ``adcp.types``. +from adcp.types.generated_poc.core.canonical_format_kind import ( + CanonicalFormatKind, +) +from adcp.types.generated_poc.core.canonical_projection_ref import ( + AssetSource as CanonicalAssetSource, +) +from adcp.types.generated_poc.core.canonical_projection_ref import ( + CanonicalProjectionReference, +) +from adcp.types.generated_poc.core.canonical_projection_ref import ( + SlotsOverrideItem as CanonicalSlotOverride, +) + # AdCP 3.0.1 renamed core/format-id.json title from "Format ID" to # "Format Reference (Structured Object)". The canonical class lives at # core/format_id.py:FormatReferenceStructuredObject; the bundled-message @@ -223,6 +271,72 @@ from adcp.types.generated_poc.core.format_id import ( FormatReferenceStructuredObject as FormatId, ) +from adcp.types.generated_poc.core.product_format_declaration import ( + SellerPreference as ProductFormatSellerPreference, +) +from adcp.types.generated_poc.formats.canonical._base import ( + CanonicalFormatBase, +) +from adcp.types.generated_poc.formats.canonical._base import ( + CompositionModel as CanonicalCompositionModel, +) +from adcp.types.generated_poc.formats.canonical.agent_placement import ( + CanonicalFormatAgentPlacementAiSurfaceSponsoredPlacement as CanonicalFormatAgentPlacement, +) +from adcp.types.generated_poc.formats.canonical.audio_daast import ( + CanonicalFormatDaastAudio, +) +from adcp.types.generated_poc.formats.canonical.audio_hosted import ( + CanonicalFormatHostedAudio, +) +from adcp.types.generated_poc.formats.canonical.display_tag import ( + CanonicalFormatDisplayTag, +) +from adcp.types.generated_poc.formats.canonical.html5 import ( + CanonicalFormatHtml5Banner, +) +from adcp.types.generated_poc.formats.canonical.image import ( + CanonicalFormatImage, +) +from adcp.types.generated_poc.formats.canonical.image_carousel import ( + CanonicalFormatImageCarousel, +) +from adcp.types.generated_poc.formats.canonical.native_in_feed import ( + CanonicalFormatNativeInFeed, +) +from adcp.types.generated_poc.formats.canonical.responsive_creative import ( + CanonicalFormatResponsiveCreative, +) +from adcp.types.generated_poc.formats.canonical.sponsored_placement import ( + CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven as CanonicalFormatSponsoredPlacement, +) +from adcp.types.generated_poc.formats.canonical.video_hosted import ( + CanonicalFormatHostedVideo, +) +from adcp.types.generated_poc.formats.canonical.video_vast import ( + CanonicalFormatVastVideo, +) +from adcp.types.generated_poc.registries.v1_canonical_mapping import ( + V2 as V1CanonicalV2Projection, # noqa: N811 — codegen class ``V2``; rename here +) +from adcp.types.generated_poc.registries.v1_canonical_mapping import ( + Dimensions as V1CanonicalDimensions, +) +from adcp.types.generated_poc.registries.v1_canonical_mapping import ( + Mapping as V1CanonicalMapping, +) +from adcp.types.generated_poc.registries.v1_canonical_mapping import ( + Structural as V1CanonicalStructural, +) +from adcp.types.generated_poc.registries.v1_canonical_mapping import ( + V1Pattern as V1CanonicalGlobPattern, +) +from adcp.types.generated_poc.registries.v1_canonical_mapping import ( + V1Pattern1 as V1CanonicalStructuralPattern, +) +from adcp.types.generated_poc.registries.v1_canonical_mapping import ( + V1V2CanonicalFormatMappingRegistry, +) try: from adcp.types.generated_poc.creative.sync_creatives_response import ( @@ -1632,6 +1746,40 @@ class UnknownGroupAsset(_BaseGroupAsset): "AccountReferenceByNaturalKey", # Format identifier (canonical core class, AdCP 3.0.1+) "FormatId", + # Canonical-formats v2 surface (AdCP 3.1) + "CanonicalAssetSource", + "CanonicalCompositionModel", + "CanonicalFormatAgentPlacement", + "CanonicalFormatBase", + "CanonicalFormatDaastAudio", + "CanonicalFormatDisplayTag", + "CanonicalFormatHostedAudio", + "CanonicalFormatHostedVideo", + "CanonicalFormatHtml5Banner", + "CanonicalFormatImage", + "CanonicalFormatImageCarousel", + "CanonicalFormatKind", + "CanonicalFormatNativeInFeed", + "CanonicalFormatResponsiveCreative", + "CanonicalFormatSponsoredPlacement", + "CanonicalFormatVastVideo", + "CanonicalProjectionReference", + "CanonicalSlotOverride", + "PixelTrackerAsset", + "PixelTrackerEvent", + "PixelTrackerMethod", + # Error envelope sub-enums (for SDK advisory construction) + "Recovery", + "Source", + "ProductFormatDeclaration", + "ProductFormatSellerPreference", + "V1CanonicalDimensions", + "V1CanonicalGlobPattern", + "V1CanonicalMapping", + "V1CanonicalStructural", + "V1CanonicalStructuralPattern", + "V1CanonicalV2Projection", + "V1V2CanonicalFormatMappingRegistry", # Activation key variants "SegmentIdActivationKey", "KeyValueActivationKey", diff --git a/src/adcp/types/canonical_decl.py b/src/adcp/types/canonical_decl.py new file mode 100644 index 00000000..81dd484f --- /dev/null +++ b/src/adcp/types/canonical_decl.py @@ -0,0 +1,288 @@ +"""Wire-faithful ``ProductFormatDeclaration`` for the v2 catalog surface. + +The upstream schema ``core/product-format-declaration.json`` is a +discriminated ``oneOf`` over 13 ``format_kind`` values, each binding +``params`` to a canonical-specific schema. ``datamodel-code-generator`` +collapses this shape to a single class carrying only the shared +properties — ``format_kind`` and ``params`` disappear entirely because +they live on the per-variant branches. + +That generated stub is unusable for canonical-formats: it can't carry +the discriminator the projection layer routes on, and it silently drops +``params`` (``extra='ignore'``) so adopters who construct a declaration +with a typed canonical body lose it on serialization. + +This module replaces the public ``ProductFormatDeclaration`` symbol +with a hand-rolled class that: + +* Carries all 9 shared properties the generator emits. +* Adds ``format_kind: CanonicalFormatKind`` (the discriminator). +* Adds ``params: dict[str, Any]`` — the per-canonical body. Required + per the upstream schema (``required: ["format_kind", "params"]``). + Kept as an open dict at this level so the same class works across + all 13 canonical kinds; callers needing typed access SHOULD use + :meth:`ProductFormatDeclaration.params_as` to validate against the + typed canonical format class. +* Sets ``extra='allow'`` so future ``ProductFormatDeclaration`` field + additions in 3.1.x don't break round-trip through this model. Extra + fields are scanned for credential-shaped key suffixes at construction + time; presence of one raises (see ``_CREDENTIAL_SHAPED_KEY_SUFFIXES``). +* Enforces the schema's normative cross-field constraint that + ``canonical_formats_only=True`` and ``v1_format_ref[]`` are mutually + exclusive (``product-format-declaration.json`` ``allOf.not`` clause). + +The generated class is preserved as ``_GeneratedProductFormatDeclaration`` +for callers that need the original codegen output (validation hooks, +schema-loader cross-references). New code SHOULD import the +hand-rolled class via :mod:`adcp.types`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated, Any, TypeVar + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from adcp.types.base import AdCPBaseModel +from adcp.types.generated_poc.core.canonical_format_kind import ( + CanonicalFormatKind, +) +from adcp.types.generated_poc.core.format_id import ( + FormatReferenceStructuredObject, +) +from adcp.types.generated_poc.core.platform_extension_ref import ( + PlatformExtensionReference, +) +from adcp.types.generated_poc.core.product_format_declaration import ( + ProductFormatDeclaration as _GeneratedProductFormatDeclaration, +) +from adcp.types.generated_poc.core.product_format_declaration import ( + SellerPreference, +) +from adcp.types.generated_poc.enums.channels import MediaChannel + +if TYPE_CHECKING: + from typing_extensions import Self + + +# Credential-shaped key suffixes — mirrors the dispatcher's +# ``_CREDENTIAL_SHAPED_KEY_SUFFIXES`` in ``adcp.decisioning.dispatch``. +# ``extra='allow'`` on this model + the open ``params`` dict are both +# adopter-controlled bags that round-trip through ``format_options[]`` +# into buyer responses and the idempotency replay cache. Sellers +# accidentally stuffing credentials onto a declaration is the same bug +# class as the ``ctx_metadata`` credential leak — block it at +# construction with the same suffix list. +_CREDENTIAL_SHAPED_KEY_SUFFIXES: tuple[str, ...] = ( + "credential", + "credentials", + "token", + "secret", + "api_key", + "apikey", + "password", + "bearer", +) + + +def _key_is_credential_shaped(key: str) -> bool: + """Case-insensitive suffix match against ``_CREDENTIAL_SHAPED_KEY_SUFFIXES``.""" + lowered = key.lower() + return any(lowered.endswith(suffix) for suffix in _CREDENTIAL_SHAPED_KEY_SUFFIXES) + + +def _walk_for_credential_keys(value: Any, *, path: str = "") -> str | None: + """Return the first credential-shaped key path under ``value``, else ``None``. + + Walks ``dict`` / ``list`` / ``tuple`` recursively. Pydantic models + are walked via ``model_dump(mode="python")`` so the structured + extras stored under ``__pydantic_extra__`` are reachable. + """ + if isinstance(value, dict): + for key, sub in value.items(): + sub_path = f"{path}.{key}" if path else str(key) + if isinstance(key, str) and _key_is_credential_shaped(key): + return sub_path + found = _walk_for_credential_keys(sub, path=sub_path) + if found is not None: + return found + elif isinstance(value, (list, tuple)): + for i, item in enumerate(value): + found = _walk_for_credential_keys(item, path=f"{path}[{i}]") + if found is not None: + return found + elif isinstance(value, BaseModel): + return _walk_for_credential_keys(value.model_dump(mode="python"), path=path) + return None + + +_TypedParams = TypeVar("_TypedParams", bound=BaseModel) + + +class ProductFormatDeclaration(AdCPBaseModel): + """v2 catalog-side format declaration carrying the canonical discriminator. + + Wire-faithful Python representation of + ``core/product-format-declaration.json``. See the module docstring for + why this class replaces the codegen output. + """ + + model_config = ConfigDict(extra="allow") + + format_kind: Annotated[ + CanonicalFormatKind, + Field(description="The canonical format kind this declaration declares."), + ] + params: Annotated[ + dict[str, Any], + Field( + description=( + "Per-canonical body. Shape varies by format_kind — see the " + "canonical's own schema (``formats/canonical/.json``). " + "Use :meth:`params_as` for typed access." + ), + ), + ] + capability_id: Annotated[ + str | None, + Field( + description=( + "Stable identifier for this declaration. REQUIRED when the " + "parent product's format_options[] contains multiple " + "declarations sharing the same format_kind." + ), + ), + ] = None + display_name: Annotated[ + str | None, + Field(description="Optional seller-controlled human-readable label."), + ] = None + applies_to_channels: Annotated[ + list[MediaChannel] | None, + Field( + description=( + "Optional subset of the parent product's channels to which " + "this declaration applies." + ), + ), + ] = None + seller_preference: Annotated[ + SellerPreference | None, + Field(description="Soft routing hint within the accepted set."), + ] = None + canonical_formats_only: Annotated[ + bool, + Field( + description=( + "When true, this declaration has no clean v1 projection — " + "SDKs MUST NOT synthesize a v1 format_id. Mutually exclusive " + "with ``v1_format_ref``." + ), + ), + ] = False + experimental: Annotated[ + bool, + Field( + description=("When true, THIS seller's specific declaration may not work as declared."), + ), + ] = False + format_shape: Annotated[ + str | None, + Field( + description=( + "REQUIRED when format_kind='custom'; otherwise MUST be absent. " + "Recognized format-shape-vocabulary entry." + ), + ), + ] = None + v1_format_ref: Annotated[ + list[FormatReferenceStructuredObject] | None, + Field( + description=( + "Authoritative v2 → v1 link as one or more v1 format_id " + "({agent_url, id}) values. Mutually exclusive with " + "``canonical_formats_only=True``." + ), + min_length=1, + ), + ] = None + format_schema: Annotated[ + PlatformExtensionReference | None, + Field( + description=( + "REQUIRED when format_kind='custom'; otherwise MUST be absent. " + "URI+digest reference to the custom shape's schema." + ), + ), + ] = None + + @model_validator(mode="after") + def _check_mutual_exclusion(self) -> Self: + """Enforce the schema's ``allOf.not`` clause. + + ``product-format-declaration.json`` declares + ``canonical_formats_only=True`` and ``v1_format_ref[]`` mutually + exclusive. The Pydantic model rejects the combination at + construction so the SDK never launders a wire-invalid declaration + into a wire-valid one. + """ + if self.canonical_formats_only and self.v1_format_ref: + raise ValueError( + "ProductFormatDeclaration: canonical_formats_only=True is " + "mutually exclusive with v1_format_ref[] — a declaration can " + "EITHER assert no v1 projection OR link to v1 named formats, " + "never both. See product-format-declaration.json#allOf.not." + ) + return self + + @model_validator(mode="after") + def _reject_credential_shaped_extras(self) -> Self: + """Fail-closed scan for credential-shaped keys in ``params`` + extras. + + ``params`` is an open dict and ``model_config['extra']='allow'`` + means unknown top-level fields are stored on the instance. Both + are adopter-controlled bags that round-trip through + ``format_options[]`` responses and the idempotency replay cache. + Mirrors the dispatcher's ``ctx_metadata`` credential gate. + """ + for bag_name, bag_value in ( + ("params", self.params), + ("extras", self.__pydantic_extra__), + ): + if bag_value is None: + continue + found = _walk_for_credential_keys(bag_value, path=bag_name) + if found is not None: + raise ValueError( + f"ProductFormatDeclaration: {found!r} matches a " + f"credential-shaped key suffix and will round-trip to " + f"buyers via format_options[]. Move the value to " + f"AuthInfo.credential or a typed credential class. " + f"See CLAUDE.md → 'ctx_metadata: write-only credentials " + f"prohibited' for the equivalent dispatch-side rule." + ) + return self + + def params_as(self, canonical_type: type[_TypedParams]) -> _TypedParams: + """Validate ``params`` against the typed canonical-format class. + + Lets buyers and seller-side validators recover full typing on + the per-canonical body — e.g., ``decl.params_as(CanonicalFormatImage)`` + returns a ``CanonicalFormatImage`` with ``.sizes`` / ``.format`` / + etc. narrowed. Raises :class:`pydantic.ValidationError` when + ``params`` doesn't match the canonical's schema. + + Args: + canonical_type: A Pydantic model class from the canonical + vocabulary (e.g., :class:`adcp.types.CanonicalFormatImage`). + + Returns: + An instance of ``canonical_type`` validated against ``params``. + """ + return canonical_type.model_validate(self.params) + + +__all__ = [ + "ProductFormatDeclaration", + "_GeneratedProductFormatDeclaration", +] diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index 3fb0dc11..86de462f 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -504,6 +504,24 @@ "CalibrateContentResponse", "CalibrateContentResponse1", "CalibrateContentSuccessResponse", + "CanonicalAssetSource", + "CanonicalCompositionModel", + "CanonicalFormatAgentPlacement", + "CanonicalFormatBase", + "CanonicalFormatDaastAudio", + "CanonicalFormatDisplayTag", + "CanonicalFormatHostedAudio", + "CanonicalFormatHostedVideo", + "CanonicalFormatHtml5Banner", + "CanonicalFormatImage", + "CanonicalFormatImageCarousel", + "CanonicalFormatKind", + "CanonicalFormatNativeInFeed", + "CanonicalFormatResponsiveCreative", + "CanonicalFormatSponsoredPlacement", + "CanonicalFormatVastVideo", + "CanonicalProjectionReference", + "CanonicalSlotOverride", "Capability", "Catalog", "CatalogAction", @@ -778,6 +796,9 @@ "PaymentTerms", "Performance", "PerformanceFeedback", + "PixelTrackerAsset", + "PixelTrackerEvent", + "PixelTrackerMethod", "Placement", "PlatformDeployment", "PlatformDestination", @@ -809,6 +830,8 @@ "ProductCardDetailed", "ProductCatalog", "ProductFilters", + "ProductFormatDeclaration", + "ProductFormatSellerPreference", "Property", "PropertyId", "PropertyIdActivationKey", @@ -841,6 +864,7 @@ "QuartileData", "QuerySummary", "ReachUnit", + "Recovery", "Refine", "RefinementApplied", "RefinementApplied1", @@ -895,6 +919,7 @@ "Sort", "SortApplied", "SortDirection", + "Source", "Status", "StatusSummary", "SyncAccountsErrorResponse", @@ -977,6 +1002,13 @@ "UrlPreviewRender", "UrlType", "UrlVastAsset", + "V1CanonicalDimensions", + "V1CanonicalGlobPattern", + "V1CanonicalMapping", + "V1CanonicalStructural", + "V1CanonicalStructuralPattern", + "V1CanonicalV2Projection", + "V1V2CanonicalFormatMappingRegistry", "ValidateContentDeliveryErrorResponse", "ValidateContentDeliveryRequest", "ValidateContentDeliveryResponse", @@ -1009,6 +1041,8 @@ "WebhookMetadata", "WebhookResponseType", "WholesaleFeedEvent", + "WholesaleFeedEvent", + "WholesaleFeedWebhook", "WholesaleFeedWebhook", "aliases", "generated", diff --git a/tests/test_canonical_formats_declaration.py b/tests/test_canonical_formats_declaration.py new file mode 100644 index 00000000..55ffeb16 --- /dev/null +++ b/tests/test_canonical_formats_declaration.py @@ -0,0 +1,188 @@ +"""Wire-faithful behaviour of the hand-rolled ``ProductFormatDeclaration``. + +Covers the contracts the upstream schema enforces that codegen drops: + +* ``params`` required (``required: ["format_kind", "params"]``). +* ``canonical_formats_only`` mutually exclusive with ``v1_format_ref[]`` + (``allOf.not`` clause in the schema). +* Credential-shaped keys in ``params`` or model extras are rejected at + construction (parallels the dispatcher's ``ctx_metadata`` gate). +* ``params_as`` validates the open dict against a typed canonical class. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from adcp.types import ( + CanonicalFormatImage, + CanonicalFormatKind, + CanonicalFormatVastVideo, + FormatId, + ProductFormatDeclaration, +) + + +def _ref(id_: str = "display_300x250_image") -> FormatId: + return FormatId(agent_url="https://creative.adcontextprotocol.org", id=id_) + + +# --------------------------------------------------------------------------- +# params required +# --------------------------------------------------------------------------- + + +def test_params_is_required() -> None: + """Schema declares ``required: ["format_kind", "params"]``.""" + with pytest.raises(ValidationError) as exc: + ProductFormatDeclaration(format_kind=CanonicalFormatKind.image) # type: ignore[call-arg] + + msgs = str(exc.value) + assert "params" in msgs + + +# --------------------------------------------------------------------------- +# canonical_formats_only ⊥ v1_format_ref (allOf.not) +# --------------------------------------------------------------------------- + + +def test_canonical_formats_only_excludes_v1_format_ref() -> None: + """The schema's ``allOf.not`` clause forbids the combination at the wire level.""" + with pytest.raises(ValidationError) as exc: + ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={}, + canonical_formats_only=True, + v1_format_ref=[_ref()], + ) + + msg = str(exc.value) + assert "mutually exclusive" in msg + + +def test_canonical_formats_only_alone_is_accepted() -> None: + """The exclusion fires only on the combination — either alone is fine.""" + decl = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={}, + canonical_formats_only=True, + ) + assert decl.canonical_formats_only is True + assert decl.v1_format_ref is None + + +def test_v1_format_ref_alone_is_accepted() -> None: + decl = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={}, + v1_format_ref=[_ref()], + ) + assert decl.canonical_formats_only is False + assert decl.v1_format_ref == [_ref()] + + +# --------------------------------------------------------------------------- +# Credential-shaped key guard (parallels ctx_metadata gate) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "credential_key", + [ + "api_token", + "bearer_token", + "upstream.api_key", + "x_apikey", + "user_credential", + "OAuthBearer", + ], +) +def test_credential_shaped_keys_in_params_are_rejected(credential_key: str) -> None: + """``params`` is open; credential-shaped keys would round-trip to buyers.""" + with pytest.raises(ValidationError) as exc: + ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={credential_key: "sekret"}, + ) + assert "credential-shaped" in str(exc.value) + + +def test_credential_shaped_keys_in_nested_params_are_rejected() -> None: + """Walks the params dict recursively — nested credentials are caught.""" + with pytest.raises(ValidationError) as exc: + ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={"vendor": {"upstream": {"api_token": "sekret"}}}, + ) + assert "credential-shaped" in str(exc.value) + + +def test_credential_shaped_keys_in_extras_are_rejected() -> None: + """``extra='allow'`` opens a second credential-stuffing surface; gated too.""" + with pytest.raises(ValidationError) as exc: + ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={}, + api_key="sekret", # type: ignore[call-arg] + ) + assert "credential-shaped" in str(exc.value) + + +def test_non_credential_extras_pass_through() -> None: + """Forward-compat: unknown non-credential extras are preserved.""" + decl = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={}, + correlation_id="trace_xyz", # type: ignore[call-arg] + future_field="value", # type: ignore[call-arg] + ) + dumped = decl.model_dump() + assert dumped["correlation_id"] == "trace_xyz" + assert dumped["future_field"] == "value" + + +# --------------------------------------------------------------------------- +# params_as +# --------------------------------------------------------------------------- + + +def test_params_as_validates_image_canonical() -> None: + decl = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={ + "sizes": [{"width": 300, "height": 250}], + "asset_source": "buyer_uploaded", + }, + ) + + typed = decl.params_as(CanonicalFormatImage) + + assert isinstance(typed, CanonicalFormatImage) + assert typed.sizes[0].width == 300 + assert typed.sizes[0].height == 250 + + +def test_params_as_raises_on_invalid_params_shape() -> None: + """params_as is a validate, not a cast — type-incorrect input raises.""" + decl = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + # ``sizes`` MUST be a list of {width, height} objects per the schema; + # passing a scalar is a wire-shape violation. + params={"sizes": 12345}, + ) + + with pytest.raises(ValidationError): + decl.params_as(CanonicalFormatImage) + + +def test_params_as_returns_typed_vast_video() -> None: + decl = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.video_vast, + params={"vast_version": "4.2"}, + ) + + typed = decl.params_as(CanonicalFormatVastVideo) + + assert isinstance(typed, CanonicalFormatVastVideo) + assert typed.vast_version.value == "4.2" diff --git a/tests/test_canonical_formats_options.py b/tests/test_canonical_formats_options.py new file mode 100644 index 00000000..71063360 --- /dev/null +++ b/tests/test_canonical_formats_options.py @@ -0,0 +1,267 @@ +"""Closed-set ``format_options[]`` validator behaviour. + +Sellers MUST reject a ``create_media_buy`` whose creative manifest +targets a ``format_kind`` outside the product's published +``format_options[]``. These tests exercise the pre-call guard. +""" + +from __future__ import annotations + +import pytest + +from adcp.canonical_formats import ( + FormatKindNotInClosedSetError, + find_declaration_by_kind, + validate_format_kind_in_options, +) +from adcp.types import CanonicalFormatKind, ProductFormatDeclaration + + +def _decl( + kind: CanonicalFormatKind, + *, + capability_id: str | None = None, +) -> ProductFormatDeclaration: + return ProductFormatDeclaration( + format_kind=kind, + params={}, + capability_id=capability_id, + ) + + +# --------------------------------------------------------------------------- +# validate_format_kind_in_options +# --------------------------------------------------------------------------- + + +def test_validator_accepts_kind_in_closed_set() -> None: + options = [_decl(CanonicalFormatKind.image), _decl(CanonicalFormatKind.video_vast)] + # Both string and enum forms accepted. + validate_format_kind_in_options("image", options) + validate_format_kind_in_options(CanonicalFormatKind.video_vast, options) + + +def test_validator_rejects_kind_outside_closed_set() -> None: + options = [_decl(CanonicalFormatKind.image)] + with pytest.raises(FormatKindNotInClosedSetError) as exc: + validate_format_kind_in_options("audio_daast", options) + + assert exc.value.format_kind == "audio_daast" + assert exc.value.accepted_kinds == ["image"] + + +def test_validator_rejection_message_mentions_kind_and_accepted_set() -> None: + options = [_decl(CanonicalFormatKind.image), _decl(CanonicalFormatKind.html5)] + with pytest.raises(FormatKindNotInClosedSetError) as exc: + validate_format_kind_in_options("video_vast", options) + + msg = str(exc.value) + assert "video_vast" in msg + assert "image" in msg + assert "html5" in msg + + +def test_validator_against_empty_closed_set_rejects_everything() -> None: + with pytest.raises(FormatKindNotInClosedSetError) as exc: + validate_format_kind_in_options("image", []) + assert exc.value.accepted_kinds == [] + + +# --------------------------------------------------------------------------- +# find_declaration_by_kind +# --------------------------------------------------------------------------- + + +def test_lookup_returns_matching_declaration() -> None: + image_a = _decl(CanonicalFormatKind.image, capability_id="cap_a") + options = [image_a, _decl(CanonicalFormatKind.video_vast)] + + assert find_declaration_by_kind("image", options) is image_a + assert find_declaration_by_kind(CanonicalFormatKind.video_vast, options) is options[1] + + +def test_lookup_returns_none_when_no_match() -> None: + options = [_decl(CanonicalFormatKind.image)] + assert find_declaration_by_kind("audio_daast", options) is None + + +def test_lookup_disambiguates_with_capability_id() -> None: + """Two image declarations on the same product MUST be disambiguated by + ``capability_id`` per the ProductFormatDeclaration contract.""" + image_a = _decl(CanonicalFormatKind.image, capability_id="cap_a") + image_b = _decl(CanonicalFormatKind.image, capability_id="cap_b") + options = [image_a, image_b] + + assert find_declaration_by_kind("image", options, capability_id="cap_a") is image_a + assert find_declaration_by_kind("image", options, capability_id="cap_b") is image_b + assert find_declaration_by_kind("image", options, capability_id="cap_c") is None + + +def test_lookup_without_capability_id_returns_first_kind_match() -> None: + """When ``capability_id`` is omitted and multiple kinds match, the first + in declaration order wins — same precedence the registry uses elsewhere.""" + image_a = _decl(CanonicalFormatKind.image, capability_id="cap_a") + image_b = _decl(CanonicalFormatKind.image, capability_id="cap_b") + options = [image_a, image_b] + + assert find_declaration_by_kind("image", options) is image_a + + +# --------------------------------------------------------------------------- +# to_wire_error +# --------------------------------------------------------------------------- + + +def test_to_wire_error_produces_unsupported_feature() -> None: + err = FormatKindNotInClosedSetError("audio_daast", ["image", "video_vast"]) + wire = err.to_wire_error() + + assert wire.code == "UNSUPPORTED_FEATURE" + assert wire.field == "manifest.format_kind" + assert wire.details == { + "rejected_value": "audio_daast", + "accepted_values": ["image", "video_vast"], # sorted, dedup'd + } + + +def test_to_wire_error_field_override() -> None: + err = FormatKindNotInClosedSetError("image", ["video_vast"]) + wire = err.to_wire_error(field="packages[0].manifest.format_kind") + assert wire.field == "packages[0].manifest.format_kind" + + +def test_to_wire_error_accepted_values_dedup_and_sort() -> None: + err = FormatKindNotInClosedSetError("custom", ["image", "image", "audio_daast"]) + wire = err.to_wire_error() + assert wire.details["accepted_values"] == ["audio_daast", "image"] + + +# --------------------------------------------------------------------------- +# find_declaration_by_v1_format_id (seller-side v1 inbound lookup) +# --------------------------------------------------------------------------- + + +def test_v1_inbound_lookup_finds_declaration_by_ref() -> None: + from adcp.canonical_formats import find_declaration_by_v1_format_id + from adcp.types import FormatId + + ref = FormatId( + agent_url="https://creative.adcontextprotocol.org", + id="display_300x250_image", + ) + decl = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, params={}, v1_format_ref=[ref] + ) + + found = find_declaration_by_v1_format_id(ref, [decl]) + assert found is decl + + +def test_v1_inbound_lookup_misses_when_no_ref_matches() -> None: + from adcp.canonical_formats import find_declaration_by_v1_format_id + from adcp.types import FormatId + + decl = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={}, + v1_format_ref=[ + FormatId( + agent_url="https://creative.adcontextprotocol.org", + id="display_300x250_image", + ), + ], + ) + wrong = FormatId( + agent_url="https://creative.adcontextprotocol.org", + id="display_728x90_image", + ) + + assert find_declaration_by_v1_format_id(wrong, [decl]) is None + + +def test_v1_inbound_lookup_distinguishes_by_agent_url() -> None: + """Same ``id`` on a different ``agent_url`` is a different format identity.""" + from adcp.canonical_formats import find_declaration_by_v1_format_id + from adcp.types import FormatId + + decl = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={}, + v1_format_ref=[ + FormatId( + agent_url="https://creative.adcontextprotocol.org", + id="display_300x250_image", + ), + ], + ) + other_seller = FormatId( + agent_url="https://other.example", + id="display_300x250_image", + ) + + assert find_declaration_by_v1_format_id(other_seller, [decl]) is None + + +def test_v1_inbound_lookup_canonicalises_agent_url_host_case() -> None: + """RFC 3986 §6 host-casefolding: ``Creative.X`` must match ``creative.x``. + + Without canonicalization a seller publishing + ``https://Creative.AdContextProtocol.org`` would silently miss-match + a buyer's ``https://creative.adcontextprotocol.org`` and the SDK + would return a wrongful ``UNSUPPORTED_FEATURE``. + """ + from adcp.canonical_formats import find_declaration_by_v1_format_id + from adcp.types import FormatId + + seller_decl = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={}, + v1_format_ref=[ + FormatId( + agent_url="https://Creative.AdContextProtocol.org", + id="display_300x250_image", + ), + ], + ) + buyer_ref = FormatId( + agent_url="https://creative.adcontextprotocol.org", + id="display_300x250_image", + ) + + assert find_declaration_by_v1_format_id(buyer_ref, [seller_decl]) is seller_decl + + +def test_v1_inbound_lookup_canonicalises_default_port() -> None: + """Default-port stripping: ``https://x.example:443`` matches ``https://x.example``.""" + from adcp.canonical_formats import find_declaration_by_v1_format_id + from adcp.types import FormatId + + seller_decl = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={}, + v1_format_ref=[ + FormatId( + agent_url="https://creative.adcontextprotocol.org:443", + id="display_300x250_image", + ), + ], + ) + buyer_ref = FormatId( + agent_url="https://creative.adcontextprotocol.org", + id="display_300x250_image", + ) + + assert find_declaration_by_v1_format_id(buyer_ref, [seller_decl]) is seller_decl + + +def test_v1_inbound_lookup_with_no_refs_returns_none() -> None: + from adcp.canonical_formats import find_declaration_by_v1_format_id + from adcp.types import FormatId + + decl = ProductFormatDeclaration(format_kind=CanonicalFormatKind.image, params={}) + ref = FormatId( + agent_url="https://creative.adcontextprotocol.org", + id="display_300x250_image", + ) + + assert find_declaration_by_v1_format_id(ref, [decl]) is None diff --git a/tests/test_canonical_formats_projection.py b/tests/test_canonical_formats_projection.py new file mode 100644 index 00000000..ba4137d5 --- /dev/null +++ b/tests/test_canonical_formats_projection.py @@ -0,0 +1,337 @@ +"""v2 → v1 projection — resolution-order behaviour. + +Walks every branch of the resolution order documented at +:mod:`adcp.canonical_formats.projection`. Each test maps to a numbered +step in that contract so a future refactor that breaks one step shows +up here with a clear pointer to the spec rule it violated. +""" + +from __future__ import annotations + +import pytest + +from adcp.canonical_formats import ( + V1_TRANSLATABLE, + project_declaration_to_v1, + project_product_to_v1, +) +from adcp.canonical_formats.advisory import SDK_ID +from adcp.types import CanonicalFormatKind, FormatId, ProductFormatDeclaration + + +def _ref(id_: str = "display_300x250_image") -> FormatId: + return FormatId( + agent_url="https://creative.adcontextprotocol.org", + id=id_, + ) + + +# --------------------------------------------------------------------------- +# Step 1 — explicit v1-unreachability is silent (no refs, no advisories) +# --------------------------------------------------------------------------- + + +def test_canonical_formats_only_emits_no_refs_and_no_advisory() -> None: + """Step 1: when the seller has opted out of v1 projection, project to nothing. + + ``canonical_formats_only=True`` is mutually exclusive with + ``v1_format_ref[]`` at construction (enforced by the hand-rolled + declaration model), so the fixture here cannot also carry refs. + """ + decl = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={"sizes": [{"width": 300, "height": 250}]}, + canonical_formats_only=True, + ) + + result = project_declaration_to_v1(decl) + + assert result.format_ids == [] + assert result.advisories == [] + + +def test_custom_without_refs_is_silent() -> None: + """``custom`` is in the not-v1-translatable set; without seller refs → silent.""" + decl = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.custom, + params={}, + format_shape="multi_placement_takeover", + ) + + result = project_declaration_to_v1(decl) + + assert result.format_ids == [] + assert result.advisories == [] + + +def test_custom_with_v1_format_ref_emits_refs() -> None: + """``custom`` MAY carry seller-asserted v1 refs; step 2 flow applies.""" + refs = [_ref("acme_homepage_takeover")] + decl = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.custom, + params={}, + format_shape="multi_placement_takeover", + v1_format_ref=refs, + ) + + result = project_declaration_to_v1(decl) + + assert result.format_ids == refs + assert result.advisories == [] + + +# --------------------------------------------------------------------------- +# Step 2 — v1_format_ref present → emit, check multi-size fan-out +# --------------------------------------------------------------------------- + + +def test_seller_asserted_v1_ref_emits_refs_with_no_advisory() -> None: + refs = [_ref("display_300x250_image"), _ref("display_728x90_image")] + decl = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={"sizes": [{"width": 300, "height": 250}, {"width": 728, "height": 90}]}, + v1_format_ref=refs, + ) + + result = project_declaration_to_v1(decl) + + assert result.format_ids == refs + assert result.advisories == [] + + +def test_multi_size_lossy_fan_out_emits_lossy_advisory() -> None: + decl = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={ + "sizes": [ + {"width": 300, "height": 250}, + {"width": 728, "height": 90}, + {"width": 970, "height": 250}, + ], + }, + v1_format_ref=[_ref()], + ) + + result = project_declaration_to_v1(decl, field_path="products[0].format_options[2]") + + assert len(result.format_ids) == 1 # the partial coverage still ships + assert len(result.advisories) == 1 + advisory = result.advisories[0] + assert advisory.code == "FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE" + assert advisory.source.value == "sdk" + assert advisory.sdk_id == SDK_ID + assert advisory.field == "products[0].format_options[2]" + assert advisory.details == { + "format_kind": "image", + "v1_format_ref_count": 1, + "sizes_count": 3, + } + + +def test_single_size_with_single_ref_emits_no_lossy_advisory() -> None: + """1 ref for 1 size is not lossy; ref-for-no-sizes is also not lossy.""" + decl = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={"sizes": [{"width": 300, "height": 250}]}, + v1_format_ref=[_ref()], + ) + + result = project_declaration_to_v1(decl) + + assert len(result.format_ids) == 1 + assert result.advisories == [] + + +# --------------------------------------------------------------------------- +# Step 3 — canonical with v1_translatable=False → silent +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "kind", + [ + CanonicalFormatKind.agent_placement, + CanonicalFormatKind.sponsored_placement, + CanonicalFormatKind.responsive_creative, + CanonicalFormatKind.image_carousel, + CanonicalFormatKind.custom, + ], +) +def test_non_translatable_canonicals_are_silent_with_no_ref(kind: CanonicalFormatKind) -> None: + """Per the registry's "Direction of truth" — these canonicals never have + a v1 form regardless of registry coverage; surfacing AMBIGUOUS would + spam the wire.""" + decl = ProductFormatDeclaration(format_kind=kind, params={}) + + result = project_declaration_to_v1(decl) + + assert result.format_ids == [] + assert result.advisories == [] + + +def test_v1_translatable_table_matches_kind_enum() -> None: + """The V1_TRANSLATABLE map must cover every CanonicalFormatKind value; + a new kind added upstream without a table entry would default to True + and emit AMBIGUOUS for a structurally v1-unreachable canonical.""" + missing = [k for k in CanonicalFormatKind if k not in V1_TRANSLATABLE] + assert not missing, f"V1_TRANSLATABLE missing entries: {missing}" + + +# --------------------------------------------------------------------------- +# Step 4 — translatable canonical, no v1_format_ref → AMBIGUOUS advisory +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "kind", + [ + CanonicalFormatKind.image, + CanonicalFormatKind.html5, + CanonicalFormatKind.display_tag, + CanonicalFormatKind.video_hosted, + CanonicalFormatKind.video_vast, + CanonicalFormatKind.audio_hosted, + CanonicalFormatKind.audio_daast, + CanonicalFormatKind.native_in_feed, + ], +) +def test_translatable_canonical_without_v1_ref_emits_ambiguous( + kind: CanonicalFormatKind, +) -> None: + decl = ProductFormatDeclaration(format_kind=kind, params={}) + + result = project_declaration_to_v1(decl, product_id="prod_xyz") + + assert result.format_ids == [] + assert len(result.advisories) == 1 + advisory = result.advisories[0] + assert advisory.code == "FORMAT_DECLARATION_V1_AMBIGUOUS" + assert advisory.source.value == "sdk" + assert advisory.sdk_id == SDK_ID + assert advisory.details["format_kind"] == kind.value + assert advisory.details["product_id"] == "prod_xyz" + assert advisory.details["reason"] == "no_v1_format_ref" + + +# --------------------------------------------------------------------------- +# project_product_to_v1 — fan-out across format_options[] +# --------------------------------------------------------------------------- + + +class _StubProduct: + """Duck-typed stand-in — projection helper reads ``format_options`` and ``product_id``.""" + + def __init__( + self, + format_options: list[ProductFormatDeclaration], + product_id: str | None = None, + ) -> None: + self.format_options = format_options + self.product_id = product_id + + +def test_project_product_aggregates_per_declaration_results() -> None: + product = _StubProduct( + format_options=[ + ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={}, + v1_format_ref=[_ref("display_300x250_image")], + ), + ProductFormatDeclaration( + format_kind=CanonicalFormatKind.video_vast, + params={}, + ), + ProductFormatDeclaration( + format_kind=CanonicalFormatKind.agent_placement, + params={}, + ), + ], + product_id="prod_alpha", + ) + + result = project_product_to_v1(product, product_index=0) + + # 1 ref from the first declaration, 0 from the others. + assert len(result.format_ids) == 1 + # 1 AMBIGUOUS advisory from the video_vast declaration; agent_placement silent. + assert len(result.advisories) == 1 + assert result.advisories[0].code == "FORMAT_DECLARATION_V1_AMBIGUOUS" + # Field path is indexed against the parent product when product_index given. + assert result.advisories[0].field == "products[0].format_options[1]" + # Advisory carries product_id for downstream correlation. + assert result.advisories[0].details["product_id"] == "prod_alpha" + + +def test_project_product_without_product_index_omits_products_prefix() -> None: + product = _StubProduct( + format_options=[ + ProductFormatDeclaration(format_kind=CanonicalFormatKind.video_vast, params={}), + ], + ) + + result = project_product_to_v1(product) + + assert result.advisories[0].field == "format_options[0]" + + +def test_project_product_handles_missing_format_options() -> None: + """A product with no format_options[] (3.0-era product) projects to empty + refs / advisories — no AttributeError, no spurious advisory.""" + + class _BareProduct: + product_id = "prod_bare" + + result = project_product_to_v1(_BareProduct()) + assert result.format_ids == [] + assert result.advisories == [] + + +def test_advisory_product_id_is_truncated() -> None: + """Seller-controlled identifiers are capped before echoing into advisory details. + + Mitigates log-injection / response-spoofing via multi-hop ``errors[]``. + """ + long_id = "x" * 300 + decl = ProductFormatDeclaration(format_kind=CanonicalFormatKind.video_vast, params={}) + + result = project_declaration_to_v1(decl, product_id=long_id) + + echoed = result.advisories[0].details["product_id"] + assert echoed != long_id, "long product_id must not echo verbatim" + assert echoed.endswith("…[truncated]") + # The literal cap (128) plus the truncation marker. + assert echoed.startswith("x" * 128) + + +def test_advisory_product_id_scrubs_newlines() -> None: + """Newline injection attempts are escaped so operator log emitters + don't see forged lines from seller-controlled identifiers.""" + decl = ProductFormatDeclaration(format_kind=CanonicalFormatKind.video_vast, params={}) + + result = project_declaration_to_v1(decl, product_id="prod_alpha\nFAKE LOG LINE\nprod_omega") + + echoed = result.advisories[0].details["product_id"] + assert "\n" not in echoed + assert "\\u000a" in echoed + + +def test_advisory_product_id_scrubs_ansi_escape() -> None: + """ANSI ``\\x1b[`` escape sequences are neutralised before echoing.""" + decl = ProductFormatDeclaration(format_kind=CanonicalFormatKind.video_vast, params={}) + + result = project_declaration_to_v1(decl, product_id="prod\x1b[31mRED\x1b[0m") + + echoed = result.advisories[0].details["product_id"] + assert "\x1b" not in echoed + assert "\\u001b" in echoed + + +def test_advisory_product_id_scrubs_unicode_line_separator() -> None: + """Unicode LS/PS line separators escape too — they break naive line splitters.""" + decl = ProductFormatDeclaration(format_kind=CanonicalFormatKind.video_vast, params={}) + + result = project_declaration_to_v1(decl, product_id="prod
injected") + + echoed = result.advisories[0].details["product_id"] + assert "
" not in echoed diff --git a/tests/test_canonical_formats_public_api.py b/tests/test_canonical_formats_public_api.py new file mode 100644 index 00000000..a0dd3539 --- /dev/null +++ b/tests/test_canonical_formats_public_api.py @@ -0,0 +1,117 @@ +"""Public-API surface for canonical-formats types (AdCP 3.1). + +Guards that the canonical-formats types are reachable from +:mod:`adcp.types` (rather than only from ``generated_poc``). Failures +here mean adopter code that does ``from adcp.types import …`` will +break — the public surface is part of the contract. +""" + +from __future__ import annotations + +import adcp.types as types + +_EXPECTED_EXPORTS = ( + # Kind enum + projection ref + "CanonicalFormatKind", + "CanonicalProjectionReference", + "CanonicalAssetSource", + "CanonicalSlotOverride", + # Declaration + "ProductFormatDeclaration", + "ProductFormatSellerPreference", + # 13 canonical format classes + "CanonicalFormatBase", + "CanonicalCompositionModel", + "CanonicalFormatImage", + "CanonicalFormatHtml5Banner", + "CanonicalFormatDisplayTag", + "CanonicalFormatImageCarousel", + "CanonicalFormatHostedVideo", + "CanonicalFormatVastVideo", + "CanonicalFormatHostedAudio", + "CanonicalFormatDaastAudio", + "CanonicalFormatNativeInFeed", + "CanonicalFormatResponsiveCreative", + "CanonicalFormatAgentPlacement", + "CanonicalFormatSponsoredPlacement", + # Pixel tracker asset + "PixelTrackerAsset", + "PixelTrackerEvent", + "PixelTrackerMethod", + # Registry types + "V1V2CanonicalFormatMappingRegistry", + "V1CanonicalMapping", + "V1CanonicalGlobPattern", + "V1CanonicalStructuralPattern", + "V1CanonicalStructural", + "V1CanonicalV2Projection", + "V1CanonicalDimensions", + # Error envelope sub-enums (for SDK-source advisory construction) + "Recovery", + "Source", +) + + +def test_canonical_formats_symbols_present_on_adcp_types() -> None: + """Every expected name is reachable as ``adcp.types.``.""" + missing = [name for name in _EXPECTED_EXPORTS if not hasattr(types, name)] + assert not missing, f"missing public-API exports: {missing}" + + +def test_canonical_formats_symbols_in_dunder_all() -> None: + """Every expected name appears in ``adcp.types.__all__``.""" + declared = set(getattr(types, "__all__", [])) + missing = [name for name in _EXPECTED_EXPORTS if name not in declared] + assert not missing, f"public-API exports not declared in __all__: {missing}" + + +def test_canonical_format_kind_has_13_values() -> None: + """Adding/removing a canonical kind is a wire-breaking change — pin the count.""" + from adcp.types import CanonicalFormatKind + + assert len(CanonicalFormatKind) == 13 + + +def test_sdk_id_reachable_via_public_package_path() -> None: + """``SDK_ID`` is documented as importable from :mod:`adcp.canonical_formats`. + + It's resolved lazily via module ``__getattr__`` so this also + exercises that dispatch path against the documented import. + """ + from adcp.canonical_formats import SDK_ID + + assert isinstance(SDK_ID, str) + assert SDK_ID.startswith("adcontextprotocol-adcp-python@") + + +def test_sdk_id_uses_canonical_distribution_prefix() -> None: + """Dev installs and wheel installs MUST emit the same ``sdk_id`` prefix + so the multi-hop ``(code, field, sdk_id)`` dedup contract holds across + deployment shapes.""" + from adcp.canonical_formats import SDK_ID + + prefix, _, _ = SDK_ID.partition("@") + assert prefix == "adcontextprotocol-adcp-python" + + +def test_canonical_kind_values_match_spec() -> None: + """The 13 wire values are the canonical-formats vocabulary; lock them in.""" + from adcp.types import CanonicalFormatKind + + actual = {k.value for k in CanonicalFormatKind} + expected = { + "image", + "html5", + "display_tag", + "image_carousel", + "video_hosted", + "video_vast", + "audio_hosted", + "audio_daast", + "sponsored_placement", + "native_in_feed", + "responsive_creative", + "agent_placement", + "custom", + } + assert actual == expected diff --git a/tests/test_canonical_formats_registry.py b/tests/test_canonical_formats_registry.py new file mode 100644 index 00000000..642cbe61 --- /dev/null +++ b/tests/test_canonical_formats_registry.py @@ -0,0 +1,210 @@ +"""v1↔v2 mapping registry — loader + glob/structural matchers.""" + +from __future__ import annotations + +import pytest + +from adcp.canonical_formats import ( + glob_match, + load_default_registry, + structural_match, +) +from adcp.types import V1V2CanonicalFormatMappingRegistry + +# --------------------------------------------------------------------------- +# Registry loader +# --------------------------------------------------------------------------- + + +def test_loader_returns_typed_registry() -> None: + registry = load_default_registry() + assert isinstance(registry, V1V2CanonicalFormatMappingRegistry) + assert registry.version # semver string + assert registry.mappings # non-empty + + +def test_loader_returns_equal_content_each_call() -> None: + """Repeated loads return semantically-equal copies (cache is an internal + detail; multi-tenant isolation requires fresh instances).""" + a = load_default_registry() + b = load_default_registry() + assert a == b + + +def test_initial_registry_has_seven_pure_structural_entries() -> None: + """3.1 ships 7 pure-structural fallback entries per the registry docstring. + A change to this count is a vocabulary-governance event.""" + registry = load_default_registry() + assert len(registry.mappings) == 7 + # Every initial entry is structural — no literal globs as of 3.1. + for mapping in registry.mappings: + # ``v1_pattern`` is the discriminated union; the structural branch + # exposes ``.structural``, the glob branch exposes ``.format_id_glob``. + assert hasattr( + mapping.v1_pattern, "structural" + ), f"3.1 baseline expected pure-structural; got {mapping.v1_pattern!r}" + + +# --------------------------------------------------------------------------- +# glob_match +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "pattern,value,expected", + [ + ("*", "anything", True), + ("iab_mrec_300x250", "iab_mrec_300x250", True), + ("iab_mrec_300x250", "iab_mrec_728x90", False), + ("iab_leaderboard_*", "iab_leaderboard_728x90", True), + ("iab_leaderboard_*", "iab_mrec_300x250", False), + ("meta_*_reels", "meta_video_reels", True), + ("meta_*_reels", "meta_image_reels", True), + ("meta_*_reels", "meta_reels", False), + ], +) +def test_glob_match_handles_wildcards(pattern: str, value: str, expected: bool) -> None: + assert glob_match(value, pattern) is expected + + +def test_glob_match_treats_regex_metachars_as_literal() -> None: + """Pattern language is glob, not regex — ``.`` matches a literal dot.""" + assert glob_match("display.300", "display.300") is True + assert glob_match("displayX300", "display.300") is False + + +# --------------------------------------------------------------------------- +# structural_match +# --------------------------------------------------------------------------- + + +def test_structural_match_vast_42_against_vast_4_plus_pattern() -> None: + registry = load_default_registry() + # Entry 0 in the test fixture is the VAST ≥4.0 entry. + pattern = registry.mappings[0].v1_pattern.structural + + assert structural_match( + asset_types=["vast"], + vast_versions=["4.2"], + pattern=pattern, + ) + + +def test_structural_match_vast_30_misses_vast_4_plus_pattern() -> None: + registry = load_default_registry() + pattern = registry.mappings[0].v1_pattern.structural + + assert not structural_match( + asset_types=["vast"], + vast_versions=["3.0"], + pattern=pattern, + ) + + +def test_structural_match_vast_30_hits_legacy_pattern() -> None: + registry = load_default_registry() + # Entry 1 is the legacy VAST 3.x / 2.x entry. + pattern = registry.mappings[1].v1_pattern.structural + + assert structural_match( + asset_types=["vast"], + vast_versions=["3.0"], + pattern=pattern, + ) + assert structural_match( + asset_types=["vast"], + vast_versions=["2.0"], + pattern=pattern, + ) + + +def test_structural_match_misses_when_asset_type_absent() -> None: + registry = load_default_registry() + # Entry 3 is the zip → html5 entry. + pattern = registry.mappings[3].v1_pattern.structural + + assert not structural_match(asset_types=["url"], pattern=pattern) + assert structural_match(asset_types=["zip"], pattern=pattern) + + +def test_structural_match_with_extra_asset_types_still_matches() -> None: + """The pattern's asset_types is a *subset* requirement — adding more + asset_types in the v1 format doesn't disqualify the match.""" + registry = load_default_registry() + # Entry 4 is the video → video_hosted entry. + pattern = registry.mappings[4].v1_pattern.structural + + assert structural_match(asset_types=["video", "url"], pattern=pattern) + + +def test_structural_match_empty_pattern_matches_anything() -> None: + """An empty pattern declares no constraints; everything matches.""" + assert structural_match(asset_types=[], pattern=None) + assert structural_match(asset_types=["whatever"], pattern={}) + + +# --------------------------------------------------------------------------- +# Registry cache isolation + load-error wrapping +# --------------------------------------------------------------------------- + + +def test_loader_returns_fresh_deep_copy_per_call() -> None: + """Multi-tenant callers must not be able to poison each other's registry view.""" + a = load_default_registry() + b = load_default_registry() + assert a is not b + assert a.mappings is not b.mappings + assert a.mappings[0] is not b.mappings[0] + + +def test_loader_caller_mutation_does_not_poison_subsequent_loads() -> None: + a = load_default_registry() + original_count = len(a.mappings) + a.mappings.clear() + + b = load_default_registry() + assert len(b.mappings) == original_count + + +def test_registry_load_error_is_raised_on_malformed_bundle(monkeypatch) -> None: + """Wrap JSONDecodeError with a contextual ``RegistryLoadError``.""" + from adcp.canonical_formats import RegistryLoadError + from adcp.canonical_formats import registry as registry_mod + + registry_mod._load_registry_uncopied.cache_clear() + monkeypatch.setattr(registry_mod, "_read_registry_json", lambda: "this is not json {") + + try: + with pytest.raises(RegistryLoadError) as exc: + registry_mod._load_registry_uncopied() + assert "invalid JSON" in str(exc.value) + assert "ADCP_VERSION=" in str(exc.value) + finally: + registry_mod._load_registry_uncopied.cache_clear() + + +# --------------------------------------------------------------------------- +# Version-operator DSL +# --------------------------------------------------------------------------- + + +def test_versions_overlap_supports_strict_inequalities() -> None: + """Operators ``<``, ``>``, ``!=``, ``==`` recognised alongside ``<=``, ``>=``.""" + from adcp.canonical_formats.registry import _versions_overlap + + assert _versions_overlap("4.2", [">4.0"]) + assert not _versions_overlap("4.0", [">4.0"]) + assert _versions_overlap("3.0", ["<4.0"]) + assert not _versions_overlap("4.0", ["<4.0"]) + assert _versions_overlap("4.2", ["!=3.0"]) + assert not _versions_overlap("3.0", ["!=3.0"]) + assert _versions_overlap("4.2", ["==4.2"]) + + +def test_versions_overlap_fails_loud_on_unrecognised_operator() -> None: + """A typo like ``~>4.0`` would silently never match — fail loudly instead.""" + from adcp.canonical_formats.registry import _versions_overlap + + with pytest.raises(ValueError) as exc: + _versions_overlap("4.2", ["~>4.0"]) + assert "Unrecognised version-constraint operator" in str(exc.value) diff --git a/tests/test_import_layering.py b/tests/test_import_layering.py index 53c486a6..d89ccad5 100644 --- a/tests/test_import_layering.py +++ b/tests/test_import_layering.py @@ -46,6 +46,13 @@ # generated classes in-place to call model_rebuild() on them, giving it # the same architectural role as ``_ergonomic.py``. SRC_ROOT / "types" / "_forward_compat.py", + # ``canonical_decl.py`` (issue #741) is the hand-rolled + # ``ProductFormatDeclaration`` that replaces the codegen output — + # ``datamodel-code-generator`` flattens the upstream discriminated + # ``oneOf`` and drops the ``format_kind`` + ``params`` fields, so the + # public class is hand-rolled here. Same role as ``aliases.py`` / + # ``capabilities.py``: re-exports + overrides of generated types. + SRC_ROOT / "types" / "canonical_decl.py", } # Frozen baseline of pre-existing violations — paths relative to repo root.