Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion src/adcp/decisioning/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@
SyncAccountsResponse,
SyncAudiencesRequest,
SyncAudiencesSuccessResponse,
SyncCatalogsRequest,
SyncCatalogsSuccessResponse,
SyncCreativesRequest,
SyncCreativesSuccessResponse,
SyncPlansRequest,
Expand Down Expand Up @@ -270,6 +272,11 @@
"sync_audiences",
}
)
_CATALOG_ADVERTISED_TOOLS: frozenset[str] = frozenset(
{
"sync_catalogs",
}
)
_GOVERNANCE_ADVERTISED_TOOLS: frozenset[str] = frozenset(
{
"check_governance",
Expand Down Expand Up @@ -363,7 +370,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
Expand Down Expand Up @@ -653,6 +662,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.
Expand Down Expand Up @@ -851,6 +877,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)
Expand Down Expand Up @@ -2105,6 +2132,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]
Expand Down
43 changes: 41 additions & 2 deletions src/adcp/decisioning/specialisms/sales.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -64,6 +68,8 @@
ListCreativesResponse,
ProvidePerformanceFeedbackRequest,
ProvidePerformanceFeedbackResponse,
SyncCatalogsRequest,
SyncCatalogsSuccessResponse,
SyncCreativesRequest,
SyncCreativesSuccessResponse,
UpdateMediaBuyRequest,
Expand Down Expand Up @@ -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`.
"""
...
60 changes: 60 additions & 0 deletions tests/test_decisioning_advertised_per_specialism.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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
Expand Down Expand Up @@ -303,3 +326,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"
)
34 changes: 34 additions & 0 deletions tests/test_decisioning_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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+
Expand Down
Loading
Loading