Skip to content

Commit 50d130a

Browse files
committed
fix: reference seller 3.1 storyboard compatibility
1 parent c3837af commit 50d130a

22 files changed

Lines changed: 805 additions & 39 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ on:
99
# Pinned @adcp/sdk version. Bump deliberately; cache invalidates when this moves.
1010
# Background: adcontextprotocol/adcp-client-python#779 (Track B), adcontextprotocol/adcp#4907.
1111
env:
12-
ADCP_SDK_VERSION: "7.10.2"
12+
ADCP_SDK_VERSION: "8.1.0-beta.7"
1313

1414
concurrency:
1515
group: ci-${{ github.ref }}

examples/seller_agent.py

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import os
1919
import uuid
20+
from datetime import datetime, timezone
2021
from typing import Any
2122

2223
from adcp.server import (
@@ -79,6 +80,7 @@
7980
accounts: dict[str, dict[str, Any]] = {}
8081
media_buys: dict[str, dict[str, Any]] = {}
8182
creatives: dict[str, dict[str, Any]] = {}
83+
open_impairments: dict[tuple[str, str], dict[str, Any]] = {}
8284
proposals: dict[str, dict[str, Any]] = {}
8385
# Used when no account_id is present; single-tenant demo shortcut.
8486
# Real sellers must scope directives and tasks by account_id.
@@ -89,6 +91,78 @@
8991
# Seeded creative formats keyed by the string format ID the storyboard supplies.
9092
# list_creative_formats merges these in so storyboard references resolve.
9193
seeded_creative_formats: dict[str, dict[str, Any]] = {}
94+
95+
96+
def _now_z() -> str:
97+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
98+
99+
100+
def _package_creative_ids(pkg: dict[str, Any]) -> list[str]:
101+
ids: list[str] = []
102+
for assignment in pkg.get("creative_assignments") or []:
103+
if isinstance(assignment, dict) and assignment.get("creative_id"):
104+
ids.append(str(assignment["creative_id"]))
105+
for creative in pkg.get("creatives") or []:
106+
if isinstance(creative, dict) and creative.get("creative_id"):
107+
ids.append(str(creative["creative_id"]))
108+
return list(dict.fromkeys(ids))
109+
110+
111+
def _health_fields_for_media_buy(media_buy_id: str | None, mb: dict[str, Any]) -> dict[str, Any]:
112+
impaired_packages: dict[str, list[str]] = {}
113+
for pkg in mb.get("packages", []):
114+
package_id = pkg.get("package_id")
115+
if not package_id:
116+
continue
117+
creative_ids = _package_creative_ids(pkg)
118+
if not creative_ids:
119+
continue
120+
if any(
121+
creatives.get(creative_id, {}).get("status") in {"approved", "active"}
122+
for creative_id in creative_ids
123+
):
124+
continue
125+
for creative_id in creative_ids:
126+
creative_status = creatives.get(creative_id, {}).get("status")
127+
if creative_status in {"rejected"}:
128+
package_ids = impaired_packages.setdefault(creative_id, [])
129+
if package_id not in package_ids:
130+
package_ids.append(package_id)
131+
media_buy_key = media_buy_id or "__anonymous__"
132+
active_keys = {(media_buy_key, creative_id) for creative_id in impaired_packages}
133+
for key in [
134+
key for key in open_impairments if key[0] == media_buy_key and key not in active_keys
135+
]:
136+
del open_impairments[key]
137+
138+
impairments: list[dict[str, Any]] = []
139+
for creative_id, package_ids in impaired_packages.items():
140+
creative = creatives.get(creative_id, {})
141+
key = (media_buy_key, creative_id)
142+
if key not in open_impairments:
143+
open_impairments[key] = {
144+
"impairment_id": f"imp-{uuid.uuid4().hex[:8]}",
145+
"observed_at": creative.get("status_changed_at") or _now_z(),
146+
}
147+
impairment = open_impairments[key]
148+
impairments.append(
149+
{
150+
"impairment_id": impairment["impairment_id"],
151+
"resource_type": "creative",
152+
"resource_id": creative_id,
153+
"package_ids": package_ids,
154+
"transition": {"from": "approved", "to": "rejected"},
155+
"reason_code": "content_rejected",
156+
"reason": "Creative is no longer approved for delivery.",
157+
"observed_at": impairment["observed_at"],
158+
"remediation": "Assign an approved replacement creative.",
159+
}
160+
)
161+
if impairments:
162+
return {"health": "impaired", "impairments": impairments}
163+
return {"health": "ok", "impairments": []}
164+
165+
92166
# Single-shot directives registered by force_create_media_buy_arm; keyed by account_id.
93167
pending_directives: dict[str, dict[str, Any]] = {}
94168
# Tasks registered when create_media_buy consumes a 'submitted' directive; keyed by task_id.
@@ -310,9 +384,7 @@ async def sync_governance(self, params: dict[str, Any], context: Any = None) ->
310384
{
311385
"account": acct_ref,
312386
"status": "synced",
313-
"governance_agents": [
314-
{"url": a.get("url"), "categories": a.get("categories", [])} for a in agents
315-
],
387+
"governance_agents": [{"url": a.get("url")} for a in agents],
316388
}
317389
)
318390
return sync_governance_response(results)
@@ -345,7 +417,7 @@ async def get_products(self, params: dict[str, Any], context: Any = None) -> dic
345417
}
346418
]
347419
return {
348-
**products_response(PRODUCTS),
420+
**products_response(PRODUCTS, cache_scope="public"),
349421
"proposals": [
350422
{
351423
"proposal_id": proposal_id,
@@ -355,7 +427,7 @@ async def get_products(self, params: dict[str, Any], context: Any = None) -> dic
355427
}
356428
],
357429
}
358-
return products_response(PRODUCTS)
430+
return products_response(PRODUCTS, cache_scope="public")
359431

