Module adcp.compat.legacy.v2_5.sync_creatives

v2.5 → v3 adapter for sync_creatives.

Three wire-shape changes between v2.5 and v3:

  1. format_id shape. v2.5 buyers sent a bare string ("display_300x250"); v3 requires the structured form {"agent_url": "...", "id": "display_300x250"}. We inject the canonical creative-agent URL (the AdCP standard registry) when the value is a string.
  2. asset_type discriminator. v3 requires every asset to declare its type explicitly. v2.5 relied on the asset key as the type hint ({"image": {...}}) or on field presence (url + dims → image). The adapter infers the discriminator using the same rules the spec documents for backwards compatibility.
  3. imageurl demotion. v3's image variant requires width and height. A v2.5 asset typed as image but missing dims is semantically a URL reference; demote rather than reject.

Each rule is reversible in principle, but :attr:AdapterPair.normalize_response is left None here — the response shape didn't change between v2.5 and v3 for sync_creatives.

Direct port of src/lib/adapters/legacy/v2-5/sync_creatives.ts / src/lib/utils/sync-creatives-adapter.ts.

Functions

def adapt_request(payload: dict[str, Any]) ‑> dict[str, typing.Any]
Expand source code
def adapt_request(payload: dict[str, Any]) -> dict[str, Any]:
    """Translate a v2.5 ``sync_creatives`` request to v3 shape.

    Returns a new dict — callers can rely on the original being
    untouched (idempotency under retry).
    """
    out = dict(payload)
    creatives = out.get("creatives")
    if not isinstance(creatives, list):
        return out

    new_creatives: list[Any] = []
    for creative in creatives:
        if not isinstance(creative, dict):
            new_creatives.append(creative)
            continue

        new_creative = dict(creative)

        # Hook 1: bare format_id string → structured.
        fid = new_creative.get("format_id")
        if isinstance(fid, str):
            new_creative["format_id"] = {
                "agent_url": _CANONICAL_CREATIVE_AGENT_URL,
                "id": fid,
            }

        # Hooks 2 + 3: per-asset coercions.
        assets = new_creative.get("assets")
        if isinstance(assets, dict):
            new_creative["assets"] = {
                key: _coerce_asset(key, value) if isinstance(value, dict) else value
                for key, value in assets.items()
            }

        new_creatives.append(new_creative)

    out["creatives"] = new_creatives
    return out

Translate a v2.5 sync_creatives request to v3 shape.

Returns a new dict — callers can rely on the original being untouched (idempotency under retry).

def is_legacy_shape(payload: dict[str, Any]) ‑> bool
Expand source code
def is_legacy_shape(payload: dict[str, Any]) -> bool:
    """v2.5 ``sync_creatives`` has at least one creative whose
    ``format_id`` is a bare string (v3 always emits the structured
    ``{agent_url, id}`` form). Strong signal — v3 wouldn't emit this
    even with ``adcp_version`` omitted."""
    creatives = payload.get("creatives")
    if not isinstance(creatives, list):
        return False
    for creative in creatives:
        if isinstance(creative, dict) and isinstance(creative.get("format_id"), str):
            return True
    return False

v2.5 sync_creatives has at least one creative whose format_id is a bare string (v3 always emits the structured {agent_url, id} form). Strong signal — v3 wouldn't emit this even with adcp_version omitted.