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
116 changes: 110 additions & 6 deletions scripts/post_generate_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1536,6 +1536,112 @@ def _intersection_field(field: str, keep_base: str, all_bases: list[str]) -> str
print(" No allOf-merge field override conflicts found")


def expose_account_reference_union_fields() -> None:
"""Replace generated AccountReference wrappers with their concrete arms.

``AccountReference`` is public as a composable object-union alias, but
datamodel-codegen still annotates every schema reference with its outer
``RootModel`` class. Rewrite those generated annotations at the source so
request, nested-input, response, and canonical-clone paths all expose the
same concrete arm types without import-time Pydantic patching.
"""
account_ref_source = OUTPUT_DIR / "core" / "account_ref.py"
if not account_ref_source.exists():
print(" account reference model not found (skipping union-field fix)")
return

tree = ast.parse(account_ref_source.read_text())
wrapper = next(
(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == "AccountReference"
),
None,
)
if wrapper is None:
raise RuntimeError("generated account_ref.py has no AccountReference wrapper")

root_base = next(
(
base
for base in wrapper.bases
if isinstance(base, ast.Subscript)
and isinstance(base.value, ast.Name)
and base.value.id == "RootModel"
),
None,
)
if root_base is None:
raise RuntimeError("generated AccountReference has no RootModel union base")

def union_arm_names(node: ast.expr) -> list[str]:
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
return [*union_arm_names(node.left), *union_arm_names(node.right)]
if isinstance(node, ast.Name):
return [node.id]
raise RuntimeError(
"generated AccountReference has an unsupported union expression: "
f"{ast.unparse(node)}"
)

arm_names = union_arm_names(root_base.slice)
if len(arm_names) < 2 or len(set(arm_names)) != len(arm_names):
raise RuntimeError(f"generated AccountReference has invalid union arms: {arm_names!r}")

pattern = re.compile(r"\b(account_ref(?:_\d+)?)\.AccountReference\b(?!\d)")
total_files = 0
total_fields = 0

for py_file in sorted(OUTPUT_DIR.rglob("*.py")):
source = py_file.read_text()
fixed, replacements = pattern.subn(
lambda match: " | ".join(f"{match.group(1)}.{arm_name}" for arm_name in arm_names),
source,
)
if not replacements:
continue
py_file.write_text(fixed)
total_files += 1
total_fields += replacements

if total_fields:
print(
f" Exposed AccountReference union arms in {total_fields} field(s) "
f"across {total_files} file(s)"
)
else:
print(" AccountReference field annotations already expose concrete arms")


def fix_postal_union_arm_order() -> None:
"""Prefer the legacy postal arm when a payload omits ``country``.

The generated native arm contains country-specific models whose ``country``
fields have defaults. When that arm appears first, a legacy payload such as
``{"system": "us_zip", ...}`` is accepted as native and serializes with an
injected ``country`` that is incompatible with the retained fused system.
The legacy arm forbids extra fields, so putting it first is safe: native
payloads with ``country`` fall through to the native arm.
"""
target = OUTPUT_DIR / "core" / "postal_area.py"
if not target.exists():
print(" postal area model not found (skipping arm-order fix)")
return

source = target.read_text()
old = "PostalArea1 | PostalArea2"
new = "PostalArea2 | PostalArea1"
replacements = source.count(old)
if replacements:
target.write_text(source.replace(old, new))
print(f" core/postal_area.py: reordered {replacements} postal union annotation(s)")
elif new in source:
print(" postal area union already prefers the legacy arm")
else:
raise RuntimeError("generated postal_area.py has an unexpected outer union shape")


