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: 61 additions & 4 deletions src/adcp/decisioning/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,11 @@
REQUIRED_METHODS_PER_SPECIALISM: dict[str, frozenset[str]] = {
# Five sales-* specialisms share the unified hybrid SalesPlatform
# surface. Per the SalesPlatform docstring, every sales-* claim
# requires the five core methods. The four optional methods
# requires the five core methods. The four rc.1-promoted methods
# (get_media_buys, provide_performance_feedback,
# list_creative_formats, list_creatives) are present-or-absent —
# not enforced here. The v6.0 rc.1 spec mandates them; v6.0 alpha
# tolerates absence so adopters can ship in stages.
# list_creative_formats, list_creatives) are warn-if-absent here
# see WARN_IF_MISSING_PER_SPECIALISM. They become hard-enforced
# (AdcpError) in v6.0 rc.1.
"sales-non-guaranteed": frozenset(
{
"get_products",
Expand Down Expand Up @@ -333,6 +333,38 @@
}


# ---------------------------------------------------------------------------
# WARN_IF_MISSING_PER_SPECIALISM — v6.0 rc.1 promotion candidates
# ---------------------------------------------------------------------------

#: Methods that are required by the spec in v6.0 rc.1 but tolerated-absent
#: in v6.0 alpha so adopters can ship in stages. ``validate_platform`` emits
#: a one-time ``UserWarning`` per missing method at server boot — one warning
#: per unique method name, deduplicated across multiple ``sales-*`` specialism
#: claims on the same platform.
#:
#: When v6.0 rc.1 ships, move each entry here into
#: :data:`REQUIRED_METHODS_PER_SPECIALISM` (which converts the warning to an
#: ``AdcpError`` hard-fail) and drop it from this dict.
_SALES_WARN_IF_MISSING: frozenset[str] = frozenset(
{
"get_media_buys",
"provide_performance_feedback",
"list_creative_formats",
"list_creatives",
}
)

WARN_IF_MISSING_PER_SPECIALISM: dict[str, frozenset[str]] = {
"sales-non-guaranteed": _SALES_WARN_IF_MISSING,
"sales-guaranteed": _SALES_WARN_IF_MISSING,
"sales-broadcast-tv": _SALES_WARN_IF_MISSING,
"sales-social": _SALES_WARN_IF_MISSING,
"sales-proposal-mode": _SALES_WARN_IF_MISSING,
"sales-catalog-driven": _SALES_WARN_IF_MISSING,
}


# ---------------------------------------------------------------------------
# INTERNAL_ERROR breadcrumbs (Emma AudioStack P2)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -630,6 +662,31 @@ def validate_platform(platform: DecisioningPlatform) -> None:
},
)

# v6.0 rc.1 promotion warnings. Methods in WARN_IF_MISSING_PER_SPECIALISM
# become required at rc.1; warn once per method at server boot so adopters
# surface the gap before buyers hit NOT_SUPPORTED at runtime.
# Deduped: platforms claiming multiple sales-* specialisms warn once per
# unique missing method (keyed on method name, not specialism).
warn_missing: dict[str, str] = {} # method → first claiming specialism
for specialism in platform.capabilities.specialisms:
for method_name in WARN_IF_MISSING_PER_SPECIALISM.get(specialism, frozenset()):
if method_name not in warn_missing and not _has_overridden_method(
platform, method_name
):
warn_missing[method_name] = specialism
platform_cls = type(platform).__qualname__
for method_name, specialism in sorted(warn_missing.items()):
warnings.warn(
(
f"{platform_cls} claims {specialism!r} but is missing "
f"{method_name!r}, which becomes required in v6.0 rc.1. "
f"Buyers calling this method will receive AdcpError(NOT_SUPPORTED) "
f"until it is implemented on your platform subclass."
),
UserWarning,
stacklevel=2,
)


