|
| 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