diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index 9751deec6..136142b08 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -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", @@ -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) # --------------------------------------------------------------------------- @@ -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``. diff --git a/src/adcp/decisioning/specialisms/sales.py b/src/adcp/decisioning/specialisms/sales.py index 886005bc9..ca03c3d01 100644 --- a/src/adcp/decisioning/specialisms/sales.py +++ b/src/adcp/decisioning/specialisms/sales.py @@ -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 @@ -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, @@ -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. """ ... @@ -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. """ ... @@ -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. """ ... @@ -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. """ ... diff --git a/tests/test_decisioning_dispatch.py b/tests/test_decisioning_dispatch.py index 5725700c4..5a16ac7e4 100644 --- a/tests/test_decisioning_dispatch.py +++ b/tests/test_decisioning_dispatch.py @@ -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, @@ -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") @@ -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.""" @@ -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