def _has_overridden_method(platform: DecisioningPlatform, method_name: str) -> bool:
"""True when the platform subclass provides ``method_name``.
Expand Down
29 changes: 18 additions & 11 deletions src/adcp/decisioning/specialisms/sales.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,20 @@
* :meth:`sync_creatives` — hybrid for creative review
* :meth:`get_media_buy_delivery` — sync delivery read

Optional methods present-or-absent (gated by specialism — see per-method
docstrings):
Warn-if-absent methods (v6.0 alpha → hard-required in v6.0 rc.1):

:func:`adcp.decisioning.dispatch.validate_platform` emits a one-time
``UserWarning`` at server boot for each of these that is missing.
They become hard-enforced (``AdcpError``) in v6.0 rc.1.

* :meth:`get_media_buys`
* :meth:`provide_performance_feedback`
* :meth:`list_creative_formats`
* :meth:`list_creatives`
* :meth:`sync_catalogs` — required when claiming
``sales-catalog-driven``

Specialism-only required methods (already hard-enforced at boot):

* :meth:`sync_catalogs` — required when claiming ``sales-catalog-driven``

The framework's :func:`validate_platform` walks ``capabilities.specialisms``
and confirms each specialism's required methods exist on the platform
Expand Down Expand Up @@ -179,7 +184,7 @@ def get_media_buy_delivery(
"""Sync delivery read — pacing, spend, impressions per package."""
...

# ---- Optional (gated by specialism — present-or-absent) ----
# ---- Warn-if-absent (required in v6.0 rc.1 — hard-enforced at rc.1) ----

def get_media_buys(
self,
Expand All @@ -188,9 +193,8 @@ def get_media_buys(
) -> MaybeAsync[GetMediaBuysResponse]:
"""List media buys for the resolved account.

Required when claiming any ``sales-*`` specialism in v6.0 rc.1+.
``validate_platform`` fails server boot if a sales-claiming
platform doesn't implement this.
``validate_platform`` emits a ``UserWarning`` at server boot in v6.0
if this method is absent. Becomes a hard boot-time error in v6.0 rc.1.
"""
...

Expand All @@ -201,7 +205,8 @@ def provide_performance_feedback(
) -> MaybeAsync[ProvidePerformanceFeedbackResponse]:
"""Buyer-supplied performance signal back to the seller.

Required when claiming any ``sales-*`` specialism in v6.0 rc.1+.
``validate_platform`` emits a ``UserWarning`` at server boot in v6.0
if this method is absent. Becomes a hard boot-time error in v6.0 rc.1.
"""
...

Expand All @@ -212,7 +217,8 @@ def list_creative_formats(
) -> MaybeAsync[ListCreativeFormatsResponse]:
"""Catalog of accepted creative formats.

Required when claiming any ``sales-*`` specialism in v6.0 rc.1+.
``validate_platform`` emits a ``UserWarning`` at server boot in v6.0
if this method is absent. Becomes a hard boot-time error in v6.0 rc.1.
"""
...

Expand All @@ -223,6 +229,7 @@ def list_creatives(
) -> MaybeAsync[ListCreativesResponse]:
"""List the seller's view of buyer-uploaded creatives.

