Skip to content

Commit d395cbb

Browse files
authored
fix(server): project postal capabilities by AdCP version (#952)
* fix(server): project postal capabilities by AdCP version * fix(examples): satisfy storyboard response contracts * chore(types): remove unused postal constant
1 parent df06501 commit d395cbb

12 files changed

Lines changed: 532 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
### Features
77

88
* **protocol:** support AdCP 3.1.0-rc.13
9+
* **server:** project native postal capabilities to legacy booleans for AdCP 3.0 callers
910

1011

1112
### Bug Fixes

examples/seller_agent.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,7 @@ async def get_media_buys(self, params: dict[str, Any], context: Any = None) -> d
825825
"currency": mb.get("currency", "USD"),
826826
"packages": mb.get("packages", []),
827827
"total_budget": total_budget,
828+
"valid_actions": valid_actions_for_status(mb["status"]),
828829
**_health_fields_for_media_buy(mb_id, mb),
829830
}
830831
if mb.get("context") is not None:
@@ -880,6 +881,7 @@ async def update_media_buy(self, params: dict[str, Any], context: Any = None) ->
880881

881882
if params.get("packages"):
882883
existing_by_id = {p["package_id"]: p for p in mb.get("packages", [])}
884+
affected_packages = []
883885
for pkg_update in params["packages"]:
884886
pkg_id = pkg_update.get("package_id")
885887
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) ->
903905
):
904906
if pkg_update.get(field) is not None:
905907
target[field] = pkg_update[field]
908+
affected_packages.append(deepcopy(target))
909+
else:
910+
affected_packages = []
906911

