From 6590b039a298b04960011801d2913df3ebf9c7e8 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 14 Jun 2026 07:43:17 -0400 Subject: [PATCH 1/3] fix(server): project postal capabilities by AdCP version --- CHANGELOG.md | 1 + src/adcp/decisioning/handler.py | 32 +++- src/adcp/decisioning/platform.py | 9 +- src/adcp/types/__init__.py | 2 + src/adcp/types/capabilities.py | 2 + src/adcp/types/projections.py | 119 ++++++++++++++ tests/fixtures/public_api_snapshot.json | 1 + ...est_decisioning_capabilities_projection.py | 150 ++++++++++++++++++ 8 files changed, 312 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 890b5377e..ebf7e1358 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Features * **protocol:** support AdCP 3.1.0-rc.13 +* **server:** project native postal capabilities to legacy booleans for AdCP 3.0 callers ### Bug Fixes diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index 9e861a18b..e47b3102c 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -198,6 +198,7 @@ VerifyBrandClaimResponse, VerifyBrandClaimsRequest, VerifyBrandClaimsResponseBulk, + project_geo_postal_areas, ) if TYPE_CHECKING: @@ -1585,6 +1586,10 @@ async def get_adcp_capabilities( * Wire-level ``specialisms`` field from spec-known entries in :attr:`DecisioningCapabilities.specialisms` (novel / typo strings are filtered — only spec-defined slugs reach the wire). + * ``media_buy.execution.targeting.geo_postal_areas`` from one + typed declaration into the caller's negotiated postal shape: + native country-keyed systems for explicit AdCP 3.1+ callers, + deprecated fused booleans for unversioned / 3.0 callers. Legacy-field projection (deprecation warnings emitted): @@ -1606,8 +1611,8 @@ async def get_adcp_capabilities( support) override :meth:`DecisioningPlatform.get_adcp_capabilities_for_request`. That hook returns a typed :class:`DecisioningCapabilities` - override; this method remains responsible for the canonical - wire projection. + override before the automatic postal projection runs; this method + remains responsible for the canonical wire projection. """ from adcp.decisioning.types import AdcpError @@ -1717,7 +1722,28 @@ async def get_adcp_capabilities( if caps.account is not None: response["account"] = caps.account.model_dump(mode="json", exclude_none=True) if caps.media_buy is not None: - response["media_buy"] = caps.media_buy.model_dump(mode="json", exclude_none=True) + media_buy = caps.media_buy.model_dump(mode="json", exclude_none=True) + execution_model = getattr(caps.media_buy, "execution", None) + targeting_model = getattr(execution_model, "targeting", None) + geo_postal_areas = getattr(targeting_model, "geo_postal_areas", None) + if geo_postal_areas is not None: + projected_geo_postal_areas = project_geo_postal_areas( + geo_postal_areas, + context.resolved_adcp_version if context is not None else None, + ) + execution = media_buy.get("execution") + if isinstance(execution, dict): + targeting = execution.get("targeting") + if isinstance(targeting, dict): + if projected_geo_postal_areas: + targeting["geo_postal_areas"] = projected_geo_postal_areas + else: + targeting.pop("geo_postal_areas", None) + if not targeting: + execution.pop("targeting", None) + if not execution: + media_buy.pop("execution", None) + response["media_buy"] = media_buy if caps.signals is not None: response["signals"] = caps.signals.model_dump(mode="json", exclude_none=True) if caps.governance is not None: diff --git a/src/adcp/decisioning/platform.py b/src/adcp/decisioning/platform.py index d7702b45f..295b36d05 100644 --- a/src/adcp/decisioning/platform.py +++ b/src/adcp/decisioning/platform.py @@ -98,6 +98,10 @@ class DecisioningCapabilities: :param media_buy: Media-buy protocol capabilities — pricing models, reporting delivery methods, execution targeting, etc. Expected when ``media_buy`` is in ``supported_protocols``. + ``execution.targeting.geo_postal_areas`` may be declared once + using the native AdCP 3.1 country-keyed model; the framework + auto-projects it to the deprecated fused boolean model for + pre-3.1 capability callers. :param signals: Signals protocol capabilities. Only emit when ``signals`` is in ``supported_protocols``. :param governance: Governance protocol capabilities. @@ -434,7 +438,10 @@ def get_adcp_capabilities_for_request( complete :class:`DecisioningCapabilities` instance for this request. The framework still performs the canonical ``get_adcp_capabilities`` response projection; this hook is not a raw - wire-response override. The hook may be synchronous or asynchronous. + wire-response override. Manual postal compatibility projection is not + needed; request-scoped overrides run before the framework projects + ``geo_postal_areas`` for the caller's AdCP version. The hook may be + synchronous or asynchronous. """ del params, context return None diff --git a/src/adcp/types/__init__.py b/src/adcp/types/__init__.py index a079497d3..11c24da2f 100644 --- a/src/adcp/types/__init__.py +++ b/src/adcp/types/__init__.py @@ -793,6 +793,7 @@ from adcp.types.projections import ( AccountResponse, BusinessEntityResponse, + project_geo_postal_areas, to_account_response, ) from adcp.types.registry import BrandSource @@ -1596,6 +1597,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: "ProductCatalog", "TextSubAsset", # Response-shape projection helpers + "project_geo_postal_areas", "to_account_response", # Cross-module name collision aliases (#911, Step 2) # Creative diff --git a/src/adcp/types/capabilities.py b/src/adcp/types/capabilities.py index cf6c5a614..0beb97658 100644 --- a/src/adcp/types/capabilities.py +++ b/src/adcp/types/capabilities.py @@ -34,6 +34,7 @@ from typing import get_args as _get_args +from adcp.types._generated import LegacyPostalCodeSystem from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import ( A2ui, Accreditation, @@ -194,6 +195,7 @@ "Identity", "KeyOrigins", "KeywordTargets", + "LegacyPostalCodeSystem", "MatchingLatencyHours", "Measurement", "Metric", diff --git a/src/adcp/types/projections.py b/src/adcp/types/projections.py index 63690072c..5aad56c9f 100644 --- a/src/adcp/types/projections.py +++ b/src/adcp/types/projections.py @@ -24,11 +24,129 @@ from __future__ import annotations +import re +from collections.abc import Iterator, Mapping from typing import Any from pydantic import Field, field_validator +from adcp._version import normalize_to_release_precision from adcp.types import Account, BusinessEntity +from adcp.types.capabilities import GeoPostalAreas, LegacyPostalCodeSystem + +_NATIVE_TO_LEGACY_POSTAL: dict[tuple[str, str], LegacyPostalCodeSystem] = { + ("US", "zip"): LegacyPostalCodeSystem.us_zip, + ("US", "zip_plus_four"): LegacyPostalCodeSystem.us_zip_plus_four, + ("GB", "outward"): LegacyPostalCodeSystem.gb_outward, + ("GB", "full"): LegacyPostalCodeSystem.gb_full, + ("CA", "fsa"): LegacyPostalCodeSystem.ca_fsa, + ("CA", "full"): LegacyPostalCodeSystem.ca_full, + ("DE", "plz"): LegacyPostalCodeSystem.de_plz, + ("FR", "code_postal"): LegacyPostalCodeSystem.fr_code_postal, + ("AU", "postcode"): LegacyPostalCodeSystem.au_postcode, + ("CH", "plz"): LegacyPostalCodeSystem.ch_plz, + ("AT", "plz"): LegacyPostalCodeSystem.at_plz, +} +_LEGACY_TO_NATIVE_POSTAL: dict[str, tuple[str, str]] = { + legacy.value: native for native, legacy in _NATIVE_TO_LEGACY_POSTAL.items() +} +_NATIVE_POSTAL_COUNTRIES: tuple[str, ...] = ( + "US", + "GB", + "CA", + "DE", + "CH", + "AT", + "FR", + "AU", + "BR", + "IN", + "ZA", +) +_COUNTRY_KEY_RE = re.compile(r"^[A-Z]{2}$") + + +def _is_native_geo_postal_version(version: str | None) -> bool: + """Return whether ``version`` should emit the AdCP 3.1 postal shape.""" + if version is None: + return False + try: + normalized = normalize_to_release_precision(version) + except ValueError: + return False + release = normalized.split("-", 1)[0] + major_raw, minor_raw = release.split(".", 1) + try: + major = int(major_raw) + minor = int(minor_raw) + except ValueError: + return False + return major > 3 or (major == 3 and minor >= 1) + + +def _postal_system_value(system: Any) -> str: + return str(system.value if hasattr(system, "value") else system) + + +def _append_unique(target: dict[str, list[str]], country: str, system: str) -> None: + systems = target.setdefault(country, []) + if system not in systems: + systems.append(system) + + +def _geo_postal_payload(value: GeoPostalAreas | Mapping[str, Any]) -> dict[str, Any]: + if isinstance(value, Mapping): + return dict(value) + return value.model_dump(mode="json", exclude_none=True) + + +def _iter_native_postal_systems(payload: Mapping[str, Any]) -> Iterator[tuple[str, Any]]: + for country, systems in payload.items(): + if not _COUNTRY_KEY_RE.fullmatch(country) or not systems: + continue + yield country, systems + + +def project_geo_postal_areas( + value: GeoPostalAreas | Mapping[str, Any], + version: str | None, +) -> dict[str, Any]: + """Project postal capability declarations for the caller's AdCP version. + + AdCP 3.1 introduced native country-keyed postal capabilities such as + ``{"US": ["zip"]}``. AdCP 3.0 clients expect the deprecated fused + booleans such as ``{"us_zip": true}``. This helper lets sellers keep + one typed :class:`GeoPostalAreas` declaration and serializes only the + shape the caller negotiated. + + Native systems with no legacy 3.0 alias (currently BR ``cep``, IN + ``pin``, and ZA ``postal_code``) are omitted from 3.0 projections. + Legacy booleans set to ``False`` are treated as absent so projection + never invents support. + """ + payload = _geo_postal_payload(value) + if _is_native_geo_postal_version(version): + projected: dict[str, list[str]] = {} + for country, systems in _iter_native_postal_systems(payload): + for system in systems: + _append_unique(projected, country, _postal_system_value(system)) + for legacy in LegacyPostalCodeSystem: + if payload.get(legacy.value) is not True: + continue + country, system = _LEGACY_TO_NATIVE_POSTAL[legacy.value] + _append_unique(projected, country, system) + return projected + + projected_legacy: dict[str, bool] = {} + for country, systems in _iter_native_postal_systems(payload): + for system in systems: + legacy_alias = _NATIVE_TO_LEGACY_POSTAL.get((country, _postal_system_value(system))) + if legacy_alias is not None: + projected_legacy[legacy_alias.value] = True + for legacy in LegacyPostalCodeSystem: + if payload.get(legacy.value) is True: + projected_legacy[legacy.value] = True + return projected_legacy class BusinessEntityResponse(BusinessEntity): @@ -95,5 +213,6 @@ def to_account_response(account: Account) -> AccountResponse: __all__ = [ "AccountResponse", "BusinessEntityResponse", + "project_geo_postal_areas", "to_account_response", ] diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index dc3f4b9e3..fd6635ee1 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -1196,6 +1196,7 @@ "WholesaleFeedWebhook", "aliases", "generated", + "project_geo_postal_areas", "to_account_response" ] } diff --git a/tests/test_decisioning_capabilities_projection.py b/tests/test_decisioning_capabilities_projection.py index ef4a468ca..0c21b2fc6 100644 --- a/tests/test_decisioning_capabilities_projection.py +++ b/tests/test_decisioning_capabilities_projection.py @@ -28,6 +28,8 @@ from adcp.decisioning.capabilities import ( Account, Adcp, + Execution, + GeoPostalAreas, IdempotencySupported, IdempotencyUnsupported, Measurement, @@ -35,11 +37,13 @@ Metric, Portfolio, SupportedProtocol, + Targeting, WebhookSigning, ) from adcp.decisioning.handler import SPECIALISM_TO_PROTOCOLS, PlatformHandler from adcp.decisioning.types import AdcpError from adcp.server.base import ToolContext +from adcp.types import project_geo_postal_areas from adcp.validation.schema_validator import validate_response @@ -153,6 +157,152 @@ def test_sales_platform_projects_pricing_models(executor: ThreadPoolExecutor) -> assert response["media_buy"]["supported_pricing_models"] == ["cpm"] +def _postal_platform(geo_postal_areas: GeoPostalAreas) -> DecisioningPlatform: + class _PostalPlatform(DecisioningPlatform): + accounts = SingletonAccounts(account_id="test") + + _PostalPlatform.capabilities = DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], + supported_protocols=[SupportedProtocol.media_buy], + account=Account(supported_billing=["operator", "agent"]), + media_buy=MediaBuy( + supported_pricing_models=["cpm"], + execution=Execution( + targeting=Targeting(geo_postal_areas=geo_postal_areas), + ), + ), + ) + return _PostalPlatform() + + +def _projected_geo_postal_areas( + executor: ThreadPoolExecutor, + geo_postal_areas: GeoPostalAreas, + *, + version: str | None, +) -> dict: + handler = _build_handler(_postal_platform(geo_postal_areas), executor) + context = ToolContext(resolved_adcp_version=version) + response = asyncio.run(handler.get_adcp_capabilities(context=context)) + return response["media_buy"]["execution"]["targeting"]["geo_postal_areas"] + + +def test_native_postal_capabilities_remain_native_for_31_callers( + executor: ThreadPoolExecutor, +) -> None: + projected = _projected_geo_postal_areas( + executor, + GeoPostalAreas(US=["zip", "zip_plus_four"], BR=["cep"]), + version="3.1", + ) + + assert projected == {"US": ["zip", "zip_plus_four"], "BR": ["cep"]} + + +def test_public_postal_projection_preserves_future_native_country_keys() -> None: + projected = project_geo_postal_areas({"NL": ["postal_code"], "US": ["zip"]}, "3.1") + + assert projected == {"NL": ["postal_code"], "US": ["zip"]} + + +def test_native_postal_capabilities_project_to_legacy_for_30_callers( + executor: ThreadPoolExecutor, +) -> None: + projected = _projected_geo_postal_areas( + executor, + GeoPostalAreas(US=["zip", "zip_plus_four"], BR=["cep"]), + version="3.0", + ) + + assert projected == {"us_zip": True, "us_zip_plus_four": True} + + +def test_native_postal_capabilities_without_legacy_alias_are_omitted_for_30_callers( + executor: ThreadPoolExecutor, +) -> None: + handler = _build_handler(_postal_platform(GeoPostalAreas(BR=["cep"])), executor) + + response = asyncio.run( + handler.get_adcp_capabilities(context=ToolContext(resolved_adcp_version="3.0")) + ) + + assert "execution" not in response["media_buy"] + + +def test_postal_capabilities_30_projection_is_schema_valid( + executor: ThreadPoolExecutor, +) -> None: + handler = _build_handler(_postal_platform(GeoPostalAreas(US=["zip"])), executor) + + response = asyncio.run( + handler.get_adcp_capabilities(context=ToolContext(resolved_adcp_version="3.0")) + ) + + outcome = validate_response("get_adcp_capabilities", response, version="3.0") + assert outcome.valid, f"validation failed: {outcome.issues}" + + +def test_postal_capabilities_native_projection_is_schema_valid( + executor: ThreadPoolExecutor, +) -> None: + handler = _build_handler(_postal_platform(GeoPostalAreas(US=["zip"])), executor) + + response = asyncio.run( + handler.get_adcp_capabilities(context=ToolContext(resolved_adcp_version="3.1")) + ) + + outcome = validate_response("get_adcp_capabilities", response) + assert outcome.valid, f"validation failed: {outcome.issues}" + + +def test_legacy_postal_capabilities_remain_legacy_for_30_callers( + executor: ThreadPoolExecutor, +) -> None: + projected = _projected_geo_postal_areas( + executor, + GeoPostalAreas(us_zip=True, us_zip_plus_four=True), + version="3.0", + ) + + assert projected == {"us_zip": True, "us_zip_plus_four": True} + + +def test_legacy_postal_capabilities_project_to_native_for_31_callers( + executor: ThreadPoolExecutor, +) -> None: + projected = _projected_geo_postal_areas( + executor, + GeoPostalAreas(us_zip=True, us_zip_plus_four=True), + version="3.1", + ) + + assert projected == {"US": ["zip", "zip_plus_four"]} + + +def test_unversioned_postal_capabilities_fall_back_to_30_projection( + executor: ThreadPoolExecutor, +) -> None: + handler = _build_handler(_postal_platform(GeoPostalAreas(US=["zip"])), executor) + + response = asyncio.run(handler.get_adcp_capabilities()) + + assert response["media_buy"]["execution"]["targeting"]["geo_postal_areas"] == {"us_zip": True} + + +def test_absent_postal_capabilities_stay_absent_for_all_versions( + executor: ThreadPoolExecutor, +) -> None: + handler = _build_handler(_SalesPlatform(), executor) + + unversioned = asyncio.run(handler.get_adcp_capabilities()) + native = asyncio.run( + handler.get_adcp_capabilities(context=ToolContext(resolved_adcp_version="3.1")) + ) + + assert "execution" not in unversioned["media_buy"] + assert "execution" not in native["media_buy"] + + def test_sales_platform_response_is_spec_conformant(executor: ThreadPoolExecutor) -> None: """End-to-end: the projected response validates against the bundled ``get_adcp_capabilities`` JSON schema. Catches the original From e8aacb2f04dea7721fdc5b25b12691f0cf46e689 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 14 Jun 2026 08:47:12 -0400 Subject: [PATCH 2/3] fix(examples): satisfy storyboard response contracts --- examples/seller_agent.py | 6 + examples/v3_reference_seller/src/platform.py | 203 +++++++++++++----- .../tests/test_smoke_broadening.py | 70 ++++++ tests/test_seller_agent_storyboard.py | 6 + 4 files changed, 233 insertions(+), 52 deletions(-) diff --git a/examples/seller_agent.py b/examples/seller_agent.py index 186e6b7c2..88af60d19 100644 --- a/examples/seller_agent.py +++ b/examples/seller_agent.py @@ -825,6 +825,7 @@ async def get_media_buys(self, params: dict[str, Any], context: Any = None) -> d "currency": mb.get("currency", "USD"), "packages": mb.get("packages", []), "total_budget": total_budget, + "valid_actions": valid_actions_for_status(mb["status"]), **_health_fields_for_media_buy(mb_id, mb), } if mb.get("context") is not None: @@ -880,6 +881,7 @@ async def update_media_buy(self, params: dict[str, Any], context: Any = None) -> if params.get("packages"): existing_by_id = {p["package_id"]: p for p in mb.get("packages", [])} + affected_packages = [] for pkg_update in params["packages"]: pkg_id = pkg_update.get("package_id") if pkg_id and pkg_id not in existing_by_id: @@ -903,6 +905,9 @@ async def update_media_buy(self, params: dict[str, Any], context: Any = None) -> ): if pkg_update.get(field) is not None: target[field] = pkg_update[field] + affected_packages.append(deepcopy(target)) + else: + affected_packages = [] status = mb["status"] if status == "pending_creatives" and params.get("packages"): @@ -933,6 +938,7 @@ async def update_media_buy(self, params: dict[str, Any], context: Any = None) -> mb["revision"] = mb.get("revision", 1) + 1 resp = update_media_buy_response( mb_id, + affected_packages=affected_packages or None, status=mb["status"], revision=mb["revision"], valid_actions=valid_actions_for_status(mb["status"]) or None, diff --git a/examples/v3_reference_seller/src/platform.py b/examples/v3_reference_seller/src/platform.py index c2abfa416..7a81043c3 100644 --- a/examples/v3_reference_seller/src/platform.py +++ b/examples/v3_reference_seller/src/platform.py @@ -66,7 +66,8 @@ import random from dataclasses import replace as _dc_replace from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, cast +from urllib.parse import urlsplit from sqlalchemy import select @@ -97,6 +98,7 @@ ) from adcp.decisioning.specialisms import SalesPlatform from adcp.server import current_tenant +from adcp.server.helpers import valid_actions_for_status from adcp.types import ( BusinessEntity, CreateMediaBuyRequest, @@ -112,6 +114,7 @@ ListCreativeFormatsResponse, ListCreativesRequest, ListCreativesResponse, + MediaBuyStatus, Product, ProvidePerformanceFeedbackRequest, ProvidePerformanceFeedbackSuccessResponse, @@ -593,21 +596,99 @@ def _project_request_package_echo(pkg: Any) -> dict[str, Any]: if value is None: continue if hasattr(value, "model_dump"): - out[field] = value.model_dump(mode="json", exclude_none=True) + out[field] = _normalize_echo_urls(value.model_dump(mode="json", exclude_none=True)) elif isinstance(value, list): - out[field] = [ - ( - item.model_dump(mode="json", exclude_none=True) - if hasattr(item, "model_dump") - else item - ) - for item in value - ] + out[field] = _normalize_echo_urls( + [ + ( + item.model_dump(mode="json", exclude_none=True) + if hasattr(item, "model_dump") + else item + ) + for item in value + ] + ) else: out[field] = value return out +def _normalize_echo_urls(value: Any) -> Any: + """Keep buyer-supplied agent_url echoes byte-stable after Pydantic parsing.""" + if isinstance(value, dict): + normalized: dict[str, Any] = {} + for key, item in value.items(): + if key == "agent_url" and isinstance(item, str): + parts = urlsplit(item) + normalized[key] = ( + item[:-1] + if item.endswith("/") + and parts.scheme + and parts.netloc + and parts.path == "/" + and not parts.query + and not parts.fragment + else item + ) + else: + normalized[key] = _normalize_echo_urls(item) + return normalized + if isinstance(value, list): + return [_normalize_echo_urls(item) for item in value] + return value + + +def _format_dimensions(v1_format_id: str) -> tuple[int, int]: + if "970x250" in v1_format_id: + return (970, 250) + if "728x90" in v1_format_id: + return (728, 90) + return (300, 250) + + +def _product_format_options( + *, + product_id: str, + name: str, + format_ids: list[dict[str, Any]], +) -> list[dict[str, Any]]: + options: list[dict[str, Any]] = [] + for i, fmt in enumerate(format_ids): + v1_format_id = str(fmt.get("id") or "display_300x250") + v1_agent_url = str(fmt.get("agent_url") or "https://reference.adcp.org") + option_id = f"reference_{product_id}_{i}" + display_name = f"{name} - {v1_format_id}" + base = { + "format_option_id": option_id, + "display_name": display_name, + "v1_format_ref": [{"agent_url": v1_agent_url, "id": v1_format_id}], + } + if "video" in v1_format_id or "ctv" in v1_format_id: + options.append( + { + **base, + "format_kind": "video_hosted", + "params": {}, + } + ) + continue + + width, height = _format_dimensions(v1_format_id) + options.append( + { + **base, + "format_kind": "image", + "params": { + "sizes": [{"width": width, "height": height}], + "asset_source": "buyer_uploaded", + "ssl_required": True, + "image_formats": ["jpg", "png", "gif"], + }, + } + ) + return options + + def _projected_package_state(state: dict[str, Any]) -> dict[str, Any]: """Project a shadow-store package entry onto the wire-shape fields. @@ -857,42 +938,52 @@ async def get_products( channel = upstream_row.get("channel", "display") fallback_id = "display_300x250" if channel == "display" else "video_16x9_30s" format_ids = [{"agent_url": agent_url, "id": fallback_id}] - products.append( - Product.model_validate( + format_options = _product_format_options( + product_id=upstream_row["product_id"], + name=upstream_row["name"], + format_ids=format_ids, + ) + product_payload = { + "product_id": upstream_row["product_id"], + "name": upstream_row["name"], + "description": upstream_row.get("name", ""), + "delivery_type": upstream_row.get("delivery_type", "non_guaranteed"), + "publisher_properties": [ + # The reference seller is a single-publisher + # demo; ``selection_type='all'`` matches the + # spec's "all properties from this publisher" + # discriminator. Multi-publisher adopters + # narrow with ``selection_type='by_id'`` / + # ``'by_tag'``. { - "product_id": upstream_row["product_id"], - "name": upstream_row["name"], - "description": upstream_row.get("name", ""), - "delivery_type": upstream_row.get("delivery_type", "non_guaranteed"), - "publisher_properties": [ - # The reference seller is a single-publisher - # demo; ``selection_type='all'`` matches the - # spec's "all properties from this publisher" - # discriminator. Multi-publisher adopters - # narrow with ``selection_type='by_id'`` / - # ``'by_tag'``. - { - "publisher_domain": "reference.adcp.org", - "selection_type": "all", - } - ], - "format_ids": format_ids, - "reporting_capabilities": { - "available_reporting_frequencies": ["daily"], - "expected_delay_minutes": 240, - "timezone": "UTC", - "supports_webhooks": False, - "available_metrics": [ - "impressions", - "spend", - "clicks", - ], - "date_range_support": "date_range", - }, - "pricing_options": [pricing_option], + "publisher_domain": "reference.adcp.org", + "selection_type": "all", } - ) - ) + ], + "format_ids": format_ids, + "format_options": format_options, + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 240, + "timezone": "UTC", + "supports_webhooks": False, + "available_metrics": [ + "impressions", + "spend", + "clicks", + ], + "date_range_support": "date_range", + }, + "pricing_options": [pricing_option], + } + product = Product.model_validate(product_payload) + # The generated ProductFormatDeclaration currently omits the + # canonical discriminator fields during validation. Restore + # the already-built wire declarations so 3.1 translators see + # the published closed format set. + product.format_ids = format_ids # type: ignore[assignment] + product.format_options = format_options # type: ignore[assignment] + products.append(product) return GetProductsResponse(products=products) # ----- refine_get_products --------------------------------------------- @@ -1393,13 +1484,15 @@ async def update_media_buy( if response_status == "pending_creatives" and any_creatives: response_status = "pending_start" - return UpdateMediaBuySuccessResponse.model_validate( - { - "media_buy_id": media_buy_id, - "status": response_status, - "revision": revision, - "affected_packages": affected_packages or None, - } + return cast( + UpdateMediaBuySuccessResponse, + cast(Any, UpdateMediaBuySuccessResponse).model_construct( + media_buy_id=media_buy_id, + media_buy_status=MediaBuyStatus(response_status), + status="completed", + revision=revision, + affected_packages=affected_packages or None, + ), ) # ----- sync_creatives -------------------------------------------------- @@ -1696,6 +1789,7 @@ async def get_media_buys( "packages": packages, "created_at": order.get("created_at"), "updated_at": order.get("updated_at"), + "valid_actions": valid_actions_for_status(wire_status), } if buy_state.get("context") is not None: media_buy["context"] = buy_state["context"] @@ -1709,7 +1803,12 @@ async def get_media_buys( "offset": offset, }, ) - return GetMediaBuysResponse.model_validate({"media_buys": media_buys}) + return GetMediaBuysResponse.model_validate( + { + "media_buys": media_buys, + "sandbox": getattr(ctx.account, "mode", None) in {"mock", "sandbox"}, + } + ) # ----- provide_performance_feedback ------------------------------------ diff --git a/examples/v3_reference_seller/tests/test_smoke_broadening.py b/examples/v3_reference_seller/tests/test_smoke_broadening.py index fe5b4666f..b0f2105f1 100644 --- a/examples/v3_reference_seller/tests/test_smoke_broadening.py +++ b/examples/v3_reference_seller/tests/test_smoke_broadening.py @@ -485,6 +485,12 @@ async def test_get_products_translates_upstream_to_adcp(respx_mock: Any) -> None assert cpm.fixed_price == 35.0 assert cpm.currency == "USD" assert cpm.min_spend_per_package == 25_000.0 + product_payload = p.model_dump(mode="json", exclude_none=True) + assert [fmt["id"] for fmt in product_payload["format_ids"]] == ["video_16x9_30s"] + assert product_payload["format_options"][0]["format_kind"] == "video_hosted" + assert [fmt["id"] for fmt in product_payload["format_options"][0]["v1_format_ref"]] == [ + "video_16x9_30s" + ] # The SDK's UpstreamHttpClient carried StaticBearer for auth; # the upstream helper added the X-Network-Code per-call header. sent_request = respx_mock.calls.last.request @@ -998,6 +1004,68 @@ async def test_update_media_buy_unknown_package_id_raises_not_found( assert excinfo.value.code == "PACKAGE_NOT_FOUND" +@pytest.mark.asyncio +@respx.mock(base_url=_RESPX_BASE_URL) +async def test_update_media_buy_affected_packages_echo_list_agent_urls( + respx_mock: Any, +) -> None: + """Pydantic AnyUrl normalizes host-only URLs with a trailing slash; + package echoes keep the buyer's list-agent URL stable.""" + from adcp.types import UpdateMediaBuyRequest, UpdateMediaBuySuccessResponse + + respx_mock.get("/v1/orders/ord_test").mock( + return_value=httpx.Response( + 200, + json={"order_id": "ord_test", "status": "delivering"}, + ) + ) + respx_mock.get("/v1/orders/ord_test/lineitems").mock( + return_value=httpx.Response( + 200, + json={"line_items": [{"line_item_id": "li_known"}]}, + ) + ) + + platform = _platform_with_upstream() + platform._buy_state["ord_test"] = { # noqa: SLF001 - example shadow-store regression test + "packages": {"li_known": {"canceled": False, "paused": False}}, + "canceled": False, + "paused": False, + } + ctx = _build_ctx() + patch = UpdateMediaBuyRequest.model_validate( + { + "account": {"account_id": "signed-buyer-main"}, + "media_buy_id": "ord_test", + "idempotency_key": "k_" + "l" * 18, + "packages": [ + { + "package_id": "li_known", + "targeting_overlay": { + "property_list": { + "agent_url": "https://governance.pinnacle-agency.example", + "list_id": "prop_news", + }, + "collection_list": { + "agent_url": "https://governance.pinnacle-agency.example", + "list_id": "coll_news", + }, + }, + } + ], + } + ) + + result = await platform.update_media_buy("ord_test", patch, ctx) + assert isinstance(result, UpdateMediaBuySuccessResponse) + payload = result.model_dump(mode="json", exclude_none=True) + targeting = payload["affected_packages"][0]["targeting_overlay"] + assert targeting["property_list"]["agent_url"] == ("https://governance.pinnacle-agency.example") + assert targeting["collection_list"]["agent_url"] == ( + "https://governance.pinnacle-agency.example" + ) + + @pytest.mark.asyncio async def test_create_media_buy_aggressive_terms_raises_terms_rejected() -> None: """``measurement_terms.billing_measurement.max_variance_percent == 0`` @@ -1140,6 +1208,8 @@ async def test_get_media_buys_filters_by_advertiser_id(respx_mock: Any) -> None: assert media_buys[0]["media_buy_id"] == "ord_volta_1" # delivering → active per the AdCP MediaBuyStatus mapping. assert media_buys[0]["status"] == "active" + assert "pause" in media_buys[0]["valid_actions"] + assert payload["sandbox"] is True @pytest.mark.asyncio diff --git a/tests/test_seller_agent_storyboard.py b/tests/test_seller_agent_storyboard.py index 6fd1e9222..a8441516b 100644 --- a/tests/test_seller_agent_storyboard.py +++ b/tests/test_seller_agent_storyboard.py @@ -252,6 +252,7 @@ async def test_available_actions_are_resolved_persisted_and_enforced() -> None: package_id = create_resp["packages"][0]["package_id"] read_resp = await seller.get_media_buys({"media_buy_ids": [media_buy_id]}) assert read_resp["media_buys"][0]["available_actions"] == create_resp["available_actions"] + assert read_resp["media_buys"][0]["valid_actions"] update_resp = await seller.update_media_buy( { @@ -260,6 +261,8 @@ async def test_available_actions_are_resolved_persisted_and_enforced() -> None: } ) assert update_resp["media_buy_id"] == media_buy_id + assert update_resp["affected_packages"][0]["package_id"] == package_id + assert update_resp["affected_packages"][0]["budget"] == 12000 assert update_resp["available_actions"][0]["action"] == "increase_budget" extend_resp = await seller.update_media_buy( @@ -607,6 +610,9 @@ async def test_update_media_buy_persists_targeting_overlay() -> None: } ) assert update_resp.get("media_buy_id") == mb_id, f"Update failed: {update_resp}" + assert update_resp["affected_packages"][0]["targeting_overlay"] == overlay + assert update_resp["affected_packages"][0]["creative_assignments"] == assignments + assert update_resp["affected_packages"][0]["creatives"] == creatives # All three fields must be persisted on the package — round-tripping through # get_media_buys is the storyboard contract for delivery_reporting + inventory. From 569c28f560c856c13f04428a961da9402ff5504f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 14 Jun 2026 09:02:48 -0400 Subject: [PATCH 3/3] chore(types): remove unused postal constant --- src/adcp/types/projections.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/adcp/types/projections.py b/src/adcp/types/projections.py index 5aad56c9f..2b432695c 100644 --- a/src/adcp/types/projections.py +++ b/src/adcp/types/projections.py @@ -50,19 +50,6 @@ _LEGACY_TO_NATIVE_POSTAL: dict[str, tuple[str, str]] = { legacy.value: native for native, legacy in _NATIVE_TO_LEGACY_POSTAL.items() } -_NATIVE_POSTAL_COUNTRIES: tuple[str, ...] = ( - "US", - "GB", - "CA", - "DE", - "CH", - "AT", - "FR", - "AU", - "BR", - "IN", - "ZA", -) _COUNTRY_KEY_RE = re.compile(r"^[A-Z]{2}$")