Skip to content

Commit 4e3473a

Browse files
bokelleyclaude
andauthored
feat(compat): v2.5 create_media_buy + update_media_buy adapters (stage 5c) (#669)
* feat(compat): v2.5 get_products adapter (stage 5b) Port of pricing-adapter.ts (inverted from JS direction) plus brand/catalog/channel translation. JS is v3→v2 (client side); our server-side use case is v2→v3, so request and response swap roles. * brand_manifest (v2.5 URL) ↔ brand.domain (v3 BrandReference) * promoted_offerings (v2.5 nested) ↔ catalog (v3 discriminated union) * Channels — lossy both directions: - video → olv+ctv, audio → streaming_audio, native → display, retail → retail_media - response collapses back, deduped * Pricing options: - rate + is_fixed ↔ fixed_price - price_guidance.floor ↔ top-level floor_price - Percentile fields stay in price_guidance 20 new tests cover both directions, idempotency on v3-already-shape, and unknown-channel pass-through. Updated test_v2_5_tool_without_adapter_raises_invalid_request to use create_media_buy (still unported in Stage 5c). Stage 5c will port create_media_buy + update_media_buy (creative-adapter.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(compat): pin v2/v3 precedence for half-migrated pricing options Review feedback on PR #668 flagged that the v2/v3 precedence in ``_normalize_pricing_option`` is correct but undocumented. Add a test that pins ``rate`` (v2) over ``fixed_price`` (v3) when both are present — matches the JS reference implementation's behaviour and documents intent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(compat): v2.5 create_media_buy + update_media_buy adapters (stage 5c) Closes out the v2.5 → v3 adapter catalog. Shared helpers in ``_media_buy_helpers`` (creative_ids ↔ creative_assignments, brand manifest, null-array coercion). Translations: * Package shape: v2.5 creative_ids ↔ v3 creative_assignments. The response-direction collapse drops v3-only weight + placement_ids silently — v2.5 buyers can't surface them, so the alternative (raise on every response with weights) rejects the typical case. * create_media_buy: brand_manifest URL → brand.domain. * Null-array coercion (creative_assignments / creative_ids / products set to null become absent fields). update_media_buy does NOT translate brand_manifest — updates don't carry brand in v3, and v2.5 buyers sending the field on an update are passing through metadata the handler can ignore. Tested explicitly. Updated test_v2_5_tool_without_adapter_raises_invalid_request to use check_governance (v3-added tool with no v2.5 adapter) since create_media_buy is now covered. Full v2.5 catalog now adapted: sync_creatives, list_creative_formats, preview_creative, get_products, create_media_buy, update_media_buy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(compat): consolidate strip_url_scheme into shared module Review feedback on PR #669. The helper was defined byte-for-byte in both ``_media_buy_helpers.py`` and ``get_products.py``; future edits would drift. Move to ``adcp.compat.legacy.v2_5._url`` so both import the same canonical implementation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2025520 commit 4e3473a

9 files changed

Lines changed: 479 additions & 38 deletions

src/adcp/compat/legacy/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
"list_creative_formats",
5050
"preview_creative",
5151
"get_products",
52+
"create_media_buy",
53+
"update_media_buy",
5254
),
5355
),
5456
}

