Skip to content

Commit 4ba19fc

Browse files
bokelleyclaude
andauthored
feat(server): createMediaBuyStore — opt-in targeting_overlay echo for property-lists / collection-lists sellers (#474)
Port of JS @adcp/sdk@6.7's `createMediaBuyStore` (commit dda2a77e). Wraps an adopter-supplied `MediaBuyStore` with specialism-aware gating: when the seller declares `property-lists` or `collection-lists` in `DecisioningCapabilities.specialisms`, every method delegates to the adopter store; otherwise every method is a no-op pass-through and the adopter store is never invoked. Closes the seller spec gap on `schemas/cache/<v>/media-buy/get-media-buys-response.json`'s mandated echo of persisted `property_list` / `collection_list` references for sellers claiming those specialisms. Refs #462, #452. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 385dc80 commit 4ba19fc

3 files changed

Lines changed: 601 additions & 0 deletions

File tree

src/adcp/decisioning/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ def create_media_buy(
8989
ValidationError,
9090
)
9191
from adcp.decisioning.helpers import ref_account_id
92+
from adcp.decisioning.media_buy_store import (
93+
MediaBuyStore,
94+
create_media_buy_store,
95+
)
9296
from adcp.decisioning.mock_ad_server import (
9397
InMemoryMockAdServer,
9498
MockAdServer,
@@ -274,6 +278,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
274278
"MEDIA_BUY_TRANSITIONS",
275279
"MaybeAsync",
276280
"MediaBuyNotFoundError",
281+
"MediaBuyStore",
277282
"MockAdServer",
278283
"NoAuth",
279284
"OAuthCredential",
@@ -317,6 +322,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
317322
"bearer_only_registry",
318323
"compose_method",
319324
"create_adcp_server_from_platform",
325+
"create_media_buy_store",
320326
"create_oauth_passthrough_resolver",
321327
"create_roster_account_store",
322328
"create_tenant_store",
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""``create_media_buy_store`` — opt-in framework wiring that gates
2+
``targeting_overlay`` echo on the seller's declared specialisms.
3+
4+
The seller's spec contract on
5+
``schemas/cache/<v>/media-buy/get-media-buys-response.json`` requires
6+
that sellers claiming the ``property-lists`` or ``collection-lists``
7+
specialism echo persisted ``property_list`` / ``collection_list``
8+
references inside the ``packages[].targeting_overlay`` they return on
9+
``get_media_buys``. This factory lets adopters wire that contract once,
10+
at the framework boundary, instead of every adapter persisting +
11+
echoing by hand.
12+
13+
Mirrors the JS-side ``createMediaBuyStore`` from ``@adcp/sdk@6.7``
14+
(commit ``dda2a77e``) with one shape change: the JS factory builds the
15+
persistence itself on top of an ``AdcpStateStore``. The Python SDK
16+
doesn't ship an ``AdcpStateStore`` Protocol, so adopters supply their
17+
own :class:`MediaBuyStore` implementation; this factory wraps it with
18+
specialism-aware gating. Adopters who want a reference impl can crib
19+
the in-memory pattern from the test suite.
20+
21+
Usage::
22+
23+
from adcp.decisioning import create_media_buy_store
24+
25+
platform.media_buy_store = create_media_buy_store(
26+
adopter_store, capabilities=platform.capabilities,
27+
)
28+
29+
Behavior:
30+
31+
* Seller claims ``property-lists`` or ``collection-lists`` →
32+
every method delegates to the adopter store.
33+
* Seller claims neither → ``persist_from_create`` and
34+
``merge_from_update`` are no-ops; ``backfill`` returns the response
35+
unchanged. The adopter store is never invoked.
36+
37+
The wrapper is always a fresh object so adopters can reason about
38+
identity at the assignment site without aliasing surprises.
39+
"""
40+
41+
from __future__ import annotations
42+
43+
import inspect
44+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
45+
46+
if TYPE_CHECKING:
47+
from adcp.decisioning.platform import DecisioningCapabilities
48+
49+
__all__ = [
50+
"MediaBuyStore",
51+
"create_media_buy_store",
52+
]
53+
54+
#: Specialism slugs that require ``targeting_overlay`` echo on
55+
#: ``get_media_buys``. A seller claiming either MUST echo the persisted
56+
#: ``property_list`` / ``collection_list`` reference per
57+
#: ``schemas/cache/<v>/media-buy/get-media-buys-response.json``.
58+
_OVERLAY_ECHO_SPECIALISMS: frozenset[str] = frozenset({"property-lists", "collection-lists"})
59+
60+
61+
@runtime_checkable
62+
class MediaBuyStore(Protocol):
63+
"""Adopter-supplied persistence + echo for ``targeting_overlay``.
64+
65+
Three methods cover the full lifecycle of a per-package overlay:
66+
67+
* :meth:`persist_from_create` records overlays from a successful
68+
``create_media_buy``, joining the request's per-package overlay
69+
with the response's seller-assigned ``package_id`` (or
70+
``buyer_ref`` when present).
71+
* :meth:`merge_from_update` applies ``update_media_buy`` patches
72+
with deep-merge semantics: keys absent from the patch keep prior
73+
values, keys present with non-null values replace, keys present
74+
and ``None`` clear.
75+
* :meth:`backfill` fills in missing
76+
``packages[].targeting_overlay`` on a ``get_media_buys`` response
77+
from the persisted store. Mutates the response in place; packages
78+
the seller already echoed are left untouched.
79+
80+
Methods may be sync or async — the wrapper awaits at call time.
81+
"""
82+
83+
def persist_from_create(
84+
self,
85+
account_id: str,
86+
request: Any,
87+
result: Any,
88+
) -> Any: ...
89+
90+
def merge_from_update(
91+
self,
92+
account_id: str,
93+
media_buy_id: str,
94+
patch: Any,
95+
) -> Any: ...
96+
97+
def backfill(self, account_id: str, result: Any) -> Any: ...
98+
99+
100+
async def _await_maybe(value: Any) -> Any:
101+
"""Resolve a value that may be a coroutine OR a plain return.
102+
103+
Mirrors the helper in :mod:`adcp.decisioning.tenant_store`; adopter
104+
callbacks are sync OR async and the wrapper keeps its own dispatch
105+
uniform without forcing every adopter into ``async def``.
106+
"""
107+
if inspect.isawaitable(value):
108+
return await value
109+
return value
110+
111+
112+
class _ActiveMediaBuyStore:
113+
"""Wrapper that delegates every method to the adopter store.
114+
115+
Constructed when the seller claims ``property-lists`` or
116+
``collection-lists``. Adds the sync/async normalization layer over
117+
the adopter callbacks so the framework can ``await`` uniformly.
118+
"""
119+
120+
__slots__ = ("_inner",)
121+
122+
def __init__(self, inner: MediaBuyStore) -> None:
123+
self._inner = inner
124+
125+
async def persist_from_create(self, account_id: str, request: Any, result: Any) -> None:
126+
await _await_maybe(self._inner.persist_from_create(account_id, request, result))
127+
128+
async def merge_from_update(self, account_id: str, media_buy_id: str, patch: Any) -> None:
129+
await _await_maybe(self._inner.merge_from_update(account_id, media_buy_id, patch))
130+
131+
async def backfill(self, account_id: str, result: Any) -> Any:
132+
return await _await_maybe(self._inner.backfill(account_id, result))
133+
134+
135+
class _NoopMediaBuyStore:
136+
"""Pass-through wrapper for sellers not claiming the echo specialisms.
137+
138+
Holds a reference to the adopter store for parity with the active
139+
path — adopters can swap their seller's ``capabilities`` between
140+
builds without touching the wiring. The reference is kept private
141+
(``_inner``) to discourage adopter code from reaching past the
142+
wrapper to an unwrapped store.
143+
"""
144+
145+
__slots__ = ("_inner",)
146+
147+
def __init__(self, inner: MediaBuyStore) -> None:
148+
self._inner = inner
149+
150+
async def persist_from_create(self, account_id: str, request: Any, result: Any) -> None:
151+
del account_id, request, result # no-op pass-through
152+
153+
async def merge_from_update(self, account_id: str, media_buy_id: str, patch: Any) -> None:
154+
del account_id, media_buy_id, patch # no-op pass-through
155+
156+
async def backfill(self, account_id: str, result: Any) -> Any:
157+
del account_id
158+
return result
159+
160+
161+
def create_media_buy_store(
162+
adopter_store: MediaBuyStore,
163+
*,
164+
capabilities: DecisioningCapabilities,
165+
) -> MediaBuyStore:
166+
"""Wrap an adopter :class:`MediaBuyStore` with specialism-aware
167+
``targeting_overlay`` echo gating.
168+
169+
:param adopter_store: The persistence + echo implementation. Must
170+
satisfy the :class:`MediaBuyStore` Protocol — three methods
171+
(``persist_from_create``, ``merge_from_update``, ``backfill``)
172+
sync or async.
173+
:param capabilities: The seller's :class:`DecisioningCapabilities`.
174+
Read once at construction to decide whether the wrapper
175+
delegates or no-ops; not re-read per request, so adopters who
176+
mutate ``capabilities.specialisms`` after building the store
177+
won't see the change reflected. Build-time decision matches the
178+
boot-time validation pattern used elsewhere
179+
(``validate_platform``).
180+
181+
:returns: A :class:`MediaBuyStore` wrapper. When ``capabilities.specialisms``
182+
intersects ``{property-lists, collection-lists}``, every method
183+
delegates to ``adopter_store``. Otherwise every method is a
184+
no-op pass-through and the adopter store is never invoked.
185+
186+
The returned object is always distinct from ``adopter_store`` — even
187+
on the no-op path — so adopters can reason about identity at the
188+
assignment site (``platform.media_buy_store = ...``) without
189+
aliasing the underlying persistence layer.
190+
"""
191+
claimed = set(capabilities.specialisms) & _OVERLAY_ECHO_SPECIALISMS
192+
if claimed:
193+
return _ActiveMediaBuyStore(adopter_store)
194+
return _NoopMediaBuyStore(adopter_store)

0 commit comments

Comments
 (0)