360432
async def create_media_buy(self, params: dict[str, Any], context: Any = None) -> dict[str, Any]:
361433
account_id = (params.get("account") or {}).get("account_id") or _DEFAULT_ACCOUNT_ID
@@ -478,6 +550,7 @@ async def get_media_buys(self, params: dict[str, Any], context: Any = None) -> d
478550
"currency": mb.get("currency", "USD"),
479551
"packages": mb.get("packages", []),
480552
"total_budget": total_budget,
553+
**_health_fields_for_media_buy(mb_id, mb),
481554
}
482555
)
483556
return media_buys_response(results)
@@ -605,12 +678,11 @@ async def sync_creatives(self, params: dict[str, Any], context: Any = None) -> d
605678
results = []
606679
for c in params.get("creatives", []):
607680
creative_id = c.get("creative_id") or f"c-{uuid.uuid4().hex[:8]}"
608-
creatives[creative_id] = {**c, "status": "approved"}
681+
creatives[creative_id] = {**c, "status": "approved", "status_changed_at": _now_z()}
609682
results.append(
610683
{
611684
"creative_id": creative_id,
612685
"action": "created",
613-
"status": "approved",
614686
}
615687
)
616688
# Transition any media buys waiting on creatives to pending_start
@@ -696,6 +768,7 @@ async def force_creative_status(
696768
current_state=prev,
697769
)
698770
c["status"] = status
771+
c["status_changed_at"] = _now_z()
699772
return {"previous_state": prev, "current_state": status}
700773