Required when claiming any ``sales-*`` specialism in v6.0 rc.1+.
``validate_platform`` emits a ``UserWarning`` at server boot in v6.0
if this method is absent. Becomes a hard boot-time error in v6.0 rc.1.
"""
...
119 changes: 119 additions & 0 deletions tests/test_decisioning_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from adcp.decisioning.dispatch import (
REQUIRED_METHODS_PER_SPECIALISM,
SPEC_SPECIALISM_ENUM,
WARN_IF_MISSING_PER_SPECIALISM,
_build_request_context,
_invoke_platform_method,
_project_handoff,
Expand All @@ -50,6 +51,8 @@ def executor():


class _ValidPlatform(DecisioningPlatform):
"""Fully-implemented sales-non-guaranteed platform — all 9 methods."""

capabilities = DecisioningCapabilities(specialisms=["sales-non-guaranteed"])
accounts = SingletonAccounts(account_id="hello")

Expand All @@ -68,6 +71,18 @@ def sync_creatives(self, req, ctx):
def get_media_buy_delivery(self, req, ctx):
return {"deliveries": []}

def get_media_buys(self, req, ctx):
return {"media_buys": []}

def provide_performance_feedback(self, req, ctx):
return {}

def list_creative_formats(self, req, ctx):
return {"formats": []}

def list_creatives(self, req, ctx):
return {"creatives": []}


def test_validate_platform_passes_for_valid_subclass() -> None:
"""Happy path — fully-implemented platform passes validation."""
Expand Down Expand Up @@ -322,6 +337,110 @@ class _UnenforcedSpecPlatform(DecisioningPlatform):
assert "spec-recognized" in str(matched[0].message)


def test_validate_platform_warns_on_missing_rc1_methods() -> None:
"""Platform claiming sales-non-guaranteed that implements the 5 required
core methods but omits the 4 v6.0 rc.1 promotion methods emits one
UserWarning per missing method at server boot."""

class _FiveCoreOnlyPlatform(DecisioningPlatform):
capabilities = DecisioningCapabilities(specialisms=["sales-non-guaranteed"])
accounts = SingletonAccounts(account_id="hello")

def get_products(self, req, ctx):
return {}

def create_media_buy(self, req, ctx):
return {}

def update_media_buy(self, media_buy_id, patch, ctx):
return {}

def sync_creatives(self, req, ctx):
return {}

def get_media_buy_delivery(self, req, ctx):
return {}

with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always", UserWarning)
validate_platform(_FiveCoreOnlyPlatform())

rc1_warns = [w for w in caught if "v6.0 rc.1" in str(w.message)]
assert len(rc1_warns) == 4, f"Expected 4 rc.1 warnings, got: {rc1_warns}"
warning_text = " ".join(str(w.message) for w in rc1_warns)
assert "get_media_buys" in warning_text
assert "provide_performance_feedback" in warning_text
assert "list_creative_formats" in warning_text
assert "list_creatives" in warning_text


def test_validate_platform_no_rc1_warn_when_all_methods_present() -> None:
"""Fully-implemented platform (all 9 methods) emits no rc.1 UserWarnings."""
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always", UserWarning)
validate_platform(_ValidPlatform())

rc1_warns = [w for w in caught if "v6.0 rc.1" in str(w.message)]
assert rc1_warns == [], f"Unexpected rc.1 warnings on fully-impl platform: {rc1_warns}"


def test_validate_platform_rc1_warns_deduped_across_sales_specialisms() -> None:
"""Platform claiming two sales-* specialisms emits exactly 4 rc.1 warnings
(one per missing method, not one per specialism × method)."""

class _DualSalesPlatform(DecisioningPlatform):
capabilities = DecisioningCapabilities(
specialisms=["sales-non-guaranteed", "sales-guaranteed"]
)
accounts = SingletonAccounts(account_id="hello")

def get_products(self, req, ctx):
return {}

def create_media_buy(self, req, ctx):
return {}

def update_media_buy(self, media_buy_id, patch, ctx):
return {}

def sync_creatives(self, req, ctx):
return {}

def get_media_buy_delivery(self, req, ctx):
return {}

with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always", UserWarning)
validate_platform(_DualSalesPlatform())

rc1_warns = [w for w in caught if "v6.0 rc.1" in str(w.message)]
assert len(rc1_warns) == 4, (
f"Expected exactly 4 deduplicated rc.1 warnings, got {len(rc1_warns)}: {rc1_warns}"
)


def test_warn_if_missing_per_specialism_pinned_for_sales() -> None:
"""Contract test — locks the rc.1-promoted method set so future spec
churn surfaces as a visible test failure."""
expected_warn_methods = {
"get_media_buys",
"provide_performance_feedback",
"list_creative_formats",
"list_creatives",
}
for slug in (
"sales-non-guaranteed",
"sales-guaranteed",
"sales-broadcast-tv",
"sales-social",
"sales-proposal-mode",
"sales-catalog-driven",
):
assert WARN_IF_MISSING_PER_SPECIALISM[slug] == expected_warn_methods, (
f"WARN_IF_MISSING_PER_SPECIALISM rc.1 method drift on {slug}"
)


def test_validate_platform_typo_check_uses_spec_enum() -> None:
"""Typo detector matches against the full spec enum, not just
REQUIRED_METHODS keys. A typo of ``signal-marketplace`` (a spec
Expand Down
Loading