Module adcp.capabilities

Feature capability resolution for AdCP.

Shared logic for resolving feature support from a capabilities response. Used by both the client (buyer-side validation) and server (seller-side validation).

Functions

def build_synthetic_capabilities(supported_protocols: list[str], *, major_versions: list[int] | None = None) ‑> dict[str, typing.Any]
Expand source code
def build_synthetic_capabilities(
    supported_protocols: list[str],
    *,
    major_versions: list[int] | None = None,
) -> dict[str, Any]:
    """Build a synthetic capabilities response for pre-v3 sellers.

    Use this when connecting to a seller that doesn't support
    ``get_adcp_capabilities`` (pre-v3 sellers). The returned dict
    can be passed to ``FeatureResolver`` after wrapping with the
    appropriate Pydantic model.

    Args:
        supported_protocols: List of protocol domains the seller supports
            (e.g., ``["media_buy"]``).
        major_versions: ADCP major versions the seller supports.
            Defaults to ``[2]``.

    Returns:
        A dict matching the GetAdcpCapabilitiesResponse shape.
    """
    return {
        "adcp": {"major_versions": major_versions or [2]},
        "supported_protocols": supported_protocols,
    }

Build a synthetic capabilities response for pre-v3 sellers.

Use this when connecting to a seller that doesn't support get_adcp_capabilities (pre-v3 sellers). The returned dict can be passed to FeatureResolver after wrapping with the appropriate Pydantic model.

Args

supported_protocols
List of protocol domains the seller supports (e.g., ["media_buy"]).
major_versions
ADCP major versions the seller supports. Defaults to [2].

Returns

A dict matching the GetAdcpCapabilitiesResponse shape.

def looks_like_v3_capabilities(data: Any) ‑> bool
Expand source code
def looks_like_v3_capabilities(data: Any) -> bool:
    """Heuristic: does this ``get_adcp_capabilities`` response look v3-shaped?

    Used by ``ADCPClient.refresh_capabilities`` when the response fails strict
    schema validation but is structurally non-empty. The question the heuristic
    answers is "is this a v3 agent with a wire-shape bug, or a v2 agent that
    happens to advertise the tool?". Falling back to v2 in the former case
    masks the original bug behind cascading v2.5-schema-not-found errors;
    treating it as v3 surfaces the wire-shape bug at its source.

    Affirmative v3 signals (any one is enough):

    - ``adcp`` block (only v3 servers carry the
      ``{ major_versions, idempotency, ... }`` envelope)
    - ``supported_protocols`` array (v3-only top-level field)
    - any v3 protocol-level capability block (``account``, ``media_buy``,
      ``signals``, ``creative``, ``brand``, ``governance``,
      ``sponsored_intelligence``, ``compliance_testing``)

    v2 servers don't expose ``get_adcp_capabilities`` at all (the tool itself
    is a v3-only addition), so reaching this function with a non-empty payload
    already strongly implies v3 — but the structural check belt-and-suspenders
    against genuinely empty / null responses.

    Args:
        data: Raw response payload (typically a dict, but accepts any value
            so callers don't have to narrow before calling).

    Returns:
        True if any v3 signal is present; False for empty, null, non-dict,
        or shape-mismatched inputs.
    """
    if not _is_plain_object(data):
        return False
    if _is_plain_object(data.get("adcp")):
        return True
    if isinstance(data.get("supported_protocols"), list):
        return True
    v3_blocks = (
        "account",
        "media_buy",
        "signals",
        "creative",
        "brand",
        "governance",
        "sponsored_intelligence",
        "compliance_testing",
    )
    return any(_is_plain_object(data.get(block)) for block in v3_blocks)

Heuristic: does this get_adcp_capabilities response look v3-shaped?

Used by ADCPClient.refresh_capabilities when the response fails strict schema validation but is structurally non-empty. The question the heuristic answers is "is this a v3 agent with a wire-shape bug, or a v2 agent that happens to advertise the tool?". Falling back to v2 in the former case masks the original bug behind cascading v2.5-schema-not-found errors; treating it as v3 surfaces the wire-shape bug at its source.