701774
async def simulate_delivery(
@@ -900,6 +973,7 @@ async def seed_creative(
900973
data = dict(fixture or {})
901974
cid = creative_id or data.get("creative_id") or f"c-seeded-{uuid.uuid4().hex[:8]}"
902975
data["creative_id"] = cid
976+
data.setdefault("status_changed_at", _now_z())
903977
creatives[cid] = data
904978
return {"creative_id": cid}
905979

examples/v3_reference_seller/tests/test_smoke_broadening.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -727,7 +727,9 @@ async def test_create_media_buy_echoes_packages_with_seller_minted_ids(
727727
assert pkg.targeting_overlay.collection_list.list_id == "coll_evening_news"
728728
# Buyer supplied a creative_assignment — status reflects upstream-derived
729729
# status ("approved" → "pending_start"), not pending_creatives.
730-
assert result.status.value == "pending_start"
730+
assert result.status == "completed"
731+
assert result.media_buy_status is not None
732+
assert result.media_buy_status.value == "pending_start"
731733

732734

733735
@pytest.mark.asyncio
@@ -786,7 +788,9 @@ async def test_create_media_buy_no_creatives_returns_pending_creatives_status(
786788
)
787789
result = await platform.create_media_buy(req, ctx)
788790
assert isinstance(result, CreateMediaBuySuccessResponse)
789-
assert result.status.value == "pending_creatives"
791+
assert result.status == "completed"
792+
assert result.media_buy_status is not None
793+
assert result.media_buy_status.value == "pending_creatives"
790794
assert result.packages is not None
791795
assert result.packages[0].package_id is not None
792796
assert result.packages[0].package_id.startswith("li_test_")
@@ -824,7 +828,9 @@ async def test_update_media_buy_cancel_marks_local_state(respx_mock: Any) -> Non
824828
)
825829
result = await platform.update_media_buy("ord_test", patch, ctx)
826830
assert isinstance(result, UpdateMediaBuySuccessResponse)
827-
assert result.status.value == "canceled"
831+
assert result.status == "completed"
832+
assert result.media_buy_status is not None
833+
assert result.media_buy_status.value == "canceled"
828834
assert result.revision == 1
829835

830836
# Re-cancel — irreversible.

scripts/consolidate_exports.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ def _stem_matches_export(module_stem: str, export_name: str) -> bool:
383383
"UpdateContentStandardsResponse2": "UpdateContentStandardsResponse",
384384
"UpdateMediaBuyResponse1": "UpdateMediaBuyResponse",
385385
"UpdateMediaBuyResponse2": "UpdateMediaBuyResponse",
386+
"UpdateMediaBuyResponse3": "UpdateMediaBuyResponse",
386387
"ValidateContentDeliveryResponse1": "ValidateContentDeliveryResponse",
387388
"ValidateContentDeliveryResponse2": "ValidateContentDeliveryResponse",
388389
}

scripts/post_generate_fixes.py

Lines changed: 110 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,12 @@ def inject_literal_discriminator_defaults() -> None:
790790
continue
791791
if stmt.value is not None:
792792
continue # already has a default
793+
if (
794+
node.name in {"CreateMediaBuyResponse1", "UpdateMediaBuyResponse1"}
795+
and isinstance(stmt.target, ast.Name)
796+
and stmt.target.id == "status"
797+
):
798+
continue
793799
literal_value = _extract_single_literal_value(stmt.annotation)
794800
if literal_value is None:
795801
continue
@@ -916,10 +922,8 @@ def _first_subscript_arg(node: ast.Subscript) -> ast.AST | None:
916922
# Response payloads adopters subclass to add internal-only fields.
917923
# `UpdateMediaBuySuccessResponse` is the success variant of the
918924
# `UpdateMediaBuyResponse` discriminated union — emitted as
919-
# `UpdateMediaBuyResponse1` (v3.0) and `UpdateMediaBuyResponse3`
920-
# (v3.0.6 bundled).
925+
# `UpdateMediaBuyResponse1`.
921926
("UpdateMediaBuyResponse1", "affected_packages"),
922-
("UpdateMediaBuyResponse3", "affected_packages"),
923927
("GetMediaBuyDeliveryResponse", "media_buy_deliveries"),
924928
("GetCreativeDeliveryResponse", "creatives"),
925929
("Signal", "deployments"),
@@ -1516,11 +1520,28 @@ def restore_response_variant_aliases() -> None:
15161520
from collections.abc import Sequence
15171521
from typing import Any, Literal, TypeAlias
15181522
1519-
from pydantic import ConfigDict
1523+
from pydantic import ConfigDict, model_validator
15201524
15211525
from ..core import error as error_1
1526+
from ..core import ext as ext_1
15221527
from ..core import package as package_1
1528+
from ..core.protocol_envelope import ProtocolEnvelope
15231529
from ..enums import media_buy_status as media_buy_status_1
1530+
from ..enums import task_status as task_status_1
1531+
1532+
1533+
_MEDIA_BUY_STATUS_VALUES = {
1534+
"pending_creatives",
1535+
"pending_start",
1536+
"active",
1537+
"paused",
1538+
"rejected",
1539+
"canceled",
1540+
}
1541+
1542+
1543+
def _value(value: Any) -> Any:
1544+
return getattr(value, "value", value)
15241545
"""
15251546

15261547
simple_error_arms: dict[str, tuple[str, str, str]] = {
@@ -1907,7 +1928,26 @@ class CreateMediaBuyResponse1(AdcpVersionEnvelope):
19071928
packages: list[package_1.Package]
19081929
buyer_ref: str | None = None
19091930
media_buy_status: media_buy_status_1.MediaBuyStatus | None = None
1910-
status: media_buy_status_1.MediaBuyStatus | None = None
1931+
status: Literal["completed"]
1932+
1933+
@model_validator(mode='before')
1934+
@classmethod
1935+
def _normalize_legacy_status(cls, data: Any) -> Any:
1936+
if not isinstance(data, dict):
1937+
return data
1938+
raw_status = _value(data.get("status"))
1939+
media_buy_status = _value(data.get("media_buy_status"))
1940+
if raw_status is None:
1941+
data = dict(data)
1942+
data["status"] = "completed"
1943+
elif media_buy_status is None and raw_status in _MEDIA_BUY_STATUS_VALUES:
1944+
data = dict(data)
1945+
data["media_buy_status"] = raw_status
1946+
data["status"] = "completed"
1947+
elif media_buy_status is not None and raw_status == media_buy_status:
1948+
data = dict(data)
1949+
data["status"] = "completed"
1950+
return data
19111951
19121952
19131953
class CreateMediaBuyResponse2(AdcpVersionEnvelope):
@@ -1916,14 +1956,31 @@ class CreateMediaBuyResponse2(AdcpVersionEnvelope):
19161956
19171957
19181958
class CreateMediaBuyResponse3(AdcpVersionEnvelope, ProtocolEnvelope):
1919-
model_config = ConfigDict(extra='allow')
1920-
status: Any = 'submitted'
1959+
model_config = ConfigDict(extra='allow', use_enum_values=True, validate_default=True)
1960+
status: Literal[task_status_1.TaskStatus.submitted] = task_status_1.TaskStatus.submitted
19211961
task_id: str
1962+
errors: list[error_1.Error] | None = None
1963+
ext: ext_1.ExtensionObject | None = None
1964+
1965+
@model_validator(mode='before')
1966+
@classmethod
1967+
def _normalize_submitted_status(cls, data: Any) -> Any:
1968+
if isinstance(data, dict) and data.get("status") == "submitted":
1969+
data = dict(data)
1970+
data["status"] = task_status_1.TaskStatus.submitted
1971+
return data
19221972
19231973
19241974
CreateMediaBuyResponse: TypeAlias = (
19251975
CreateMediaBuyResponse1 | CreateMediaBuyResponse2 | CreateMediaBuyResponse3
19261976
)
1977+
1978+
__all__ = [
1979+
"CreateMediaBuyResponse",
1980+
"CreateMediaBuyResponse1",
1981+
"CreateMediaBuyResponse2",
1982+
"CreateMediaBuyResponse3",
1983+
]
19271984
""",
19281985
)
19291986

