diff --git a/MIGRATION_v5_to_v6.md b/MIGRATION_v5_to_v6.md index f7f00c290..49e3bf389 100644 --- a/MIGRATION_v5_to_v6.md +++ b/MIGRATION_v5_to_v6.md @@ -302,3 +302,24 @@ Round-trip tests in `tests/test_canonical_formats_roundtrip.py` pin the projection layer against these fixtures so an upstream-contract drift (e.g., a dropped `canonical:` annotation, a renamed slot) surfaces immediately in CI. + +### 6.1.0-beta.1 + +- **`SalesPlatform` Protocol gains `sync_catalogs`.** The method is + required at boot only when claiming `sales-catalog-driven` + (`validate_platform` enforces this). However, because `SalesPlatform` + is `@runtime_checkable`, any code doing + `isinstance(my_platform, SalesPlatform)` will now return `False` on + platforms that don't implement `sync_catalogs` — even those claiming + `sales-non-guaranteed` or other sales-* specialisms. + + **Migration:** Add a stub `sync_catalogs` to any platform that uses + `SalesPlatform` for a structural `isinstance` check: + + ```python + def sync_catalogs(self, req, ctx): + return {"catalogs": []} + ``` + + Platforms actually claiming `sales-catalog-driven` must implement the + full contract (see `examples/hello_seller_catalog.py`). diff --git a/examples/hello_seller_catalog.py b/examples/hello_seller_catalog.py new file mode 100644 index 000000000..16e472fac --- /dev/null +++ b/examples/hello_seller_catalog.py @@ -0,0 +1,104 @@ +"""Hello-seller-catalog — minimal sales-catalog-driven adopter. + +The smallest possible ``sales-catalog-driven`` seller. The specialism +adds ``sync_catalogs`` on top of the standard sales surface, which lets +buyers discover existing catalog state and push catalog updates before +``sync_creatives`` / catalog-referenced media buys. + +Run:: + + uv run python examples/hello_seller_catalog.py +""" + +from __future__ import annotations + +from typing import Any + +from adcp import AdcpError +from adcp.decisioning import ( + DecisioningCapabilities, + DecisioningPlatform, + RequestContext, + SingletonAccounts, + serve, +) + + +class HelloCatalogSeller(DecisioningPlatform): + """Minimal ``sales-catalog-driven`` adopter. + + ``sync_catalogs`` is required when claiming this specialism — + ``validate_platform`` hard-fails at boot if the method is absent. + + Discovery mode: ``req.catalogs is None`` means the buyer wants + existing catalog state without modification. Always check before + applying mutations. + + Return a list of catalog-result rows (ergonomic form) or a fully-shaped + ``SyncCatalogsSuccessResponse``. The framework wraps the list form to + ``{catalogs: [...]}`` on the wire. + """ + + capabilities = DecisioningCapabilities( + specialisms=["sales-catalog-driven"], + supported_billing=("agent",), + ) + accounts = SingletonAccounts(account_id="hello-catalog") + + def get_products(self, req: Any, ctx: RequestContext[Any]) -> dict[str, Any]: + return {"products": []} + + def create_media_buy(self, req: Any, ctx: RequestContext[Any]) -> dict[str, Any]: + return {"media_buy_id": "mb_1", "status": "active"} + + def update_media_buy( + self, media_buy_id: str, patch: Any, ctx: RequestContext[Any] + ) -> dict[str, Any]: + return {"media_buy_id": media_buy_id, "status": "active"} + + def sync_creatives(self, req: Any, ctx: RequestContext[Any]) -> dict[str, Any]: + return {"creatives": []} + + def get_media_buy_delivery(self, req: Any, ctx: RequestContext[Any]) -> dict[str, Any]: + return {"media_buy_deliveries": []} + + def sync_catalogs(self, req: Any, ctx: RequestContext[Any]) -> list[dict[str, Any]]: + """Sync product catalogs with the platform. + + Spec-required guard: ``delete_missing=True`` with ``catalogs=None`` + is undefined — reject it rather than silently deleting buyer-managed + catalogs. + + Discovery mode (``req.catalogs is None``): return existing catalogs + without any mutation. + """ + if getattr(req, "delete_missing", False) and getattr(req, "catalogs", None) is None: + raise AdcpError("INVALID_REQUEST", field="catalogs") + + if getattr(req, "catalogs", None) is None: + # Discovery mode — return existing catalog state, no mutations. + return [] + + # Push mode — upsert the supplied catalogs. + return [ + { + "catalog_id": getattr(c, "catalog_id", str(i)), + "action": "created", + "item_count": 0, + } + for i, c in enumerate(req.catalogs or []) + ] + + +def main() -> None: + """Boot the seller on http://localhost:3001/mcp. + + ``auto_emit_completion_webhooks=False`` opts out so this example + boots without a ``webhook_sender``. In production, wire + ``webhook_sender=`` for buyer notification. + """ + serve(HelloCatalogSeller(), auto_emit_completion_webhooks=False) + + +if __name__ == "__main__": + main() diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index a46f83f8a..384db3414 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -165,6 +165,8 @@ SyncAccountsResponse, SyncAudiencesRequest, SyncAudiencesSuccessResponse, + SyncCatalogsRequest, + SyncCatalogsSuccessResponse, SyncCreativesRequest, SyncCreativesSuccessResponse, SyncPlansRequest, @@ -284,6 +286,11 @@ "sync_audiences", } ) +_CATALOG_ADVERTISED_TOOLS: frozenset[str] = frozenset( + { + "sync_catalogs", + } +) _SPONSORED_INTELLIGENCE_ADVERTISED_TOOLS: frozenset[str] = frozenset( { "si_get_offering", @@ -369,6 +376,8 @@ # AudiencePlatform adopter-internal helper (not wire-served, but # listed here for symmetry should a future shim wire it) "poll_audience_statuses", + # Required for sales-catalog-driven; absent on all other sales-* platforms + "sync_catalogs", } ) @@ -394,7 +403,9 @@ "sales-guaranteed": _SALES_ADVERTISED_TOOLS | _ACCOUNT_ADVERTISED_TOOLS, "sales-broadcast-tv": _SALES_ADVERTISED_TOOLS | _ACCOUNT_ADVERTISED_TOOLS, "sales-social": _SALES_ADVERTISED_TOOLS | _ACCOUNT_ADVERTISED_TOOLS, - "sales-catalog-driven": _SALES_ADVERTISED_TOOLS | _ACCOUNT_ADVERTISED_TOOLS, + "sales-catalog-driven": ( + _SALES_ADVERTISED_TOOLS | _ACCOUNT_ADVERTISED_TOOLS | _CATALOG_ADVERTISED_TOOLS + ), "sales-proposal-mode": _SALES_ADVERTISED_TOOLS | _ACCOUNT_ADVERTISED_TOOLS, # Creative — Builder + AdServer. Builder claims expose # build_creative + optional preview_creative; AdServer adds @@ -794,6 +805,23 @@ def _project_sync_audiences(result: Any) -> Any: return result +def _project_sync_catalogs(result: Any) -> Any: + """Project the adopter's ``sync_catalogs`` return into the wire envelope shape. + + Adopters may return a list of catalog-result rows (ergonomic form) or a + fully-shaped :class:`SyncCatalogsSuccessResponse`. The wire envelope per + ``schemas/cache/media-buy/sync-catalogs-response.json`` is + ``{catalogs: [rows]}``. This helper wraps the list case. + """ + if isinstance(result, list): + return { + "catalogs": [ + r.model_dump(mode="json") if hasattr(r, "model_dump") else r for r in result + ] + } + return result + + def _project_sync_accounts(result: Any) -> Any: """Project the adopter's ``upsert`` return into the ``sync_accounts`` wire envelope. @@ -1014,6 +1042,7 @@ class PlatformHandler(ADCPHandler[ToolContext]): | set(_CREATIVE_ADVERTISED_TOOLS) | set(_SIGNALS_ADVERTISED_TOOLS) | set(_AUDIENCE_ADVERTISED_TOOLS) + | set(_CATALOG_ADVERTISED_TOOLS) | set(_GOVERNANCE_ADVERTISED_TOOLS) | set(_BRAND_RIGHTS_ADVERTISED_TOOLS) | set(_CONTENT_STANDARDS_ADVERTISED_TOOLS) @@ -2376,6 +2405,42 @@ async def sync_audiences( # type: ignore[override] self._maybe_auto_emit_sync_completion("sync_audiences", params, projected) return cast("SyncAudiencesSuccessResponse", projected) + async def sync_catalogs( # type: ignore[override] + self, + params: SyncCatalogsRequest, + context: ToolContext | None = None, + ) -> SyncCatalogsSuccessResponse: + """Sync product catalogs with the platform. + + The platform method receives the full :class:`SyncCatalogsRequest` + so adopters can inspect ``req.catalogs``, ``req.delete_missing``, + ``req.dry_run``, and ``req.validation_mode``. Discovery mode + (``req.catalogs is None``) returns existing synced catalogs without + modification — the platform method must handle ``req.catalogs is None`` + as a read-only path. + + Two return arms per the per-specialism Protocol: a list of + :class:`SyncCatalogResult` rows (ergonomic form) or a fully-shaped + :class:`SyncCatalogsSuccessResponse`. The shim projects the list arm + to the wire envelope ``{catalogs: [...]}`` so adopters can return + the ergonomic form. + """ + self._require_platform_method("sync_catalogs") + tool_ctx = context or ToolContext() + account = await self._resolve_account(getattr(params, "account", None), tool_ctx) + ctx = self._build_ctx(tool_ctx, account) + result = await _invoke_platform_method( + self._platform, + "sync_catalogs", + params, + ctx, + executor=self._executor, + registry=self._registry, + ) + projected = _project_sync_catalogs(result) + self._maybe_auto_emit_sync_completion("sync_catalogs", params, projected) + return cast("SyncCatalogsSuccessResponse", projected) + # ----- CampaignGovernancePlatform ----- async def check_governance( # type: ignore[override] diff --git a/src/adcp/decisioning/specialisms/sales.py b/src/adcp/decisioning/specialisms/sales.py index 24a1a0395..65ee85ed9 100644 --- a/src/adcp/decisioning/specialisms/sales.py +++ b/src/adcp/decisioning/specialisms/sales.py @@ -25,8 +25,12 @@ * :meth:`provide_performance_feedback` * :meth:`list_creative_formats` * :meth:`list_creatives` -* :meth:`sync_catalogs` — required when claiming - ``sales-catalog-driven`` + +Required only when claiming ``sales-catalog-driven``: + +* :meth:`sync_catalogs` — catalog sync and discovery. ``validate_platform`` + hard-fails at server boot if a ``sales-catalog-driven`` platform doesn't + implement this. The framework's :func:`validate_platform` walks ``capabilities.specialisms`` and confirms each specialism's required methods exist on the platform @@ -64,6 +68,8 @@ ListCreativesResponse, ProvidePerformanceFeedbackRequest, ProvidePerformanceFeedbackResponse, + SyncCatalogsRequest, + SyncCatalogsSuccessResponse, SyncCreativesRequest, SyncCreativesSuccessResponse, UpdateMediaBuyRequest, @@ -269,3 +275,36 @@ def list_creatives( Required when claiming any ``sales-*`` specialism in v6.0 rc.1+. """ ... + + # ---- Required when claiming ``sales-catalog-driven`` ---- + + def sync_catalogs( + self, + req: SyncCatalogsRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[SyncCatalogsSuccessResponse]: + """Sync product catalogs with the platform. + + **Required** when claiming ``sales-catalog-driven``. + ``validate_platform`` hard-fails at server boot when this method is + absent on a ``sales-catalog-driven`` platform. + + Discovery mode: when ``req.catalogs is None``, return the account's + existing synced catalogs without modification (read-only path per + AdCP spec). Check ``req.catalogs`` before applying any mutations:: + + def sync_catalogs(self, req, ctx): + if req.catalogs is None: + return self._get_existing_catalogs(ctx.account_id) + return self._upsert_catalogs(req.catalogs, ctx) + + **Important:** ``req.delete_missing=True`` with ``req.catalogs=None`` + is spec-undefined — reject it with + ``AdcpError("INVALID_REQUEST", field="catalogs")`` rather than + silently deleting buyer-managed catalogs. + + Return a list of :class:`~adcp.types.SyncCatalogResult` rows + (ergonomic form) or a fully-shaped + :class:`~adcp.types.SyncCatalogsSuccessResponse`. + """ + ... diff --git a/tests/test_decisioning_advertised_per_specialism.py b/tests/test_decisioning_advertised_per_specialism.py index 02e21ea7c..6ffcd5cd3 100644 --- a/tests/test_decisioning_advertised_per_specialism.py +++ b/tests/test_decisioning_advertised_per_specialism.py @@ -117,6 +117,29 @@ def build_creative(self, req, ctx): return {"creative_manifest": {"creative_id": "cr_1"}} +class _CatalogDrivenPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["sales-catalog-driven"]) + accounts = SingletonAccounts(account_id="catalog-driven") + + def get_products(self, req, ctx): + return {"products": []} + + def create_media_buy(self, req, ctx): + return {"media_buy_id": "x", "status": "active"} + + def update_media_buy(self, media_buy_id, patch, ctx): + return {"media_buy_id": media_buy_id, "status": "active"} + + def sync_creatives(self, req, ctx): + return {"creatives": []} + + def get_media_buy_delivery(self, req, ctx): + return {"media_buy_deliveries": []} + + def sync_catalogs(self, req, ctx): + return {"catalogs": []} + + def test_sales_only_does_not_advertise_creative_or_signals_tools(executor) -> None: """Regression: sales-only adopter saw acquire_rights, build_creative, check_governance, etc. in tools/list. After the per-specialism @@ -333,3 +356,40 @@ def test_class_level_inspection_preserves_full_universe() -> None: assert "get_products" in tools assert "build_creative" in tools assert "acquire_rights" in tools + + +def test_catalog_driven_advertises_sync_catalogs(executor) -> None: + """``sales-catalog-driven`` must expose ``sync_catalogs`` via + ``tools/list``. This was the missing wire-up reported in issue #786: + the tool existed and types were defined, but no specialism mapping + surfaced it to buyers.""" + handler = PlatformHandler( + _CatalogDrivenPlatform(), + executor=executor, + registry=InMemoryTaskRegistry(), + ) + tools = {tool["name"] for tool in get_tools_for_handler(handler)} + + assert "sync_catalogs" in tools, ( + "sales-catalog-driven platform must advertise sync_catalogs; " + "check SPECIALISM_TO_ADVERTISED_TOOLS['sales-catalog-driven']" + ) + # Sales tools also present. + assert "get_products" in tools + assert "create_media_buy" in tools + + +def test_non_catalog_sales_does_not_advertise_sync_catalogs(executor) -> None: + """``sync_catalogs`` is specific to ``sales-catalog-driven`` — no + other sales-* specialism should surface it.""" + handler = PlatformHandler( + _SalesOnlyPlatform(), + executor=executor, + registry=InMemoryTaskRegistry(), + ) + tools = {tool["name"] for tool in get_tools_for_handler(handler)} + + assert "sync_catalogs" not in tools, ( + "sales-non-guaranteed must not advertise sync_catalogs; " + "sync_catalogs is only for sales-catalog-driven" + ) diff --git a/tests/test_decisioning_dispatch.py b/tests/test_decisioning_dispatch.py index 24023701c..558419877 100644 --- a/tests/test_decisioning_dispatch.py +++ b/tests/test_decisioning_dispatch.py @@ -127,6 +127,40 @@ def update_media_buy(self, media_buy_id, patch, ctx): assert "get_media_buy_delivery" in missing_methods +def test_validate_platform_raises_on_catalog_driven_without_sync_catalogs() -> None: + """Platform claims sales-catalog-driven but doesn't implement + ``sync_catalogs`` — hard-fails at server boot (D12 boot-fail rule). + Docstring on SalesPlatform.sync_catalogs promises this; this test + pins the behavior so a future refactor can't silently break it.""" + + class _CatalogDrivenNoMethod(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["sales-catalog-driven"]) + accounts = SingletonAccounts(account_id="hello") + + def get_products(self, req, ctx): + return {"products": []} + + def create_media_buy(self, req, ctx): + return {"media_buy_id": "mb_1"} + + def update_media_buy(self, media_buy_id, patch, ctx): + return {"media_buy_id": media_buy_id, "status": "active"} + + def sync_creatives(self, req, ctx): + return {"creatives": []} + + def get_media_buy_delivery(self, req, ctx): + return {"media_buy_deliveries": []} + + # Deliberately no sync_catalogs. + + with pytest.raises(AdcpError) as exc_info: + validate_platform(_CatalogDrivenNoMethod()) + assert exc_info.value.code == "INVALID_REQUEST" + missing_methods = {m["method"] for m in exc_info.value.details["missing"]} + assert "sync_catalogs" in missing_methods + + def test_validate_platform_warns_on_novel_specialism() -> None: """Truly novel specialism (no close spelling match to any known slug) emits UserWarning, NOT a raise. Forward-compat with v6.x+ diff --git a/tests/test_decisioning_handler_shims.py b/tests/test_decisioning_handler_shims.py index a264e0aeb..9f8b14b3e 100644 --- a/tests/test_decisioning_handler_shims.py +++ b/tests/test_decisioning_handler_shims.py @@ -40,6 +40,7 @@ PlatformHandler, _project_build_creative, _project_sync_audiences, + _project_sync_catalogs, ) from adcp.decisioning.webhook_emit import _BACKGROUND_WEBHOOK_TASKS from adcp.server.base import ToolContext @@ -84,6 +85,8 @@ def test_advertised_tools_covers_every_specialism_wire_tool() -> None: "activate_signal", # Audience "sync_audiences", + # Catalog (sales-catalog-driven only) + "sync_catalogs", # Governance "check_governance", "sync_plans", @@ -131,6 +134,7 @@ def test_advertised_tools_covers_every_specialism_wire_tool() -> None: "get_signals", "activate_signal", "sync_audiences", + "sync_catalogs", "check_governance", "sync_plans", "report_plan_outcome", @@ -256,6 +260,222 @@ def sync_audiences(self, audiences, ctx): assert received_audiences == fake_audiences +@pytest.mark.asyncio +async def test_sync_catalogs_shim_passes_full_request_to_platform(executor) -> None: + """The ``sync_catalogs`` shim passes the full ``SyncCatalogsRequest`` + to the platform method (no arg projection). Adopters receive the full + request so they can inspect ``req.catalogs``, ``req.delete_missing``, + ``req.dry_run``, and ``req.validation_mode``.""" + received_req = [] + + class _CatalogAgent(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["sales-catalog-driven"]) + accounts = SingletonAccounts(account_id="hello") + + def get_products(self, req, ctx): + return {"products": []} + + def create_media_buy(self, req, ctx): + return {"media_buy_id": "x", "status": "active"} + + def update_media_buy(self, media_buy_id, patch, ctx): + return {"media_buy_id": media_buy_id, "status": "active"} + + def sync_creatives(self, req, ctx): + return {"creatives": []} + + def get_media_buy_delivery(self, req, ctx): + return {"media_buy_deliveries": []} + + def sync_catalogs(self, req, ctx): + received_req.append(req) + return {"catalogs": []} + + handler = PlatformHandler( + _CatalogAgent(), + executor=executor, + registry=InMemoryTaskRegistry(), + ) + from adcp.types import SyncCatalogsRequest + + fake_catalogs = [{"type": "product", "catalog_id": "feed-1"}] + req = SyncCatalogsRequest.model_construct(catalogs=fake_catalogs) + await handler.sync_catalogs(req, ToolContext()) + assert len(received_req) == 1 + assert received_req[0] is req + + +@pytest.mark.asyncio +async def test_sync_catalogs_discovery_mode_passes_none_catalogs(executor) -> None: + """Discovery mode (``req.catalogs is None``) passes the request + through intact — the platform receives ``req`` with ``catalogs=None`` + and can distinguish discovery from an explicit empty push.""" + received_catalogs_value = [] + + class _CatalogAgent(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["sales-catalog-driven"]) + accounts = SingletonAccounts(account_id="hello") + + def get_products(self, req, ctx): + return {"products": []} + + def create_media_buy(self, req, ctx): + return {"media_buy_id": "x", "status": "active"} + + def update_media_buy(self, media_buy_id, patch, ctx): + return {"media_buy_id": media_buy_id, "status": "active"} + + def sync_creatives(self, req, ctx): + return {"creatives": []} + + def get_media_buy_delivery(self, req, ctx): + return {"media_buy_deliveries": []} + + def sync_catalogs(self, req, ctx): + received_catalogs_value.append(req.catalogs) + return {"catalogs": []} + + handler = PlatformHandler( + _CatalogAgent(), + executor=executor, + registry=InMemoryTaskRegistry(), + ) + from adcp.types import SyncCatalogsRequest + + req = SyncCatalogsRequest.model_construct(catalogs=None) + await handler.sync_catalogs(req, ToolContext()) + assert received_catalogs_value == [None] + + +@pytest.mark.asyncio +async def test_sync_catalogs_list_return_projected_to_envelope(executor) -> None: + """When the platform returns a list of catalog results (ergonomic arm), + the shim wraps it in ``{catalogs: [...]}``.""" + + class _CatalogAgent(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["sales-catalog-driven"]) + accounts = SingletonAccounts(account_id="hello") + + def get_products(self, req, ctx): + return {"products": []} + + def create_media_buy(self, req, ctx): + return {"media_buy_id": "x", "status": "active"} + + def update_media_buy(self, media_buy_id, patch, ctx): + return {"media_buy_id": media_buy_id, "status": "active"} + + def sync_creatives(self, req, ctx): + return {"creatives": []} + + def get_media_buy_delivery(self, req, ctx): + return {"media_buy_deliveries": []} + + def sync_catalogs(self, req, ctx): + return [{"catalog_id": "feed-1", "action": "created", "item_count": 100}] + + handler = PlatformHandler( + _CatalogAgent(), + executor=executor, + registry=InMemoryTaskRegistry(), + ) + from adcp.types import SyncCatalogsRequest + + req = SyncCatalogsRequest.model_construct( + catalogs=[{"type": "product", "catalog_id": "feed-1"}] + ) + result = await handler.sync_catalogs(req, ToolContext()) + assert result == { + "catalogs": [{"catalog_id": "feed-1", "action": "created", "item_count": 100}] + } + + +# ---- _project_sync_catalogs arms ---- + + +def test_project_sync_catalogs_wraps_pydantic_row_list() -> None: + """A list of Pydantic-like catalog-result rows wraps into ``{catalogs: [...]}``.""" + + class _Row: + def __init__(self, cid: str) -> None: + self.cid = cid + + def model_dump(self, mode: str = "json") -> dict: + return {"catalog_id": self.cid, "action": "created"} + + projected = _project_sync_catalogs([_Row("c1"), _Row("c2")]) + assert projected == { + "catalogs": [ + {"catalog_id": "c1", "action": "created"}, + {"catalog_id": "c2", "action": "created"}, + ] + } + + +def test_project_sync_catalogs_wraps_plain_dict_row_list() -> None: + """List of plain dicts (no model_dump) — the row passthrough + inside the comprehension is exercised.""" + projected = _project_sync_catalogs([{"catalog_id": "c1", "action": "updated"}]) + assert projected == {"catalogs": [{"catalog_id": "c1", "action": "updated"}]} + + +def test_project_sync_catalogs_passthrough_envelope_dict() -> None: + """Already-shaped envelope is unchanged.""" + envelope = {"catalogs": [{"catalog_id": "c1"}]} + assert _project_sync_catalogs(envelope) is envelope + + +def test_project_sync_catalogs_passthrough_non_list() -> None: + """Non-list, non-Pydantic shape (e.g. SyncCatalogsSuccessResponse instance + or unexpected sentinel) is returned unchanged so the wire validator + surfaces a precise mis-shape error.""" + sentinel = "unexpected_string" + assert _project_sync_catalogs(sentinel) == sentinel + + +# ---- sync_catalogs UNSUPPORTED_FEATURE gate ---- + + +@pytest.mark.asyncio +async def test_sync_catalogs_unsupported_when_platform_lacks_method(executor) -> None: + """A sales-non-guaranteed platform that doesn't implement ``sync_catalogs`` + surfaces ``UNSUPPORTED_FEATURE`` rather than ``INTERNAL_ERROR`` from the + AttributeError wrapper in ``_invoke_platform_method``.""" + + class _NoSyncCatalogs(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["sales-non-guaranteed"]) + accounts = SingletonAccounts(account_id="hello") + + def get_products(self, req, ctx): + return {"products": []} + + def create_media_buy(self, req, ctx): + return {"media_buy_id": "mb_1"} + + def update_media_buy(self, media_buy_id, patch, ctx): + return {"media_buy_id": media_buy_id, "status": "active"} + + def sync_creatives(self, req, ctx): + return {"creatives": []} + + def get_media_buy_delivery(self, req, ctx): + return {"media_buy_deliveries": []} + + # Deliberately no sync_catalogs. + + handler = PlatformHandler( + _NoSyncCatalogs(), + executor=executor, + registry=InMemoryTaskRegistry(), + ) + from adcp.types import SyncCatalogsRequest + + with pytest.raises(AdcpError) as exc_info: + await handler.sync_catalogs(SyncCatalogsRequest.model_construct(), ToolContext()) + assert exc_info.value.code == "UNSUPPORTED_FEATURE" + assert "sync_catalogs" in str(exc_info.value) + + @pytest.mark.asyncio async def test_check_governance_shim_routes_to_platform(executor) -> None: class _GovernanceAgent(DecisioningPlatform): diff --git a/tests/test_decisioning_specialisms.py b/tests/test_decisioning_specialisms.py index 0356cd6ea..ae070a6bc 100644 --- a/tests/test_decisioning_specialisms.py +++ b/tests/test_decisioning_specialisms.py @@ -365,6 +365,11 @@ def get_media_buy_delivery(self, req, ctx): # against a minimal-but-complete impl rather than checking # ``_is_protocol`` (a private CPython typing internal — brittle # against typing-module changes). + # ``sync_catalogs`` is required on the Protocol body (gated to + # ``sales-catalog-driven`` at runtime by validate_platform, but + # the @runtime_checkable isinstance path requires all declared + # methods — a known and documented structural consequence of + # adding it to the Protocol rather than a separate mixin). class _SalesShim: def get_products(self, req, ctx): return {"products": []} @@ -393,6 +398,9 @@ def list_creative_formats(self, req, ctx): def list_creatives(self, req, ctx): return {} + def sync_catalogs(self, req, ctx): + return {} + assert isinstance(_SalesShim(), SalesPlatform) diff --git a/tests/test_lazy_platform_router.py b/tests/test_lazy_platform_router.py index c283fbc0f..d89df7847 100644 --- a/tests/test_lazy_platform_router.py +++ b/tests/test_lazy_platform_router.py @@ -84,6 +84,11 @@ def get_media_buy_delivery(self, req: Any, ctx: RequestContext[Any]) -> dict[str self.calls.append(("get_media_buy_delivery", ctx.account.id)) return {"media_buy_deliveries": []} + def sync_catalogs(self, req: Any, ctx: RequestContext[Any]) -> dict[str, Any]: + # Stub — sales-non-guaranteed doesn't use this, but SalesPlatform Protocol + # requires all declared methods for @runtime_checkable isinstance. + return {"catalogs": []} + class _AsyncSalesPlatform(_SyncSalesPlatform): async def get_products( # type: ignore[override]