def fix_postal_country_system_pairing() -> None:
"""Restore postal country/system pairing dropped by model generation.

Expand Down Expand Up @@ -4738,9 +4844,7 @@ def preserve_request_signing_operation_strings() -> None:
OUTPUT_DIR / "bundled" / "protocol" / "get_adcp_capabilities_response.py",
)
operation_item = "Annotated[str, Field(pattern='^[a-z][a-z0-9_]*$')]"
item_model = re.compile(
r"list\[(?:RequiredForItem|WarnForItem|SupportedForItem)\d*\]"
)
item_model = re.compile(r"list\[(?:RequiredForItem|WarnForItem|SupportedForItem)\d*\]")

for target in targets:
if not target.exists():
Expand All @@ -4751,9 +4855,7 @@ def preserve_request_signing_operation_strings() -> None:
if class_start < 0 or class_end < 0:
continue
request_signing = source[class_start:class_end]
fixed_class, replacements = item_model.subn(
f"list[{operation_item}]", request_signing
)
fixed_class, replacements = item_model.subn(f"list[{operation_item}]", request_signing)
if replacements:
fixed = source[:class_start] + fixed_class + source[class_end:]
target.write_text(fixed)
Expand All @@ -4780,6 +4882,8 @@ def main():
rewrite_response_list_to_sequence,
fix_reuse_model_discriminator_bug,
fix_allof_merge_field_override_conflicts,
expose_account_reference_union_fields,
fix_postal_union_arm_order,
fix_postal_country_system_pairing,
fix_adagents_duplicate_aliases,
restore_format_category_deprecation_shim,
Expand Down
41 changes: 25 additions & 16 deletions src/adcp/server/a2a_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import os
import warnings
from contextvars import ContextVar
from functools import lru_cache
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4

Expand All @@ -44,21 +45,6 @@
from adcp.server.base import ADCPHandler, ToolContext
from adcp.server.helpers import ResponseEnhancer, _apply_response_enhancer

# Decisioning-layer ``AdcpError`` (from ``adcp.decisioning.types``) is the
# wire-shaped structured error platform methods raise. It is NOT a subclass
# of :class:`adcp.exceptions.ADCPError`; the executor must catch both so
# storyboards graded against decisioning adopters see the same structured
# envelope as MCP. Lazy import — ``adcp.decisioning`` pulls in the
# decisioning graph, which the A2A server module shouldn't load at import
# time. When the import fails (decisioning extra not installed), only the
# client-side ``ADCPError`` path is active.
try:
from adcp.decisioning.types import AdcpError as _DecisioningAdcpError
except Exception: # pragma: no cover - decisioning is an optional dep surface
_DECISIONING_ADCP_ERROR_TYPES: tuple[type[BaseException], ...] = ()
else:
_DECISIONING_ADCP_ERROR_TYPES = (_DecisioningAdcpError,)

if TYPE_CHECKING:
from collections.abc import Sequence

Expand Down Expand Up @@ -143,6 +129,29 @@ async def agent_card_url(request: Request) -> str:
from adcp.server.test_controller import TestControllerStore, _handle_test_controller

logger = logging.getLogger(__name__)


@lru_cache(maxsize=1)
def _load_decisioning_adcp_error_types() -> tuple[type[BaseException], ...]:
"""Load the decisioning error type after application imports settle."""
from adcp.decisioning.types import AdcpError as DecisioningAdcpError

return (DecisioningAdcpError,)


def _get_decisioning_adcp_error_types() -> tuple[type[BaseException], ...]:
"""Return structured decisioning errors without caching import failures."""
try:
return _load_decisioning_adcp_error_types()
except ImportError:
logger.warning(
"Unable to import the decisioning AdcpError type; "
"decisioning errors cannot be projected on A2A yet",
exc_info=True,
)
return ()


_A2A_REQUEST_CONTEXT: ContextVar[Any | None] = ContextVar("adcp_a2a_request_context", default=None)
_A2A_PARSED_REQUEST_SCOPE_KEY = "adcp.a2a_parsed_request"

Expand Down Expand Up @@ -431,7 +440,7 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non
# ``adcp_error`` envelope per transport-errors.mdx §A2A Binding.
structured_error_types: tuple[type[BaseException], ...] = (
ADCPError,
*_DECISIONING_ADCP_ERROR_TYPES,
*_get_decisioning_adcp_error_types(),
)
try:
result = await self._dispatch_with_middleware(skill_name, params, tool_context)
Expand Down
6 changes: 5 additions & 1 deletion src/adcp/server/governance_enforcement.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from dataclasses import dataclass
from typing import Any, TypeAlias

from adcp.decisioning.errors import PermissionDeniedError
from adcp.governance import (
GovernanceAuthorizationFailure,
GovernanceAuthorizationSuccess,
Expand Down Expand Up @@ -146,6 +145,11 @@ async def verify_then_call(
if not result.ok:
if on_rejected is not None:
await _maybe_await(on_rejected(result, context))
# Keep server package initialization independent of the
# decisioning graph; that graph imports webhook helpers
# which may already be mid-import in standalone consumers.
from adcp.decisioning.errors import PermissionDeniedError

raise PermissionDeniedError(
message="Governance authorization rejected.",
field="governance_context",
Expand Down
3 changes: 2 additions & 1 deletion src/adcp/server/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ async def get_products():
from typing import Any

from adcp._version import ADCP_MAJOR_VERSION, get_supported_adcp_versions
from adcp.decisioning.account_projection import strip_credentials_from_wire_result
from adcp.server.helpers import valid_actions_for_status
from adcp.types.canonical_creative import Format, strip_legacy_creative_identity

Expand Down Expand Up @@ -184,6 +183,8 @@ def _strip_write_only_fields(value: Any) -> Any:
helper normalizes nested Pydantic models before recursing and does not
mutate the caller's value.
"""
from adcp.decisioning.account_projection import strip_credentials_from_wire_result

