Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions examples/seller_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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"):
Expand Down Expand Up @@ -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,
Expand Down
203 changes: 151 additions & 52 deletions examples/v3_reference_seller/src/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -112,6 +114,7 @@
ListCreativeFormatsResponse,
ListCreativesRequest,
ListCreativesResponse,
MediaBuyStatus,
Product,
ProvidePerformanceFeedbackRequest,
ProvidePerformanceFeedbackSuccessResponse,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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 ---------------------------------------------
Expand Down Expand Up @@ -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 --------------------------------------------------
Expand Down Expand Up @@ -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"]
Expand All @@ -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 ------------------------------------

Expand Down
70 changes: 70 additions & 0 deletions examples/v3_reference_seller/tests/test_smoke_broadening.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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``
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading