Skip to content
Merged
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
55 changes: 54 additions & 1 deletion src/adcp/migrate/v3_to_v4.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
from __future__ import annotations

import argparse
import importlib
import json
import re
import sys
Expand Down Expand Up @@ -170,8 +171,16 @@
# module. Numbered codegen names cannot be mapped safely by symbol alone: the
# same bare name may describe a different schema in another generated module.
GENERATED_POC_SOURCE_SYMBOL_MAP: dict[tuple[str, str], str] = {
("core.account", "GovernanceAgent"): "adcp.types.CoreGovernanceAgent",
("core.account_ref", "AccountReference1"): "adcp.types.AccountIdReference",
("core.account_ref", "AccountReference2"): "adcp.types.InlineAccountReference",
("core.creative_asset", "CreativeAsset"): "adcp.types.LegacyCreativeAsset",
("core.creative_filters", "CreativeFilters"): "adcp.types.LegacyCreativeFilters",
("core.product_filters", "Country"): "adcp.types.ProductFilterCountry",
("core.product_format_declaration", "ProductFormatDeclaration"): (
"adcp.types.LegacyProductFormatDeclaration"
),
("core.property", "Identifier"): "adcp.types.PropertyIdentifier",
("core.vendor_pricing_option", "VendorPricingOption"): ("adcp.types.VendorPricingOptionUnion"),
("core.vendor_pricing_option", "VendorPricingOption1"): ("adcp.types.CpmVendorPricingOption"),
("core.vendor_pricing_option", "VendorPricingOption2"): (
Expand All @@ -196,6 +205,11 @@
("media_buy.update_media_buy_response", "UpdateMediaBuyResponse2"): (
"adcp.types.LegacyUpdateMediaBuyErrorResponse"
),
("media_buy.update_media_buy_response", "UpdateMediaBuyResponse1"): (
"adcp.types.LegacyUpdateMediaBuySuccessResponse"
),
("creative.list_creatives_request", "Sort"): "adcp.types.ListCreativesSort",
("signals.get_signals_response", "Signal"): "adcp.types.GetSignalsSignal",
}


Expand All @@ -210,12 +224,38 @@
)


def _generated_symbol_replacement(module: str, symbol: str) -> str | None:
def _proposed_generated_symbol_replacement(module: str, symbol: str) -> str | None:
return GENERATED_POC_SOURCE_SYMBOL_MAP.get((module, symbol)) or GENERATED_POC_SYMBOL_MAP.get(
symbol
)


def _replacement_is_identical(module: str, symbol: str, replacement: str) -> bool:
"""Verify that a private class and its proposed public target are identical."""
if not module or not replacement.startswith("adcp.types."):
return False
try:
source_module = importlib.import_module(f"adcp.types.generated_poc.{module}")
public_module = importlib.import_module("adcp.types")
source = getattr(source_module, symbol)
public = getattr(public_module, replacement.removeprefix("adcp.types."))
except (AttributeError, ImportError):
return False
return source is public


def _generated_symbol_replacement(module: str, symbol: str) -> str | None:
replacement = _proposed_generated_symbol_replacement(module, symbol)
if replacement is None or not _replacement_is_identical(module, symbol, replacement):
return None
return replacement


def _unsafe_replacement_hint(module: str, symbol: str, replacement: str) -> str:
source = f"adcp.types.generated_poc.{module}.{symbol}"
return f"SKIP: source {source} is not identical to {replacement} — " "manual rewrite required"