src/adcp/compat/legacy/v2_5/__init__.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,42 @@
55
:func:`adcp.compat.legacy.register_adapter`. Importing this package
66
fires every registration at once.
77
8-
Current coverage:
8+
Current coverage (full v2.5 tool catalog):
99
10-
* ``sync_creatives`` — wraps bare ``format_id`` strings, infers
11-
``asset_type`` discriminators, demotes mis-typed ``image`` assets
12-
to ``url`` when dimensions are missing.
10+
* ``sync_creatives`` — bare ``format_id`` strings → structured;
11+
``asset_type`` inference; ``image``-without-dims → ``url``.
1312
* ``list_creative_formats`` — request pass-through; response rewrites
14-
v2.5 top-level ``width``/``height``/``dimensions`` into the v3
15-
``renders: [{render_id, role, dimensions}]`` array.
16-
* ``preview_creative`` — request pass-through; response renames v2.5
17-
``output_id``/``output_role`` to v3 ``render_id``/``role`` on each
18-
preview render. Handles both single-response and batch-response
19-
shapes.
20-
21-
The remaining v2.5 tools (``get_products``, ``create_media_buy``,
22-
``update_media_buy``) ship in Stage 5b — they rely on the pricing and
23-
creative-adapter helpers which are substantial standalone ports.
13+
v2.5 top-level dimensions into the v3 ``renders[]`` array.
14+
* ``preview_creative`` — request pass-through; response renames
15+
``output_id``/``output_role`` to v3 ``render_id``/``role``.
16+
* ``get_products`` — ``brand_manifest`` ↔ ``brand``,
17+
``promoted_offerings`` ↔ ``catalog``, channel-bucket ↔ slug
18+
translation, pricing-option ``rate``/``is_fixed`` ↔ ``fixed_price`` /
19+
``price_guidance.floor`` ↔ ``floor_price``.
20+
* ``create_media_buy`` — ``brand_manifest`` ↔ ``brand``, package
21+
``creative_ids`` ↔ ``creative_assignments``. Response collapses v3
22+
assignment objects back to v2.5 ID lists (``weight`` /
23+
``placement_ids`` dropped — v2.5 buyers can't act on them).
24+
* ``update_media_buy`` — same package translation as
25+
``create_media_buy`` (no ``brand_manifest`` on updates).
2426
"""
2527

2628
from __future__ import annotations
2729

2830
from adcp.compat.legacy.v2_5 import ( # noqa: F401
31+
create_media_buy,
2932
get_products,
3033
list_creative_formats,
3134
preview_creative,
3235
sync_creatives,
36+
update_media_buy,
3337
)
3438

3539
__all__ = [
40+
"create_media_buy",
3641
"get_products",
3742
"list_creative_formats",
3843
"preview_creative",
3944
"sync_creatives",
45+
"update_media_buy",
4046
]
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Shared v2.5 ↔ v3 helpers for ``create_media_buy`` and ``update_media_buy``.
2+
3+
These two tools share most of the wire-shape deltas (packages,
4+
``brand_manifest``, ``buyer_ref``), so the per-tool adapter modules
5+
re-export the same primitives from here.
6+
7+
Direct port (with direction inverted) of
8+
``src/lib/utils/creative-adapter.ts``. The JS direction is *client*
9+
side (v3 → v2). Our server-side direction is v2 → v3 for requests and
10+
v3 → v2 for responses.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from typing import Any
16+
17+
from adcp.compat.legacy.v2_5._url import strip_url_scheme
18+
19+
20+
def adapt_brand_manifest_to_brand(payload: dict[str, Any]) -> dict[str, Any]:
21+
"""Rewrite v2.5 ``brand_manifest`` (URL string) to v3 ``brand``
22+
``{domain: ...}``. Caller-supplied ``brand`` wins when both fields
23+
are present (half-migrated buyer)."""
24+
out = dict(payload)
25+
manifest = out.pop("brand_manifest", None)
26+
if isinstance(manifest, str) and manifest and "brand" not in out:
27+
out["brand"] = {"domain": strip_url_scheme(manifest)}
28+
return out
29+
30+
31+
def adapt_package_request(pkg: dict[str, Any]) -> dict[str, Any]:
32+
"""Rewrite a v2.5 package request to v3 shape.
33+
34+
Translations:
35+
36+
* ``creative_ids: list[str]`` → ``creative_assignments: list[{creative_id}]``.
37+
v2.5 packages reference creatives by ID alone; v3 wraps each in an
38+
assignment object so future ``weight`` / ``placement_ids`` fields
39+
can attach. The v2.5 → v3 path can't synthesize those (the buyer
40+
never sent them), so we just lift the ID.
41+
* ``buyer_ref`` survives unchanged. v3 doesn't model it on packages
42+
explicitly but tolerates ``additionalProperties`` per the spec, so
43+
passing it through preserves the buyer's idempotency-by-name
44+
semantics if their handler expects it.
45+
"""
46+
out = dict(pkg)
47+
creative_ids = out.get("creative_ids")
48+
if isinstance(creative_ids, list):
49+
out.pop("creative_ids", None)
50+
out["creative_assignments"] = [
51+
{"creative_id": cid} for cid in creative_ids if isinstance(cid, str)
52+
]
53+
return out
54+
55+
56+
def normalize_package_response(pkg: dict[str, Any]) -> dict[str, Any]:
57+
"""Rewrite a v3 package response back to v2.5 shape for legacy buyers.
58+
59+
Translations:
60+
61+
* ``creative_assignments: [{creative_id, weight?, placement_ids?}]``
62+
→ ``creative_ids: [creative_id, ...]``. **Lossy** — ``weight`` and
63+
``placement_ids`` have no v2.5 equivalent and are dropped. v2.5
64+
buyers can't act on them, so silent collapse is acceptable.
65+
* Null arrays (``creative_assignments: null``, ``creative_ids: null``,
66+
``products: null``) are coerced to absent fields. Some servers emit
67+
explicit ``null`` for optional arrays; downstream consumers expect
68+
either absent or a real list.
69+
"""
70+
cleaned = dict(pkg)
71+
for field in ("creative_assignments", "creative_ids", "products"):
72+
if cleaned.get(field) is None and field in cleaned:
73+
cleaned.pop(field, None)
74+
75+
assignments = cleaned.get("creative_assignments")
76+
if isinstance(assignments, list):
77+
creative_ids: list[str] = []
78+
for assignment in assignments:
79+
if not isinstance(assignment, dict):
80+
continue
81+
cid = assignment.get("creative_id")
82+
if isinstance(cid, str):
83+
creative_ids.append(cid)
84+
cleaned.pop("creative_assignments", None)
85+
cleaned["creative_ids"] = creative_ids
86+
87+
return cleaned
88+
89+
90+
def normalize_media_buy_response(response: dict[str, Any]) -> dict[str, Any]:
91+
"""Apply :func:`normalize_package_response` to every package in a
92+
media-buy response. Pass-through when ``packages`` is absent (error
93+
responses, terminal envelopes)."""
94+
packages = response.get("packages")
95+
if not isinstance(packages, list):
96+
return response
97+
return {
98+
**response,
99+
"packages": [normalize_package_response(p) if isinstance(p, dict) else p for p in packages],
100+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Shared URL helpers for the v2.5 adapter modules.
2+
3+
Several v2.5 → v3 translations need to convert v2.5 URL-string fields
4+
(``brand_manifest``) into v3 bare-domain references (``brand.domain``).
5+
The helper lives here so ``get_products`` and ``create_media_buy`` can
6+
import the same canonical implementation.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
12+
def strip_url_scheme(url: str) -> str:
13+
"""``https://acme.example.com/`` → ``acme.example.com``.
14+
15+
Tolerates missing scheme (returns the input domain-shaped string
16+
after trailing-slash strip), ``http://`` schemes (legacy clients
17+
don't all enforce https), and trailing slashes from sloppy
18+
concatenation.
19+
"""
20+
s = url.strip()
21+
for prefix in ("https://", "http://"):
22+
if s.startswith(prefix):
23+
s = s[len(prefix) :]
24+
break
25+
return s.rstrip("/")
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""v2.5 → v3 adapter for ``create_media_buy``.
2+
3+
Wire-shape deltas:
4+
5+
* ``brand_manifest`` (v2.5 URL string) → ``brand: {domain}`` (v3).
6+
* Per-package ``creative_ids`` (v2.5) → ``creative_assignments`` (v3).
7+
* Response packages get the reverse rewrite (``creative_assignments`` →
8+
``creative_ids``, dropping v3-only ``weight``/``placement_ids``).
9+
10+
Buyer identity (``buyer_ref``) is preserved as-is; v3 tolerates the
11+
field via ``additionalProperties`` and adopters that rely on
12+
buyer-controlled idempotency keep their dedupe semantics.
13+
14+
Direct port (inverted) of
15+
``src/lib/adapters/legacy/v2-5/create_media_buy.ts`` +
16+
``src/lib/utils/creative-adapter.ts``.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from typing import Any
22+
23+
from adcp.compat.legacy import register_adapter
24+
from adcp.compat.legacy.types import AdapterPair
25+
from adcp.compat.legacy.v2_5._media_buy_helpers import (
26+
adapt_brand_manifest_to_brand,
27+
adapt_package_request,
28+
normalize_media_buy_response,
29+
)
30+
31+
32+
def adapt_request(payload: dict[str, Any]) -> dict[str, Any]:
33+
"""Translate a v2.5 ``create_media_buy`` request to v3 shape."""
34+
out = adapt_brand_manifest_to_brand(payload)
35+
36+
packages = out.get("packages")
37+
if isinstance(packages, list):
38+
out["packages"] = [adapt_package_request(p) if isinstance(p, dict) else p for p in packages]
39+
40+
return out
41+
42+
43+
ADAPTER = AdapterPair(
44+
tool_name="create_media_buy",
45+
adapt_request=adapt_request,
46+
normalize_response=normalize_media_buy_response,
47+
)
48+
register_adapter("2.5", ADAPTER)

src/adcp/compat/legacy/v2_5/get_products.py

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040

4141
from adcp.compat.legacy import register_adapter
4242
from adcp.compat.legacy.types import AdapterPair
43+
from adcp.compat.legacy.v2_5._url import strip_url_scheme
4344

4445
# v2.5 channel buckets to v3 channel slugs. Multi-mapped buckets resolve
4546
# to all listed v3 channels; downstream consumers can narrow further via
@@ -122,28 +123,14 @@ def _adapt_pricing_option_for_v2(option: dict[str, Any]) -> dict[str, Any]:
122123
return rest
123124

124125

125-
def _strip_url_scheme(url: str) -> str:
126-
"""Extract a bare domain from a brand-manifest URL (``https://x.com`` → ``x.com``).
127-
128-
v2.5 buyers historically sent the full URL; v3 expects just the
129-
domain. Tolerates schemes that are missing or non-https.
130-
"""
131-
s = url.strip()
132-
for prefix in ("https://", "http://"):
133-
if s.startswith(prefix):
134-
s = s[len(prefix) :]
135-
break
136-
return s.rstrip("/")
137-
138-
139126
def adapt_request(payload: dict[str, Any]) -> dict[str, Any]:
140127
"""Translate a v2.5 ``get_products`` request to v3 shape."""
141128
out = dict(payload)
142129

143130
# brand_manifest (v2.5 URL string) → brand.domain (v3 BrandReference)
144131
brand_manifest = out.pop("brand_manifest", None)
145132
if isinstance(brand_manifest, str) and brand_manifest and "brand" not in out:
146-
out["brand"] = {"domain": _strip_url_scheme(brand_manifest)}
133+
out["brand"] = {"domain": strip_url_scheme(brand_manifest)}
147134

148135
# promoted_offerings → catalog
149136
promoted = out.pop("promoted_offerings", None)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""v2.5 → v3 adapter for ``update_media_buy``.
2+
3+
Same wire-shape deltas as ``create_media_buy`` for the package shape
4+
(``creative_ids`` ↔ ``creative_assignments``), but no
5+
``brand_manifest`` translation — updates don't carry brand info.
6+
7+
Response shape matches ``create_media_buy``: package
8+
``creative_assignments`` collapse back to ``creative_ids`` for the
9+
v2.5 buyer.
10+
11+
Direct port (inverted) of
12+
``src/lib/adapters/legacy/v2-5/update_media_buy.ts`` +
13+
``src/lib/utils/creative-adapter.ts``.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from typing import Any
19+
20+
from adcp.compat.legacy import register_adapter
21+
from adcp.compat.legacy.types import AdapterPair
22+
from adcp.compat.legacy.v2_5._media_buy_helpers import (
23+
adapt_package_request,
24+
normalize_media_buy_response,
25+
)
26+
27+
28+
def adapt_request(payload: dict[str, Any]) -> dict[str, Any]:
29+
"""Translate a v2.5 ``update_media_buy`` request to v3 shape."""
30+
out = dict(payload)
31+
32+
packages = out.get("packages")
33+
if isinstance(packages, list):
34+
out["packages"] = [adapt_package_request(p) if isinstance(p, dict) else p for p in packages]
35+
36+
return out
37+
38+
39+
ADAPTER = AdapterPair(
40+
tool_name="update_media_buy",
41+
adapt_request=adapt_request,
42+
normalize_response=normalize_media_buy_response,
43+
)
44+
register_adapter("2.5", ADAPTER)

tests/test_dispatcher_legacy_adapter_routing.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -96,32 +96,32 @@ async def test_v2_5_sync_creatives_infers_asset_type_before_handler() -> None:
9696

9797
@pytest.mark.asyncio
9898
async def test_v2_5_tool_without_adapter_raises_invalid_request() -> None:
99-
"""A v2.5 buyer calling a tool with no registered adapter sees
99+
"""A v2.5 buyer calling a tool outside the v2.5 catalog sees
100100
``INVALID_REQUEST`` before the handler runs.
101101
102-
``create_media_buy`` is one of the tools Stage 5c still has to
103-
port — pick that as a known-unsupported until Stage 5c lands.
102+
``check_governance`` was added in v3 — no v2.5 adapter exists.
103+
Pick that as a known-out-of-v2.5-scope tool.
104104
"""
105105

106-
class _CreateMediaBuyHandler(ADCPHandler[Any]):
106+
class _CheckGovernanceHandler(ADCPHandler[Any]):
107107
def __init__(self) -> None:
108108
self.received: list[dict[str, Any]] = []
109109

110-
async def create_media_buy(
110+
async def check_governance(
111111
self, params: dict[str, Any], ctx: ToolContext
112112
) -> dict[str, Any]:
113113
self.received.append(params)
114-
return {"media_buy_id": "mb-1"}
114+
return {"approved": True}
115115

116-
handler = _CreateMediaBuyHandler()
117-
caller = create_tool_caller(handler, "create_media_buy")
116+
handler = _CheckGovernanceHandler()
117+
caller = create_tool_caller(handler, "check_governance")
118118

119119
with pytest.raises(ADCPTaskError) as exc_info:
120120
await caller({"adcp_version": "2.5"})
121121

122122
err = exc_info.value.errors[0]
123123
assert err.code == "INVALID_REQUEST"
124-
assert "create_media_buy" in err.message
124+
assert "check_governance" in err.message
125125
assert "2.5" in err.message
126126
assert err.details is not None
127127
assert err.details.get("legacy_version") == "2.5"

0 commit comments

Comments
 (0)