diff --git a/src/adcp/__init__.py b/src/adcp/__init__.py index 144fc7fd..73768009 100644 --- a/src/adcp/__init__.py +++ b/src/adcp/__init__.py @@ -43,6 +43,7 @@ ADCPToolNotFoundError, ADCPWebhookError, ADCPWebhookSignatureError, + ConfigurationError, IdempotencyConflictError, IdempotencyExpiredError, IdempotencyUnsupportedError, @@ -838,6 +839,7 @@ def get_adcp_version() -> str: "AdagentsValidationError", "AdagentsNotFoundError", "AdagentsTimeoutError", + "ConfigurationError", "RegistryError", # Validation utilities "SchemaValidationError", diff --git a/src/adcp/_version.py b/src/adcp/_version.py new file mode 100644 index 00000000..3fbb13c3 --- /dev/null +++ b/src/adcp/_version.py @@ -0,0 +1,149 @@ +"""Internal helpers for AdCP protocol version pinning. + +Version pinning is per-instance (Stripe model): each ``ADCPClient`` / +``ADCPMultiAgentClient`` / ``ADCPServerBuilder`` accepts an +``adcp_version`` constructor option that selects which AdCP release the +SDK speaks for that instance. Default is the SDK's compile-time pin +(``ADCP_VERSION`` packaged with the wheel). + +Stage 2 (this module): validates the pin at construction and exposes +the resolved value via ``get_adcp_version()``. Cross-major pins raise +:class:`adcp.exceptions.ConfigurationError`. No wire behavior change +yet — Stage 3 lifts the cross-major fence and threads per-instance +schema/validator selection through the validation hooks. + +Release-precision strings are the canonical input form (``"3.0"``, +``"3.1"``, ``"3.1-beta"``). Patch-precision strings (``"3.0.1"``) are +accepted for backwards compatibility with the legacy ADCP_VERSION file +shape, but the SDK normalizes to release precision internally and on +the wire — patches are not part of the negotiation contract per the +spec's three-tier model. See specs/version-negotiation.md upstream. +""" + +from __future__ import annotations + +import re + +# Release-precision versions this SDK can speak. Patch-level pinning is +# intentionally absent — patches don't change the wire contract by +# definition, so making them part of the pin is a category error. +COMPATIBLE_ADCP_VERSIONS: tuple[str, ...] = ("3.0", "3.1") + +# Major version this SDK is built for. Cross-major pins are rejected at +# construction. To speak a different major, install the SDK major that +# targets it. +ADCP_MAJOR_VERSION: int = 3 + +# Matches release-precision (3.0, 3.1) and patch-precision (3.0.0, +# 3.0.1) semver, with optional pre-release tag (3.1-beta, 3.1.0-rc.1) +# and optional build metadata (3.0.1+canary, 3.1.0-beta+exp.sha.5114f85). +# Captures the major as group 1. +_VERSION_RE: re.Pattern[str] = re.compile( + r"^(\d+)\.(\d+)(?:\.(\d+))?(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$" +) + + +def normalize_to_release_precision(version: str) -> str: + """Strip patch component (and build metadata) for wire emission. + + Per the AdCP version-negotiation spec + (``core/version-envelope.json``), wire values for ``adcp_version`` + are release-precision only. SDKs that read full-semver values + from bundle metadata (``ADCP_VERSION`` file, ``published_version``, + etc.) MUST normalize before emitting on the wire — meta-field + values are not valid wire values. + + Pre-release tags are preserved (they describe the release line); + build metadata is dropped (it's purely a build identifier, never + part of a contract). + + Examples: + + - ``"3.0"`` → ``"3.0"`` (already release-precision) + - ``"3.0.0"`` → ``"3.0"`` + - ``"3.0.1"`` → ``"3.0"`` + - ``"3.1-beta"`` → ``"3.1-beta"`` + - ``"3.1.0-beta"`` → ``"3.1-beta"`` + - ``"3.1.0-rc.1"`` → ``"3.1-rc.1"`` + - ``"3.0.1+canary"`` → ``"3.0"`` + - ``"3.1.0-beta+sha.5"`` → ``"3.1-beta"`` + + Raises :class:`ValueError` on unparseable strings. + """ + match = _VERSION_RE.match(version) + if match is None: + raise ValueError(f"adcp_version {version!r} is not a valid semver-shaped string.") + major, release = match.group(1), match.group(2) + # Skip past patch component (group 3) if present, then take whatever's + # left (pre-release tag and/or build metadata) and drop the +build half. + rest_start = match.end(3) if match.group(3) is not None else match.end(2) + rest = version[rest_start:] + if "+" in rest: + rest = rest.split("+", 1)[0] + return f"{major}.{release}{rest}" + + +def parse_adcp_major_version(version: str) -> int: + """Extract the major component from a release- or patch-precision version string. + + Accepts ``"3.0"``, ``"3.1"``, ``"3.0.1"``, ``"3.1-beta"``, + ``"3.1.0-rc.1"``, etc. Raises :class:`ValueError` (caught by + :func:`resolve_adcp_version` and reraised as + :class:`ConfigurationError`) on anything else. + + The integer return value is the only thing the cross-major fence + cares about — release-vs-patch precision is preserved for downstream + use elsewhere. + """ + match = _VERSION_RE.match(version) + if match is None: + raise ValueError( + f"adcp_version {version!r} is not a valid semver-shaped string. " + f"Expected release-precision (e.g. '3.0', '3.1') or " + f"patch-precision (e.g. '3.0.1'); pre-release tags allowed " + f"(e.g. '3.1-beta')." + ) + return int(match.group(1)) + + +def _read_packaged_version() -> str: + """Return the ``ADCP_VERSION`` value packaged with the wheel.""" + from importlib.resources import files + + return (files("adcp") / "ADCP_VERSION").read_text().strip() + + +def resolve_adcp_version(pin: str | None) -> str: + """Validate and resolve a constructor-supplied ``adcp_version`` pin. + + - ``None`` → reads the packaged ``ADCP_VERSION`` file (SDK default). + - Same-major pin → accepted. + - Cross-major pin → raises :class:`ConfigurationError`. + - Unparseable string → raises :class:`ConfigurationError`. + + All resolved pins are normalized to release-precision before being + returned, per the spec's wire-value rule + (``core/version-envelope.json``). Patch-precision inputs like + ``"3.0.1"`` are accepted (the ``ADCP_VERSION`` file ships in this + shape today) but stored and emitted as ``"3.0"``. ``get_adcp_version()`` + therefore returns release-precision regardless of what the caller + passed; this is intentional — wire values are the canonical form. + """ + # Imported here to avoid a circular import at module load time. + from adcp.exceptions import ConfigurationError + + raw = pin if pin is not None else _read_packaged_version() + + try: + major = parse_adcp_major_version(raw) + except ValueError as exc: + raise ConfigurationError(str(exc)) from exc + + if major != ADCP_MAJOR_VERSION: + raise ConfigurationError( + f"adcp_version={raw!r} targets major {major}, but this SDK speaks " + f"AdCP {ADCP_MAJOR_VERSION}.x. Install the SDK major that targets " + f"AdCP {major}.x — cross-major pinning is not supported." + ) + + return normalize_to_release_precision(raw) diff --git a/src/adcp/client.py b/src/adcp/client.py index 5428fc37..dec5f982 100644 --- a/src/adcp/client.py +++ b/src/adcp/client.py @@ -21,6 +21,7 @@ import httpx from mcp import ClientSession +from adcp._version import resolve_adcp_version from adcp.capabilities import TASK_FEATURE_MAP, FeatureResolver from adcp.exceptions import ADCPError, ADCPWebhookSignatureError from adcp.protocols.a2a import A2AAdapter @@ -336,6 +337,7 @@ def __init__( context_id: str | None = None, validation: ValidationHookConfig | None = None, force_a2a_version: str | None = None, + adcp_version: str | None = None, ): """ Initialize ADCP client for a single agent. @@ -403,12 +405,13 @@ def __init__( both ``context_id`` AND ``active_task_id``. Raises ``TypeError`` if passed with a non-A2A protocol. - force_a2a_version: A2A-only. Pin the wire version by - filtering the peer's advertised - ``supported_interfaces`` to entries whose - ``protocol_version`` matches. Intended for tests or - for forcing a 0.3-speaking path against a - dual-advertising peer. Raises + force_a2a_version: A2A-only. Pin the **A2A transport + version** (e.g. ``"0.3"``, ``"1.0"``) by filtering the + peer's advertised ``supported_interfaces`` to entries + whose ``protocol_version`` matches. Not for AdCP + protocol pinning — see ``adcp_version`` for that. + Intended for tests or for forcing a 0.3-speaking path + against a dual-advertising peer. Raises :class:`ADCPConnectionError` on the first call if no advertised interface matches. ``None`` (default) lets the SDK's ``ClientFactory`` pick the most capable @@ -417,7 +420,41 @@ def __init__( advertises before pinning. Raises ``TypeError`` if passed with a non-A2A protocol. + adcp_version: AdCP protocol release this client speaks + (release-precision string, e.g. ``"3.0"``, ``"3.1"``, + ``"3.1-beta"``). Stripe-style per-instance pin: the + value is sent as ``adcp_version`` on every outbound + request once Stage 3 wires it through the validation + hooks; today (Stage 2), it's plumbing only — stored on + the instance and exposed via :meth:`get_adcp_version`, + with no wire impact yet. ``None`` (default) resolves + to the SDK's compile-time pin (``ADCP_VERSION`` + packaged with the wheel). Cross-major pins raise + :class:`ConfigurationError` at construction; install + the SDK major that targets your wire version instead. + Patch-precision strings (``"3.0.1"``) and build + metadata (``"3.0.1+canary"``) are accepted at construction + but normalized to release-precision before wire emission + per the spec — patches and build metadata are not part + of the negotiation contract. ``get_adcp_version()`` + returns the normalized form. + + Caller-supplied ``adcp_version`` on a per-call params + dict wins over the constructor pin: the enricher is + the default, not an override. Once Stage 3 threads + schema selection through, this becomes a supported + per-call override; today it's plumbing-level only. + + Migration from ``adcp_major_version`` (legacy integer + wire field): generated request types still expose + ``adcp_major_version: int | None`` from the pre-#3493 + schema. Both fields will coexist on the wire through + 3.x; servers prefer the new ``adcp_version`` when both + are present. Stop populating ``adcp_major_version`` on + request models once your seller advertises 3.1 in + ``supported_versions``. """ + self._adcp_version: str = resolve_adcp_version(adcp_version) self.agent_config = agent_config self.webhook_url_template = webhook_url_template self.webhook_secret = webhook_secret @@ -462,6 +499,17 @@ def __init__( # Apply schema validation modes (default: requests=warn, responses=strict # in dev/test, warn in production — see ``ValidationHookConfig`` docs). self.adapter.configure_validation(validation) + # Auto-inject the per-instance ``adcp_version`` pin into every + # outbound request envelope. Caller-supplied values on the + # request object win — the enricher is the default, not an + # override — so per-call overrides remain available once the + # generated request types declare the field. + _pinned_version = self._adcp_version + + def _inject_adcp_version(params: dict[str, Any]) -> dict[str, Any]: + return {"adcp_version": _pinned_version, **params} + + self.adapter.envelope_enricher = _inject_adcp_version if context_id: # Empty string is treated as "not provided" — callers using @@ -479,6 +527,22 @@ def __init__( self.simple = SimpleAPI(self) + def get_adcp_version(self) -> str: + """Return the AdCP protocol release this client is pinned to. + + Resolved at construction from the ``adcp_version`` kwarg, with + fallback to the SDK's compile-time pin (``ADCP_VERSION`` + packaged with the wheel) when the caller didn't pin + explicitly. Same value across the client's lifetime — the pin + is per-instance, not per-call. + + See ``__init__``'s ``adcp_version`` parameter for the full + semantics, including the cross-major fence and the Stage 2 vs + Stage 3 distinction (today the pin is plumbing only; Stage 3 + threads it through schema/validator selection). + """ + return self._adcp_version + @property def context_id(self) -> str | None: """Current A2A conversation context_id. @@ -4216,6 +4280,7 @@ def __init__( on_activity: Callable[[Activity], None] | None = None, handlers: dict[str, Callable[..., Any]] | None = None, signing: SigningConfig | None = None, + adcp_version: str | dict[str, str] | None = None, ): """ Initialize multi-agent client. @@ -4229,19 +4294,76 @@ def __init__( signing: Optional RFC 9421 signing config forwarded to every per-agent ADCPClient. The same identity signs traffic to all agents. See ADCPClient.__init__ for details. + adcp_version: AdCP protocol release pin. Three forms: + + - ``None`` (default): every per-agent ADCPClient resolves + the SDK's compile-time pin. + - ``str`` (e.g. ``"3.1"``): every agent uses this pin. + - ``dict[str, str]`` (e.g. + ``{"seller_a": "3.0", "seller_b": "3.1"}``): per-agent + override map keyed by ``agent.id``. Agents missing + from the map fall back to the SDK default — useful + for holdco/multi-tenant operators where one seller is + ahead of the others on the upgrade cadence. + + See ADCPClient.__init__ for per-instance semantics. + Cross-major pins raise ConfigurationError at construction. """ - self.agents = { - agent.id: ADCPClient( - agent, - webhook_url_template=webhook_url_template, - webhook_secret=webhook_secret, - on_activity=on_activity, - signing=signing, - ) - for agent in agents - } + # Per-agent map → resolve each pin individually for the dict form; + # otherwise use the uniform pin for all agents. + if isinstance(adcp_version, dict): + self._adcp_version: str | None = None # mixed pins + self._per_agent_versions: dict[str, str] = { + agent_id: resolve_adcp_version(pin) for agent_id, pin in adcp_version.items() + } + default_pin = resolve_adcp_version(None) + self.agents = { + agent.id: ADCPClient( + agent, + webhook_url_template=webhook_url_template, + webhook_secret=webhook_secret, + on_activity=on_activity, + signing=signing, + adcp_version=self._per_agent_versions.get(agent.id, default_pin), + ) + for agent in agents + } + else: + self._adcp_version = resolve_adcp_version(adcp_version) + self._per_agent_versions = {} + self.agents = { + agent.id: ADCPClient( + agent, + webhook_url_template=webhook_url_template, + webhook_secret=webhook_secret, + on_activity=on_activity, + signing=signing, + adcp_version=self._adcp_version, + ) + for agent in agents + } self.handlers = handlers or {} + def get_adcp_version(self) -> str: + """Return the AdCP protocol release pin for this multi-client. + + Returns the uniform pin when all agents share one. Raises + :class:`ValueError` when agents have heterogeneous pins (the + ``dict[str, str]`` constructor form) — in that case, query + the per-agent pin via ``multi.agent(agent_id).get_adcp_version()``. + """ + if self._adcp_version is not None: + return self._adcp_version + # Heterogeneous: surface uniformly if all agents agree at runtime. + versions = {client.get_adcp_version() for client in self.agents.values()} + if len(versions) == 1: + return next(iter(versions)) + raise ValueError( + "Multi-agent client has heterogeneous adcp_version pins; " + "use multi.agent(agent_id).get_adcp_version() to read per-agent. " + f"Pins by agent: { {a: c.get_adcp_version() for a, c in self.agents.items()} }" + ) + def agent(self, agent_id: str) -> ADCPClient: """Get client for specific agent.""" if agent_id not in self.agents: diff --git a/src/adcp/exceptions.py b/src/adcp/exceptions.py index 21672173..c5007a08 100644 --- a/src/adcp/exceptions.py +++ b/src/adcp/exceptions.py @@ -412,6 +412,20 @@ def __init__( super().__init__(message, agent_id, agent_uri, suggestion) +class ConfigurationError(ADCPError): + """Invalid SDK configuration detected at construction time. + + Raised when a value passed to a client/server constructor cannot + be reconciled with the SDK's compile-time pin — most commonly a + cross-major ``adcp_version`` (e.g. ``adcp_version="4.0"`` against + an SDK built for AdCP 3.x), or an unparseable version string. + + Recovery: install the SDK major that targets the wire version you + want to speak. Cross-major pinning is not supported within a + single SDK major. + """ + + IDEMPOTENCY_ERROR_CODE_MAP: dict[str, type[ADCPTaskError]] = { "IDEMPOTENCY_CONFLICT": IdempotencyConflictError, "IDEMPOTENCY_EXPIRED": IdempotencyExpiredError, diff --git a/src/adcp/protocols/a2a.py b/src/adcp/protocols/a2a.py index 6a78f1cf..1d9a2a99 100644 --- a/src/adcp/protocols/a2a.py +++ b/src/adcp/protocols/a2a.py @@ -401,6 +401,8 @@ async def _call_a2a_tool( params, idempotency_key = _idempotency.inject_key( tool_name, params, client_token=self.idempotency_client_token ) + # Apply per-instance envelope enrichment (e.g. adcp_version pin). + params = self._enrich_outgoing_params(params) # Pre-send schema validation. Matches the MCP adapter: strict mode # surfaces as TaskStatus.FAILED so the SDK's unified failure model diff --git a/src/adcp/protocols/base.py b/src/adcp/protocols/base.py index a624b233..f19fb53f 100644 --- a/src/adcp/protocols/base.py +++ b/src/adcp/protocols/base.py @@ -54,6 +54,31 @@ def __init__(self, agent_config: AgentConfig): # ``ADCPClient(validation=...)`` or an env override. self.request_validation_mode: ValidationMode = "warn" self.response_validation_mode: ValidationMode = "strict" + # Optional hook applied to every outbound request params dict + # before validation/send. The owning ADCPClient installs one to + # auto-inject ``adcp_version`` from the per-instance pin. Returns + # a new dict (the original is not mutated). Caller-supplied + # values on the original dict win — the enricher is the default, + # not an override. + # + # Contract: the validator runs on the enriched dict, so any field + # the enricher injects must be either (a) declared in the request + # schema, or (b) tolerated by the schema's ``additionalProperties`` + # policy. Top-level Request models in this SDK declare + # ``extra="allow"`` (see ``AdCPBaseModel`` overrides in generated + # types) — flipping any of them to ``extra="forbid"`` would break + # this assumption silently. + self.envelope_enricher: Callable[[dict[str, Any]], dict[str, Any]] | None = None + + def _enrich_outgoing_params(self, params: Any) -> Any: + """Apply ``envelope_enricher`` to an outbound params dict. + + No-op for non-dict params (rare — most tool methods pass dicts + from ``model_dump()``) and when no enricher is installed. + """ + if self.envelope_enricher is None or not isinstance(params, dict): + return params + return self.envelope_enricher(params) def configure_validation(self, config: ValidationHookConfig | None) -> None: """Apply a client's :class:`ValidationHookConfig` to this adapter.""" diff --git a/src/adcp/protocols/mcp.py b/src/adcp/protocols/mcp.py index 46f21e16..a88167de 100644 --- a/src/adcp/protocols/mcp.py +++ b/src/adcp/protocols/mcp.py @@ -508,6 +508,10 @@ async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskRe params, idempotency_key = _idempotency.inject_key( tool_name, params, client_token=self.idempotency_client_token ) + # Apply per-instance envelope enrichment (e.g. adcp_version pin). + # Runs after idempotency injection so the enriched dict is the + # one that's validated and sent. + params = self._enrich_outgoing_params(params) try: # Pre-send schema validation — throws in strict, logs in warn, diff --git a/src/adcp/server/builder.py b/src/adcp/server/builder.py index f8704eb5..c439a512 100644 --- a/src/adcp/server/builder.py +++ b/src/adcp/server/builder.py @@ -115,11 +115,31 @@ async def get_products(params, context=None): serve(server, name="my-seller") """ - def __init__(self, name: str, *, version: str = "1.0.0") -> None: + def __init__( + self, + name: str, + *, + version: str = "1.0.0", + adcp_version: str | None = None, + ) -> None: + from adcp._version import resolve_adcp_version + self.name = name self.version = version + self._adcp_version: str = resolve_adcp_version(adcp_version) self._handlers: dict[str, Callable[..., Any]] = {} + def get_adcp_version(self) -> str: + """Return the AdCP protocol release this server is pinned to. + + Resolved at construction from the ``adcp_version`` kwarg, with + fallback to the SDK's compile-time pin (``ADCP_VERSION`` + packaged with the wheel). Stage 2 plumbing — Stage 3 will use + this to select which schema set the server validates handler + responses against and which capability shape it advertises. + """ + return self._adcp_version + def __getattr__(self, task_name: str) -> Callable[..., Any]: """Return a decorator that registers a handler for the given task.""" if task_name.startswith("_"): @@ -156,8 +176,13 @@ def build_handler(self) -> ADCPHandler[Any]: if domains: from adcp.server.responses import capabilities_response + pinned_version = self._adcp_version + async def auto_capabilities(params: Any, context: Any = None) -> dict[str, Any]: - return capabilities_response(domains) + return capabilities_response( + domains, + adcp_version=pinned_version, + ) handlers["get_adcp_capabilities"] = auto_capabilities diff --git a/src/adcp/server/responses.py b/src/adcp/server/responses.py index dc826ece..1ce525e4 100644 --- a/src/adcp/server/responses.py +++ b/src/adcp/server/responses.py @@ -45,6 +45,9 @@ def capabilities_response( supported_protocols: list[str], *, major_versions: list[int] | None = None, + adcp_version: str | None = None, + supported_versions: list[str] | None = None, + build_version: str | None = None, sandbox: bool = True, features: dict[str, Any] | None = None, idempotency: dict[str, Any] | None = None, @@ -57,7 +60,23 @@ def capabilities_response( Valid values: media_buy, signals, governance, creative, brand, sponsored_intelligence. ``compliance_testing`` is NOT a protocol — pass it via the ``compliance_testing`` kwarg. - major_versions: AdCP major versions. Defaults to [3]. + major_versions: AdCP major versions. Defaults to [3]. Deprecated in + favor of ``supported_versions`` (release-precision); both are + emitted through 3.x for backwards compatibility. + adcp_version: Server's pinned release this response was built + for (release-precision string, e.g. ``"3.1"``). When set, + included on the response envelope so buyers can read what + release the server actually served. Typically passed by + ``ADCPServerBuilder``'s auto-capabilities handler from its + per-instance pin. + supported_versions: Release-precision versions this server speaks + (e.g. ``["3.0", "3.1"]``). Authoritative for buyer-side + release pinning per the version-negotiation RFC. When omitted + and ``adcp_version`` is set, defaults to ``[adcp_version]``. + build_version: Optional advisory metadata — full + VERSION.RELEASE.PATCH of the server's build (e.g. + ``"3.1.2"``). Useful for incident triage; not part of the + wire negotiation contract. sandbox: Whether this is a sandbox agent. Defaults to True. features: Additional feature flags. idempotency: Optional idempotency declaration, nested under @@ -80,6 +99,12 @@ def capabilities_response( ) """ adcp_info: dict[str, Any] = {"major_versions": major_versions or [3]} + if supported_versions is None and adcp_version is not None: + supported_versions = [adcp_version] + if supported_versions: + adcp_info["supported_versions"] = supported_versions + if build_version is not None: + adcp_info["build_version"] = build_version if idempotency: adcp_info["idempotency"] = idempotency resp: dict[str, Any] = { @@ -87,6 +112,8 @@ def capabilities_response( "supported_protocols": supported_protocols, "sandbox": sandbox, } + if adcp_version is not None: + resp["adcp_version"] = adcp_version if features: resp["features"] = features if compliance_testing is not None: diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index fc5cffa2..2957ed9c 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -75,6 +75,7 @@ "Checkpoint", "ComplyTestControllerRequest", "ComplyTestControllerResponse", + "ConfigurationError", "ConsentBasis", "ContentIdType", "ContextMatchRequest", diff --git a/tests/test_adcp_version_option.py b/tests/test_adcp_version_option.py new file mode 100644 index 00000000..da1ee465 --- /dev/null +++ b/tests/test_adcp_version_option.py @@ -0,0 +1,298 @@ +"""Tests for the per-instance ``adcp_version`` constructor option (Stage 2). + +Validates the plumbing only — Stage 3 (per-instance schema/validator +selection, wire emission) is deferred. These tests assert that: + +- Default resolution reads the packaged ``ADCP_VERSION`` file. +- Same-major release- and patch-precision pins are accepted as-is. +- Cross-major pins raise ``ConfigurationError`` at construction. +- Unparseable strings raise ``ConfigurationError``. +- All four constructor surfaces (``ADCPClient``, + ``ADCPMultiAgentClient``, ``ADCPServerBuilder``, ``adcp_server``) + honor the option and expose ``get_adcp_version()``. +""" + +from __future__ import annotations + +import pytest + +from adcp import ADCPClient, ADCPMultiAgentClient, get_adcp_spec_version +from adcp._version import ( + ADCP_MAJOR_VERSION, + COMPATIBLE_ADCP_VERSIONS, + normalize_to_release_precision, + parse_adcp_major_version, + resolve_adcp_version, +) +from adcp.exceptions import ConfigurationError +from adcp.server.builder import ADCPServerBuilder, adcp_server +from adcp.types import AgentConfig, Protocol + +# --------------------------------------------------------------------------- +# parse_adcp_major_version +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "version,expected_major", + [ + ("3.0", 3), + ("3.1", 3), + ("3.0.0", 3), + ("3.0.1", 3), + ("3.1-beta", 3), + ("3.1.0-rc.1", 3), + ("4.0", 4), + ("10.20", 10), + ], +) +def test_parse_adcp_major_version_extracts_major(version: str, expected_major: int) -> None: + assert parse_adcp_major_version(version) == expected_major + + +@pytest.mark.parametrize( + "bad_version", + [ + "banana", + "", + "3", # bare major, no release component + "3.x", + "v3.0", + "3.0.0.0", + ], +) +def test_parse_adcp_major_version_rejects_garbage(bad_version: str) -> None: + with pytest.raises(ValueError): + parse_adcp_major_version(bad_version) + + +# --------------------------------------------------------------------------- +# resolve_adcp_version +# --------------------------------------------------------------------------- + + +def test_resolve_default_returns_normalized_packaged_version() -> None: + """Default pin is the packaged ADCP_VERSION, normalized to release-precision.""" + assert resolve_adcp_version(None) == normalize_to_release_precision(get_adcp_spec_version()) + + +@pytest.mark.parametrize( + "version,expected", + [ + ("3.0", "3.0"), + ("3.1", "3.1"), + ("3.0.0", "3.0"), # normalized — patch stripped + ("3.0.1", "3.0"), # normalized — patch stripped + ("3.1-beta", "3.1-beta"), + ("3.1.0-rc.1", "3.1-rc.1"), # normalized — patch stripped, pre-release kept + ], +) +def test_resolve_same_major_normalized(version: str, expected: str) -> None: + """All same-major pins resolve to release-precision per the spec wire rule.""" + assert resolve_adcp_version(version) == expected + + +# --------------------------------------------------------------------------- +# normalize_to_release_precision +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "input,expected", + [ + ("3.0", "3.0"), + ("3.1", "3.1"), + ("3.0.0", "3.0"), + ("3.0.1", "3.0"), + ("3.1-beta", "3.1-beta"), + ("3.1.0-beta", "3.1-beta"), + ("3.1.0-rc.1", "3.1-rc.1"), + ("3.1.2-beta.5", "3.1-beta.5"), + ("10.20.30", "10.20"), + # Build metadata stripped (not part of contract). + ("3.0.1+canary", "3.0"), + ("3.0+exp.sha.5114f85", "3.0"), + ("3.1.0-beta+sha.5", "3.1-beta"), + ], +) +def test_normalize_strips_patch_keeps_prerelease(input: str, expected: str) -> None: + assert normalize_to_release_precision(input) == expected + + +def test_normalize_rejects_garbage() -> None: + import pytest as _pytest + + with _pytest.raises(ValueError): + normalize_to_release_precision("banana") + + +@pytest.mark.parametrize("version", ["4.0", "2.0", "5.1", "1.0.0"]) +def test_resolve_cross_major_rejected(version: str) -> None: + with pytest.raises(ConfigurationError) as exc: + resolve_adcp_version(version) + assert "cross-major" in str(exc.value).lower() or "major" in str(exc.value).lower() + + +@pytest.mark.parametrize("bad", ["banana", "", "3", "v3.0"]) +def test_resolve_unparseable_rejected(bad: str) -> None: + with pytest.raises(ConfigurationError): + resolve_adcp_version(bad) + + +def test_compatible_versions_constant_matches_major() -> None: + """Every entry in COMPATIBLE_ADCP_VERSIONS must agree on major.""" + for v in COMPATIBLE_ADCP_VERSIONS: + assert parse_adcp_major_version(v) == ADCP_MAJOR_VERSION + + +# --------------------------------------------------------------------------- +# ADCPClient +# --------------------------------------------------------------------------- + + +def _agent_config() -> AgentConfig: + return AgentConfig( + id="test", + agent_uri="https://test.example.com", + protocol=Protocol.A2A, + ) + + +def test_adcp_client_default_uses_normalized_packaged_version() -> None: + client = ADCPClient(_agent_config()) + assert client.get_adcp_version() == normalize_to_release_precision(get_adcp_spec_version()) + + +@pytest.mark.parametrize( + "version,expected", + [ + ("3.0", "3.0"), + ("3.1", "3.1"), + ("3.1-beta", "3.1-beta"), + ("3.0.0", "3.0"), # patch input → release stored + ("3.0.1", "3.0"), + ], +) +def test_adcp_client_pin_normalized(version: str, expected: str) -> None: + client = ADCPClient(_agent_config(), adcp_version=version) + assert client.get_adcp_version() == expected + + +def test_adcp_client_cross_major_rejected() -> None: + with pytest.raises(ConfigurationError): + ADCPClient(_agent_config(), adcp_version="4.0") + + +def test_adcp_client_unparseable_rejected() -> None: + with pytest.raises(ConfigurationError): + ADCPClient(_agent_config(), adcp_version="banana") + + +# --------------------------------------------------------------------------- +# ADCPMultiAgentClient +# --------------------------------------------------------------------------- + + +def test_multi_agent_default_uses_packaged_version() -> None: + multi = ADCPMultiAgentClient(agents=[_agent_config()]) + assert multi.get_adcp_version() == normalize_to_release_precision(get_adcp_spec_version()) + + +def test_multi_agent_pin_forwards_to_per_agent() -> None: + multi = ADCPMultiAgentClient(agents=[_agent_config()], adcp_version="3.1") + assert multi.get_adcp_version() == "3.1" + assert multi.agent("test").get_adcp_version() == "3.1" + + +def test_multi_agent_cross_major_rejected() -> None: + with pytest.raises(ConfigurationError): + ADCPMultiAgentClient(agents=[_agent_config()], adcp_version="4.0") + + +def _two_agents() -> list[AgentConfig]: + return [ + AgentConfig(id="seller_a", agent_uri="https://a.example.com", protocol=Protocol.A2A), + AgentConfig(id="seller_b", agent_uri="https://b.example.com", protocol=Protocol.A2A), + ] + + +def test_multi_agent_per_agent_map_pins_each_agent_independently() -> None: + """Per-agent dict form lets a holdco pin each seller separately.""" + multi = ADCPMultiAgentClient( + agents=_two_agents(), + adcp_version={"seller_a": "3.0", "seller_b": "3.1"}, + ) + assert multi.agent("seller_a").get_adcp_version() == "3.0" + assert multi.agent("seller_b").get_adcp_version() == "3.1" + + +def test_multi_agent_per_agent_map_falls_back_to_default_for_missing_keys() -> None: + multi = ADCPMultiAgentClient( + agents=_two_agents(), + adcp_version={"seller_a": "3.1"}, + ) + assert multi.agent("seller_a").get_adcp_version() == "3.1" + # seller_b missing from map → SDK default. + assert multi.agent("seller_b").get_adcp_version() == normalize_to_release_precision( + get_adcp_spec_version() + ) + + +def test_multi_agent_get_version_raises_on_heterogeneous_pins() -> None: + multi = ADCPMultiAgentClient( + agents=_two_agents(), + adcp_version={"seller_a": "3.0", "seller_b": "3.1"}, + ) + with pytest.raises(ValueError) as exc: + multi.get_adcp_version() + msg = str(exc.value) + assert "heterogeneous" in msg + assert "seller_a" in msg and "seller_b" in msg + + +def test_multi_agent_get_version_returns_uniform_when_map_agrees() -> None: + """Dict form with all agents at the same pin still resolves uniformly.""" + multi = ADCPMultiAgentClient( + agents=_two_agents(), + adcp_version={"seller_a": "3.1", "seller_b": "3.1"}, + ) + assert multi.get_adcp_version() == "3.1" + + +def test_multi_agent_per_agent_map_cross_major_rejected() -> None: + with pytest.raises(ConfigurationError): + ADCPMultiAgentClient( + agents=_two_agents(), + adcp_version={"seller_a": "3.0", "seller_b": "4.0"}, + ) + + +# --------------------------------------------------------------------------- +# ADCPServerBuilder + adcp_server() factory +# --------------------------------------------------------------------------- + + +def test_server_builder_default_uses_packaged_version() -> None: + builder = ADCPServerBuilder("my-seller") + assert builder.get_adcp_version() == normalize_to_release_precision(get_adcp_spec_version()) + + +@pytest.mark.parametrize("version", ["3.0", "3.1"]) +def test_server_builder_explicit_pin_accepted(version: str) -> None: + builder = ADCPServerBuilder("my-seller", adcp_version=version) + assert builder.get_adcp_version() == version + + +def test_server_builder_cross_major_rejected() -> None: + with pytest.raises(ConfigurationError): + ADCPServerBuilder("my-seller", adcp_version="4.0") + + +def test_adcp_server_factory_passes_adcp_version_through() -> None: + builder = adcp_server("my-seller", adcp_version="3.1") + assert builder.get_adcp_version() == "3.1" + + +def test_adcp_server_factory_cross_major_rejected() -> None: + with pytest.raises(ConfigurationError): + adcp_server("my-seller", adcp_version="4.0") diff --git a/tests/test_adcp_version_wire.py b/tests/test_adcp_version_wire.py new file mode 100644 index 00000000..41a89b9a --- /dev/null +++ b/tests/test_adcp_version_wire.py @@ -0,0 +1,145 @@ +"""Stage 3a wire emission tests for the per-instance ``adcp_version`` pin. + +Validates: + +- Outbound request params get ``adcp_version`` injected from the + client's per-instance pin (via ``ProtocolAdapter.envelope_enricher``). +- Caller-supplied values on the params dict win over the enricher. +- ``capabilities_response()`` emits ``supported_versions`` / + ``build_version`` / top-level ``adcp_version`` when the server + builder's pin is threaded through. +- ``ADCPServerBuilder``'s auto-generated capabilities handler passes + the pin into ``capabilities_response()``. +""" + +from __future__ import annotations + +import asyncio + +from adcp import ADCPClient +from adcp.server.builder import ADCPServerBuilder, adcp_server +from adcp.server.responses import capabilities_response +from adcp.types import AgentConfig, Protocol + + +def _agent_config() -> AgentConfig: + return AgentConfig( + id="test", + agent_uri="https://test.example.com", + protocol=Protocol.A2A, + ) + + +# --------------------------------------------------------------------------- +# envelope_enricher / outbound injection +# --------------------------------------------------------------------------- + + +def test_envelope_enricher_injects_pin() -> None: + client = ADCPClient(_agent_config(), adcp_version="3.1") + enriched = client.adapter._enrich_outgoing_params({"brief": "hi"}) + assert enriched == {"adcp_version": "3.1", "brief": "hi"} + + +def test_envelope_enricher_does_not_overwrite_caller_value() -> None: + """Caller-supplied adcp_version on the params dict wins over the pin.""" + client = ADCPClient(_agent_config(), adcp_version="3.1") + enriched = client.adapter._enrich_outgoing_params({"adcp_version": "3.0", "brief": "hi"}) + assert enriched == {"adcp_version": "3.0", "brief": "hi"} + + +def test_envelope_enricher_passes_through_non_dict() -> None: + """Rare but possible: non-dict params (e.g. None or a list) pass through.""" + client = ADCPClient(_agent_config(), adcp_version="3.1") + assert client.adapter._enrich_outgoing_params(None) is None + assert client.adapter._enrich_outgoing_params([1, 2, 3]) == [1, 2, 3] + + +def test_envelope_enricher_uses_default_when_pin_omitted() -> None: + """Default pin = packaged ADCP_VERSION, normalized to release-precision.""" + from adcp import get_adcp_spec_version + from adcp._version import normalize_to_release_precision + + client = ADCPClient(_agent_config()) + enriched = client.adapter._enrich_outgoing_params({}) + assert enriched["adcp_version"] == normalize_to_release_precision(get_adcp_spec_version()) + + +# --------------------------------------------------------------------------- +# capabilities_response() +# --------------------------------------------------------------------------- + + +def test_capabilities_response_omits_new_fields_when_no_pin() -> None: + """Backwards compatible: existing callers see no new fields.""" + resp = capabilities_response(["media_buy"]) + assert "adcp_version" not in resp + assert "supported_versions" not in resp["adcp"] + assert "build_version" not in resp["adcp"] + assert resp["adcp"]["major_versions"] == [3] + + +def test_capabilities_response_emits_supported_versions_from_pin() -> None: + """Single-pin server: supported_versions defaults to [pin].""" + resp = capabilities_response(["media_buy"], adcp_version="3.1") + assert resp["adcp_version"] == "3.1" + assert resp["adcp"]["supported_versions"] == ["3.1"] + # Legacy field still emitted for back-compat. + assert resp["adcp"]["major_versions"] == [3] + + +def test_capabilities_response_explicit_supported_versions_override() -> None: + """Multi-release server: caller passes explicit list.""" + resp = capabilities_response( + ["media_buy"], + adcp_version="3.1", + supported_versions=["3.0", "3.1"], + ) + assert resp["adcp_version"] == "3.1" + assert resp["adcp"]["supported_versions"] == ["3.0", "3.1"] + + +def test_capabilities_response_emits_build_version() -> None: + resp = capabilities_response( + ["media_buy"], + adcp_version="3.1", + build_version="3.1.2", + ) + assert resp["adcp"]["build_version"] == "3.1.2" + + +# --------------------------------------------------------------------------- +# ADCPServerBuilder auto-capabilities passes pin through +# --------------------------------------------------------------------------- + + +def test_server_builder_auto_capabilities_emits_pin() -> None: + """The auto-generated get_adcp_capabilities handler threads the pin + into capabilities_response().""" + builder = adcp_server("my-seller", adcp_version="3.1") + + @builder.get_products + async def _get_products(params, context=None): + return {"products": []} + + handler = builder.build_handler() + + response = asyncio.run(handler.get_adcp_capabilities({}, None)) + assert response["adcp_version"] == "3.1" + assert response["adcp"]["supported_versions"] == ["3.1"] + + +def test_server_builder_auto_capabilities_uses_default_pin() -> None: + """No explicit pin → packaged ADCP_VERSION (normalized) drives the response.""" + from adcp import get_adcp_spec_version + from adcp._version import normalize_to_release_precision + + builder = ADCPServerBuilder("my-seller") + + @builder.get_products + async def _get_products(params, context=None): + return {"products": []} + + handler = builder.build_handler() + response = asyncio.run(handler.get_adcp_capabilities({}, None)) + assert response["adcp_version"] == normalize_to_release_precision(get_adcp_spec_version())