# Regex for numbered Assets direct imports (``Assets5``, ``Assets14``, etc).
# Bare ``Assets`` (no digits) is a legitimate base class alias; the
# regex requires at least one digit to avoid false positives.
Expand Down Expand Up @@ -575,6 +615,19 @@ def scan_file(
# the import-path fix covers it.
if auto_apply and symbol in NUMBERED_ASSETS_RENAMES:
continue
proposed = _proposed_generated_symbol_replacement(module, symbol)
if proposed is not None:
findings.append(
Finding(
kind="flag_private",
path=str(path),
line=lineno,
column=sym_col,
before=symbol,
hint=_unsafe_replacement_hint(module, symbol, proposed),
)
)
continue
findings.append(
Finding(
kind="flag_private",
Expand Down
72 changes: 71 additions & 1 deletion src/adcp/testing/decisioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

from __future__ import annotations

import warnings
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any, Literal
from urllib.parse import urlparse
Expand All @@ -49,7 +50,12 @@
)
from adcp.server.auth import BearerTokenAuth
from adcp.server.helpers import ResponseEnhancer
from adcp.server.serve import ASGIMiddlewareEntry, ContextFactory, SkillMiddleware
from adcp.server.serve import (
ASGIMiddlewareEntry,
ContextFactory,
LifespanHook,
SkillMiddleware,
)
from adcp.server.spec_compat import PreValidationHooks


Expand Down Expand Up @@ -148,12 +154,17 @@ def build_asgi_app(
context_factory: ContextFactory | None = None,
middleware: Sequence[SkillMiddleware] | None = None,
streaming_responses: bool = False,
stateless_http: bool = False,
session_idle_timeout: float | None = 1800.0,
max_active_sessions: int | None = None,
enable_dns_rebinding_protection: bool | None = None,
max_request_size: int | None = None,
validation: ValidationHookConfig | None = DEFAULT_VALIDATION,
discovery_base_url: str | None = None,
pre_validation_hooks: PreValidationHooks | None = None,
response_enhancer: ResponseEnhancer | None = None,
on_startup: Sequence[LifespanHook] | None = None,
on_shutdown: Sequence[LifespanHook] | None = None,
**factory_kwargs: Any,
) -> Any:
"""Build a Starlette ASGI app for in-process integration tests.
Expand Down Expand Up @@ -219,6 +230,15 @@ def build_asgi_app(
every tool dispatch. Forwarded to :func:`create_mcp_server`.
:param streaming_responses: Forwarded to :func:`create_mcp_server`.
Default ``False``.
:param stateless_http: Forwarded to :func:`create_mcp_server` for
``transport="mcp"`` and to the production composition path for
``transport="both"``. Ignored by ``transport="a2a"``.
:param session_idle_timeout: Idle reap deadline for stateful MCP
sessions. Defaults to 1800 seconds. Forwarded for ``"mcp"`` and
``"both"``; ignored by ``"a2a"``.
:param max_active_sessions: Optional cap for active stateful MCP
sessions. Forwarded for ``"mcp"`` and ``"both"``; ignored by
``"a2a"``.
:param enable_dns_rebinding_protection: Forwarded to
:func:`create_mcp_server`. ``None`` → FastMCP default.
:param max_request_size: Request body size cap in bytes. ``None`` →
Expand All @@ -244,6 +264,10 @@ def build_asgi_app(
:func:`create_mcp_server`, so in-process tests exercise the same
enhancer wiring your production :func:`serve` call uses. ``None``
→ no enhancer (default).
:param on_startup: Async zero-argument hooks run after the MCP and A2A
framework lifespans start. Requires ``transport="both"``.
:param on_shutdown: Async zero-argument hooks run before the MCP and A2A
framework lifespans stop. Requires ``transport="both"``.
:param factory_kwargs: Forwarded to
:func:`create_adcp_server_from_platform`. Accepted keys:
``executor``, ``registry``, ``webhook_sender``,
Expand All @@ -257,6 +281,27 @@ def build_asgi_app(
"""
if transport not in ("mcp", "a2a", "both"):
raise ValueError(f"Unsupported transport {transport!r}; expected 'mcp', 'a2a', or 'both'.")
if (on_startup or on_shutdown) and transport != "both":
raise ValueError(
"on_startup / on_shutdown hooks require transport='both', "
f"got transport={transport!r}."
)
if transport == "a2a":
ignored_session_settings = []
if stateless_http:
ignored_session_settings.append("stateless_http")
if session_idle_timeout != 1800.0:
ignored_session_settings.append("session_idle_timeout")
if max_active_sessions is not None:
ignored_session_settings.append("max_active_sessions")
if ignored_session_settings:
warnings.warn(
"build_asgi_app sets MCP-only session fields "
f"{sorted(ignored_session_settings)} but transport='a2a'. "
"These fields will be ignored.",
UserWarning,
stacklevel=2,
)

from adcp.decisioning.serve import create_adcp_server_from_platform
from adcp.server.serve import (
Expand Down Expand Up @@ -291,6 +336,9 @@ def build_asgi_app(
advertise_all=advertise_all,
max_request_size=max_request_size,
streaming_responses=streaming_responses,
stateless_http=stateless_http,
session_idle_timeout=session_idle_timeout,
max_active_sessions=max_active_sessions,
validation=validation,
pre_validation_hooks=pre_validation_hooks,
response_enhancer=response_enhancer,
Expand All @@ -299,6 +347,8 @@ def build_asgi_app(
allowed_origins=allowed_origins,
enable_dns_rebinding_protection=enable_dns_rebinding_protection,
auth=auth,
on_startup=on_startup,
on_shutdown=on_shutdown,
include_discovery=discovery_base_url is not None,
)
return _apply_asgi_middleware(app, asgi_middleware)
Expand Down Expand Up @@ -331,6 +381,9 @@ def build_asgi_app(
context_factory=context_factory,
middleware=middleware,
streaming_responses=streaming_responses,
stateless_http=stateless_http,
session_idle_timeout=session_idle_timeout,
max_active_sessions=max_active_sessions,
enable_dns_rebinding_protection=enable_dns_rebinding_protection,
validation=validation,
pre_validation_hooks=pre_validation_hooks,
Expand Down Expand Up @@ -373,12 +426,17 @@ async def build_test_client(
context_factory: ContextFactory | None = None,
middleware: Sequence[SkillMiddleware] | None = None,
streaming_responses: bool = False,
stateless_http: bool = False,
session_idle_timeout: float | None = 1800.0,
max_active_sessions: int | None = None,
enable_dns_rebinding_protection: bool | None = None,
max_request_size: int | None = None,
validation: ValidationHookConfig | None = DEFAULT_VALIDATION,
discovery_base_url: str | None = None,
pre_validation_hooks: PreValidationHooks | None = None,
response_enhancer: ResponseEnhancer | None = None,
on_startup: Sequence[LifespanHook] | None = None,
on_shutdown: Sequence[LifespanHook] | None = None,
**factory_kwargs: Any,
) -> AsyncIterator[httpx.AsyncClient]:
"""Async context manager yielding an ``httpx.AsyncClient`` wired against
Expand Down Expand Up @@ -427,6 +485,9 @@ async def build_test_client(
:param context_factory: Forwarded to :func:`build_asgi_app`.
:param middleware: Forwarded to :func:`build_asgi_app`.
:param streaming_responses: Forwarded to :func:`build_asgi_app`.
:param stateless_http: Forwarded to :func:`build_asgi_app`.
:param session_idle_timeout: Forwarded to :func:`build_asgi_app`.
:param max_active_sessions: Forwarded to :func:`build_asgi_app`.
:param enable_dns_rebinding_protection: Forwarded to
:func:`build_asgi_app`.
:param max_request_size: Forwarded to :func:`build_asgi_app`.
Expand All @@ -442,6 +503,10 @@ async def build_test_client(
:param response_enhancer: Forwarded to :func:`build_asgi_app`. Wire
the same enhancer your production :func:`serve` call uses so
in-process tests exercise the enhancer path.
:param on_startup: Forwarded to :func:`build_asgi_app`. Requires
``transport="both"``.
:param on_shutdown: Forwarded to :func:`build_asgi_app`. Requires
``transport="both"``.
:param factory_kwargs: Forwarded to
:func:`create_adcp_server_from_platform` via :func:`build_asgi_app`
(executor, registry, webhook_sender, etc.).
Expand Down Expand Up @@ -492,12 +557,17 @@ async def build_test_client(
context_factory=context_factory,
middleware=middleware,
streaming_responses=streaming_responses,
stateless_http=stateless_http,
session_idle_timeout=session_idle_timeout,
max_active_sessions=max_active_sessions,
enable_dns_rebinding_protection=enable_dns_rebinding_protection,
max_request_size=max_request_size,
validation=validation,
discovery_base_url=discovery_base_url,
pre_validation_hooks=pre_validation_hooks,
response_enhancer=response_enhancer,
on_startup=on_startup,
on_shutdown=on_shutdown,
**factory_kwargs,
)
async with LifespanManager(app):
Expand Down
6 changes: 6 additions & 0 deletions src/adcp/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,7 @@
"UpdateContentStandardsSuccessResponse",
"UpdateMediaBuyErrorResponse",
"LegacyUpdateMediaBuyErrorResponse",
"LegacyUpdateMediaBuySuccessResponse",
"UpdateMediaBuyResponse1",
"UpdateMediaBuyResponse3",
"UpdateMediaBuyPackagesRequest",
Expand Down Expand Up @@ -845,6 +846,8 @@
"SignalCoverageForecast",
"SignalCoverageRange",
"MissingMetric",
"PropertyIdentifier",
"ProductFilterCountry",
# Cross-module name collision aliases (#911, Step 2)
# Creative
"DeliveryCreative",
Expand Down Expand Up @@ -1456,6 +1459,7 @@ def __dir__() -> list[str]:
LegacySyncCreativesRequest,
LegacyUpdateMediaBuyErrorResponse,
LegacyUpdateMediaBuyRequest,
LegacyUpdateMediaBuySuccessResponse,
ListAccountsRequest,
ListAccountsResponse,
ListCollectionListsRequest,
Expand Down Expand Up @@ -1567,13 +1571,15 @@ def __dir__() -> list[str]:
ProductCard,
ProductCardDetailed,
ProductCatalog,
ProductFilterCountry,
ProductFilters,
ProductFormatDeclaration,
ProductFormatSellerPreference,
ProductSignalTargetingOption,
Property,
PropertyId,
PropertyIdActivationKey,
PropertyIdentifier,
PropertyIdentifierTypes,
PropertyList,
PropertyListChangedWebhook,
Expand Down
6 changes: 6 additions & 0 deletions src/adcp/types/_eager.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,7 @@
JavascriptFormatGroupAsset,
KeyValueActivationKey,
LegacyUpdateMediaBuyErrorResponse,
LegacyUpdateMediaBuySuccessResponse,
ListContentStandardsErrorResponse,
ListContentStandardsResponse1,
ListContentStandardsSuccessResponse,
Expand Down Expand Up @@ -628,8 +629,10 @@
PreviewRenderingOrigin,
PricingOption,
ProductAllocation,
ProductFilterCountry,
ProductFormatSellerPreference,
PropertyId,
PropertyIdentifier,
PropertyTag,
Provenance,
ProvenanceDeclaredBy,
Expand Down Expand Up @@ -1681,6 +1684,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
"UpdateFrequency",
"UpdateMediaBuyErrorResponse",
"LegacyUpdateMediaBuyErrorResponse",
"LegacyUpdateMediaBuySuccessResponse",
"UpdateMediaBuyPackagesRequest",
"UpdateMediaBuyPropertiesRequest",
"UpdateMediaBuyRequest",
Expand Down Expand Up @@ -1763,6 +1767,8 @@ def __init__(self, *args: object, **kwargs: object) -> None:
"PercentOfMediaVendorPricingOption",
"PerUnitVendorPricingOption",
"ProductAllocation",
"ProductFilterCountry",
"PropertyIdentifier",
"SignalCoverageForecast",
"SignalCoverageRange",
"TrustedMatch",
Expand Down
12 changes: 12 additions & 0 deletions src/adcp/types/aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -2039,6 +2039,12 @@ class UnknownGroupAsset(_BaseGroupAsset):
from adcp.types.generated_poc.core.overlay import (
Unit as OverlayUnit,
)
from adcp.types.generated_poc.core.product_filters import (
Country as ProductFilterCountry,
)
from adcp.types.generated_poc.core.property import (
Identifier as PropertyIdentifier,
)
from adcp.types.generated_poc.core.provenance import (
DeclaredBy as ProvenanceDeclaredBy,
)
Expand Down Expand Up @@ -2099,6 +2105,9 @@ class UnknownGroupAsset(_BaseGroupAsset):
from adcp.types.generated_poc.media_buy.sync_event_sources_response import (
Setup as SyncEventSourcesSetup,
)
from adcp.types.generated_poc.media_buy.update_media_buy_response import (
UpdateMediaBuyResponse1 as LegacyUpdateMediaBuySuccessResponse,
)
from adcp.types.generated_poc.protocol.get_adcp_capabilities_response import (
Account as CapabilitiesAccount,
)
Expand Down Expand Up @@ -2266,6 +2275,8 @@ class UnknownGroupAsset(_BaseGroupAsset):
"SignalCoverageForecast",
"SignalCoverageRange",
"MissingMetric",
"PropertyIdentifier",
"ProductFilterCountry",
# Canonical-formats v2 surface (AdCP 3.1)
"CanonicalAssetSource",
"CanonicalCompositionModel",
Expand Down Expand Up @@ -2435,6 +2446,7 @@ class UnknownGroupAsset(_BaseGroupAsset):
"UpdateMediaBuySuccessResponse",
"UpdateMediaBuyErrorResponse",
"LegacyUpdateMediaBuyErrorResponse",
"LegacyUpdateMediaBuySuccessResponse",
"UpdateMediaBuyResponse3",
"UpdateMediaBuySubmittedResponse",
# Validate content delivery responses
Expand Down
Loading
Loading