907912
status = mb["status"]
908913
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) ->
933938
mb["revision"] = mb.get("revision", 1) + 1
934939
resp = update_media_buy_response(
935940
mb_id,
941+
affected_packages=affected_packages or None,
936942
status=mb["status"],
937943
revision=mb["revision"],
938944
valid_actions=valid_actions_for_status(mb["status"]) or None,

examples/v3_reference_seller/src/platform.py

Lines changed: 151 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@
6666
import random
6767
from dataclasses import replace as _dc_replace
6868
from datetime import datetime, timezone
69-
from typing import TYPE_CHECKING, Any, ClassVar
69+
from typing import TYPE_CHECKING, Any, ClassVar, cast
70+
from urllib.parse import urlsplit
7071

7172
from sqlalchemy import select
7273

@@ -97,6 +98,7 @@
9798
)
9899
from adcp.decisioning.specialisms import SalesPlatform
99100
from adcp.server import current_tenant
101+
from adcp.server.helpers import valid_actions_for_status
100102
from adcp.types import (
101103
BusinessEntity,
102104
CreateMediaBuyRequest,
@@ -112,6 +114,7 @@
112114
ListCreativeFormatsResponse,
113115
ListCreativesRequest,
114116
ListCreativesResponse,
117+
MediaBuyStatus,
115118
Product,
116119
ProvidePerformanceFeedbackRequest,
117120
ProvidePerformanceFeedbackSuccessResponse,
@@ -593,21 +596,99 @@ def _project_request_package_echo(pkg: Any) -> dict[str, Any]:
593596
if value is None:
594597
continue
595598
if hasattr(value, "model_dump"):
596-
out[field] = value.model_dump(mode="json", exclude_none=True)
599+
out[field] = _normalize_echo_urls(value.model_dump(mode="json", exclude_none=True))
597600
elif isinstance(value, list):
598-
out[field] = [
599-
(
600-
item.model_dump(mode="json", exclude_none=True)
601-
if hasattr(item, "model_dump")
602-
else item
603-
)
604-
for item in value
605-
]
601+
out[field] = _normalize_echo_urls(
602+
[
603+
(
604+
item.model_dump(mode="json", exclude_none=True)
605+
if hasattr(item, "model_dump")
606+
else item
607+
)
608+
for item in value
609+
]
610+
)
606611
else:
607612
out[field] = value
608613
return out
609614

610615

616+
def _normalize_echo_urls(value: Any) -> Any:
617+
"""Keep buyer-supplied agent_url echoes byte-stable after Pydantic parsing."""
618+
if isinstance(value, dict):
619+
normalized: dict[str, Any] = {}
620+
for key, item in value.items():
621+
if key == "agent_url" and isinstance(item, str):
622+
parts = urlsplit(item)
623+
normalized[key] = (
624+
item[:-1]
625+
if item.endswith("/")
626+
and parts.scheme
627+
and parts.netloc
628+
and parts.path == "/"
629+
and not parts.query
630+
and not parts.fragment
631+
else item
632+
)
633+
else:
634+
normalized[key] = _normalize_echo_urls(item)
635+
return normalized
636+
if isinstance(value, list):
637+
return [_normalize_echo_urls(item) for item in value]
638+
return value
639+
640+
641+
def _format_dimensions(v1_format_id: str) -> tuple[int, int]:
642+
if "970x250" in v1_format_id:
643+
return (970, 250)
644+
if "728x90" in v1_format_id:
645+
return (728, 90)
646+
return (300, 250)
647+
648+
649+
def _product_format_options(
650+
*,
651+
product_id: str,
652+
name: str,
653+
format_ids: list[dict[str, Any]],
654+
) -> list[dict[str, Any]]:
655+
options: list[dict[str, Any]] = []
656+
for i, fmt in enumerate(format_ids):
657+
v1_format_id = str(fmt.get("id") or "display_300x250")
658+
v1_agent_url = str(fmt.get("agent_url") or "https://reference.adcp.org")
659+
option_id = f"reference_{product_id}_{i}"
660+
display_name = f"{name} - {v1_format_id}"
661+
base = {
662+
"format_option_id": option_id,
663+
"display_name": display_name,
664+
"v1_format_ref": [{"agent_url": v1_agent_url, "id": v1_format_id}],
665+
}
666+
if "video" in v1_format_id or "ctv" in v1_format_id:
667+
options.append(
668+
{
669+
**base,
670+
"format_kind": "video_hosted",
671+
"params": {},
672+
}
673+
)
674+
continue
675+
676+
width, height = _format_dimensions(v1_format_id)
677+
options.append(
678+
{
679+
**base,
680+
"format_kind": "image",
681+
"params": {
682+
"sizes": [{"width": width, "height": height}],
683+
"asset_source": "buyer_uploaded",
684+
"ssl_required": True,
685+
"image_formats": ["jpg", "png", "gif"],
686+
},
687+
}
688+
)
689+
return options
690+
691+
611692
def _projected_package_state(state: dict[str, Any]) -> dict[str, Any]:
612693
"""Project a shadow-store package entry onto the wire-shape fields.
613694
@@ -857,42 +938,52 @@ async def get_products(
857938
channel = upstream_row.get("channel", "display")
858939
fallback_id = "display_300x250" if channel == "display" else "video_16x9_30s"
859940
format_ids = [{"agent_url": agent_url, "id": fallback_id}]
860-
products.append(
861-
Product.model_validate(
941+
format_options = _product_format_options(
942+
product_id=upstream_row["product_id"],
943+
name=upstream_row["name"],
944+
format_ids=format_ids,
945+
)
946+
product_payload = {
947+
"product_id": upstream_row["product_id"],
948+
"name": upstream_row["name"],
949+
"description": upstream_row.get("name", ""),
950+
"delivery_type": upstream_row.get("delivery_type", "non_guaranteed"),
951+
"publisher_properties": [
952+
# The reference seller is a single-publisher
953+
# demo; ``selection_type='all'`` matches the
954+
# spec's "all properties from this publisher"
955+
# discriminator. Multi-publisher adopters
956+
# narrow with ``selection_type='by_id'`` /
957+
# ``'by_tag'``.
862958
{
863-
"product_id": upstream_row["product_id"],
864-
"name": upstream_row["name"],
865-
"description": upstream_row.get("name", ""),
866-
"delivery_type": upstream_row.get("delivery_type", "non_guaranteed"),
867-
"publisher_properties": [
868-
# The reference seller is a single-publisher
869-
# demo; ``selection_type='all'`` matches the
870-
# spec's "all properties from this publisher"
871-
# discriminator. Multi-publisher adopters
872-
# narrow with ``selection_type='by_id'`` /
873-
# ``'by_tag'``.
874-
{
875-
"publisher_domain": "reference.adcp.org",
876-
"selection_type": "all",
877-
}
878-
],
879-
"format_ids": format_ids,
880-
"reporting_capabilities": {
881-
"available_reporting_frequencies": ["daily"],
882-
"expected_delay_minutes": 240,
883-
"timezone": "UTC",
884-
"supports_webhooks": False,
885-
"available_metrics": [
886-
"impressions",
887-
"spend",
888-
"clicks",
889-
],
890-
"date_range_support": "date_range",
891-
},
892-
"pricing_options": [pricing_option],
959+
"publisher_domain": "reference.adcp.org",
960+
"selection_type": "all",
893961
}
894-
)
895-
)
962+
],
963+
"format_ids": format_ids,
964+
"format_options": format_options,
965+
"reporting_capabilities": {
966+
"available_reporting_frequencies": ["daily"],
967+
"expected_delay_minutes": 240,
968+
"timezone": "UTC",
969+
"supports_webhooks": False,
970+
"available_metrics": [
971+
"impressions",
972+
"spend",
973+
"clicks",
974+
],
975+
"date_range_support": "date_range",
976+
},
977+
"pricing_options": [pricing_option],
978+
}
979+
product = Product.model_validate(product_payload)
980+
# The generated ProductFormatDeclaration currently omits the
981+
# canonical discriminator fields during validation. Restore
982+
# the already-built wire declarations so 3.1 translators see
983+
# the published closed format set.
984+
product.format_ids = format_ids # type: ignore[assignment]
985+
product.format_options = format_options # type: ignore[assignment]
986+
products.append(product)
896987
return GetProductsResponse(products=products)
897988

898989
# ----- refine_get_products ---------------------------------------------
@@ -1393,13 +1484,15 @@ async def update_media_buy(
13931484
if response_status == "pending_creatives" and any_creatives:
13941485
response_status = "pending_start"
13951486

1396-
return UpdateMediaBuySuccessResponse.model_validate(
1397-
{
1398-
"media_buy_id": media_buy_id,
1399-
"status": response_status,
1400-
"revision": revision,
1401-
"affected_packages": affected_packages or None,
1402-
}
1487+
return cast(
1488+
UpdateMediaBuySuccessResponse,
1489+
cast(Any, UpdateMediaBuySuccessResponse).model_construct(
1490+
media_buy_id=media_buy_id,
1491+
media_buy_status=MediaBuyStatus(response_status),
1492+
status="completed",
1493+
revision=revision,
1494+
affected_packages=affected_packages or None,
1495+
),
14031496
)
14041497

14051498
# ----- sync_creatives --------------------------------------------------
@@ -1696,6 +1789,7 @@ async def get_media_buys(
16961789
"packages": packages,
16971790
"created_at": order.get("created_at"),
16981791
"updated_at": order.get("updated_at"),
1792+
"valid_actions": valid_actions_for_status(wire_status),
16991793
}
17001794
if buy_state.get("context") is not None:
17011795
media_buy["context"] = buy_state["context"]
@@ -1709,7 +1803,12 @@ async def get_media_buys(
17091803
"offset": offset,
17101804
},
17111805
)
1712-
return GetMediaBuysResponse.model_validate({"media_buys": media_buys})
1806+
return GetMediaBuysResponse.model_validate(
1807+
{
1808+
"media_buys": media_buys,
1809+
"sandbox": getattr(ctx.account, "mode", None) in {"mock", "sandbox"},
1810+
}
1811+
)
17131812

17141813
# ----- provide_performance_feedback ------------------------------------
17151814

examples/v3_reference_seller/tests/test_smoke_broadening.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,12 @@ async def test_get_products_translates_upstream_to_adcp(respx_mock: Any) -> None
485485
assert cpm.fixed_price == 35.0
486486
assert cpm.currency == "USD"
487487
assert cpm.min_spend_per_package == 25_000.0
488+
product_payload = p.model_dump(mode="json", exclude_none=True)
489+
assert [fmt["id"] for fmt in product_payload["format_ids"]] == ["video_16x9_30s"]
490+
assert product_payload["format_options"][0]["format_kind"] == "video_hosted"
491+
assert [fmt["id"] for fmt in product_payload["format_options"][0]["v1_format_ref"]] == [
492+
"video_16x9_30s"
493+
]
488494
# The SDK's UpstreamHttpClient carried StaticBearer for auth;
489495
# the upstream helper added the X-Network-Code per-call header.
490496
sent_request = respx_mock.calls.last.request
@@ -998,6 +1004,68 @@ async def test_update_media_buy_unknown_package_id_raises_not_found(
9981004
assert excinfo.value.code == "PACKAGE_NOT_FOUND"
9991005

10001006

1007+
@pytest.mark.asyncio
1008+
@respx.mock(base_url=_RESPX_BASE_URL)
1009+
async def test_update_media_buy_affected_packages_echo_list_agent_urls(
1010+
respx_mock: Any,
1011+
) -> None:
1012+
"""Pydantic AnyUrl normalizes host-only URLs with a trailing slash;
1013+
package echoes keep the buyer's list-agent URL stable."""
1014+
from adcp.types import UpdateMediaBuyRequest, UpdateMediaBuySuccessResponse
1015+
1016+
respx_mock.get("/v1/orders/ord_test").mock(
1017+
return_value=httpx.Response(
1018+
200,
1019+
json={"order_id": "ord_test", "status": "delivering"},
1020+
)
1021+
)
1022+
respx_mock.get("/v1/orders/ord_test/lineitems").mock(
1023+
return_value=httpx.Response(
1024+
200,
1025+
json={"line_items": [{"line_item_id": "li_known"}]},
1026+
)
1027+
)
1028+
1029+
platform = _platform_with_upstream()
1030+
platform._buy_state["ord_test"] = { # noqa: SLF001 - example shadow-store regression test
1031+
"packages": {"li_known": {"canceled": False, "paused": False}},
1032+
"canceled": False,
1033+
"paused": False,
1034+
}
1035+
ctx = _build_ctx()
1036+
patch = UpdateMediaBuyRequest.model_validate(
1037+
{
1038+
"account": {"account_id": "signed-buyer-main"},
1039+
"media_buy_id": "ord_test",
1040+
"idempotency_key": "k_" + "l" * 18,
1041+
"packages": [
1042+
{
1043+
"package_id": "li_known",
1044+
"targeting_overlay": {
1045+
"property_list": {
1046+
"agent_url": "https://governance.pinnacle-agency.example",
1047+
"list_id": "prop_news",
1048+
},
1049+
"collection_list": {
1050+
"agent_url": "https://governance.pinnacle-agency.example",
1051+
"list_id": "coll_news",
1052+
},
1053+
},
1054+
}
1055+
],
1056+
}
1057+
)
1058+
1059+
result = await platform.update_media_buy("ord_test", patch, ctx)
1060+
assert isinstance(result, UpdateMediaBuySuccessResponse)
1061+
payload = result.model_dump(mode="json", exclude_none=True)
1062+
targeting = payload["affected_packages"][0]["targeting_overlay"]
1063+
assert targeting["property_list"]["agent_url"] == ("https://governance.pinnacle-agency.example")
1064+
assert targeting["collection_list"]["agent_url"] == (
1065+
"https://governance.pinnacle-agency.example"
1066+
)
1067+
1068+
10011069
@pytest.mark.asyncio
10021070
async def test_create_media_buy_aggressive_terms_raises_terms_rejected() -> None:
10031071
"""``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:
11401208
assert media_buys[0]["media_buy_id"] == "ord_volta_1"
11411209
# delivering → active per the AdCP MediaBuyStatus mapping.
11421210
assert media_buys[0]["status"] == "active"
1211+
assert "pause" in media_buys[0]["valid_actions"]
1212+
assert payload["sandbox"] is True
11431213

11441214

11451215
@pytest.mark.asyncio

0 commit comments

Comments
 (0)