Add universal_macro_translation + encode_unreserved to the Python SDK, pinned to a shared golden fixture
The JS SDK is adding a producer-side universal-macro translation helper for seller agents (adcontextprotocol/adcp-client#2263; spec guide adcontextprotocol/adcp#5646). Every seller agent needs this behavior, and the Python adcp package ships the full seller side (serve(), ADCPAgentExecutor, builders, examples/seller_agent.py) — but has no translation helper and no RFC-3986 unreserved encoder. A Python seller reading the new guide gets a TypeScript function it cannot call.
Why this matters (and why parity alone isn't enough)
The seller's pixel translation is an internal step — it is never serialized on any AdCP wire surface. So no conformance storyboard can observe it; the JS expect_universal_macro_substituted assertion grades a different, observable thing (the macro_values → output-manifest contract). That means the only enforcement of helper correctness is unit tests — and the JS conformance check only exercises alphanumeric IDs, which encode identically under every encoder. A Python port that uses urllib.parse.quote() defaults would silently drift from JS on every reserved character (quote('a/b') == 'a/b' vs JS a%2Fb) and nothing would catch it.
What to build
adcp.substitution.encode_unreserved(raw) -> str — port the JS encoder byte-for-byte: explicit unreserved whitelist (ALPHA / DIGIT / - . _ ~), UTF-8 encode then uppercase %NN. Do NOT use urllib.parse.quote defaults (safe='/' leaves / unescaped; even safe='' relies on CPython's unreserved set, not the contract's).
adcp.substitution.universal_macro_translation(pixel_url, mapping) -> {url, dropped_params, unmapped_macros} — mirror @adcp/sdk's substitution/translate.ts:
{ "value": v } → substituted now, encode_unreserved'd
{ "native": t } → inserted raw (ad-server token survives)
- unmapped-macro param → dropped, reported in
dropped_params / unmapped_macros
- already-minted (no-macro) param → untouched
- single-pass: a
{MACRO} inside a substituted value is data, not re-expanded
- query string only (path/fragment left raw — documented limitation)
The contract pin (do this regardless)
Land a language-neutral golden fixture that BOTH SDKs validate against — draft attached in .context/universal-macro-translation.vectors.json, proposed home static/test-vectors/universal-macro-translation.json in the adcp repo. The JS PR's substitution-translate.test.js cases seed it. Without this, the two encoders will drift and conformance won't catch it.
Scope
- Helper only — NOT the assertion. There is no Python storyboard runner; the JS
@adcp/sdk runner grades Python sellers over the wire. Don't port expect_universal_macro_substituted.
- Natural home: a new
adcp.substitution module (neighbors canonical_formats/pixel_tracker.py, which already handles pixel/tracker projection).
- No dependency on #2263 publishing — the helper + fixture are independent and can land in parallel.
Guardrails to fold into the helper/docstring
- Only build-time IDs (
{MEDIA_BUY_ID}, {PACKAGE_ID}, {CREATIVE_ID}) and catalog data macros take value; every impression-time macro (privacy/consent {GDPR_CONSENT}/{US_PRIVACY}/{GPP_STRING}, device, geo, {CACHEBUSTER}, {TIMESTAMP}) MUST be native — a value mapping freezes per-impression data into every impression.
native is inserted raw with no validation → document the trust boundary (must come from trusted ad-server integration config, not buyer input).
Refs: adcontextprotocol/adcp-client#2263 (JS helper + assertion), adcontextprotocol/adcp#5646 (spec guide + storyboard).
Add
universal_macro_translation+encode_unreservedto the Python SDK, pinned to a shared golden fixtureThe JS SDK is adding a producer-side universal-macro translation helper for seller agents (adcontextprotocol/adcp-client#2263; spec guide adcontextprotocol/adcp#5646). Every seller agent needs this behavior, and the Python
adcppackage ships the full seller side (serve(),ADCPAgentExecutor, builders,examples/seller_agent.py) — but has no translation helper and no RFC-3986 unreserved encoder. A Python seller reading the new guide gets a TypeScript function it cannot call.Why this matters (and why parity alone isn't enough)
The seller's pixel translation is an internal step — it is never serialized on any AdCP wire surface. So no conformance storyboard can observe it; the JS
expect_universal_macro_substitutedassertion grades a different, observable thing (themacro_values→ output-manifest contract). That means the only enforcement of helper correctness is unit tests — and the JS conformance check only exercises alphanumeric IDs, which encode identically under every encoder. A Python port that usesurllib.parse.quote()defaults would silently drift from JS on every reserved character (quote('a/b') == 'a/b'vs JSa%2Fb) and nothing would catch it.What to build
adcp.substitution.encode_unreserved(raw) -> str— port the JS encoder byte-for-byte: explicit unreserved whitelist (ALPHA / DIGIT / - . _ ~), UTF-8 encode then uppercase%NN. Do NOT useurllib.parse.quotedefaults (safe='/'leaves/unescaped; evensafe=''relies on CPython's unreserved set, not the contract's).adcp.substitution.universal_macro_translation(pixel_url, mapping) -> {url, dropped_params, unmapped_macros}— mirror @adcp/sdk'ssubstitution/translate.ts:{ "value": v }→ substituted now,encode_unreserved'd{ "native": t }→ inserted raw (ad-server token survives)dropped_params/unmapped_macros{MACRO}inside a substituted value is data, not re-expandedThe contract pin (do this regardless)
Land a language-neutral golden fixture that BOTH SDKs validate against — draft attached in
.context/universal-macro-translation.vectors.json, proposed homestatic/test-vectors/universal-macro-translation.jsonin the adcp repo. The JS PR'ssubstitution-translate.test.jscases seed it. Without this, the two encoders will drift and conformance won't catch it.Scope
@adcp/sdkrunner grades Python sellers over the wire. Don't portexpect_universal_macro_substituted.adcp.substitutionmodule (neighborscanonical_formats/pixel_tracker.py, which already handles pixel/tracker projection).Guardrails to fold into the helper/docstring
{MEDIA_BUY_ID},{PACKAGE_ID},{CREATIVE_ID}) and catalog data macros takevalue; every impression-time macro (privacy/consent{GDPR_CONSENT}/{US_PRIVACY}/{GPP_STRING}, device, geo,{CACHEBUSTER},{TIMESTAMP}) MUST benative— avaluemapping freezes per-impression data into every impression.nativeis inserted raw with no validation → document the trust boundary (must come from trusted ad-server integration config, not buyer input).Refs: adcontextprotocol/adcp-client#2263 (JS helper + assertion), adcontextprotocol/adcp#5646 (spec guide + storyboard).