Module adcp.decisioning.compose
Method-level composition for :class:DecisioningPlatform adopters.
Provides :func:compose_method() for wrapping a single platform method
with typed before / after hooks, plus three pre-built
BeforeHook factories — :func:require_account_match(),
:func:require_advertiser_match(), :func:require_org_scope() — for
common authorization gates.
Mirrors the JS-side composeMethod helper at
src/lib/server/decisioning/compose.ts. Adopters layer cross-cutting
concerns (security gates, audit, enrichment, caching) on individual
methods of an existing platform without re-typing every method by hand.
before returning None falls through to the wrapped method.
Returning :class:ShortCircuit wraps the value to short-circuit;
after (if any) still runs on the short-circuit value before it
flows back to the caller. The discriminated wrapper avoids the
None-as-sentinel footgun: adopters who forget the wrapper and
return a bare value get a :class:TypeError at runtime, not silent
short-circuit-with-None.
after runs BEFORE response-schema validation — decorations must
satisfy the wire schema. Vendor-specific data goes under ext
(the spec's typed extension surface), not at the top level.
Global variables
var AfterHook : TypeAlias-
Runs whether the result came from the inner method or from a
beforeshort-circuit. Receives the originalparamsandctxfor context-dependent enrichment. var BeforeHook : TypeAlias-
Returning
Nonefalls through to the inner method. Returning :class:ShortCircuitwraps the value to short-circuit. var PlatformMethod : TypeAlias-
All composed methods are async. Adopter platforms whose underlying implementations are sync should wrap them in a thin
async defshim before passing to :func:compose_method().
Functions
def compose_method(inner: Callable[[Req, RequestContext[Any]], Awaitable[Res]],
*,
before: Callable[[Req, RequestContext[Any]], Awaitable[ShortCircuit[Res] | None]] | None = None,
after: Callable[[Res, Req, RequestContext[Any]], Awaitable[Res]] | None = None) ‑> Callable[[~Req, RequestContext[typing.Any]], Awaitable[~Res]]-
Expand source code
def compose_method( inner: Callable[[Req, RequestContext[Any]], Awaitable[Res]], *, before: ( Callable[ [Req, RequestContext[Any]], Awaitable[ShortCircuit[Res] | None], ] | None ) = None, after: ( Callable[ [Res, Req, RequestContext[Any]], Awaitable[Res], ] | None ) = None, ) -> Callable[[Req, RequestContext[Any]], Awaitable[Res]]: """Wrap a platform method with typed ``before`` / ``after`` hooks. Type-preserving: the returned callable has the same ``async (params, ctx) -> Res`` signature as ``inner`` so it slots into a typed :class:`DecisioningPlatform` shape without casts. Validates ``inner`` is callable eagerly at wrap time so adopters who reference an optional method that wasn't implemented on the underlying platform get a clear :class:`TypeError` at module load rather than at first traffic. Example:: from adcp.decisioning.compose import compose_method, ShortCircuit async def before_hook(req, ctx): if req.optimization == "price": return ShortCircuit(value=cached_price_opt) return None async def after_hook(result, req, ctx): return result.model_copy( update={ "ext": { **(result.ext or {}), "carbon_grams_per_impression": await score(result), } } ) wrapped = compose_method( base_platform.get_media_buy_delivery, before=before_hook, after=after_hook, ) :param inner: The platform method to wrap. Must be callable; non-callables raise :class:`TypeError` immediately. :param before: Optional pre-call hook. Returning :class:`ShortCircuit` skips the inner method and feeds the wrapped value through ``after``. Returning ``None`` falls through. Returning a bare non-:class:`ShortCircuit` non-``None`` value raises :class:`TypeError`. :param after: Optional post-call hook. Runs whether the result came from ``inner`` or from a ``before`` short-circuit, and BEFORE response-schema validation. Decorations must satisfy the wire schema; vendor-specific data goes under ``ext``. :returns: A wrapper with the same signature as ``inner``. :raises TypeError: when ``inner`` is not callable. """ if not callable(inner): raise TypeError( f"compose_method: 'inner' must be callable, got " f"{type(inner).__name__}. Did you reference an optional " f"method that wasn't implemented on the platform?" ) async def wrapper(req: Req, ctx: RequestContext[Any]) -> Res: result: Res if before is not None: early = await before(req, ctx) if early is None: result = await inner(req, ctx) elif isinstance(early, ShortCircuit): result = early.value else: raise TypeError( f"compose_method: before hook returned " f"{type(early).__name__}; expected None or " f"ShortCircuit. Wrap the value: " f"`return ShortCircuit(value=...)`." ) else: result = await inner(req, ctx) if after is not None: result = await after(result, req, ctx) return result return wrapperWrap a platform method with typed
before/afterhooks.Type-preserving: the returned callable has the same
async (params, ctx) -> Ressignature asinnerso it slots into a typed :class:DecisioningPlatformshape without casts.Validates
inneris callable eagerly at wrap time so adopters who reference an optional method that wasn't implemented on the underlying platform get a clear :class:TypeErrorat module load rather than at first traffic.Example::
from adcp.decisioning.compose import compose_method, ShortCircuit async def before_hook(req, ctx): if req.optimization == "price": return ShortCircuit(value=cached_price_opt) return None async def after_hook(result, req, ctx): return result.model_copy( update={ "ext": { **(result.ext or {}), "carbon_grams_per_impression": await score(result), } } ) wrapped = compose_method( base_platform.get_media_buy_delivery, before=before_hook, after=after_hook, ):param inner: The platform method to wrap. Must be callable; non-callables raise :class:
TypeErrorimmediately. :param before: Optional pre-call hook. Returning :class:ShortCircuitskips the inner method and feeds the wrapped value throughafter. ReturningNonefalls through. Returning a bare non-:class:ShortCircuitnon-Nonevalue raises :class:TypeError. :param after: Optional post-call hook. Runs whether the result came frominneror from abeforeshort-circuit, and BEFORE response-schema validation. Decorations must satisfy the wire schema; vendor-specific data goes underext. :returns: A wrapper with the same signature asinner. :raises TypeError: wheninneris not callable. def require_account_match(expected_account_field: str = 'account_id') ‑> Callable[[typing.Any, RequestContext[typing.Any]], Awaitable[ShortCircuit[typing.Any] | None]]-
Expand source code
def require_account_match( expected_account_field: str = "account_id", ) -> BeforeHook[Any, Any]: """Build a :data:`BeforeHook` that requires the request's account field equal ``ctx.account.id``. The most common security composer — gates a method so a buyer can only operate on their own account. Apply via :func:`compose_method`:: wrapped = compose_method( base.get_media_buy_delivery, before=require_account_match(), ) :param expected_account_field: Name of the field on the request Pydantic model carrying the buyer-supplied account id. Default ``"account_id"`` matches the AdCP wire convention. :returns: A :data:`BeforeHook` that raises :class:`AdcpError` with ``PERMISSION_DENIED`` on mismatch (or missing field) and falls through (returns ``None``) on match. """ async def hook(req: Any, ctx: RequestContext[Any]) -> ShortCircuit[Any] | None: requested = _read_field(req, expected_account_field) if requested != ctx.account.id: raise AdcpError( "PERMISSION_DENIED", message=_DENIED_MESSAGE, recovery="correctable", ) return None return hookBuild a :data:
BeforeHookthat requires the request's account field equalctx.account.id.The most common security composer — gates a method so a buyer can only operate on their own account. Apply via :func:
compose_method()::wrapped = compose_method( base.get_media_buy_delivery, before=require_account_match(), ):param expected_account_field: Name of the field on the request Pydantic model carrying the buyer-supplied account id. Default
"account_id"matches the AdCP wire convention. :returns: A :data:BeforeHookthat raises :class:AdcpErrorwithPERMISSION_DENIEDon mismatch (or missing field) and falls through (returnsNone) on match. def require_advertiser_match(expected_advertiser_field: str = 'advertiser_id') ‑> Callable[[typing.Any, RequestContext[typing.Any]], Awaitable[ShortCircuit[typing.Any] | None]]-
Expand source code
def require_advertiser_match( expected_advertiser_field: str = "advertiser_id", ) -> BeforeHook[Any, Any]: """Build a :data:`BeforeHook` that requires the request's advertiser field equal ``ctx.account.metadata['advertiser_id']``. Use for per-advertiser scope below the account level — adopters who run multi-advertiser accounts and need to prevent cross- advertiser access within an account. :param expected_advertiser_field: Name of the field on the request Pydantic model carrying the buyer-supplied advertiser id. Default ``"advertiser_id"`` matches the AdCP wire convention. :returns: A :data:`BeforeHook` that raises :class:`AdcpError` with ``PERMISSION_DENIED`` on mismatch (or missing ``advertiser_id`` in metadata) and falls through on match. """ async def hook(req: Any, ctx: RequestContext[Any]) -> ShortCircuit[Any] | None: requested = _read_field(req, expected_advertiser_field) scoped = _read_metadata_field(ctx.account.metadata, "advertiser_id") if requested != scoped: raise AdcpError( "PERMISSION_DENIED", message=_DENIED_MESSAGE, recovery="correctable", ) return None return hookBuild a :data:
BeforeHookthat requires the request's advertiser field equalctx.account.metadata['advertiser_id'].Use for per-advertiser scope below the account level — adopters who run multi-advertiser accounts and need to prevent cross- advertiser access within an account.
:param expected_advertiser_field: Name of the field on the request Pydantic model carrying the buyer-supplied advertiser id. Default
"advertiser_id"matches the AdCP wire convention. :returns: A :data:BeforeHookthat raises :class:AdcpErrorwithPERMISSION_DENIEDon mismatch (or missingadvertiser_idin metadata) and falls through on match. def require_org_scope(expected_org_field: str = 'organization_id') ‑> Callable[[typing.Any, RequestContext[typing.Any]], Awaitable[ShortCircuit[typing.Any] | None]]-
Expand source code
def require_org_scope( expected_org_field: str = "organization_id", ) -> BeforeHook[Any, Any]: """Build a :data:`BeforeHook` that requires the request's organization field equal ``ctx.account.metadata['organization_id']``. Use for org-level multi-tenancy where a single org owns multiple accounts and the authorization decision is at the org level (not the per-account level). :param expected_org_field: Name of the field on the request Pydantic model carrying the buyer-supplied organization id. Default ``"organization_id"`` matches the AdCP wire convention. :returns: A :data:`BeforeHook` that raises :class:`AdcpError` with ``PERMISSION_DENIED`` on mismatch (or missing ``organization_id`` in metadata) and falls through on match. """ async def hook(req: Any, ctx: RequestContext[Any]) -> ShortCircuit[Any] | None: requested = _read_field(req, expected_org_field) scoped = _read_metadata_field(ctx.account.metadata, "organization_id") if requested != scoped: raise AdcpError( "PERMISSION_DENIED", message=_DENIED_MESSAGE, recovery="correctable", ) return None return hookBuild a :data:
BeforeHookthat requires the request's organization field equalctx.account.metadata['organization_id'].Use for org-level multi-tenancy where a single org owns multiple accounts and the authorization decision is at the org level (not the per-account level).
:param expected_org_field: Name of the field on the request Pydantic model carrying the buyer-supplied organization id. Default
"organization_id"matches the AdCP wire convention. :returns: A :data:BeforeHookthat raises :class:AdcpErrorwithPERMISSION_DENIEDon mismatch (or missingorganization_idin metadata) and falls through on match.
Classes
class ShortCircuit (value: T)-
Expand source code
@dataclass(frozen=True) class ShortCircuit(Generic[T]): """Wrapper a ``before`` hook returns to skip the inner method. Returning ``ShortCircuit(value=...)`` from a :data:`BeforeHook` skips the wrapped inner method and feeds the wrapped value through ``after`` (if any) back to the caller. Returning ``None`` falls through to the inner method. Discriminated wrapper rather than a sentinel ``None``: adopters who omit the wrapper and return a bare value get a :class:`TypeError` at runtime rather than silent short-circuit-with-``None`` behavior — the most common footgun when porting middleware between languages. :param value: The result to return in place of calling the inner method. Must satisfy the wire schema (any decoration via ``after`` runs before response-schema validation). """ value: TWrapper a
beforehook returns to skip the inner method.Returning
ShortCircuit(value=...)from a :data:BeforeHookskips the wrapped inner method and feeds the wrapped value throughafter(if any) back to the caller. ReturningNonefalls through to the inner method.Discriminated wrapper rather than a sentinel
None: adopters who omit the wrapper and return a bare value get a :class:TypeErrorat runtime rather than silent short-circuit-with-Nonebehavior — the most common footgun when porting middleware between languages.:param value: The result to return in place of calling the inner method. Must satisfy the wire schema (any decoration via
afterruns before response-schema validation).Ancestors
- typing.Generic
Instance variables
var value : ~T