return strip_credentials_from_wire_result("sync_accounts", value)


Expand Down
17 changes: 5 additions & 12 deletions src/adcp/server/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -2735,17 +2735,10 @@ def _register_tool(
from pydantic import ConfigDict

from adcp.exceptions import ADCPError
from adcp.server.translate import build_mcp_error_result

# Lazy import — decisioning is optional for non-platform handlers,
# but when present its ``AdcpError`` carries structured ``details``
# (caused_by, validation_errors) that need to reach the wire.
try:
from adcp.decisioning.types import AdcpError as DecisioningAdcpError # noqa: N813
except Exception:
decisioning_error_types: tuple[type[BaseException], ...] = ()
else:
decisioning_error_types = (DecisioningAdcpError,)
from adcp.server.translate import (
_get_decisioning_adcp_error_types,
build_mcp_error_result,
)

async def fn(**kwargs: Any) -> dict[str, Any]:
# Caller identity: FastMCP does not expose an authenticated principal
Expand Down Expand Up @@ -2824,7 +2817,7 @@ async def _call_handler() -> Any:
# ``adcp.exceptions.ADCPError`` (different class hierarchy
# — ``adcp.decisioning.types.AdcpError``). Catch it explicitly
# and project the same structured envelope.
if isinstance(exc, decisioning_error_types):
if isinstance(exc, _get_decisioning_adcp_error_types()):
return build_mcp_error_result( # type: ignore[return-value]
exc,
params=kwargs,
Expand Down
27 changes: 19 additions & 8 deletions src/adcp/server/translate.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from __future__ import annotations

import json
from functools import lru_cache
from typing import TYPE_CHECKING, Any, Literal, cast
from urllib.parse import urlparse

Expand All @@ -49,6 +50,23 @@
from adcp.types import Error
from adcp.types.core import Protocol


@lru_cache(maxsize=1)
def _load_decisioning_adcp_error_types() -> tuple[type[BaseException], ...]:
"""Load the decisioning error type after application imports settle."""
from adcp.decisioning.types import AdcpError as DecisioningAdcpError

return (DecisioningAdcpError,)


def _get_decisioning_adcp_error_types() -> tuple[type[BaseException], ...]:
"""Return decisioning error types without caching transient failures."""
try:
return _load_decisioning_adcp_error_types()
except ImportError:
return ()


if TYPE_CHECKING:
from adcp.server.base import ToolContext

Expand Down Expand Up @@ -122,14 +140,7 @@ def _extract_structured_fields(
Used by both ``translate_error`` and ``build_mcp_error_result`` so the
field-extraction logic stays in one place.
"""
# Lazy import — ``adcp.decisioning.types`` pulls in the decisioning
# graph, which translate.py shouldn't load at module-import time.
try:
from adcp.decisioning.types import AdcpError as DecisioningAdcpError # noqa: N813
except Exception:
decisioning_error_types: tuple[type[BaseException], ...] = ()
else:
decisioning_error_types = (DecisioningAdcpError,)
decisioning_error_types = _get_decisioning_adcp_error_types()

field: str | None = None
if isinstance(exc, Error):
Expand Down
2 changes: 1 addition & 1 deletion src/adcp/types/aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ def _generated_alias(name: str, fallback_name: str) -> Any:
# of the generator's outer RootModel wrappers. They compose cleanly in adopter
# annotations without imposing another wrapper around their constituent arms.
PostalArea = _Annotated[
PostalArea1 | PostalArea2,
PostalArea2 | PostalArea1,
BeforeValidator(_g.PostalArea._validate_country_system_pairing),
]
"""Postal-area union; validate raw values with ``TypeAdapter(PostalArea)``."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class GetAccountFinancialsRequest(AdcpVersionEnvelope):
extra='allow',
)
account: Annotated[
account_ref.AccountReference,
account_ref.AccountReference1 | account_ref.AccountReference2,
Field(description='Account to query financials for. Must be an operator-billed account.'),
]
period: Annotated[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ class Invoice(AdcpVersionEnvelope):

class GetAccountFinancialsResponse1(AdcpVersionEnvelope):
model_config = ConfigDict(extra='allow')
account: account_ref_1.AccountReference
account: account_ref_1.AccountReference1 | account_ref_1.AccountReference2
currency: Annotated[str, StringConstraints(pattern='^[A-Z]{3}$')]
period: date_range_1.DateRange
timezone: str
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class ListAccountsRequest(AdcpVersionEnvelope):
extra='allow',
)
account: Annotated[
account_ref.AccountReference | None,
account_ref.AccountReference1 | account_ref.AccountReference2 | None,
Field(
description='Optional exact account filter. Use `account_id` to retrieve one known seller/storefront account, or the complete natural key (`brand` + `operator` + optional `operator_unit`, fixed `currency`, buyer-selected account `timezone`, and `sandbox`) for buyer-declared accounts. When present, the seller returns only matching accounts visible to the authenticated caller.'
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class UsageItem(AdCPBaseModel):
extra='allow',
)
account: Annotated[
account_ref.AccountReference, Field(description='Account for this usage record.')
account_ref.AccountReference1 | account_ref.AccountReference2, Field(description='Account for this usage record.')
]
media_buy_id: Annotated[
str | None,
Expand Down
4 changes: 2 additions & 2 deletions src/adcp/types/generated_poc/account/sync_accounts_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class Accounts(AdCPBaseModel):
extra='allow',
)
account: Annotated[
account_ref.AccountReference | None,
account_ref.AccountReference1 | account_ref.AccountReference2 | None,
Field(
description='Settings-update key. When present, this entry targets an existing account by `account_id` (seller-owned account namespace) or natural key (buyer-declared account settings-update against a previously-provisioned account). Mutually exclusive with the flat `brand` + `operator` + `billing` provisioning trio. When `account` is present, the seller MUST NOT create a new account — entries that would otherwise trigger provisioning are rejected with `UNSUPPORTED_PROVISIONING`.'
),
Expand Down Expand Up @@ -127,7 +127,7 @@ class Accounts1(AdCPBaseModel):
extra='allow',
)
account: Annotated[
account_ref.AccountReference,
account_ref.AccountReference1 | account_ref.AccountReference2,
Field(
description='Settings-update key. When present, this entry targets an existing account by `account_id` (seller-owned account namespace) or natural key (buyer-declared account settings-update against a previously-provisioned account). Mutually exclusive with the flat `brand` + `operator` + `billing` provisioning trio. When `account` is present, the seller MUST NOT create a new account — entries that would otherwise trigger provisioning are rejected with `UNSUPPORTED_PROVISIONING`.'
),
Expand Down
Loading
Loading