Affirmative v3 signals (any one is enough):

  • adcp block (only v3 servers carry the { major_versions, idempotency, ... } envelope)
  • supported_protocols array (v3-only top-level field)
  • any v3 protocol-level capability block (account, media_buy, signals, creative, brand, governance, sponsored_intelligence, compliance_testing)

v2 servers don't expose get_adcp_capabilities at all (the tool itself is a v3-only addition), so reaching this function with a non-empty payload already strongly implies v3 — but the structural check belt-and-suspenders against genuinely empty / null responses.

Args

data
Raw response payload (typically a dict, but accepts any value so callers don't have to narrow before calling).

Returns

True if any v3 signal is present; False for empty, null, non-dict, or shape-mismatched inputs.

def validate_capabilities(handler: Any, capabilities: GetAdcpCapabilitiesResponse) ‑> list[str]
Expand source code
def validate_capabilities(
    handler: Any,
    capabilities: GetAdcpCapabilitiesResponse,
) -> list[str]:
    """Check that a handler implements the methods required by its declared features.

    Compares the features declared in a capabilities response against the handler's
    method implementations. Returns warnings for features that are declared but
    whose corresponding handler methods are not overridden from the base class.

    This is a development-time check — call it at startup to catch misconfigurations.

    Args:
        handler: An ADCPHandler instance (or any object with handler methods).
        capabilities: The capabilities response the handler will serve.

    Returns:
        List of warning strings. Empty if everything is consistent.
    """
    # Late import to avoid circular dependency: server.base imports from adcp.types
    # which may transitively import from this module.
    from adcp.server.base import ADCPHandler

    resolver = FeatureResolver(capabilities)
    warnings: list[str] = []

    for feature, handler_methods in FEATURE_HANDLER_MAP.items():
        if not resolver.supports(feature):
            continue

        for method_name in handler_methods:
            if not hasattr(handler, method_name):
                warnings.append(
                    f"Feature '{feature}' is declared but handler has no " f"'{method_name}' method"
                )
                continue

            # Walk MRO to check if any class between the leaf and ADCPHandler
            # overrides the method (handles mixin / intermediate-class patterns).
            if isinstance(handler, ADCPHandler):
                overridden = any(
                    method_name in cls.__dict__
                    for cls in type(handler).__mro__
                    if cls is not ADCPHandler and not issubclass(ADCPHandler, cls)
                )
                if not overridden:
                    warnings.append(
                        f"Feature '{feature}' is declared but '{method_name}' "
                        f"is not overridden from ADCPHandler"
                    )

    return warnings

Check that a handler implements the methods required by its declared features.

Compares the features declared in a capabilities response against the handler's method implementations. Returns warnings for features that are declared but whose corresponding handler methods are not overridden from the base class.

This is a development-time check — call it at startup to catch misconfigurations.

Args

handler
An ADCPHandler instance (or any object with handler methods).
capabilities
The capabilities response the handler will serve.

Returns

List of warning strings. Empty if everything is consistent.

Classes

class FeatureResolver (capabilities: GetAdcpCapabilitiesResponse)
Expand source code
class FeatureResolver:
    """Resolves feature support from a GetAdcpCapabilitiesResponse.

    Supports multiple feature namespaces:

    - Protocol support: ``"media_buy"`` checks ``supported_protocols``
    - Extension support: ``"ext:scope3"`` checks ``extensions_supported``
    - Targeting: ``"targeting.geo_countries"`` checks
      ``media_buy.execution.targeting``
    - Media buy features: ``"inline_creative_management"`` checks
      ``media_buy.features``
    - Signals features: ``"catalog_signals"`` checks
      ``signals.features``
    """

    def __init__(self, capabilities: GetAdcpCapabilitiesResponse) -> None:
        self._caps = capabilities

        # Pre-compute the set of valid protocol names so supports() doesn't
        # need a runtime import on every call.
        from adcp.types.generated_poc.protocol.get_adcp_capabilities_response import (
            SupportedProtocol,
        )

        self._valid_protocols = {p.value for p in SupportedProtocol}
        self._declared_protocols = {p.value for p in capabilities.supported_protocols}

    @property
    def capabilities(self) -> GetAdcpCapabilitiesResponse:
        return self._caps

    def supports_v3(self) -> bool:
        """Check if the seller supports ADCP v3.

        Returns:
            True if major_versions includes 3.
        """
        for v in self._caps.adcp.major_versions:
            if (v.root if hasattr(v, "root") else v) == 3:
                return True
        return False

    def supports(self, feature: str) -> bool:
        """Check if a feature is supported."""
        caps = self._caps

        # Extension check: "ext:scope3"
        if feature.startswith("ext:"):
            ext_name = feature[4:]
            if caps.extensions_supported is None:
                return False
            return any(item.root == ext_name for item in caps.extensions_supported)

        # Targeting check: "targeting.geo_countries"
        if feature.startswith("targeting."):
            attr_name = feature[len("targeting.") :]
            if caps.media_buy is None or caps.media_buy.execution is None:
                return False
            targeting = caps.media_buy.execution.targeting
            if targeting is None:
                return False
            if attr_name not in type(targeting).model_fields:
                return False
            val = getattr(targeting, attr_name, None)
            # For bool fields, check truthiness. For object fields (like geo_metros),
            # presence means supported.
            return val is not None and val is not False

        # Protocol check: if the string is a known protocol name, resolve it
        # against supported_protocols and stop — don't fall through to features.
        if feature in self._declared_protocols:
            return True
        if feature in self._valid_protocols:
            return False

        # Media buy features check
        if caps.media_buy is not None and caps.media_buy.features is not None:
            if feature in type(caps.media_buy.features).model_fields:
                val = getattr(caps.media_buy.features, feature, None)
                if val is True:
                    return True

        # Signals features check
        if caps.signals is not None and caps.signals.features is not None:
            if feature in type(caps.signals.features).model_fields:
                val = getattr(caps.signals.features, feature, None)
                if val is True:
                    return True

        return False

    def require(
        self,
        *features: str,
        agent_id: str | None = None,
        agent_uri: str | None = None,
    ) -> None:
        """Assert that all listed features are supported.

        Args:
            *features: Feature identifiers to require.
            agent_id: Optional agent ID for error context.
            agent_uri: Optional agent URI for error context.

        Raises:
            ADCPFeatureUnsupportedError: If any features are not supported.
        """
        unsupported = [f for f in features if not self.supports(f)]
        if not unsupported:
            return

        declared = self.get_declared_features()

        raise ADCPFeatureUnsupportedError(
            unsupported_features=unsupported,
            declared_features=declared,
            agent_id=agent_id,
            agent_uri=agent_uri,
        )

    def get_declared_features(self) -> list[str]:
        """Collect all features the response declares as supported."""
        caps = self._caps
        declared: list[str] = []

        # Supported protocols
        for p in caps.supported_protocols:
            declared.append(p.value)

        # Media buy features
        if caps.media_buy is not None and caps.media_buy.features is not None:
            for field_name in type(caps.media_buy.features).model_fields:
                if getattr(caps.media_buy.features, field_name, None) is True:
                    declared.append(field_name)

        # Signals features
        if caps.signals is not None and caps.signals.features is not None:
            for field_name in type(caps.signals.features).model_fields:
                if getattr(caps.signals.features, field_name, None) is True:
                    declared.append(field_name)

        # Targeting features
        if caps.media_buy is not None and caps.media_buy.execution is not None:
            targeting = caps.media_buy.execution.targeting
            if targeting is not None:
                for field_name in type(targeting).model_fields:
                    val = getattr(targeting, field_name, None)
                    if val is not None and val is not False:
                        declared.append(f"targeting.{field_name}")

        # Extensions
        if caps.extensions_supported is not None:
            for item in caps.extensions_supported:
                declared.append(f"ext:{item.root}")

        return declared

Resolves feature support from a GetAdcpCapabilitiesResponse.

Supports multiple feature namespaces:

  • Protocol support: "media_buy" checks supported_protocols
  • Extension support: "ext:scope3" checks extensions_supported
  • Targeting: "targeting.geo_countries" checks media_buy.execution.targeting
  • Media buy features: "inline_creative_management" checks media_buy.features
  • Signals features: "catalog_signals" checks signals.features

Instance variables

prop capabilities : GetAdcpCapabilitiesResponse
Expand source code
@property
def capabilities(self) -> GetAdcpCapabilitiesResponse:
    return self._caps

Methods

def get_declared_features(self) ‑> list[str]
Expand source code
def get_declared_features(self) -> list[str]:
    """Collect all features the response declares as supported."""
    caps = self._caps
    declared: list[str] = []

    # Supported protocols
    for p in caps.supported_protocols:
        declared.append(p.value)

    # Media buy features
    if caps.media_buy is not None and caps.media_buy.features is not None:
        for field_name in type(caps.media_buy.features).model_fields:
            if getattr(caps.media_buy.features, field_name, None) is True:
                declared.append(field_name)

    # Signals features
    if caps.signals is not None and caps.signals.features is not None:
        for field_name in type(caps.signals.features).model_fields:
            if getattr(caps.signals.features, field_name, None) is True:
                declared.append(field_name)

    # Targeting features
    if caps.media_buy is not None and caps.media_buy.execution is not None:
        targeting = caps.media_buy.execution.targeting
        if targeting is not None:
            for field_name in type(targeting).model_fields:
                val = getattr(targeting, field_name, None)
                if val is not None and val is not False:
                    declared.append(f"targeting.{field_name}")

    # Extensions
    if caps.extensions_supported is not None:
        for item in caps.extensions_supported:
            declared.append(f"ext:{item.root}")

    return declared

Collect all features the response declares as supported.

def require(self, *features: str, agent_id: str | None = None, agent_uri: str | None = None) ‑> None
Expand source code
def require(
    self,
    *features: str,
    agent_id: str | None = None,
    agent_uri: str | None = None,
) -> None:
    """Assert that all listed features are supported.

    Args:
        *features: Feature identifiers to require.
        agent_id: Optional agent ID for error context.
        agent_uri: Optional agent URI for error context.

    Raises:
        ADCPFeatureUnsupportedError: If any features are not supported.
    """
    unsupported = [f for f in features if not self.supports(f)]
    if not unsupported:
        return

    declared = self.get_declared_features()

    raise ADCPFeatureUnsupportedError(
        unsupported_features=unsupported,
        declared_features=declared,
        agent_id=agent_id,
        agent_uri=agent_uri,
    )

Assert that all listed features are supported.

Args

*features
Feature identifiers to require.
agent_id
Optional agent ID for error context.
agent_uri
Optional agent URI for error context.

Raises

ADCPFeatureUnsupportedError
If any features are not supported.
def supports(self, feature: str) ‑> bool
Expand source code
def supports(self, feature: str) -> bool:
    """Check if a feature is supported."""
    caps = self._caps

    # Extension check: "ext:scope3"
    if feature.startswith("ext:"):
        ext_name = feature[4:]
        if caps.extensions_supported is None:
            return False
        return any(item.root == ext_name for item in caps.extensions_supported)

    # Targeting check: "targeting.geo_countries"
    if feature.startswith("targeting."):
        attr_name = feature[len("targeting.") :]
        if caps.media_buy is None or caps.media_buy.execution is None:
            return False
        targeting = caps.media_buy.execution.targeting
        if targeting is None:
            return False
        if attr_name not in type(targeting).model_fields:
            return False
        val = getattr(targeting, attr_name, None)
        # For bool fields, check truthiness. For object fields (like geo_metros),
        # presence means supported.
        return val is not None and val is not False

    # Protocol check: if the string is a known protocol name, resolve it
    # against supported_protocols and stop — don't fall through to features.
    if feature in self._declared_protocols:
        return True
    if feature in self._valid_protocols:
        return False

    # Media buy features check
    if caps.media_buy is not None and caps.media_buy.features is not None:
        if feature in type(caps.media_buy.features).model_fields:
            val = getattr(caps.media_buy.features, feature, None)
            if val is True:
                return True

    # Signals features check
    if caps.signals is not None and caps.signals.features is not None:
        if feature in type(caps.signals.features).model_fields:
            val = getattr(caps.signals.features, feature, None)
            if val is True:
                return True

    return False

Check if a feature is supported.

def supports_v3(self) ‑> bool
Expand source code
def supports_v3(self) -> bool:
    """Check if the seller supports ADCP v3.

    Returns:
        True if major_versions includes 3.
    """
    for v in self._caps.adcp.major_versions:
        if (v.root if hasattr(v, "root") else v) == 3:
            return True
    return False

Check if the seller supports ADCP v3.

Returns

True if major_versions includes 3.