@@ -1941,15 +1998,59 @@ class UpdateMediaBuyResponse1(AdcpVersionEnvelope):
19411998
packages: list[package_1.Package] | None = None
19421999
buyer_ref: str | None = None
19432000
media_buy_status: media_buy_status_1.MediaBuyStatus | None = None
1944-
status: media_buy_status_1.MediaBuyStatus | None = None
2001+
status: Literal["completed"]
2002+
2003+
@model_validator(mode='before')
2004+
@classmethod
2005+
def _normalize_legacy_status(cls, data: Any) -> Any:
2006+
if not isinstance(data, dict):
2007+
return data
2008+
raw_status = _value(data.get("status"))
2009+
media_buy_status = _value(data.get("media_buy_status"))
2010+
if raw_status is None:
2011+
data = dict(data)
2012+
data["status"] = "completed"
2013+
elif media_buy_status is None and raw_status in _MEDIA_BUY_STATUS_VALUES:
2014+
data = dict(data)
2015+
data["media_buy_status"] = raw_status
2016+
data["status"] = "completed"
2017+
elif media_buy_status is not None and raw_status == media_buy_status:
2018+
data = dict(data)
2019+
data["status"] = "completed"
2020+
return data
19452021
19462022
19472023
class UpdateMediaBuyResponse2(AdcpVersionEnvelope):
19482024
model_config = ConfigDict(extra='allow')
19492025
errors: list[error_1.Error]
19502026
19512027
1952-
UpdateMediaBuyResponse: TypeAlias = UpdateMediaBuyResponse1 | UpdateMediaBuyResponse2
2028+
class UpdateMediaBuyResponse3(AdcpVersionEnvelope, ProtocolEnvelope):
2029+
model_config = ConfigDict(extra='allow', use_enum_values=True, validate_default=True)
2030+
status: Literal[task_status_1.TaskStatus.submitted] = task_status_1.TaskStatus.submitted
2031+
task_id: str
2032+
errors: list[error_1.Error] | None = None
2033+
ext: ext_1.ExtensionObject | None = None
2034+
2035+
@model_validator(mode='before')
2036+
@classmethod
2037+
def _normalize_submitted_status(cls, data: Any) -> Any:
2038+
if isinstance(data, dict) and data.get("status") == "submitted":
2039+
data = dict(data)
2040+
data["status"] = task_status_1.TaskStatus.submitted
2041+
return data
2042+
2043+
2044+
UpdateMediaBuyResponse: TypeAlias = (
2045+
UpdateMediaBuyResponse1 | UpdateMediaBuyResponse2 | UpdateMediaBuyResponse3
2046+
)
2047+
2048+
__all__ = [
2049+
"UpdateMediaBuyResponse",
2050+
"UpdateMediaBuyResponse1",
2051+
"UpdateMediaBuyResponse2",
2052+
"UpdateMediaBuyResponse3",
2053+
]
19532054
""",
19542055
)
19552056

0 commit comments

Comments
 (0)