From cc11643352a078b977449f44dcd2d5687ad29bf6 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 07:50:57 -0400 Subject: [PATCH 01/28] fix(server): compact a pinned seller's tools/list so an SDK 10 buyer can call it (#1702) A version-pinned MCP seller (an adcp_version pin, which every decisioning PlatformHandler carries) advertised the exact bundled wire schemas in tools/list, ignoring schema_mode. get_products's response alone resolves 278 definitions, and MCP cannot share definitions between tools, so a plain eleven-tool seller listed 106,250 JSON members (2.5 MB). An SDK 10 buyer bounds a response's JSON members and its MCP client lists tools before every call, so every call to such a seller failed with ADCPResponseTooComplexError before the handler ran (#1692). This line's client has no member bound; the fix is to the listing a 9.x seller serves. The default schema_mode="compact" now compacts the bundled schema by the rules the unpinned path already applied to Pydantic output (every root field with its description and rules, every required nested field, every scalar with its constraints, each dropped optional nested field re-admitted as true), after dropping nested cross-field rules (if/then/else, not), nested x- metadata, nested $schema and definition descriptions, and advertising oneOf as anyOf so a loosened arm can never make a valid response fail the client's output-schema check. Local $ref pointers are JSON-Pointer-unescaped (the bundled definitions are named after paths, "external:core~1product.json"). The compact shape is memoized with the loader state. schema_mode="defs" and "inline" keep the exact bundled schema. The startup size line reports JSON members as well as bytes and warns past half of either SDK 10 buyer bound. The issue's seller lists 17,181 members (467 KB). Every tool the SDK can serve, pinned to the packaged release, lists 66,028 members and 1.8 MB, under half of both bounds; a guard test pins that, and an end-to-end test has an SDK client at defaults call get_products on an SDK seller at defaults over MCP. Fixes #1692 Co-authored-by: Claude Fable 5.1 --- CHANGELOG.md | 23 ++ docs/handler-authoring.md | 23 ++ src/adcp/server/mcp_tools.py | 221 +++++++++++-- src/adcp/server/serve.py | 55 +++- src/adcp/validation/schema_loader.py | 36 ++ tests/test_discovery_payload_size.py | 329 ++++++++++++++++++- tests/test_mcp_client_seller_defaults_e2e.py | 153 +++++++++ tests/test_mcp_schema_materialization.py | 16 +- 8 files changed, 819 insertions(+), 37 deletions(-) create mode 100644 tests/test_mcp_client_seller_defaults_e2e.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b96a7a8b9..76bb733b99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,29 @@ ### Bug fixes +* **server:** a version-pinned MCP seller — an `adcp_version` pin, which every + decisioning `PlatformHandler` carries — advertised the *exact* bundled wire + schemas in `tools/list`, ignoring `schema_mode`. A single `get_products` + response resolves about 280 definitions, and MCP cannot share definitions + between tools, so a plain eleven-tool seller listed 106,250 JSON members + (2.5 MB) and every SDK 10 client call to it failed with + `ADCPResponseTooComplexError` before the handler ran, because the MCP + client lists tools before each call and the SDK 10 client bounds a + response's JSON members. The default `schema_mode="compact"` now compacts + the bundled schemas by the rules the unpinned path already applied to + Pydantic output — every root field with its description and rules, every + required nested field, every scalar with its constraints, each dropped + optional nested field re-admitted as `true` — and additionally drops nested + cross-field rules (`if`/`then`/`else`, `not`), nested `x-` metadata, nested + `$schema` and definition descriptions, and advertises `oneOf` as `anyOf` so + a loosened arm can never make a valid response fail the client's + output-schema check. The issue's seller lists 17,181 members (467 KB); + every tool the SDK can serve, pinned, lists about 67,000 members and + 1.8 MB, under half of both SDK 10 buyer bounds, and a guard test pins that. + `schema_mode="defs"` (and `"inline"`) keep the exact bundled schema. The + startup size line now reports JSON members as well as bytes and warns past + half of either bound. Fixes #1692. + * **server:** MCP ``outputSchema`` now honours ``schema_mode`` instead of always inlining every ``$ref``. Inlining expands each shared subtree once per reference, so ``ListCreativesResponse`` was advertised at 4.8 MB where diff --git a/docs/handler-authoring.md b/docs/handler-authoring.md index fefd635b2d..74fa0de0b2 100644 --- a/docs/handler-authoring.md +++ b/docs/handler-authoring.md @@ -82,6 +82,29 @@ budget. ``schema_mode="defs"`` is also available when a client resolves references and needs the complete, unpruned Pydantic schema graph. +**Pinned servers.** A server with an ``adcp_version`` pin advertises the bundled wire schemas of that release rather than the Pydantic +graph. ``schema_mode`` applies there too: ``"compact"`` keeps every root field +with its description and rules, every required nested field, every scalar +with its constraints, and re-admits each dropped optional nested field as +``true``, while dropping nested cross-field rules (``if``/``then``/``else``, +``not``), nested ``x-`` metadata and definition descriptions; ``"defs"`` and +``"inline"`` keep the exact bundled schema, complete ``$defs`` included. The +exact schema is large — a single ``get_products`` response resolves about 280 +definitions — and MCP cannot share definitions between tools, so an +eleven-tool seller advertising it listed 106,250 JSON members against a buyer +member bound of 100,000 and could not be called at all (#1692). Compact, the +same seller lists about 17,000. + +Buyers bound a response's JSON members (object keys plus array elements) as +well as its bytes, and the MCP client lists tools before every call, so a +listing over either bound fails every tool call. The startup line reports +both figures and warns past half of either bound: + +``` +mcp server tools/list payload: 466730 bytes and 17181 JSON members across 11 tools +``` + + **Keeping discovery small.** The two levers are the number of tools you advertise (declare ``advertised_tools``; avoid ``advertise_all=True`` unless you mean it) and the schema mode. Both are visible in the startup line: diff --git a/src/adcp/server/mcp_tools.py b/src/adcp/server/mcp_tools.py index 26d01f8af4..de3688d579 100644 --- a/src/adcp/server/mcp_tools.py +++ b/src/adcp/server/mcp_tools.py @@ -62,6 +62,16 @@ _SCHEMA_MODES: frozenset[str] = frozenset({"compact", "defs", "inline"}) _SCHEMA_LITERAL_KEYS: frozenset[str] = frozenset({"const", "default", "examples"}) +#: Subschemas whose meaning inverts under compaction. Pruning only ever +#: *loosens* a subschema (an optional nested field is dropped and re-admitted +#: as ``true``), which is sound for a positive assertion but not for a negated +#: or conditional one: a looser ``not`` rejects more, and a looser ``if`` +#: selects its ``then`` arm more often. Both are copied verbatim, and the +#: definitions they reference are kept whole. The bundled wire schemas use +#: both heavily (``if``/``then`` discriminators on ``status``, ``pricing_model`` +#: and the like); Pydantic rarely emits either. +_SCHEMA_VERBATIM_KEYS: frozenset[str] = frozenset({"if", "not"}) +_SCALAR_SCHEMA_TYPES: frozenset[str] = frozenset({"boolean", "integer", "number", "string"}) # These optional nested fields are part of the advertised mutation contract, # even though the surrounding request models do not require them. Their schema @@ -1613,7 +1623,12 @@ def _resolve(node: Any) -> Any: # untouched rather than risk silent corruption. unresolved[0] = True return {k: _resolve(v) for k, v in node.items()} - def_name = ref[len("#/$defs/") :] + def_name = _local_definition_name(ref, whole=True) + if def_name is None: + # A pointer into the middle of a definition: the body is + # not a whole definition, so leave the reference. + unresolved[0] = True + return {k: _resolve(v) for k, v in node.items()} if def_name in resolving_defs: # Cycle — leave the $ref intact so a spec-compliant # client can still resolve via $defs. @@ -1664,6 +1679,25 @@ def _resolve(node: Any) -> Any: return result +def _local_definition_name(ref: Any, *, whole: bool = False) -> str | None: + """The ``$defs`` entry a local reference resolves through, else ``None``. + + A JSON Pointer escapes ``~`` as ``~0`` and ``/`` as ``~1`` (RFC 6901 §3), + and the bundled wire schemas name their definitions after source paths + (``external:core/product.json``), so the escape is live in every + ``$ref`` they emit; Pydantic's class-named definitions never need it. A + pointer that continues *into* a definition (``#/$defs/X/properties/y``) + reaches ``X`` and is reported as such — unless ``whole`` is set, for + callers that need the referenced body to *be* the definition. + """ + if not isinstance(ref, str) or not ref.startswith("#/$defs/"): + return None + segment, _, rest = ref[len("#/$defs/") :].partition("/") + if whole and rest: + return None + return segment.replace("~1", "/").replace("~0", "~") + + def _copy_json_without_aliases(value: Any) -> Any: """Copy JSON-like data while materializing shared branches separately.""" if isinstance(value, dict): @@ -1677,9 +1711,9 @@ def _local_ref_names(value: Any) -> set[str]: """Collect definition names referenced by a JSON Schema fragment.""" names: set[str] = set() if isinstance(value, dict): - ref = value.get("$ref") - if isinstance(ref, str) and ref.startswith("#/$defs/"): - names.add(ref.removeprefix("#/$defs/")) + name = _local_definition_name(value.get("$ref")) + if name is not None: + names.add(name) for key, item in value.items(): if key != "$defs" and key not in _SCHEMA_LITERAL_KEYS: names.update(_local_ref_names(item)) @@ -1690,7 +1724,14 @@ def _local_ref_names(value: Any) -> set[str]: def _protected_definition_names(schema: dict[str, Any]) -> frozenset[str]: - """Find transitive definitions used by protected discovery fields.""" + """Find transitive definitions kept whole by compaction. + + Those reached from a protected discovery field + (:data:`_COMPACT_NESTED_DISCOVERY_FIELDS`), and those reached from a + subschema that is copied verbatim (:data:`_SCHEMA_VERBATIM_KEYS`): a + ``not`` or ``if`` that resolves a reference must find the definition it + was written against, not a loosened one. + """ direct: set[str] = set() def _visit(value: Any) -> None: @@ -1701,7 +1742,9 @@ def _visit(value: Any) -> None: if name in properties: direct.update(_local_ref_names(properties[name])) for key, item in value.items(): - if key not in _SCHEMA_LITERAL_KEYS: + if key in _SCHEMA_VERBATIM_KEYS: + direct.update(_local_ref_names(item)) + elif key not in _SCHEMA_LITERAL_KEYS: _visit(item) elif isinstance(value, list): for item in value: @@ -1729,18 +1772,27 @@ def _is_scalar_discovery_schema( *, _seen: frozenset[str] = frozenset(), ) -> bool: - """Return whether a field is a scalar (or nullable scalar) schema.""" + """Return whether a field is a scalar (or nullable scalar) schema. + + Pydantic spells a nullable scalar as ``anyOf: [{type: string}, {type: + null}]``; the bundled wire schemas spell it ``type: ["string", "null"]``. + Both count. + """ if not isinstance(schema, dict): return False - if schema.get("type") in {"boolean", "integer", "number", "string"}: + schema_type = schema.get("type") + if isinstance(schema_type, list): + non_null_types = [item for item in schema_type if item != "null"] + return bool(non_null_types) and all(item in _SCALAR_SCHEMA_TYPES for item in non_null_types) + if schema_type in _SCALAR_SCHEMA_TYPES: return True if "const" in schema and not isinstance(schema["const"], (dict, list)): return True ref = schema.get("$ref") - if isinstance(ref, str) and ref.startswith("#/$defs/"): - name = ref.removeprefix("#/$defs/") - if name in _seen or name not in defs: + if isinstance(ref, str): + name = _local_definition_name(ref, whole=True) + if name is None or name in _seen or name not in defs: return False return _is_scalar_discovery_schema( defs[name], @@ -1823,7 +1875,7 @@ def _prune_to_required_fields( result: dict[str, Any] = {} for key, value in schema.items(): - if key in _SCHEMA_LITERAL_KEYS: + if key in _SCHEMA_LITERAL_KEYS or key in _SCHEMA_VERBATIM_KEYS: result[key] = _copy_json_without_aliases(value) continue if key == "$defs" and isinstance(value, dict): @@ -2665,6 +2717,131 @@ def _resolve_handler_adcp_version( return candidate if isinstance(candidate, str) else None +#: Keywords whose value is a map of *names* to schemas rather than a schema. +#: Simplification must not read those names as keywords: a product has a +#: property literally called ``description``. +_SCHEMA_MAP_KEYS: frozenset[str] = frozenset( + {"properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies"} +) +#: Cross-field rules the compact wire shape drops below the root: a +#: conditional (``if``/``then``/``else``) or a negation (``not``). Dropping a +#: constraint only loosens a schema, and the server still validates every +#: request against the exact bundled contract, so nothing is accepted that +#: the exact schema would refuse. At the root they describe the tool's own +#: required-field rules (``buying_mode`` on ``get_products``) and stay. +_WIRE_NESTED_RULE_KEYS: frozenset[str] = frozenset({"if", "then", "else", "not"}) + + +def _simplify_wire_schema( + node: Any, *, root: bool = True, in_defs: bool = False, names: bool = False +) -> Any: + """Reduce a bundled wire schema to what a compact ``tools/list`` carries. + + Every change here either has no validation semantics or only *loosens* + the schema, so what it accepts is a superset of the exact contract, which + :func:`_prune_to_required_fields` then relies on. ``schema_mode="defs"`` + still carries all of it. + + * ``x-`` extension keys (``x-added-in``, ``x-entity``, + ``x-adcp-validation``, ...) below the root are dropped. At the root they + describe the tool itself (``x-mutates-state``, ``x-operation-family``) + and stay. + * ``$schema`` below the root is dropped; draft-07 does not read it there. + * ``description`` inside ``$defs`` is dropped. Root fields keep theirs: + those are what a reader of ``tools/list`` acts on. + * Below the root, :data:`_WIRE_NESTED_RULE_KEYS` are dropped. + * ``oneOf`` becomes ``anyOf``. The bundled schemas discriminate ``oneOf`` + arms with scalar constants and ``required`` lists, both of which + compaction keeps, but an arm that a dropped rule or pruned optional + field used to narrow could come to overlap another, and ``oneOf`` would + then reject a valid instance. ``anyOf`` cannot; it is also what Pydantic + emits for the same unions on the unpinned path. + + Values of literal keywords (``const``, ``default``, ``examples``, + ``enum``) are arbitrary JSON and are copied untouched. + """ + if isinstance(node, dict): + result: dict[str, Any] = {} + for key, value in node.items(): + if not names: + if not root and (key.startswith("x-") or key == "$schema"): + continue + if not root and key in _WIRE_NESTED_RULE_KEYS: + continue + if in_defs and key == "description": + continue + if key in _SCHEMA_LITERAL_KEYS or key == "enum": + result[key] = _copy_json_without_aliases(value) + continue + simplified = _simplify_wire_schema( + value, + root=False, + in_defs=in_defs or (not names and key == "$defs"), + names=not names and key in _SCHEMA_MAP_KEYS, + ) + if not names and key == "oneOf" and isinstance(simplified, list): + if "anyOf" in node: + result.setdefault("allOf", []).append({"anyOf": simplified}) + else: + result["anyOf"] = simplified + continue + result[key] = simplified + return result + if isinstance(node, list): + return [ + _simplify_wire_schema(item, root=False, in_defs=in_defs, names=False) for item in node + ] + return node + + +def _compact_wire_schema(schema: dict[str, Any]) -> dict[str, Any]: + """Compact a bundled wire schema for ``tools/list``. + + :func:`_simplify_wire_schema`, then the transform the unpinned path + applies to Pydantic output (:func:`_prune_to_required_fields` and + :func:`_drop_unreachable_defs`), on the self-contained production-profile + schema a pinned server advertises. That schema is already deduplicated + within one tool through ``$defs``, but it carries the complete transitive + closure of the wire contract — ``get_products``'s response alone resolves + 278 definitions and 14,865 JSON members — and MCP has no way to share + definitions *between* tools, so an eleven-tool seller listed 106,250 + members where the client's default bound was 100,000 (#1692). Compaction + keeps every root field with its description and rules, every required + nested field, every scalar with its constraints, and every protected + subschema, and re-admits each dropped optional nested field as the + permissive ``true``; the result stays a description every real response + validates against, at about a fifth of the size. + """ + return _drop_unreachable_defs(_prune_to_required_fields(_simplify_wire_schema(schema))) + + +def _versioned_discovery_schema( + tool_name: str, + direction: Literal["request", "sync"], + version: str, + *, + compact: bool, +) -> dict[str, Any] | None: + """The bundled wire schema a pinned server advertises, compact on request. + + Both shapes are memoized with the loader state for ``version`` — see + :func:`adcp.validation.schema_loader.get_derived_mcp_schema` — so a + per-request caller (the pre-auth agent-card route) pays the transform + once, and a loader reset discards it with everything else. + """ + if not compact: + return get_mcp_schema(tool_name, direction, version=version) + from adcp.validation.schema_loader import get_derived_mcp_schema + + return get_derived_mcp_schema( + tool_name, + direction, + version=version, + derivation="compact", + derive=_compact_wire_schema, + ) + + #: Non-compact schemas, memoized per (kind, mode, tool). The compact path #: reads the already-built ``ADCP_TOOL_DEFINITIONS``; without this the other #: two modes regenerate the whole Pydantic graph on *every* call, which lands @@ -2749,8 +2926,10 @@ def get_tools_for_handler( modes regenerate input *and* output schemas on every call with no memoization — seconds per construction for a 13-tool handler — where ``"compact"`` reads the memoized registry. - Version-pinned handlers continue to advertise their exact bundled - wire schemas. + A version-pinned handler advertises the bundled wire schemas of + that release: compacted by the same rules under ``"compact"``, + and exact — complete local ``$defs`` — under ``"defs"`` and + ``"inline"``. Returns: Filtered list of tool definitions. @@ -2860,14 +3039,18 @@ def get_tools_for_handler( f"no bundled AdCP schemas are available for adcp_version={resolved_version!r}" ) - # A pinned server advertises the exact bundled wire contract. This also - # removes tools absent from that release (for example, the compact 3.2 - # lifecycle on a 3.1 endpoint) instead of leaking the process-global + # A pinned server advertises the bundled wire contract of its release — + # compacted for discovery in the default mode, exact in the others. This + # also removes tools absent from that release (for example, the compact + # 3.2 lifecycle on a 3.1 endpoint) instead of leaking the process-global # current-model surface into tools/list. + compact = schema_mode == "compact" versioned: list[dict[str, Any]] = [] for tool in selected: name = tool["name"] - input_schema = get_mcp_schema(name, "request", version=resolved_version) + input_schema = _versioned_discovery_schema( + name, "request", resolved_version, compact=compact + ) if input_schema is None: continue # The current-model schemas can be large and are replaced below by @@ -2897,7 +3080,7 @@ def get_tools_for_handler( versioned.append(definition) continue definition["inputSchema"] = input_schema - output_schema = get_mcp_schema(name, "sync", version=resolved_version) + output_schema = _versioned_discovery_schema(name, "sync", resolved_version, compact=compact) if output_schema is not None: output_schema.setdefault("type", "object") definition["outputSchema"] = output_schema diff --git a/src/adcp/server/serve.py b/src/adcp/server/serve.py index ad879d0ff0..53d057b111 100644 --- a/src/adcp/server/serve.py +++ b/src/adcp/server/serve.py @@ -630,10 +630,20 @@ class introduces a new specialism (a custom subclass that's not in #: buyer enforces is worse than no warning. BUYER_RESPONSE_BYTE_BUDGET: int = 4 * 1024 * 1024 +#: The default JSON member bound an SDK 10 buyer applies to one agent +#: response after the byte budget (``adcp._json_bounds.MAX_JSON_MEMBERS`` +#: there: the 4 MiB budget at eight bytes per member; 10.0.0b0 and b1 +#: enforced 100,000). A ``tools/list`` is read through the same bound as a +#: tool result, and the MCP client lists tools before it calls one, so a +#: listing over this bound fails *every* tool call, not just discovery +#: (#1692). This SDK line has no client-side member bound of its own. +BUYER_RESPONSE_MEMBER_BUDGET: int = 524_288 + #: Where to start telling the operator. Half the budget, because the payload #: grows with every tool a seller implements and the operator wants to hear #: about it while there is still room, not once buyers are already failing. _DISCOVERY_PAYLOAD_WARN_BYTES: int = BUYER_RESPONSE_BYTE_BUDGET // 2 +_DISCOVERY_PAYLOAD_WARN_MEMBERS: int = BUYER_RESPONSE_MEMBER_BUDGET // 2 def _discovery_payload_bytes(tool_defs: Sequence[dict[str, Any]]) -> int: @@ -661,6 +671,25 @@ def _discovery_payload_bytes(tool_defs: Sequence[dict[str, Any]]) -> int: return 0 +def _discovery_payload_members(tool_defs: Sequence[dict[str, Any]]) -> int: + """JSON object members plus array elements in the ``tools/list`` result. + + The quantity the buyer's member bound counts, measured the same way: every + key of every object and every element of every array, at every depth. + """ + pending: list[Any] = [{"tools": [_discovery_entry(tool_def) for tool_def in tool_defs]}] + members = 0 + while pending: + node = pending.pop() + if isinstance(node, dict): + members += len(node) + pending.extend(node.values()) + elif isinstance(node, list): + members += len(node) + pending.extend(node) + return members + + def _discovery_entry(tool_def: dict[str, Any]) -> dict[str, Any]: return { key: value @@ -695,40 +724,44 @@ def warn_if_discovery_payload_is_oversized( Returns the measured size so callers can log or assert on it. """ size = _discovery_payload_bytes(tool_defs) + members = _discovery_payload_members(tool_defs) logger.info( - "mcp server tools/list payload: %d bytes across %d tools", + "mcp server tools/list payload: %d bytes and %d JSON members across %d tools", size, + members, len(tool_defs), ) - if size <= _DISCOVERY_PAYLOAD_WARN_BYTES: + if size <= _DISCOVERY_PAYLOAD_WARN_BYTES and members <= _DISCOVERY_PAYLOAD_WARN_MEMBERS: return size - over_budget = size > BUYER_RESPONSE_BYTE_BUDGET + over_budget = size > BUYER_RESPONSE_BYTE_BUDGET or members > BUYER_RESPONSE_MEMBER_BUDGET + bounds = f"{BUYER_RESPONSE_BYTE_BUDGET} bytes, {BUYER_RESPONSE_MEMBER_BUDGET} JSON members" verdict = ( - "Buyers applying the AdCP default response budget " - f"({BUYER_RESPONSE_BYTE_BUDGET} bytes) will REJECT this tools/list, so " - "the agent will look unreachable rather than verbose." + f"Buyers applying the AdCP default response bounds ({bounds}) will " + "REJECT this tools/list, so the agent will look unreachable rather " + "than verbose." if over_budget - else "That is over half the AdCP default buyer response budget " - f"({BUYER_RESPONSE_BYTE_BUDGET} bytes); the next few tools may not fit." + else f"That is over half the AdCP default buyer response bounds ({bounds}); " + "the next few tools may not fit." ) advice = ( "Advertise only the tools this agent serves (declare " "``advertised_tools`` and drop advertise_all=True)" if advertise_all - else "Advertise only the tools this agent serves (declare " "``advertised_tools``)" + else "Advertise only the tools this agent serves (declare ``advertised_tools``)" ) # A UserWarning alone is not enough: PYTHONWARNINGS=ignore and a # simplefilter in app bootstrap are both common, and this failure is # undiagnosable from the seller side. Put it on the log channel too. (logger.error if over_budget else logger.warning)( - "mcp server tools/list is %d bytes across %d tools. %s", + "mcp server tools/list is %d bytes and %d JSON members across %d tools. %s", size, + members, len(tool_defs), verdict, ) warnings.warn( - f"mcp server tools/list is {size} bytes across " + f"mcp server tools/list is {size} bytes and {members} JSON members across " f"{len(tool_defs)} tools. {verdict}\n\n" f"{advice}, and keep schema_mode at its default 'compact' — " "'inline' expands every shared subtree once per reference and is " diff --git a/src/adcp/validation/schema_loader.py b/src/adcp/validation/schema_loader.py index 1ba5c3a1ee..d35d9593d9 100644 --- a/src/adcp/validation/schema_loader.py +++ b/src/adcp/validation/schema_loader.py @@ -30,6 +30,7 @@ import re import threading import warnings +from collections.abc import Callable from copy import deepcopy from dataclasses import dataclass from functools import lru_cache @@ -263,6 +264,10 @@ def __init__(self, root: _SchemaRoot, bundle_key: str) -> None: # every returned tree independent, including any repeated branches. self.mcp_schemas: dict[tuple[str, Direction], str] = {} self.mcp_schema_lock = threading.Lock() + #: Transforms of ``mcp_schemas`` (the compact discovery shape, for + #: one), keyed by derivation name, so they live and die with this + #: state rather than in a module global that outlives a reset. + self.derived_mcp_schemas: dict[tuple[str, str, Direction], str] = {} self.registry: dict[str, dict[str, Any]] = {} self._registry_loaded = False #: Version segments that canonical ``/schemas/{segment}/`` references @@ -1291,6 +1296,37 @@ def get_mcp_schema( return cast(dict[str, Any], json.loads(cached)) +def get_derived_mcp_schema( + tool_name: str, + direction: Literal["request", "sync"], + *, + version: str | None = None, + derivation: str, + derive: Callable[[dict[str, Any]], dict[str, Any]], +) -> dict[str, Any] | None: + """Return ``derive(get_mcp_schema(...))``, memoized with the versioned state. + + ``derivation`` names the transform so unrelated derivations of the same + schema do not collide. As with :func:`get_mcp_schema`, each caller + receives an independent, alias-free JSON tree, and the memo belongs to + the loader state for ``version``: a reset discards it. Concurrent first + callers may each run ``derive`` once; the results are identical, so the + last write is as good as the first. + """ + state = _ensure_state(version) + if state is None: + return None + key = (derivation, tool_name, direction) + cached = state.derived_mcp_schemas.get(key) + if cached is None: + source = get_mcp_schema(tool_name, direction, version=version) + if source is None: + return None + cached = json.dumps(derive(source), separators=(",", ":")) + state.derived_mcp_schemas[key] = cached + return cast(dict[str, Any], json.loads(cached)) + + def list_validator_keys(*, version: str | None = None) -> list[str]: """Every ``tool::direction`` pair with a shipped schema. Used by tests.""" state = _ensure_state(version) diff --git a/tests/test_discovery_payload_size.py b/tests/test_discovery_payload_size.py index 074a5ce50b..e69a4fbc64 100644 --- a/tests/test_discovery_payload_size.py +++ b/tests/test_discovery_payload_size.py @@ -22,11 +22,20 @@ import pytest from adcp.server import ADCPHandler, create_mcp_server -from adcp.server.mcp_tools import get_tools_for_handler +from adcp.server.mcp_tools import ( + _local_ref_names, + _simplify_wire_schema, + get_tools_for_handler, +) from adcp.server.serve import ( BUYER_RESPONSE_BYTE_BUDGET, + BUYER_RESPONSE_MEMBER_BUDGET, _discovery_payload_bytes, + _discovery_payload_members, + warn_if_discovery_payload_is_oversized, ) +from adcp.server.version_policy import resolve_supported_versions +from adcp.validation.envelope import SUPPORTED_WIRE_VERSIONS # examples/ is not a package; the storyboard tests add it the same way. _EXAMPLES = str(Path(__file__).parent.parent / "examples") @@ -300,3 +309,321 @@ def _local_refs(node: Any) -> list[str]: for value in node: found.extend(_local_refs(value)) return found + + +# --------------------------------------------------------------------------- +# The member bound (#1692) +# --------------------------------------------------------------------------- +# +# The buyer bounds a response's JSON *members* (object keys plus array +# elements) as well as its bytes, and the MCP client lists tools before every +# call, so a ``tools/list`` over the member bound fails every tool call. A +# pinned seller advertised the exact bundled wire schemas — ``get_products``'s +# response alone resolved 278 definitions — and an eleven-tool seller listed +# 106,250 members against a bound of 100,000. + + +def _every_installed_wire_version() -> list[str]: + """The largest ``supported_versions`` a server can be given.""" + versions = [ + version + for version in SUPPORTED_WIRE_VERSIONS + if resolve_supported_versions([version]) is not None + ] + assert len(versions) >= 3 + return versions + + +def _members(value: Any) -> int: + if isinstance(value, dict): + return len(value) + sum(_members(v) for v in value.values()) + if isinstance(value, list): + return len(value) + sum(_members(v) for v in value) + return 0 + + +async def _served_tools_list(mcp: Any) -> dict[str, Any]: + """The ``tools/list`` result frame as the MCP server serializes it.""" + tools = [ + tool.model_dump(mode="json", by_alias=True, exclude_none=True) + for tool in await mcp.list_tools() + ] + return {"jsonrpc": "2.0", "id": 1, "result": {"tools": tools}} + + +class _IssueSeller(ADCPHandler): + """The seller from #1692: eleven sales tools, pinned to 3.2.""" + + _adcp_version = "3.2" + + async def get_products(self, params, context=None): + return {"products": []} + + async def create_media_buy(self, params, context=None): ... + + async def update_media_buy(self, params, context=None): ... + + async def get_media_buys(self, params, context=None): ... + + async def get_media_buy_delivery(self, params, context=None): ... + + async def sync_creatives(self, params, context=None): ... + + async def list_creatives(self, params, context=None): ... + + async def provide_performance_feedback(self, params, context=None): ... + + async def list_creative_formats(self, params, context=None): ... + + async def list_accounts(self, params, context=None): ... + + async def sync_accounts(self, params, context=None): ... + + +def test_member_budget_is_the_sdk_10_clients_default_bound() -> None: + """A seller warning at a threshold no buyer enforces is worse than none. + + This SDK line has no client-side member bound; the figure is what an SDK + 10 buyer enforces (``adcp._json_bounds.MAX_JSON_MEMBERS`` on ``next``). + """ + assert BUYER_RESPONSE_MEMBER_BUDGET == 524_288 + + +async def test_every_tool_at_every_version_lists_under_half_the_buyer_bounds() -> None: + """The worst case the framework can produce fits with room to grow. + + Every tool the SDK can serve, the maximum ``supported_versions``, pinned + to the packaged release (on this line ``supported_versions`` alone does + not select the discovery release; the decisioning ``PlatformHandler``, + the 9.x seller that hit #1692, always carries a pin), at the default + schema mode. Half of each buyer bound, so a seller that adds a tool, or a + bundle that adds a field, has headroom before buyers refuse the listing. + The measured figures are in the assertion messages; the byte side is the + tighter of the two. + """ + from adcp._version import resolve_adcp_version + + class _Everything(ADCPHandler): + _adcp_version = resolve_adcp_version(None) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + mcp = create_mcp_server( + _Everything(), + name="everything", + advertise_all=True, + supported_versions=_every_installed_wire_version(), + ) + frame = await _served_tools_list(mcp) + tools = frame["result"]["tools"] + assert len(tools) > 70 + + members = _members(frame) + size = len(json.dumps(frame, separators=(",", ":"))) + assert members <= BUYER_RESPONSE_MEMBER_BUDGET // 2, ( + f"{len(tools)} tools list {members} JSON members; the buyer bound is " + f"{BUYER_RESPONSE_MEMBER_BUDGET} and this must stay under half of it" + ) + assert size <= BUYER_RESPONSE_BYTE_BUDGET // 2, ( + f"{len(tools)} tools list {size} bytes; the buyer budget is " + f"{BUYER_RESPONSE_BYTE_BUDGET} and this must stay under half of it" + ) + + +async def test_the_issue_seller_lists_a_fraction_of_the_bound() -> None: + """#1692's seller: 106,250 members on 10.0.0b1 against a bound of 100,000.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + mcp = create_mcp_server(_IssueSeller(), supported_versions=["3.1", "3.2"]) + frame = await _served_tools_list(mcp) + assert len(frame["result"]["tools"]) == 11 + members = _members(frame) + assert members < 100_000 // 4, members + assert members < BUYER_RESPONSE_MEMBER_BUDGET // 10, members + + +def test_pinned_compact_is_a_fraction_of_the_exact_bundled_schema() -> None: + """``defs`` keeps the exact wire contract; ``compact`` is the default for a reason.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + compact = get_tools_for_handler(_IssueSeller()) + exact = get_tools_for_handler(_IssueSeller(), schema_mode="defs") + assert {t["name"] for t in compact} == {t["name"] for t in exact} + assert _members(compact) * 4 < _members(exact) + assert _discovery_payload_bytes(compact) * 3 < _discovery_payload_bytes(exact) + # The exact shape is still the bundled schema, references and all. + get_products = next(t for t in exact if t["name"] == "get_products") + assert len(get_products["outputSchema"]["$defs"]) > 200 + + +def test_pinned_compaction_keeps_the_root_contract() -> None: + """Every root field, its description, and the root rules survive.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + compact = {t["name"]: t for t in get_tools_for_handler(_IssueSeller())} + exact = {t["name"]: t for t in get_tools_for_handler(_IssueSeller(), schema_mode="defs")} + for name, tool in compact.items(): + for key in ("inputSchema", "outputSchema"): + if key not in tool: + assert key not in exact[name] + continue + compacted, full = tool[key], exact[name][key] + assert set(compacted.get("properties", {})) == set(full.get("properties", {})) + assert compacted.get("required") == full.get("required") + for field, schema in full.get("properties", {}).items(): + if isinstance(schema, dict) and "description" in schema: + assert compacted["properties"][field]["description"] == schema["description"] + for rule in ("if", "then", "else", "not"): + assert (rule in compacted) == (rule in full), (name, key, rule) + available = set(compacted.get("$defs", {})) + for ref in _local_ref_names(compacted): + assert ref in available, f"{name}.{key} references missing $defs/{ref}" + # Nested cross-field rules and extension metadata are gone. + nested = json.dumps(compacted.get("$defs", {})) + assert '"not":' not in nested and '"if":' not in nested + assert '"x-' not in nested and '"$schema"' not in nested + + +@pytest.mark.parametrize( + "tool_name,params", + [ + ("get_products", {"buying_mode": "brief", "brief": "video"}), + ("list_products", {}), + ("get_adcp_capabilities", {}), + ("get_media_buys", {}), + ("list_creatives", {}), + ("create_media_buy", {"buyer_ref": "ref-1", "packages": [], "promoted_offering": "x"}), + ("control_media_buy", {"media_buy_id": "mb-1", "action": "pause_media_buy"}), + ], +) +async def test_pinned_compact_output_schemas_accept_what_the_exact_schema_accepts( + tool_name: str, params: dict[str, Any] +) -> None: + """Compaction of a bundled schema must only loosen it. + + The MCP client validates ``structuredContent`` against the advertised + ``outputSchema`` before a call succeeds, so a compact schema that rejects + a response the exact wire schema accepts would turn a working seller into + a failing one. Checked on real handler output against both shapes: where + the exact schema is satisfied, the compact one must be too, and the + compact one may never report an error the exact one does not. + """ + jsonschema = pytest.importorskip("jsonschema") + from jsonschema.validators import validator_for + + seller = _sa.DemoSeller() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + compact = {t["name"]: t for t in get_tools_for_handler(seller, adcp_version="3.2")} + exact = { + t["name"]: t + for t in get_tools_for_handler(seller, adcp_version="3.2", schema_mode="defs") + } + compact_schema = compact[tool_name]["outputSchema"] + exact_schema = exact[tool_name]["outputSchema"] + + response = await getattr(seller, tool_name)({**params, "adcp_version": "3.2.0"}) + document = json.loads(json.dumps(response, default=str)) + + exact_errors = list(validator_for(exact_schema)(exact_schema).iter_errors(document)) + compact_errors = list(validator_for(compact_schema)(compact_schema).iter_errors(document)) + if not exact_errors: + jsonschema.validate(document, compact_schema) + assert len(compact_errors) <= len(exact_errors), [e.message for e in compact_errors] + + +def test_oversized_member_count_warns_at_startup_even_when_bytes_are_small() -> None: + """Dense JSON trips the member bound long before the byte budget. + + ``[0, 0, 0, ...]`` is two bytes per member, so a listing can be a tenth + of the byte budget and still be refused for its member count. The + warning has to measure the quantity the buyer measures. + """ + dense = {"enum": [0] * (BUYER_RESPONSE_MEMBER_BUDGET // 2 + 1)} + tool_defs = [{"name": "dense", "description": "d", "inputSchema": dense}] + assert _discovery_payload_bytes(tool_defs) < BUYER_RESPONSE_BYTE_BUDGET // 2 + assert _discovery_payload_members(tool_defs) > BUYER_RESPONSE_MEMBER_BUDGET // 2 + + with pytest.warns(UserWarning, match=r"JSON members.*the next few tools may not fit"): + warn_if_discovery_payload_is_oversized(tool_defs=tool_defs, advertise_all=False) + + over = {"enum": [0] * (BUYER_RESPONSE_MEMBER_BUDGET + 1)} + with pytest.warns(UserWarning, match="will REJECT this tools/list"): + warn_if_discovery_payload_is_oversized( + tool_defs=[{"name": "over", "inputSchema": over}], advertise_all=False + ) + + +def test_simplify_wire_schema_reads_names_as_names() -> None: + """A property called ``description`` is a field, not an annotation.""" + schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-mutates-state": True, + "properties": { + "description": {"type": "string", "description": "kept at the root"}, + "x-ray": {"$ref": "#/$defs/x-ray"}, + }, + "if": {"required": ["description"]}, + "then": {"required": ["x-ray"]}, + "not": {"required": ["nope"]}, + "oneOf": [{"required": ["description"]}, {"required": ["x-ray"]}], + "$defs": { + "x-ray": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "description": "dropped: inside $defs", + "x-added-in": "3.2", + "properties": { + "description": {"type": "string", "description": "a field named description"}, + "not": {"type": "boolean"}, + }, + "not": {"required": ["never"]}, + "if": {"required": ["description"]}, + "then": {"required": ["not"]}, + "oneOf": [{"required": ["description"]}, {"required": ["not"]}], + "anyOf": [{"type": "object"}], + "default": {"description": "literal", "x-keep": 1, "not": 2}, + "enum": [{"not": "literal"}], + } + }, + } + simplified = _simplify_wire_schema(schema) + + # Root: everything stays except that oneOf becomes anyOf. + assert simplified["$schema"] == schema["$schema"] + assert simplified["x-mutates-state"] is True + assert simplified["if"] == schema["if"] and simplified["then"] == schema["then"] + assert simplified["not"] == schema["not"] + assert simplified["anyOf"] == schema["oneOf"] and "oneOf" not in simplified + assert simplified["properties"]["description"]["description"] == "kept at the root" + assert "x-ray" in simplified["properties"] + + definition = simplified["$defs"]["x-ray"] + assert "$schema" not in definition and "x-added-in" not in definition + assert "description" not in definition + for rule in ("not", "if", "then", "oneOf"): + assert rule not in definition + # The field named ``description`` survives as a field (its own + # annotation goes, like every description under ``$defs``), and so does + # the field named ``not``. + assert definition["properties"]["description"] == {"type": "string"} + assert definition["properties"]["not"] == {"type": "boolean"} + # An existing anyOf is kept and the oneOf arms join it under allOf. + assert definition["anyOf"] == [{"type": "object"}] + assert definition["allOf"] == [{"anyOf": schema["$defs"]["x-ray"]["oneOf"]}] + # Literal values are not schemas. + assert definition["default"] == schema["$defs"]["x-ray"]["default"] + assert definition["enum"] == schema["$defs"]["x-ray"]["enum"] + # The input is not mutated. + assert "oneOf" in schema and "not" in schema["$defs"]["x-ray"] + + +def test_local_ref_names_unescape_json_pointers() -> None: + """Bundled definitions are named after paths: ``~1`` is a slash.""" + schema = { + "allOf": [{"$ref": "#/$defs/external:core~1product.json"}], + "properties": {"t": {"$ref": "#/$defs/tilde~0name/properties/x"}}, + "default": {"$ref": "#/$defs/not-a-reference"}, + } + assert _local_ref_names(schema) == {"external:core/product.json", "tilde~name"} diff --git a/tests/test_mcp_client_seller_defaults_e2e.py b/tests/test_mcp_client_seller_defaults_e2e.py new file mode 100644 index 0000000000..73741911ae --- /dev/null +++ b/tests/test_mcp_client_seller_defaults_e2e.py @@ -0,0 +1,153 @@ +"""An SDK client at its defaults can call an SDK seller at its defaults (#1692). + +On SDK 10 this failed before the handler ran: the MCP client lists tools +before every call, a pinned seller's ``tools/list`` advertised the exact +bundled wire schemas, and an eleven-tool listing of 106,250 JSON members was +over the SDK 10 client's member bound. A 9.x seller advertised the same +listing, so an SDK 10 buyer could not call it either; this line's client has +no member bound, so the listing is also measured against SDK 10's here. + +Nothing is configured here on either side beyond what the issue's seller +set, so a regression on either side — the seller's listing growing, or the +client's bounds tightening — fails these tests. +""" + +from __future__ import annotations + +import json +import threading +import time +import warnings +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Any + +import pytest + +from adcp import ADCPClient, AgentConfig, Protocol +from adcp.server import ADCPHandler, create_mcp_server +from adcp.server.responses import capabilities_response +from adcp.server.serve import BUYER_RESPONSE_MEMBER_BUDGET +from adcp.types import GetProductsRequest + + +class _IssueSeller(ADCPHandler): + """The seller from #1692: eleven sales tools, pinned to 3.2.""" + + _adcp_version = "3.2" + + async def get_adcp_capabilities(self, params, context=None): + # The client negotiates a version through this before its first + # task call, so the seller must answer it; the issue's seller did. + return capabilities_response(["media_buy"], idempotency={"supported": False}) + + async def get_products(self, params, context=None): + return {"products": []} + + async def create_media_buy(self, params, context=None): ... + + async def update_media_buy(self, params, context=None): ... + + async def get_media_buys(self, params, context=None): ... + + async def get_media_buy_delivery(self, params, context=None): ... + + async def sync_creatives(self, params, context=None): ... + + async def list_creatives(self, params, context=None): ... + + async def provide_performance_feedback(self, params, context=None): ... + + async def list_creative_formats(self, params, context=None): ... + + async def list_accounts(self, params, context=None): ... + + async def sync_accounts(self, params, context=None): ... + + +class _DefaultSeller(ADCPHandler): + """No pin, no ``supported_versions``: the packaged release.""" + + advertised_tools = {"get_products"} + + async def get_adcp_capabilities(self, params, context=None): + return capabilities_response(["media_buy"], idempotency={"supported": False}) + + async def get_products(self, params, context=None): + return {"products": []} + + +@contextmanager +def _serving(mcp: Any) -> Iterator[str]: + """Run the MCP app on a loopback port in a thread; yield its tool URL.""" + import uvicorn + + config = uvicorn.Config( + mcp.streamable_http_app(streamable_http_path="/mcp"), + host="127.0.0.1", + port=0, + log_level="warning", + lifespan="on", + ) + server = uvicorn.Server(config) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + deadline = time.monotonic() + 30 + while not server.started: + if not thread.is_alive() or time.monotonic() > deadline: + raise RuntimeError("the MCP server did not start") + time.sleep(0.02) + port = server.servers[0].sockets[0].getsockname()[1] + try: + yield f"http://127.0.0.1:{port}/mcp" + finally: + server.should_exit = True + thread.join(timeout=15) + + +def _members(value: Any) -> int: + if isinstance(value, dict): + return len(value) + sum(_members(v) for v in value.values()) + if isinstance(value, list): + return len(value) + sum(_members(v) for v in value) + return 0 + + +@pytest.mark.parametrize( + "handler,server_kwargs", + [ + pytest.param(_IssueSeller, {"supported_versions": ["3.1", "3.2"]}, id="issue-seller"), + pytest.param(_DefaultSeller, {}, id="default-seller"), + ], +) +async def test_default_client_calls_get_products_on_default_seller( + handler: type[ADCPHandler], server_kwargs: dict[str, Any] +) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + mcp = create_mcp_server(handler(), **server_kwargs) + + with _serving(mcp) as url: + client = ADCPClient(AgentConfig(id="seller", agent_uri=url, protocol=Protocol.MCP)) + try: + result = await client.get_products( + GetProductsRequest(buying_mode="brief", brief="video") + ) + # The listing the client just read, measured as the client bounds it. + session = await client.adapter._get_session() + listing = await session.list_tools() + finally: + await client.close() + + assert result.success, (result.error, result.adcp_error) + assert result.data is not None + assert result.data.products == [] + + frame = { + "jsonrpc": "2.0", + "id": 1, + "result": listing.model_dump(mode="json", by_alias=True, exclude_none=True), + } + members = _members(frame) + assert members <= BUYER_RESPONSE_MEMBER_BUDGET // 2, members + assert len(json.dumps(frame)) <= 2 * 1024 * 1024 diff --git a/tests/test_mcp_schema_materialization.py b/tests/test_mcp_schema_materialization.py index ec3a5f9141..78caa5bf2c 100644 --- a/tests/test_mcp_schema_materialization.py +++ b/tests/test_mcp_schema_materialization.py @@ -24,13 +24,17 @@ PINS = ("3.2.0-rc.3", "3.2.0-rc.6", "3.2.0-beta.6") -# Canonical JSON digests captured from unchanged e9a1c8fc before adding -# the materialization cache. These are transformed public schemas, -# not a claim of byte identity with the signed upstream schema files. +# Canonical JSON digests of the compact public definitions a pinned handler +# advertises (``schema_mode="compact"`` compacts the bundled wire schema since +# #1692; the digests before that were of the exact schema). These are +# transformed public schemas, not a claim of byte identity with the signed +# upstream schema files. Recompute with +# ``hashlib.sha256(canonical(public_definitions(version))).hexdigest()`` when +# the compaction rules change deliberately. EXPECTED_PUBLIC_SHA256 = { - "3.2.0-rc.3": "6656874ca37ea0732e65a5f0313c8ead7b56ed12460bdbae297c4c648fa26f14", - "3.2.0-rc.6": "d712168e85932dfabad8a76b49a24aa6411dc11748832d18dd0ae00ad7106b3e", - "3.2.0-beta.6": "e9a10b9f1ee5654a51219c91329089f591c953b972e57a913450a1b9acdb810c", + "3.2.0-rc.3": "38078a94e57b1a6c6ed7b07ff5b240f88fa666ddd420aee34735b2e68799e236", + "3.2.0-rc.6": "14a611b33e5641fc37a784fc9efd122cbdafbcc98497df93af13802f3681988e", + "3.2.0-beta.6": "df13e396034d18c8c851d8fe77356da8fe587ec4dc5b3b120026af9a02f48f82", } From 684914ff14afa38f19cf0fb834381cc01ef45485 Mon Sep 17 00:00:00 2001 From: "aao-ipr-bot[bot]" <279263549+aao-ipr-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:58:24 -0400 Subject: [PATCH 02/28] chore(main): release 9.3.1 (#1703) Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 8c3d582bbb..5005692797 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { - ".": "9.3.0", + ".": "9.3.1", "packages/adcp-reporting-storage": "0.1.0-beta.4" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 76bb733b99..04ee3e69aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -192,6 +192,13 @@ change public consumer defaults. See [format-kind migration](docs/canonical-format-kinds-migration.md). +## [9.3.1](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.3.0...v9.3.1) (2026-10-29) + + +### Bug Fixes + +* **server:** compact a pinned seller's tools/list so an SDK 10 buyer can call it ([#1702](https://github.com/adcontextprotocol/adcp-client-python/issues/1702)) ([cc11643](https://github.com/adcontextprotocol/adcp-client-python/commit/cc11643352a078b977449f44dcd2d5687ad29bf6)), closes [#1692](https://github.com/adcontextprotocol/adcp-client-python/issues/1692) + ## [9.3.0](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.2.0...v9.3.0) (2026-10-27) diff --git a/pyproject.toml b/pyproject.toml index 55a314cc50..7593bd1947 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "adcp" -version = "9.3.0" +version = "9.3.1" description = "Official Python client for the Ad Context Protocol (AdCP)" authors = [ {name = "AdCP Community", email = "maintainers@adcontextprotocol.org"} From aac52cac66ecae2d1c2c4e11be23294335a3edd8 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 17:16:29 -0400 Subject: [PATCH 03/28] fix(types): defer the import-time model rebuilds that cost adcp.client 560 MiB RSS (#1704) Importing adcp.client, adcp.server, adcp.decisioning or adcp.compat.versions realizes the type surface through adcp.types._eager, and on 9.3.0 that peaked at ~495 MiB RSS (~560 MiB for adcp.client) against ~130 MiB on 6.6. Two things built pydantic-core validators at import for models a process may never use: - The import-time patchers (_ergonomic, _forward_compat, canonical_creative, aliases) edited model_fields and then called model_rebuild(force=True) on ~35 request/response roots. Pydantic does not share nested defer_build schemas between parents, so each forced rebuild walked the model's whole subtree into a fresh validator and serializer: ~300 MiB and ~7 s of the import. They now call adcp.types.base.defer_model_rebuild(), which puts the model back into the defer_build placeholder state so the patched fields take effect on first use. Same outcome, later moment. - A generated `class X(RootModel[A | B])` built the union's validator at the RootModel[...] expression, before X or any config it declares existed; adagents.AdcpAgentsAuthorization and core.format.CanonicalParameters alone cost ~60 MiB. Generated composing roots now parametrize adcp.types.base.AdCPRootModel, which carries defer_build=True, via the new post-generate fixer defer_rootmodel_builds. Peak RSS of `python -I -c "import "` (Linux, CPython 3.12): adcp.types._eager 495 -> 114 MiB (import 14.5 s -> 4.1 s) adcp.client 561 -> 175 MiB adcp.server 565 -> 183 MiB adcp.server.test_controller 562 -> 183 MiB adcp.decisioning 573 -> 187 MiB adcp.compat.versions 498 -> 118 MiB The cost moves to first use: the first validation of a deferred root builds its schema (90-330 ms for the request models, ~2.4 s for McpWebhookPayload's JSON schema), and a process that has used every one of them sits at ~215 MiB instead of ~520 MiB. build_all_models() still realizes everything eagerly for freezegun callers. Deferring more models exposed a pydantic limitation that already applied to every defer_build model: pydantic-core reaches a serializer by reading instance.__pydantic_serializer__ when it dumps by inference (an Any-typed field, serialize_as_any, a plain-pydantic parent), and a placeholder there raised TypeError instead of building. Every deferred SDK model now carries placeholders that are also descriptors (_DeferredValSer), installed by AdCPBaseModel/AdCPRootModel.__pydantic_init_subclass__ and by defer_model_rebuild, so that read builds the model and returns the real serializer. This also fixes the pre-existing TypeError for an unvalidated or parent-built instance of any generated model dumped through such a parent. tests/test_import_memory.py imports each entry module in a subprocess and fails when its peak RSS exceeds 320 MiB; tests/test_lazy_rebuild.py grades the deferral contract, including that a canonical subclass may forward- reference a model defined later in its module (supersedes #1690). Fixes #1694 Co-authored-by: Claude Fable 5.1 --- CLAUDE.md | 21 ++ scripts/generate_ergonomic_coercion.py | 26 +- scripts/post_generate_fixes.py | 78 +++++ scripts/post_generation_manifest.json | 1 + src/adcp/types/_ergonomic.py | 42 +-- src/adcp/types/_forward_compat.py | 67 +++-- src/adcp/types/aliases.py | 8 +- src/adcp/types/base.py | 119 +++++++- src/adcp/types/canonical_creative.py | 11 +- src/adcp/types/domains/adagents.py | 8 +- .../brand/verify_brand_claims_response.py | 6 +- src/adcp/types/domains/brand_discovery.py | 34 +-- .../get_adcp_capabilities_response.py | 19 +- .../domains/content_standards/artifact.py | 6 +- .../core/acceptance_policy_profile_ids.py | 5 +- .../core/account_identity_change_preview.py | 8 +- .../domains/core/asset_group_vocabulary.py | 5 +- .../types/domains/core/assets/asset_union.py | 20 +- .../types/domains/core/assets/daast_asset.py | 6 +- .../domains/core/assets/display_tag_asset.py | 6 +- .../types/domains/core/assets/vast_asset.py | 6 +- .../domains/core/attestation_capabilities.py | 6 +- .../domains/core/audience_characteristic.py | 8 +- .../types/domains/core/audience_evidence.py | 10 +- src/adcp/types/domains/core/creative_asset.py | 6 +- .../types/domains/core/creative_assets.py | 7 +- .../domains/core/creative_localization.py | 6 +- .../core/creative_localization_readback.py | 8 +- .../types/domains/core/creative_manifest.py | 6 +- .../domains/core/daast_tracker_constraints.py | 6 +- .../domains/core/forecast_point_dimensions.py | 5 +- src/adcp/types/domains/core/format.py | 6 +- .../domains/core/format_shape_vocabulary.py | 5 +- .../types/domains/core/geo_place_system.py | 6 +- src/adcp/types/domains/core/geo_place_type.py | 6 +- .../domains/core/localized_creative_asset.py | 6 +- .../types/domains/core/macro_bearing_url.py | 6 +- .../types/domains/core/placement_identity.py | 6 +- src/adcp/types/domains/core/postal_area.py | 8 +- .../types/domains/core/product_change_map.py | 5 +- src/adcp/types/domains/core/registry_event.py | 12 +- .../reporting_verification_profile_set.py | 5 +- .../domains/core/signal_pricing_option.py | 5 +- .../core/targeting_overlay_requirements.py | 14 +- .../domains/core/targeting_overlay_support.py | 18 +- ...ting_unknown_age_eligibility_constraint.py | 5 +- ...targeting_verified_age_basis_constraint.py | 5 +- .../domains/core/vast_tracker_constraints.py | 6 +- .../creative/list_creatives_response.py | 8 +- .../domains/enums/frequency_cap_scope.py | 5 +- .../media_buy_frequency_cap_control_mode.py | 5 +- .../domains/error_details/requote_required.py | 6 +- .../governance/accepted_governance_agents.py | 6 +- .../governance/reported_outcome_error.py | 14 +- src/adcp/types/domains/manifest.py | 4 +- .../get_media_buy_delivery_request.py | 8 +- .../media_buy/get_media_buys_request.py | 8 +- .../types/domains/media_buy/product_fields.py | 5 +- .../domains/media_buy/product_refinement.py | 6 +- .../get_adcp_capabilities_response.py | 14 +- .../trusted_match/context_match_response.py | 6 +- .../provider_context_match_response.py | 6 +- tests/test_canonical_removed_fields.py | 2 +- tests/test_import_memory.py | 78 +++++ tests/test_lazy_rebuild.py | 267 ++++++++++++++++++ 65 files changed, 857 insertions(+), 285 deletions(-) create mode 100644 tests/test_import_memory.py create mode 100644 tests/test_lazy_rebuild.py diff --git a/CLAUDE.md b/CLAUDE.md index 547cbf4e8a..3c54ecf766 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,27 @@ a domain path when the name it needs is not bound there. - Add specific `type: ignore` comments (e.g., `# type: ignore[no-any-return]`) rather than blanket ignores - Test type checking in CI across multiple Python versions (3.10+) +## Import-time model patches must defer their rebuild (#1694) + +`_ergonomic`, `_forward_compat`, `canonical_creative` and `aliases` edit generated +models' `model_fields` at import. Never follow such an edit with +`model_rebuild(force=True)`: pydantic does not share nested `defer_build` +schemas between parents, so each forced rebuild of a request/response root +walks its whole subtree into pydantic-core — two dozen of them cost ~400 MiB +and 10 s at import on 9.3.0. Call `adcp.types.base.defer_model_rebuild(model)` +instead; the patched fields take effect on the model's first use. + +A generated `class X(RootModel[A | B])` builds at the `RootModel[...]` +expression, before any config `X` declares. Generated composing roots +therefore parametrize `adcp.types.base.AdCPRootModel` (deferred); the +post-generate fixer does the rewrite. Every deferred SDK model also carries +`_DeferredValSer` placeholders (installed by `__pydantic_init_subclass__`) that +build on *instance* attribute access, which is how pydantic-core reaches a +serializer when it dumps by inference (`Any` field, `serialize_as_any`); a +subclass that overrides `__pydantic_init_subclass__` must call `super()`. +`tests/test_import_memory.py` imports +each entry module in a subprocess and fails when peak RSS exceeds its budget. + ## ctx_metadata: write-only credentials prohibited `RequestContext.metadata` is for non-secret request hints, not credentials. The diff --git a/scripts/generate_ergonomic_coercion.py b/scripts/generate_ergonomic_coercion.py index 7701a78e0f..06c1fe42b5 100644 --- a/scripts/generate_ergonomic_coercion.py +++ b/scripts/generate_ergonomic_coercion.py @@ -369,15 +369,18 @@ def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None: '"""Apply type coercion to generated types for better ergonomics.', "", "This module patches the generated types to accept more flexible input types", - "while maintaining type safety. It uses Pydantic's model_rebuild() to add", - "BeforeValidator annotations to fields.", + "while maintaining type safety. It edits the generated field metadata and then", + "invalidates each patched model with ``defer_model_rebuild`` so pydantic", + "re-derives its core schema, with the coercion validators included, on first use.", "", "Why import-time patching?", " We apply coercion at module load time rather than lazily because:", " 1. Pydantic validation runs during __init__, before any lazy access", - " 2. model_rebuild() is the standard Pydantic pattern for post-hoc changes", - " 3. The cost is minimal (~10-20ms for all types, once at import)", - " 4. After import, there is zero runtime overhead", + " 2. Editing field metadata and rebuilding is the standard Pydantic pattern", + " for post-hoc changes", + " 3. The patch itself is cheap; the rebuild it needs is deferred to the", + " model's first use rather than paid at import for every model (#1694)", + " 4. After the first use of a model, there is zero runtime overhead", " 5. This approach maintains full type checker compatibility", "", "Coercion rules applied:", @@ -401,6 +404,7 @@ def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None: "", "from pydantic import BeforeValidator, WrapValidator", "", + "from adcp.types.base import defer_model_rebuild", "from adcp.types.coercion import (", " coerce_subclass_list,", " coerce_to_enum,", @@ -546,14 +550,14 @@ def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None: for c in coercions: if c["type"] == "enum": target = get_symbol_name(c["target_class"]) - field_comments.append(f'{c["field"]}: {target} | str | None') + field_comments.append(f"{c['field']}: {target} | str | None") elif c["type"] in {"enum_list", "unique_enum_list"}: target = get_symbol_name(c["target_class"]) - field_comments.append(f'{c["field"]}: list[{target} | str] | None') + field_comments.append(f"{c['field']}: list[{target} | str] | None") elif c["type"] == "context": - field_comments.append(f'{c["field"]}: ContextObject | dict | None') + field_comments.append(f"{c['field']}: ContextObject | dict | None") elif c["type"] == "ext": - field_comments.append(f'{c["field"]}: ExtensionObject | dict | None') + field_comments.append(f"{c['field']}: ExtensionObject | dict | None") elif c["type"] == "subclass_list": from collections.abc import Sequence as AbcSequence @@ -563,7 +567,7 @@ def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None: is_seq = get_origin(base_ann if base_ann is not None else ann) is AbcSequence container = "Sequence" if is_seq else "list" field_comments.append( - f'{c["field"]}: {container}[{get_symbol_name(c["target_class"])}] ' + f"{c['field']}: {container}[{get_symbol_name(c['target_class'])}] " "(accepts subclass instances)" ) @@ -648,7 +652,7 @@ def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None: lines.append(" ],") lines.append(" )") - lines.append(f" {type_name}.model_rebuild(force=True)") + lines.append(f" defer_model_rebuild({type_name})") lines.append("") # PackageUpdate is now a single class (flattened from validation-only oneOf) diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index ca20f1605f..7240657319 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -2162,6 +2162,83 @@ def add_rootmodel_getattr_proxy(): print(" No RootModel union types needed __getattr__ proxy") +def defer_rootmodel_builds() -> None: + """Declare every generated composing root against ``AdCPRootModel``. + + The generator's ``--base-class`` puts ``AdCPBaseModel`` -- and with it + ``defer_build=True`` -- under every object model, but a + ``class X(RootModel[...])`` inherits nothing from the SDK base, and a + ``model_config`` in its body comes too late: the ``RootModel[...]`` + expression has already parametrized a concrete class under pydantic's + default config and built the union's validator there. A composing root + such as ``adagents.AdcpAgentsAuthorization`` or + ``core.format.CanonicalParameters`` walked most of its domain into + pydantic-core on import that way (#1694). ``adcp.types.base.AdCPRootModel`` + parametrizes with ``defer_build=True``, so the build waits for first use + like every other generated model's. Runs after + ``rewrite_scalar_rootmodels``, which has consumed the ``RootModel[]`` + classes by then, and before the import cleanups, which drop the + ``RootModel`` import this pass leaves unused. + """ + fixed_count = 0 + + for py_file in sorted(OUTPUT_DIR.rglob("*.py")): + source = py_file.read_text() + if "RootModel[" not in source: + continue + try: + tree = ast.parse(source) + except SyntaxError: + continue + + # Byte offsets of the ``RootModel`` name in each class header, edited + # back to front so earlier offsets stay valid. + line_offsets = [0] + for line in source.splitlines(keepends=True): + line_offsets.append(line_offsets[-1] + len(line)) + edits: list[int] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + for base in node.bases: + if ( + isinstance(base, ast.Subscript) + and isinstance(base.value, ast.Name) + and base.value.id == "RootModel" + ): + edits.append(line_offsets[base.value.lineno - 1] + base.value.col_offset) + if not edits: + continue + + for offset in sorted(edits, reverse=True): + source = source[:offset] + "AdCP" + source[offset:] + if re.search(r"^from adcp\.types\.base import .*\bAdCPRootModel\b", source, re.MULTILINE): + pass + elif re.search(r"^from adcp\.types\.base import ", source, re.MULTILINE): + source = add_to_import(source, "adcp.types.base", "AdCPRootModel") + else: + source = _insert_base_import(source, "from adcp.types.base import AdCPRootModel\n") + py_file.write_text(source) + fixed_count += len(edits) + + if fixed_count > 0: + print(f" Deferred the build of {fixed_count} RootModel class(es)") + else: + print(" No RootModel classes needed a deferred build") + + +def _insert_base_import(source: str, statement: str) -> str: + """Add an ``adcp.types.base`` import to a module that has none.""" + anchor = _import_pattern("pydantic").search(source) + if anchor is not None: + return source[: anchor.start()] + statement + source[anchor.start() :] + for pattern in (r"^from typing import [^\n]*\n", r"^from __future__ import [^\n]*\n\n?"): + match = re.search(pattern, source, re.MULTILINE) + if match is not None: + return source[: match.end()] + statement + source[match.end() :] + return statement + source + + class _ScalarRootSpec(NamedTuple): """One generated class that will become a scalar subclass.""" @@ -8569,6 +8646,7 @@ def main(argv: list[str] | None = None) -> int: # Last of the substantive fixes: every earlier fixer may still emit or # edit a ``RootModel[]`` class, and this one consumes them all. rewrite_scalar_rootmodels, + defer_rootmodel_builds, rewrite_generated_enums_to_strenum, annotate_registry_track_verdict, preserve_geo_place_system_map_keys, diff --git a/scripts/post_generation_manifest.json b/scripts/post_generation_manifest.json index c02999b4dc..221ff569d5 100644 --- a/scripts/post_generation_manifest.json +++ b/scripts/post_generation_manifest.json @@ -67,6 +67,7 @@ "fix_update_rights_legacy_response_defaults": true, "fix_list_creatives_format_reference_xor": true, "rewrite_scalar_rootmodels": true, + "defer_rootmodel_builds": true, "rewrite_generated_enums_to_strenum": true, "annotate_registry_track_verdict": true, "preserve_geo_place_system_map_keys": true, diff --git a/src/adcp/types/_ergonomic.py b/src/adcp/types/_ergonomic.py index 76c054cb48..b3496ae884 100644 --- a/src/adcp/types/_ergonomic.py +++ b/src/adcp/types/_ergonomic.py @@ -6,15 +6,18 @@ """Apply type coercion to generated types for better ergonomics. This module patches the generated types to accept more flexible input types -while maintaining type safety. It uses Pydantic's model_rebuild() to add -BeforeValidator annotations to fields. +while maintaining type safety. It edits the generated field metadata and then +invalidates each patched model with ``defer_model_rebuild`` so pydantic +re-derives its core schema, with the coercion validators included, on first use. Why import-time patching? We apply coercion at module load time rather than lazily because: 1. Pydantic validation runs during __init__, before any lazy access - 2. model_rebuild() is the standard Pydantic pattern for post-hoc changes - 3. The cost is minimal (~10-20ms for all types, once at import) - 4. After import, there is zero runtime overhead + 2. Editing field metadata and rebuilding is the standard Pydantic pattern + for post-hoc changes + 3. The patch itself is cheap; the rebuild it needs is deferred to the + model's first use rather than paid at import for every model (#1694) + 4. After the first use of a model, there is zero runtime overhead 5. This approach maintains full type checker compatibility Coercion rules applied: @@ -38,6 +41,7 @@ from pydantic import BeforeValidator, WrapValidator +from adcp.types.base import defer_model_rebuild from adcp.types.coercion import ( coerce_subclass_list, coerce_to_enum, @@ -158,7 +162,7 @@ def _apply_coercion() -> None: "ext", Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], ) - ListCreativeFormatsRequest.model_rebuild(force=True) + defer_model_rebuild(ListCreativeFormatsRequest) # Apply coercion to ListCreativeFormatsRequestCreativeAgent # - type: Type | str | None @@ -206,7 +210,7 @@ def _apply_coercion() -> None: "ext", Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], ) - ListCreativeFormatsRequestCreativeAgent.model_rebuild(force=True) + defer_model_rebuild(ListCreativeFormatsRequestCreativeAgent) # Apply coercion to ListCreativesRequest # - assignment_projection: AssignmentProjection | str | None @@ -238,7 +242,7 @@ def _apply_coercion() -> None: "ext", Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], ) - ListCreativesRequest.model_rebuild(force=True) + defer_model_rebuild(ListCreativesRequest) # Apply coercion to Sort # - field: CreativeSortField | str | None @@ -253,7 +257,7 @@ def _apply_coercion() -> None: "direction", Annotated[SortDirection | None, BeforeValidator(coerce_to_enum(SortDirection))], ) - Sort.model_rebuild(force=True) + defer_model_rebuild(Sort) # Apply coercion to GetProductsRequest # - buying_mode: BuyingMode | str | None @@ -283,7 +287,7 @@ def _apply_coercion() -> None: "ext", Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], ) - GetProductsRequest.model_rebuild(force=True) + defer_model_rebuild(GetProductsRequest) # Apply coercion to PackageRequest # - pacing: Pacing | str | None @@ -322,7 +326,7 @@ def _apply_coercion() -> None: "ext", Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], ) - PackageRequest.model_rebuild(force=True) + defer_model_rebuild(PackageRequest) # Apply coercion to CreateMediaBuyRequest # - packages: Sequence[PackageRequest] (accepts subclass instances) @@ -358,7 +362,7 @@ def _apply_coercion() -> None: "ext", Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], ) - CreateMediaBuyRequest.model_rebuild(force=True) + defer_model_rebuild(CreateMediaBuyRequest) # Apply coercion to PackageUpdate # - pacing: Pacing | str | None @@ -397,7 +401,7 @@ def _apply_coercion() -> None: "ext", Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], ) - PackageUpdate.model_rebuild(force=True) + defer_model_rebuild(PackageUpdate) # Apply coercion to GetProductsResponse # - context: ContextObject | dict | None @@ -442,7 +446,7 @@ def _apply_coercion() -> None: "ext", Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], ) - GetProductsResponse.model_rebuild(force=True) + defer_model_rebuild(GetProductsResponse) # Apply coercion to ListCreativesResponse # - context: ContextObject | dict | None @@ -481,7 +485,7 @@ def _apply_coercion() -> None: "ext", Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], ) - ListCreativesResponse.model_rebuild(force=True) + defer_model_rebuild(ListCreativesResponse) # Apply coercion to ListCreativeFormatsResponse # - context: ContextObject | dict | None @@ -535,7 +539,7 @@ def _apply_coercion() -> None: "ext", Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], ) - ListCreativeFormatsResponse.model_rebuild(force=True) + defer_model_rebuild(ListCreativeFormatsResponse) # Apply coercion to CreateMediaBuyResponse1 # - context: ContextObject | dict | None @@ -580,7 +584,7 @@ def _apply_coercion() -> None: "ext", Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], ) - CreateMediaBuyResponse1.model_rebuild(force=True) + defer_model_rebuild(CreateMediaBuyResponse1) # Apply coercion to UpdateMediaBuyResponse1 # - context: ContextObject | dict | None @@ -625,7 +629,7 @@ def _apply_coercion() -> None: "ext", Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], ) - UpdateMediaBuyResponse1.model_rebuild(force=True) + defer_model_rebuild(UpdateMediaBuyResponse1) # Apply coercion to GetMediaBuyDeliveryResponse # - context: ContextObject | dict | None @@ -670,7 +674,7 @@ def _apply_coercion() -> None: "ext", Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], ) - GetMediaBuyDeliveryResponse.model_rebuild(force=True) + defer_model_rebuild(GetMediaBuyDeliveryResponse) def _patch_unique_enum_list( diff --git a/src/adcp/types/_forward_compat.py b/src/adcp/types/_forward_compat.py index f6113c22ab..0f7ea5ba26 100644 --- a/src/adcp/types/_forward_compat.py +++ b/src/adcp/types/_forward_compat.py @@ -59,7 +59,7 @@ from pydantic_core import CoreSchema, InitErrorDetails, PydanticCustomError, core_schema from pydantic_core.core_schema import ErrorType -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, defer_model_rebuild from adcp.types.canonical_creative import CreateMediaBuyRequest as _PublicCreateMediaBuyRequest from adcp.types.canonical_creative import GetProductsRequest as _PublicGetProductsRequest from adcp.types.canonical_creative import PackageRequest as PublicPackageRequest @@ -290,8 +290,10 @@ def _patch_model_field(model: type[BaseModel], field_name: str, new_annotation: """Replace a Pydantic model field's annotation in-place. Sets both model_fields (the FieldInfo dict Pydantic uses for schema - generation) and __annotations__ (for introspection), then forces a - schema rebuild. Preserves all constraints and metadata from the original field. + generation) and __annotations__ (for introspection). The caller then + invalidates the model with ``defer_model_rebuild`` so its next use builds + from the patched field. Preserves all constraints and metadata from the + original field. """ old_fi = model.model_fields.get(field_name) if old_fi is None: @@ -438,7 +440,7 @@ def _patch_targeting_overlay(model: type[BaseModel]) -> None: ), ], ) - model.model_rebuild(force=True) + defer_model_rebuild(model) def _patch_package_forecast_targeting() -> None: @@ -456,9 +458,12 @@ def _patch_package_forecast_targeting() -> None: forecast_model = forecast_module.PackageForecastInput _patch_targeting_overlay(forecast_model) request_module = import_module("adcp.types.domains.media_buy.get_products_request") - # _ergonomic eagerly builds get_products before this patch. Rebuild its - # product-scoped union arm first, then both public request parents. Names - # of generated anonymous union arms change during code generation. + # Invalidate every parent that nests the forecast input -- its + # product-scoped union arm, then both public request parents -- so none + # keeps a schema built before this patch (#1694 defers the builds to first + # use, so at import nothing is built yet; invalidating is what keeps that + # true whatever ran first). Names of generated anonymous union arms change + # during code generation. for model in vars(request_module).values(): if ( isinstance(model, type) @@ -469,9 +474,9 @@ def _patch_package_forecast_targeting() -> None: for field in model.model_fields.values() ) ): - model.model_rebuild(force=True) - request_module.GetProductsRequest.model_rebuild(force=True) - _PublicGetProductsRequest.model_rebuild(force=True) + defer_model_rebuild(model) + defer_model_rebuild(request_module.GetProductsRequest) + defer_model_rebuild(_PublicGetProductsRequest) def _apply_forward_compat() -> None: @@ -489,9 +494,9 @@ def _apply_forward_compat() -> None: _patch_targeting_overlay(model) _patch_package_forecast_targeting() - # _ergonomic eagerly builds this legacy parent before the targeting patch. - # Refresh its cached nested validator as well as the package model itself. - _GeneratedCreateMediaBuyRequest.model_rebuild(force=True) + # _ergonomic touched this legacy parent before the targeting patch. + # Invalidate it too, so its first build nests the patched package input. + defer_model_rebuild(_GeneratedCreateMediaBuyRequest) # 3.2.x permits additional registration fields but does not declare this ID. # Keep the extension outside generated code so a schema refresh cannot drop @@ -505,9 +510,9 @@ def _apply_forward_compat() -> None: reporting_operation_id_annotation, None ) ReportingWebhook.__annotations__["operation_id"] = _ReportingOperationId - ReportingWebhook.model_rebuild(force=True) - # Rebuild after targeting patches too: eager parent validators must - # capture both the reporting extension and patched package inputs. + defer_model_rebuild(ReportingWebhook) + # Invalidate the request parents as well: their first build must + # capture both the reporting extension and the patched package inputs. for request in ( AcceptProposalRequest, BuyProductsRequest, @@ -517,7 +522,7 @@ def _apply_forward_compat() -> None: _GeneratedCreateMediaBuyRequest, _GeneratedUpdateMediaBuyRequest, ): - request.model_rebuild(force=True) + defer_model_rebuild(request) # Response manifests retain unknown future kinds because the field is # ``str`` everywhere. These patches thread the readback NODES through the @@ -529,30 +534,30 @@ def _apply_forward_compat() -> None: # ``format_kind`` needs no patch: it is generated as ``str``, so a response # retains an unknown kind without one. _patch_model_field(DeliveryCreative, "variants", GenericAlias(list, _DeliveryVariant)) - DeliveryCreative.model_rebuild(force=True) - GetCreativeDeliveryResponse.model_rebuild(force=True) + defer_model_rebuild(DeliveryCreative) + defer_model_rebuild(GetCreativeDeliveryResponse) _patch_model_field(PreviewCreativeResponse3, "manifest", _ReadbackCreativeManifest | None) - PreviewCreativeResponse3.model_rebuild(force=True) + defer_model_rebuild(PreviewCreativeResponse3) _patch_model_field(BuildCreativeResponse1, "creative_manifest", _ReadbackCreativeManifest) - BuildCreativeResponse1.model_rebuild(force=True) + defer_model_rebuild(BuildCreativeResponse1) _patch_model_field( BuildCreativeResponse3, "creative_manifests", GenericAlias(list, _ReadbackCreativeManifest) ) - BuildCreativeResponse3.model_rebuild(force=True) + defer_model_rebuild(BuildCreativeResponse3) _patch_model_field( BuildCreativeResponse4, "creatives", GenericAlias(list, _BuildReadbackCreative) ) - BuildCreativeResponse4.model_rebuild(force=True) + defer_model_rebuild(BuildCreativeResponse4) for response in (ContextMatchResponseRouterPublisher, ContextMatchResponseProviderRouter): _patch_model_field(response, "offers", GenericAlias(list, _ReadbackOffer)) - response.model_rebuild(force=True) + defer_model_rebuild(response) - # This eager wrapper captured build/preview validators before the patches. - # Rebuilding it re-resolves AdcpAsyncResponseData, a union type alias, so - # completed task callbacks retain typed manifests. - McpWebhookPayload.model_rebuild(force=True) + # This wrapper nests the build/preview responses patched above. + # Invalidating it re-resolves AdcpAsyncResponseData, a union type alias, + # on first use, so completed task callbacks retain typed manifests. + defer_model_rebuild(McpWebhookPayload) # The 3.1 canonical-creatives capability was published after the bundled # generated model. Preserve it across code generation until the schema @@ -579,7 +584,7 @@ def _apply_forward_compat() -> None: ), ) features_model.__annotations__["canonical_creatives"] = canonical_creatives_annotation - features_model.model_rebuild(force=True) + defer_model_rebuild(features_model) # Canonical schemas referenced through the bundled capabilities schema are # generated a second time as identity-distinct Pydantic classes. Accept the @@ -591,7 +596,7 @@ def _apply_forward_compat() -> None: BundledAcceptancePolicyDiscovery, AcceptancePolicyDiscovery | None, ) - BundledCapabilitiesMediaBuy.model_rebuild(force=True) + defer_model_rebuild(BundledCapabilitiesMediaBuy) _patch_equivalent_model_field( BundledCapabilitiesPortfolio, @@ -605,7 +610,7 @@ def _apply_forward_compat() -> None: BundledPrimaryCountry, list[PrimaryCountry] | None, ) - BundledCapabilitiesPortfolio.model_rebuild(force=True) + defer_model_rebuild(BundledCapabilitiesPortfolio) _apply_forward_compat() diff --git a/src/adcp/types/aliases.py b/src/adcp/types/aliases.py index e9b0ca2386..740a142e67 100644 --- a/src/adcp/types/aliases.py +++ b/src/adcp/types/aliases.py @@ -39,6 +39,7 @@ from pydantic import BeforeValidator, Discriminator from adcp.types import _generated as _g +from adcp.types.base import defer_model_rebuild from adcp.types._generated import ( # Account reference variants AccountReference1, @@ -2686,8 +2687,9 @@ def _attach_selector_xor_validator(cls: type) -> None: generated selector classes can't carry the validator without modifying generated code (forbidden — overwritten on next regen). This walks the same `_internal._decorators` machinery the decorator - syntax uses, then forces a `model_rebuild` so Pydantic re-derives - its core schema with the new validator included. + syntax uses, then invalidates the built schema (`defer_model_rebuild`) + so Pydantic re-derives its core schema with the new validator included + on first use. """ cls._selector_xor_validate = _selector_xor_validate # type: ignore[attr-defined] info = _ModelValidatorDecoratorInfo(mode="after") @@ -2700,7 +2702,7 @@ def _attach_selector_xor_validator(cls: type) -> None: cls.__pydantic_decorators__.model_validators[ # type: ignore[attr-defined] "_selector_xor_validate" ] = decorator - cls.model_rebuild(force=True) # type: ignore[attr-defined] + defer_model_rebuild(cls) _attach_selector_xor_validator(_Selector1) diff --git a/src/adcp/types/base.py b/src/adcp/types/base.py index 089bc72215..5280b77bb7 100644 --- a/src/adcp/types/base.py +++ b/src/adcp/types/base.py @@ -12,7 +12,7 @@ import copy import os from collections.abc import Callable -from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, cast +from typing import TYPE_CHECKING, Annotated, Any, Final, Generic, Literal, TypeVar, cast from pydantic import ( AfterValidator, @@ -21,6 +21,7 @@ BeforeValidator, ConfigDict, GetCoreSchemaHandler, + RootModel, SerializationInfo, SerializerFunctionWrapHandler, StrictInt, @@ -29,6 +30,7 @@ WithJsonSchema, model_serializer, ) +from pydantic._internal._mock_val_ser import MockValSer, set_model_mocks from pydantic.fields import FieldInfo from pydantic_core import PydanticSerializationError, PydanticUndefined, core_schema @@ -106,6 +108,33 @@ def _resolve_extra_policy() -> Literal["ignore", "forbid"]: _EXTRA_POLICY: Literal["ignore", "forbid"] = _resolve_extra_policy() +_RootT = TypeVar("_RootT") + + +class AdCPRootModel(RootModel[_RootT], Generic[_RootT]): + """``RootModel`` under the SDK's deferred-build policy. + + ``class X(RootModel[A | B])`` parametrizes a concrete class at the + ``RootModel[...]`` expression, and that class carries pydantic's default + config: its validator for the whole union is built right there, before + ``X`` and any config ``X`` declares exist. For a composing root such as + ``adagents.AdcpAgentsAuthorization`` that walked most of its domain into + pydantic-core at import (#1694). Parametrizing this class instead inherits + ``defer_build=True`` into ``AdCPRootModel[A | B]`` and into ``X``, so the + build waits for first use like every other generated model's. + ``scripts/post_generate_fixes.defer_rootmodel_builds`` points every + generated composing root here; a value root is a scalar subclass instead + (``adcp.types._scalar``). + """ + + model_config = ConfigDict(defer_build=True) + + @classmethod + def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: + super().__pydantic_init_subclass__(**kwargs) + _install_deferred_valsers(cls) + + def _build_deferred_serializers(value: Any, seen: set[int]) -> None: """Force lazy core-schema builds for every model instance in a graph. @@ -136,6 +165,86 @@ def _build_deferred_serializers(value: Any, seen: set[int]) -> None: _build_deferred_serializers(item, seen) +class _DeferredValSer(MockValSer[Any]): + """pydantic's rebuild-on-access placeholder, extended to instance attribute access. + + ``MockValSer`` rebuilds the model when one of its *methods* is accessed, + which covers ``Model(...)`` and ``instance.model_dump()``. pydantic-core + reaches a serializer another way: when it serializes a model instance it + meets by inference — an ``Any``-typed field, ``serialize_as_any``, a + plain-pydantic parent — it reads ``instance.__pydantic_serializer__`` and + downcasts, which a placeholder fails with ``TypeError: 'MockValSer' object + is not an instance of 'SchemaSerializer'`` (#1310, #1339). A placeholder + that is also a descriptor builds the model on that instance-level read and + hands back the real object. Class-level reads still return the placeholder, + which is what pydantic's own ``isinstance(..., MockValSer)`` checks expect. + """ + + __slots__ = ("_attr",) + + def __init__(self, mock: MockValSer[Any], attr: str) -> None: + super().__init__( + mock._error_message, + code=mock._code, + val_or_ser="validator" if attr == "__pydantic_validator__" else "serializer", + attempt_rebuild=mock._attempt_rebuild, + ) + self._attr = attr + + def __get__(self, instance: object, owner: type[BaseModel] | None = None) -> Any: + if instance is None or owner is None: + return self + # An empty parent namespace: the model's own module and + # ``__pydantic_parent_namespace__`` resolve everything a generated or + # adopter class declares at module level. A class that still needs the + # caller's locals keeps the placeholder, and pydantic's frame-walking + # rebuild runs on the next method access as before. + if owner.model_rebuild(raise_errors=False, _parent_namespace_depth=0) is False: + return self + return getattr(owner, self._attr) + + +def _install_deferred_valsers(cls: type[BaseModel]) -> None: + """Swap pydantic's placeholders on ``cls`` for ones that build on instance access.""" + for attr in ("__pydantic_validator__", "__pydantic_serializer__"): + mock = cls.__dict__.get(attr) + if isinstance(mock, MockValSer) and not isinstance(mock, _DeferredValSer): + setattr(cls, attr, _DeferredValSer(mock, attr)) + + +def defer_model_rebuild(model: type[BaseModel]) -> None: + """Discard ``model``'s built core schema so pydantic rebuilds it on first use. + + The import-time patchers (``_ergonomic``, ``_forward_compat``, + ``canonical_creative``) edit ``model_fields`` in place and then need the + core schema re-derived. ``model_rebuild(force=True)`` does that + immediately: it walks the model's whole nested subtree into a fresh + pydantic-core validator and serializer, and pydantic does not share the + schemas of nested ``defer_build`` models between parents, so each forced + rebuild of a request or response root costs tens of MiB and hundreds of + milliseconds — at import, for models the process may never use (#1694). + + This helper instead puts the model back into the state ``defer_build=True`` + leaves a freshly declared class in: the built artefacts are replaced by + pydantic's rebuild-on-access placeholders, and the next validation, + serialization or schema request builds the schema from the patched fields. + The outcome is identical to a forced rebuild; only the moment differs. + :func:`adcp.testing.build_all_models` still realizes every model eagerly + for callers (freezegun) that need the build to happen before patching + ``datetime``. + + The placeholders are pydantic's own (``pydantic._internal._mock_val_ser``); + the pinned ``pydantic<2.14`` range is what makes relying on them safe, and + ``tests/test_lazy_rebuild.py`` grades the contract. + """ + model.__pydantic_complete__ = False + for attr in ("__pydantic_core_schema__", "__pydantic_validator__", "__pydantic_serializer__"): + if attr in model.__dict__ and not isinstance(getattr(model, attr), MockValSer): + delattr(model, attr) + set_model_mocks(model) + _install_deferred_valsers(model) + + def _pluralize(count: int, singular: str, plural: str | None = None) -> str: """Return singular or plural form based on count.""" if count == 1: @@ -584,6 +693,14 @@ class AdCPBaseModel(BaseModel): # the handful of models actually used are paid for. model_config = ConfigDict(extra=_EXTRA_POLICY, defer_build=True) + @classmethod + def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: + super().__pydantic_init_subclass__(**kwargs) + # The deferred build's placeholders, made to build on instance access + # as well (see ``_DeferredValSer``). A subclass that overrides this + # hook must call ``super()``. + _install_deferred_valsers(cls) + # Pydantic derives serialization JSON Schema from the model fields only when # this serializer's return is unannotated. Even ``-> Any`` erases the shape. @model_serializer(mode="wrap") diff --git a/src/adcp/types/canonical_creative.py b/src/adcp/types/canonical_creative.py index 8e3fe787ac..42c3502aea 100644 --- a/src/adcp/types/canonical_creative.py +++ b/src/adcp/types/canonical_creative.py @@ -101,7 +101,13 @@ PRODUCT_FORMAT_DECLARATION_SCHEMA, check_declaration_rules, ) -from adcp.types.base import _REFUSE_JSON_NULL, AdCPBaseModel, NoExplicitNull, inherit_field +from adcp.types.base import ( + _REFUSE_JSON_NULL, + AdCPBaseModel, + NoExplicitNull, + defer_model_rebuild, + inherit_field, +) from adcp.types.domains.core.canonical_format_kind import CanonicalFormatKind from adcp.types.domains.core.creative_asset import CreativeAsset as _CanonicalCreativeWire from adcp.types.domains.core.creative_filters import CreativeFilters as _LegacyCreativeFilters @@ -692,13 +698,14 @@ def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: are all unaffected. Measured scope: one such validator, on one model. """ + super().__pydantic_init_subclass__(**kwargs) removed = [name for name in cls.model_fields if is_legacy_creative_identity_key(name)] for name in removed: del cls.model_fields[name] cls.__annotations__.pop(name, None) setattr(cls, name, None) if removed: - cls.model_rebuild(force=True) + defer_model_rebuild(cls) @model_validator(mode="before") @classmethod diff --git a/src/adcp/types/domains/adagents.py b/src/adcp/types/domains/adagents.py index 988af46bac..d08a1a7c04 100644 --- a/src/adcp/types/domains/adagents.py +++ b/src/adcp/types/domains/adagents.py @@ -7,9 +7,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime, WireEmail +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull, WireAnyUrl, WireDatetime, WireEmail from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field, RootModel, StrictBool, model_validator +from pydantic import ConfigDict, Field, StrictBool, model_validator from .core import ( collection, @@ -1072,7 +1072,7 @@ class AdcpAgentsAuthorization213(AdcpAgentsAuthorization27): class AdcpAgentsAuthorization2( - RootModel[ + AdCPRootModel[ AdcpAgentsAuthorization28 | AdcpAgentsAuthorization29 | AdcpAgentsAuthorization210 @@ -1446,7 +1446,7 @@ def __getattr__(self, name: str) -> Any: -class AdcpAgentsAuthorization(RootModel[AdcpAgentsAuthorization1 | AdcpAgentsAuthorization2]): +class AdcpAgentsAuthorization(AdCPRootModel[AdcpAgentsAuthorization1 | AdcpAgentsAuthorization2]): root: Annotated[ AdcpAgentsAuthorization1 | AdcpAgentsAuthorization2, Field( diff --git a/src/adcp/types/domains/brand/verify_brand_claims_response.py b/src/adcp/types/domains/brand/verify_brand_claims_response.py index f81beb0535..269a0f480f 100644 --- a/src/adcp/types/domains/brand/verify_brand_claims_response.py +++ b/src/adcp/types/domains/brand/verify_brand_claims_response.py @@ -8,8 +8,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, WireAnyUrl -from pydantic import ConfigDict, Field, RootModel +from adcp.types.base import AdCPBaseModel, AdCPRootModel, WireAnyUrl +from pydantic import ConfigDict, Field from ..core import context as context_1 from ..core import error as error_1 @@ -59,7 +59,7 @@ class ResultEntry2(AdCPBaseModel): error: error_1.Error -class ResultEntry(RootModel[ResultEntry1 | ResultEntry2]): +class ResultEntry(AdCPRootModel[ResultEntry1 | ResultEntry2]): root: Annotated[ ResultEntry1 | ResultEntry2, Field( diff --git a/src/adcp/types/domains/brand_discovery.py b/src/adcp/types/domains/brand_discovery.py index 58f7afcae4..66416d1882 100644 --- a/src/adcp/types/domains/brand_discovery.py +++ b/src/adcp/types/domains/brand_discovery.py @@ -7,15 +7,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Dict, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime, WireEmail +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime, WireEmail from adcp.types._scalar import ScalarFloat, ScalarInt, ScalarStr -from pydantic import ( - ConfigDict, - Field, - RootModel, - StrictBool, - StrictFloat, -) +from pydantic import ConfigDict, Field, StrictBool, StrictFloat class RedirectReason(StrEnum): @@ -186,11 +180,11 @@ class PortfolioEntry(AdCPBaseModel): ] = None -class LocalizedName(RootModel[dict[str, str]]): +class LocalizedName(AdCPRootModel[dict[str, str]]): root: Annotated[dict[str, str], Field(max_length=1, min_length=1)] -class LocalizedScalar1(RootModel[list[LocalizedName]]): +class LocalizedScalar1(AdCPRootModel[list[LocalizedName]]): root: Annotated[ list[LocalizedName], Field( @@ -200,7 +194,7 @@ class LocalizedScalar1(RootModel[list[LocalizedName]]): ] -class LocalizedScalar(RootModel[str | LocalizedScalar1]): +class LocalizedScalar(AdCPRootModel[str | LocalizedScalar1]): root: Annotated[ str | LocalizedScalar1, Field( @@ -215,11 +209,11 @@ def __getattr__(self, name: str) -> Any: -class LocalizedStringListEntry(RootModel[dict[str, list[str]]]): +class LocalizedStringListEntry(AdCPRootModel[dict[str, list[str]]]): root: Annotated[dict[str, list[str]], Field(max_length=1, min_length=1)] -class LocalizedStringList1(RootModel[list[LocalizedStringListEntry]]): +class LocalizedStringList1(AdCPRootModel[list[LocalizedStringListEntry]]): root: Annotated[ list[LocalizedStringListEntry], Field( @@ -229,7 +223,7 @@ class LocalizedStringList1(RootModel[list[LocalizedStringListEntry]]): ] -class LocalizedStringList(RootModel[list[str] | LocalizedStringList1]): +class LocalizedStringList(AdCPRootModel[list[str] | LocalizedStringList1]): root: Annotated[ list[str] | LocalizedStringList1, Field( @@ -301,11 +295,11 @@ class ColorValue2Item(ColorValue1): pass -class ColorValue2(RootModel[list[ColorValue2Item]]): +class ColorValue2(AdCPRootModel[list[ColorValue2Item]]): root: Annotated[list[ColorValue2Item], Field(min_length=1)] -class ColorValue(RootModel[ColorValue1 | ColorValue2]): +class ColorValue(AdCPRootModel[ColorValue1 | ColorValue2]): root: ColorValue1 | ColorValue2 def __getattr__(self, name: str) -> Any: """Proxy attribute access to the wrapped type.""" @@ -394,7 +388,7 @@ class ColorRef3(AdCPBaseModel): ] -class ColorRef(RootModel[ColorRef1 | ColorRef2 | ColorRef3]): +class ColorRef(AdCPRootModel[ColorRef1 | ColorRef2 | ColorRef3]): root: Annotated[ ColorRef1 | ColorRef2 | ColorRef3, Field( @@ -501,7 +495,7 @@ class FontRole1(AdCPBaseModel): ] = None -class FontRole(RootModel[str | FontRole1]): +class FontRole(AdCPRootModel[str | FontRole1]): root: Annotated[ str | FontRole1, Field( @@ -656,7 +650,7 @@ class Tone(AdCPBaseModel): ] = None -class Tagline(RootModel[list[LocalizedName]]): +class Tagline(AdCPRootModel[list[LocalizedName]]): root: Annotated[ list[LocalizedName], Field(description='Localized taglines with BCP 47 locale codes', min_length=1), @@ -2299,7 +2293,7 @@ class BrandAgentEntry(AdCPBaseModel): ] = None -class Agents(RootModel[list[BrandAgentEntry]]): +class Agents(AdCPRootModel[list[BrandAgentEntry]]): root: Annotated[ list[BrandAgentEntry], Field( diff --git a/src/adcp/types/domains/bundled/protocol/get_adcp_capabilities_response.py b/src/adcp/types/domains/bundled/protocol/get_adcp_capabilities_response.py index 898fb65c7a..0fdb385193 100644 --- a/src/adcp/types/domains/bundled/protocol/get_adcp_capabilities_response.py +++ b/src/adcp/types/domains/bundled/protocol/get_adcp_capabilities_response.py @@ -11,16 +11,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Dict, Literal -from adcp.types.base import AdCPBaseModel, GeoPlaceSystemKey, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime, WireEmail +from adcp.types.base import AdCPBaseModel, AdCPRootModel, GeoPlaceSystemKey, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime, WireEmail from adcp.types._scalar import ScalarFloat, ScalarInt, ScalarStr -from pydantic import ( - ConfigDict, - Field, - RootModel, - StrictBool, - StrictFloat, - StringConstraints, -) +from pydantic import ConfigDict, Field, StrictBool, StrictFloat, StringConstraints class Status(StrEnum): @@ -392,7 +385,7 @@ class ReportingDestinationOffering(AdCPBaseModel): class CallerEventType( - RootModel[ + AdCPRootModel[ Literal[ 'scheduled', 'final', @@ -860,7 +853,7 @@ class Issuer2(AdCPBaseModel): ] = None -class CredentialOrigin(RootModel[WireAnyUrl]): +class CredentialOrigin(AdCPRootModel[WireAnyUrl]): root: WireAnyUrl @@ -1885,7 +1878,7 @@ class Countries8(StrEnum): ward = 'ward' -class Countries9(RootModel[WireAnyUrl]): +class Countries9(AdCPRootModel[WireAnyUrl]): root: Annotated[ WireAnyUrl, Field( @@ -1996,7 +1989,7 @@ class GeoPlaces1(StrEnum): microsoft_ads = 'microsoft_ads' -class GeoPlaces2(RootModel[WireAnyUrl]): +class GeoPlaces2(AdCPRootModel[WireAnyUrl]): root: Annotated[ WireAnyUrl, Field( diff --git a/src/adcp/types/domains/content_standards/artifact.py b/src/adcp/types/domains/content_standards/artifact.py index 4e5f2d6957..1debf931cd 100644 --- a/src/adcp/types/domains/content_standards/artifact.py +++ b/src/adcp/types/domains/content_standards/artifact.py @@ -7,8 +7,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime -from pydantic import ConfigDict, Field, RootModel +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field from ..core import format_id as format_id_1 from ..core import provenance as provenance_1 @@ -122,7 +122,7 @@ class AssetAccess3(AdCPBaseModel): method: Literal['signed_url'] = 'signed_url' -class AssetAccess(RootModel[AssetAccess1 | AssetAccess2 | AssetAccess3]): +class AssetAccess(AdCPRootModel[AssetAccess1 | AssetAccess2 | AssetAccess3]): root: Annotated[ AssetAccess1 | AssetAccess2 | AssetAccess3, Field( diff --git a/src/adcp/types/domains/core/acceptance_policy_profile_ids.py b/src/adcp/types/domains/core/acceptance_policy_profile_ids.py index dfaf4589f4..8e95b5527d 100644 --- a/src/adcp/types/domains/core/acceptance_policy_profile_ids.py +++ b/src/adcp/types/domains/core/acceptance_policy_profile_ids.py @@ -8,7 +8,8 @@ from typing import Annotated -from pydantic import Field, RootModel +from adcp.types.base import AdCPRootModel +from pydantic import Field class AcceptancePolicyProfileId(ScalarStr): @@ -16,7 +17,7 @@ class AcceptancePolicyProfileId(ScalarStr): _constraints = {'min_length': 1} -class AcceptancePolicyProfileIds(RootModel[list[AcceptancePolicyProfileId]]): +class AcceptancePolicyProfileIds(AdCPRootModel[list[AcceptancePolicyProfileId]]): root: Annotated[ list[AcceptancePolicyProfileId], Field( diff --git a/src/adcp/types/domains/core/account_identity_change_preview.py b/src/adcp/types/domains/core/account_identity_change_preview.py index d1a32b9a15..bbf5b874b7 100644 --- a/src/adcp/types/domains/core/account_identity_change_preview.py +++ b/src/adcp/types/domains/core/account_identity_change_preview.py @@ -7,9 +7,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field, RootModel +from pydantic import ConfigDict, Field from . import operator_identity @@ -55,7 +55,7 @@ class NonblockingImpact(Impact): effect: Annotated[Effect1 | None, NoExplicitNull] = None -class NonblockingImpacts(RootModel[list[NonblockingImpact]]): +class NonblockingImpacts(AdCPRootModel[list[NonblockingImpact]]): root: Annotated[ list[NonblockingImpact], Field( @@ -66,7 +66,7 @@ class NonblockingImpacts(RootModel[list[NonblockingImpact]]): ] -class BlockedImpacts(RootModel[list[Impact]]): +class BlockedImpacts(AdCPRootModel[list[Impact]]): root: Annotated[ list[Impact], Field( diff --git a/src/adcp/types/domains/core/asset_group_vocabulary.py b/src/adcp/types/domains/core/asset_group_vocabulary.py index 782e1078ee..fcdabecc5d 100644 --- a/src/adcp/types/domains/core/asset_group_vocabulary.py +++ b/src/adcp/types/domains/core/asset_group_vocabulary.py @@ -6,10 +6,11 @@ from typing import Annotated, Any -from pydantic import Field, RootModel +from adcp.types.base import AdCPRootModel +from pydantic import Field -class AdcpAssetGroupVocabularyRegistry(RootModel[Any]): +class AdcpAssetGroupVocabularyRegistry(AdCPRootModel[Any]): root: Annotated[ Any, Field( diff --git a/src/adcp/types/domains/core/assets/asset_union.py b/src/adcp/types/domains/core/assets/asset_union.py index c491405ab1..0af5a38d6c 100644 --- a/src/adcp/types/domains/core/assets/asset_union.py +++ b/src/adcp/types/domains/core/assets/asset_union.py @@ -8,15 +8,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt, WireAnyUrl, WireDatetime +from adcp.types.base import AdCPBaseModel, AdCPRootModel, SchemaInt, WireAnyUrl, WireDatetime from adcp.types._scalar import ScalarFloat, ScalarStr -from pydantic import ( - ConfigDict, - Field, - RootModel, - StrictBool, - StrictFloat, -) +from pydantic import ConfigDict, Field, StrictBool, StrictFloat class FocalPointItem(ScalarFloat): @@ -268,7 +262,7 @@ class UnavailableBehavior(StrEnum): reject = 'reject' -class MacroBearingUrl1(RootModel[WireAnyUrl]): +class MacroBearingUrl1(AdCPRootModel[WireAnyUrl]): root: Annotated[ WireAnyUrl, Field( @@ -1843,7 +1837,7 @@ class DaastAsset2(AdCPBaseModel): content: Annotated[str, Field(description='Inline DAAST XML content')] -class DaastAsset(RootModel[DaastAsset1 | DaastAsset2]): +class DaastAsset(AdCPRootModel[DaastAsset1 | DaastAsset2]): root: Annotated[ DaastAsset1 | DaastAsset2, Field( @@ -2143,7 +2137,7 @@ class PublishedPostAsset2(AdCPBaseModel): ] = None -class PublishedPostAsset(RootModel[PublishedPostAsset1 | PublishedPostAsset2]): +class PublishedPostAsset(AdCPRootModel[PublishedPostAsset1 | PublishedPostAsset2]): root: Annotated[ PublishedPostAsset1 | PublishedPostAsset2, Field( @@ -2495,7 +2489,7 @@ class VastAsset2(AdCPBaseModel): content: Annotated[str, Field(description='Inline VAST XML content')] -class VastAsset(RootModel[VastAsset1 | VastAsset2]): +class VastAsset(AdCPRootModel[VastAsset1 | VastAsset2]): root: Annotated[ VastAsset1 | VastAsset2, Field( @@ -2943,7 +2937,7 @@ class DisplayTagAsset3(AdCPBaseModel): ] -class DisplayTagAsset(RootModel[DisplayTagAsset1 | DisplayTagAsset2 | DisplayTagAsset3]): +class DisplayTagAsset(AdCPRootModel[DisplayTagAsset1 | DisplayTagAsset2 | DisplayTagAsset3]): root: Annotated[ DisplayTagAsset1 | DisplayTagAsset2 | DisplayTagAsset3, Field( diff --git a/src/adcp/types/domains/core/assets/daast_asset.py b/src/adcp/types/domains/core/assets/daast_asset.py index 9c50c486e3..e1fef3d17c 100644 --- a/src/adcp/types/domains/core/assets/daast_asset.py +++ b/src/adcp/types/domains/core/assets/daast_asset.py @@ -7,8 +7,8 @@ from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl -from pydantic import ConfigDict, Field, RootModel, StrictBool +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool from ...enums import daast_tracking_event from ...enums import daast_version as daast_version_1 @@ -353,7 +353,7 @@ class DaastAsset4(AdCPBaseModel): content: Annotated[str, Field(description='Inline DAAST XML content')] -class DaastAsset(RootModel[DaastAsset3 | DaastAsset4]): +class DaastAsset(AdCPRootModel[DaastAsset3 | DaastAsset4]): root: Annotated[ DaastAsset3 | DaastAsset4, Field( diff --git a/src/adcp/types/domains/core/assets/display_tag_asset.py b/src/adcp/types/domains/core/assets/display_tag_asset.py index 4ef590ca1c..34c5956aa2 100644 --- a/src/adcp/types/domains/core/assets/display_tag_asset.py +++ b/src/adcp/types/domains/core/assets/display_tag_asset.py @@ -7,8 +7,8 @@ from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl -from pydantic import ConfigDict, Field, RootModel, StrictBool +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool from .. import macro_bearing_url from .. import provenance as provenance_1 @@ -430,7 +430,7 @@ class DisplayTagAsset6(AdCPBaseModel): ] -class DisplayTagAsset(RootModel[DisplayTagAsset4 | DisplayTagAsset5 | DisplayTagAsset6]): +class DisplayTagAsset(AdCPRootModel[DisplayTagAsset4 | DisplayTagAsset5 | DisplayTagAsset6]): root: Annotated[ DisplayTagAsset4 | DisplayTagAsset5 | DisplayTagAsset6, Field( diff --git a/src/adcp/types/domains/core/assets/vast_asset.py b/src/adcp/types/domains/core/assets/vast_asset.py index 194b293a31..6cbec3af59 100644 --- a/src/adcp/types/domains/core/assets/vast_asset.py +++ b/src/adcp/types/domains/core/assets/vast_asset.py @@ -7,8 +7,8 @@ from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl -from pydantic import ConfigDict, Field, RootModel, StrictBool +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool from ...enums import vast_tracking_event from ...enums import vast_version as vast_version_1 @@ -363,7 +363,7 @@ class VastAsset4(AdCPBaseModel): content: Annotated[str, Field(description='Inline VAST XML content')] -class VastAsset(RootModel[VastAsset3 | VastAsset4]): +class VastAsset(AdCPRootModel[VastAsset3 | VastAsset4]): root: Annotated[ VastAsset3 | VastAsset4, Field( diff --git a/src/adcp/types/domains/core/attestation_capabilities.py b/src/adcp/types/domains/core/attestation_capabilities.py index 8f61fae29b..057fad1090 100644 --- a/src/adcp/types/domains/core/attestation_capabilities.py +++ b/src/adcp/types/domains/core/attestation_capabilities.py @@ -7,8 +7,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl -from pydantic import ConfigDict, Field, RootModel +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field from . import attestation_issuer from . import ext as ext_1 @@ -20,7 +20,7 @@ class SupportedDeliveryMethod(StrEnum): embedded = 'embedded' -class CredentialOrigin(RootModel[WireAnyUrl]): +class CredentialOrigin(AdCPRootModel[WireAnyUrl]): root: WireAnyUrl diff --git a/src/adcp/types/domains/core/audience_characteristic.py b/src/adcp/types/domains/core/audience_characteristic.py index bd6bb2eb6e..e4795dfc86 100644 --- a/src/adcp/types/domains/core/audience_characteristic.py +++ b/src/adcp/types/domains/core/audience_characteristic.py @@ -6,14 +6,14 @@ from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl -from pydantic import ConfigDict, Field, RootModel, StrictBool, StrictFloat, model_validator +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator from pydantic_core import PydanticCustomError -class Dimension(RootModel[WireAnyUrl]): +class Dimension(AdCPRootModel[WireAnyUrl]): root: Annotated[ WireAnyUrl, Field( @@ -22,7 +22,7 @@ class Dimension(RootModel[WireAnyUrl]): ] -class Value(RootModel[list[str | StrictFloat | StrictBool]]): +class Value(AdCPRootModel[list[str | StrictFloat | StrictBool]]): root: Annotated[ list[str | StrictFloat | StrictBool], Field(description='Scalar or set value for the dimension.', min_length=1), diff --git a/src/adcp/types/domains/core/audience_evidence.py b/src/adcp/types/domains/core/audience_evidence.py index f29545a1fa..a0d0147f5a 100644 --- a/src/adcp/types/domains/core/audience_evidence.py +++ b/src/adcp/types/domains/core/audience_evidence.py @@ -7,8 +7,8 @@ from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime -from pydantic import ConfigDict, Field, RootModel, StrictFloat +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StrictFloat from ..enums import audience_evidence_methodology, audience_resolution_method, audience_subject_type from . import audience_characteristic, brand_ref, date_range @@ -235,7 +235,7 @@ class Subject17(Subject14): ) -class Subject(RootModel[Subject15 | Subject16 | Subject17]): +class Subject(AdCPRootModel[Subject15 | Subject16 | Subject17]): root: Annotated[ Subject15 | Subject16 | Subject17, Field( @@ -277,7 +277,7 @@ class Subject25(Subject21): ] -class Subject2(RootModel[Subject25 | Subject26 | Subject27]): +class Subject2(AdCPRootModel[Subject25 | Subject26 | Subject27]): root: Annotated[ Subject25 | Subject26 | Subject27, Field( @@ -344,7 +344,7 @@ class Subject35(Subject31): ] = None -class Subject3(RootModel[Subject35 | Subject36 | Subject37]): +class Subject3(AdCPRootModel[Subject35 | Subject36 | Subject37]): root: Annotated[ Subject35 | Subject36 | Subject37, Field( diff --git a/src/adcp/types/domains/core/creative_asset.py b/src/adcp/types/domains/core/creative_asset.py index 88706d85ea..d325639cfe 100644 --- a/src/adcp/types/domains/core/creative_asset.py +++ b/src/adcp/types/domains/core/creative_asset.py @@ -6,8 +6,8 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import ConfigDict, Field, RootModel, StrictFloat, StringConstraints, model_validator +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull +from pydantic import ConfigDict, Field, StrictFloat, StringConstraints, model_validator from ..enums import creative_status from . import creative_assets @@ -40,7 +40,7 @@ class Input(AdCPBaseModel): ] = None -class Assets(RootModel[list[asset_union.AssetVariant]]): +class Assets(AdCPRootModel[list[asset_union.AssetVariant]]): root: Annotated[list[asset_union.AssetVariant], Field(min_length=1)] diff --git a/src/adcp/types/domains/core/creative_assets.py b/src/adcp/types/domains/core/creative_assets.py index e4cdb55297..8cde85cb26 100644 --- a/src/adcp/types/domains/core/creative_assets.py +++ b/src/adcp/types/domains/core/creative_assets.py @@ -6,17 +6,18 @@ from typing import Any, Annotated -from pydantic import Field, RootModel, StringConstraints +from adcp.types.base import AdCPRootModel +from pydantic import Field, StringConstraints from .assets import asset_union -class CreativeAssets1(RootModel[list[asset_union.AssetVariant]]): +class CreativeAssets1(AdCPRootModel[list[asset_union.AssetVariant]]): root: Annotated[list[asset_union.AssetVariant], Field(min_length=1)] class CreativeAssets( - RootModel[dict[Annotated[str, StringConstraints(pattern=r'^[a-z0-9_]+$')], asset_union.AssetVariant | CreativeAssets1]] + AdCPRootModel[dict[Annotated[str, StringConstraints(pattern=r'^[a-z0-9_]+$')], asset_union.AssetVariant | CreativeAssets1]] ): root: Annotated[ dict[Annotated[str, StringConstraints(pattern=r'^[a-z0-9_]+$')], asset_union.AssetVariant | CreativeAssets1], diff --git a/src/adcp/types/domains/core/creative_localization.py b/src/adcp/types/domains/core/creative_localization.py index 2e009526ec..d295bb054b 100644 --- a/src/adcp/types/domains/core/creative_localization.py +++ b/src/adcp/types/domains/core/creative_localization.py @@ -7,8 +7,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import ConfigDict, Field, RootModel, StringConstraints +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull +from pydantic import ConfigDict, Field, StringConstraints from . import locale_tag, localized_creative_asset @@ -53,7 +53,7 @@ class LocaleFallback(AdCPBaseModel): ] -class Assets(RootModel[list[localized_creative_asset.LocalizedCreativeAsset]]): +class Assets(AdCPRootModel[list[localized_creative_asset.LocalizedCreativeAsset]]): root: Annotated[list[localized_creative_asset.LocalizedCreativeAsset], Field(min_length=1)] diff --git a/src/adcp/types/domains/core/creative_localization_readback.py b/src/adcp/types/domains/core/creative_localization_readback.py index dd765809d1..0961e00a4c 100644 --- a/src/adcp/types/domains/core/creative_localization_readback.py +++ b/src/adcp/types/domains/core/creative_localization_readback.py @@ -7,8 +7,8 @@ from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import ConfigDict, Field, RootModel, StringConstraints +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull +from pydantic import ConfigDict, Field, StringConstraints from . import locale_tag, localized_creative_asset @@ -26,12 +26,12 @@ class LocaleFallback(AdCPBaseModel): locale_variant_id: Annotated[str, Field(max_length=255, min_length=1)] -class ResolvedAssets1(RootModel[list[localized_creative_asset.LocalizedCreativeAsset]]): +class ResolvedAssets1(AdCPRootModel[list[localized_creative_asset.LocalizedCreativeAsset]]): root: Annotated[list[localized_creative_asset.LocalizedCreativeAsset], Field(min_length=1)] class ResolvedAssets( - RootModel[ + AdCPRootModel[ dict[ Annotated[str, StringConstraints(pattern=r'^[a-z0-9_]+$')], localized_creative_asset.LocalizedCreativeAsset | ResolvedAssets1, diff --git a/src/adcp/types/domains/core/creative_manifest.py b/src/adcp/types/domains/core/creative_manifest.py index 5f3800c93b..5f408e078d 100644 --- a/src/adcp/types/domains/core/creative_manifest.py +++ b/src/adcp/types/domains/core/creative_manifest.py @@ -6,8 +6,8 @@ from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import ConfigDict, Field, RootModel, StringConstraints, model_validator +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull +from pydantic import ConfigDict, Field, StringConstraints, model_validator from . import brand_ref, creative_assets from . import ext as ext_1 @@ -31,7 +31,7 @@ def _normalize_asset_models(value: Any) -> Any: return value -class Assets(RootModel[list[asset_union.AssetVariant]]): +class Assets(AdCPRootModel[list[asset_union.AssetVariant]]): root: Annotated[list[asset_union.AssetVariant], Field(min_length=1)] diff --git a/src/adcp/types/domains/core/daast_tracker_constraints.py b/src/adcp/types/domains/core/daast_tracker_constraints.py index 3d0a9f0f45..11390a5d8f 100644 --- a/src/adcp/types/domains/core/daast_tracker_constraints.py +++ b/src/adcp/types/domains/core/daast_tracker_constraints.py @@ -7,9 +7,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull from adcp.types._scalar import ScalarStr -from pydantic import Field, RootModel +from pydantic import Field from ..enums import daast_version @@ -41,7 +41,7 @@ class DaastOffset(ScalarStr): _constraints = {'pattern': '^(\\d{2}:[0-5]\\d:[0-5]\\d(\\.\\d{3})?|(100|\\d{1,2})%)$'} -class DaastVersions(RootModel[list[daast_version.DaastVersion]]): +class DaastVersions(AdCPRootModel[list[daast_version.DaastVersion]]): root: Annotated[list[daast_version.DaastVersion], Field(min_length=1)] diff --git a/src/adcp/types/domains/core/forecast_point_dimensions.py b/src/adcp/types/domains/core/forecast_point_dimensions.py index e760b7340b..b431b2c23d 100644 --- a/src/adcp/types/domains/core/forecast_point_dimensions.py +++ b/src/adcp/types/domains/core/forecast_point_dimensions.py @@ -6,7 +6,8 @@ from typing import Any, Annotated -from pydantic import Field, RootModel +from adcp.types.base import AdCPRootModel +from pydantic import Field from . import ( forecast_dimension_audience, @@ -20,7 +21,7 @@ class ForecastPointDimensions( - RootModel[ + AdCPRootModel[ list[ forecast_dimension_geo.GeoForecastDimension | forecast_dimension_placement.PlacementForecastDimension diff --git a/src/adcp/types/domains/core/format.py b/src/adcp/types/domains/core/format.py index ee098b1b30..5198019b54 100644 --- a/src/adcp/types/domains/core/format.py +++ b/src/adcp/types/domains/core/format.py @@ -7,8 +7,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl -from pydantic import ConfigDict, Field, RootModel, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..enums import ( available_metric, @@ -2612,7 +2612,7 @@ class CanonicalParameters33(CanonicalParameters16, CanonicalParameters17): class CanonicalParameters( - RootModel[ + AdCPRootModel[ CanonicalParameters18 | CanonicalParameters19 | CanonicalParameters20 diff --git a/src/adcp/types/domains/core/format_shape_vocabulary.py b/src/adcp/types/domains/core/format_shape_vocabulary.py index 780be1cf0a..5a1f9b6f63 100644 --- a/src/adcp/types/domains/core/format_shape_vocabulary.py +++ b/src/adcp/types/domains/core/format_shape_vocabulary.py @@ -6,10 +6,11 @@ from typing import Annotated, Any -from pydantic import Field, RootModel +from adcp.types.base import AdCPRootModel +from pydantic import Field -class AdcpFormatShapeVocabularyRegistry(RootModel[Any]): +class AdcpFormatShapeVocabularyRegistry(AdCPRootModel[Any]): root: Annotated[ Any, Field( diff --git a/src/adcp/types/domains/core/geo_place_system.py b/src/adcp/types/domains/core/geo_place_system.py index cdba4ac00e..e8739fe8c5 100644 --- a/src/adcp/types/domains/core/geo_place_system.py +++ b/src/adcp/types/domains/core/geo_place_system.py @@ -7,8 +7,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import WireAnyUrl -from pydantic import Field, RootModel +from adcp.types.base import AdCPRootModel, WireAnyUrl +from pydantic import Field class GeographicPlaceIdentifierSystem1(StrEnum): @@ -17,7 +17,7 @@ class GeographicPlaceIdentifierSystem1(StrEnum): microsoft_ads = 'microsoft_ads' -class GeographicPlaceIdentifierSystem2(RootModel[WireAnyUrl]): +class GeographicPlaceIdentifierSystem2(AdCPRootModel[WireAnyUrl]): root: Annotated[ WireAnyUrl, Field( diff --git a/src/adcp/types/domains/core/geo_place_type.py b/src/adcp/types/domains/core/geo_place_type.py index 550fc18a6e..f900259549 100644 --- a/src/adcp/types/domains/core/geo_place_type.py +++ b/src/adcp/types/domains/core/geo_place_type.py @@ -7,8 +7,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import WireAnyUrl -from pydantic import Field, RootModel +from adcp.types.base import AdCPRootModel, WireAnyUrl +from pydantic import Field class GeographicPlaceType1(StrEnum): @@ -30,7 +30,7 @@ class GeographicPlaceType1(StrEnum): ward = 'ward' -class GeographicPlaceType2(RootModel[WireAnyUrl]): +class GeographicPlaceType2(AdCPRootModel[WireAnyUrl]): root: Annotated[ WireAnyUrl, Field( diff --git a/src/adcp/types/domains/core/localized_creative_asset.py b/src/adcp/types/domains/core/localized_creative_asset.py index fe80934dd6..40c60f605e 100644 --- a/src/adcp/types/domains/core/localized_creative_asset.py +++ b/src/adcp/types/domains/core/localized_creative_asset.py @@ -6,8 +6,8 @@ from typing import Any, Annotated -from adcp.types.base import AdCPBaseModel -from pydantic import Field, RootModel +from adcp.types.base import AdCPBaseModel, AdCPRootModel +from pydantic import Field from .assets.asset_union import ( AudioAsset, @@ -115,7 +115,7 @@ class LocalizedCreativeAsset22(DaastTrackerAsset, LocalizedCreativeAsset1): class LocalizedCreativeAsset( - RootModel[ + AdCPRootModel[ LocalizedCreativeAsset2 | LocalizedCreativeAsset3 | LocalizedCreativeAsset4 diff --git a/src/adcp/types/domains/core/macro_bearing_url.py b/src/adcp/types/domains/core/macro_bearing_url.py index 3d37abbd30..b3680378d0 100644 --- a/src/adcp/types/domains/core/macro_bearing_url.py +++ b/src/adcp/types/domains/core/macro_bearing_url.py @@ -8,11 +8,11 @@ from typing import Annotated -from adcp.types.base import WireAnyUrl -from pydantic import Field, RootModel +from adcp.types.base import AdCPRootModel, WireAnyUrl +from pydantic import Field -class MacroBearingUrl3(RootModel[WireAnyUrl]): +class MacroBearingUrl3(AdCPRootModel[WireAnyUrl]): root: Annotated[ WireAnyUrl, Field( diff --git a/src/adcp/types/domains/core/placement_identity.py b/src/adcp/types/domains/core/placement_identity.py index 22bbeca3df..548884c8c6 100644 --- a/src/adcp/types/domains/core/placement_identity.py +++ b/src/adcp/types/domains/core/placement_identity.py @@ -6,8 +6,8 @@ from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, RootModel +from adcp.types.base import AdCPBaseModel, AdCPRootModel +from pydantic import ConfigDict, Field from . import seller_agent_ref @@ -45,7 +45,7 @@ class PlacementIdentity2(AdCPBaseModel): ] -class PlacementIdentity(RootModel[PlacementIdentity1 | PlacementIdentity2]): +class PlacementIdentity(AdCPRootModel[PlacementIdentity1 | PlacementIdentity2]): root: Annotated[ PlacementIdentity1 | PlacementIdentity2, Field( diff --git a/src/adcp/types/domains/core/postal_area.py b/src/adcp/types/domains/core/postal_area.py index b7d6aa29e8..df9a4616ba 100644 --- a/src/adcp/types/domains/core/postal_area.py +++ b/src/adcp/types/domains/core/postal_area.py @@ -7,8 +7,8 @@ from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, RootModel, model_validator +from adcp.types.base import AdCPBaseModel, AdCPRootModel +from pydantic import ConfigDict, Field, model_validator from ..enums import legacy_postal_system, postal_system @@ -380,7 +380,7 @@ class PostalArea121(PostalArea110): class PostalArea1( - RootModel[ + AdCPRootModel[ PostalArea112 | PostalArea113 | PostalArea114 @@ -417,7 +417,7 @@ def __getattr__(self, name: str) -> Any: -class PostalArea(RootModel[PostalArea2 | PostalArea1]): +class PostalArea(AdCPRootModel[PostalArea2 | PostalArea1]): root: Annotated[ PostalArea2 | PostalArea1, Field( diff --git a/src/adcp/types/domains/core/product_change_map.py b/src/adcp/types/domains/core/product_change_map.py index 1b6619e8b6..abd5e6e33b 100644 --- a/src/adcp/types/domains/core/product_change_map.py +++ b/src/adcp/types/domains/core/product_change_map.py @@ -7,7 +7,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from pydantic import Field, RootModel, StringConstraints +from adcp.types.base import AdCPRootModel +from pydantic import Field, StringConstraints class ProductChangeMap1(StrEnum): @@ -15,7 +16,7 @@ class ProductChangeMap1(StrEnum): omit = 'omit' -class ProductChangeMap(RootModel[dict[Annotated[str, StringConstraints(min_length=1)], ProductChangeMap1]]): +class ProductChangeMap(AdCPRootModel[dict[Annotated[str, StringConstraints(min_length=1)], ProductChangeMap1]]): root: Annotated[ dict[Annotated[str, StringConstraints(min_length=1)], ProductChangeMap1], Field( diff --git a/src/adcp/types/domains/core/registry_event.py b/src/adcp/types/domains/core/registry_event.py index 050c934876..31479d7769 100644 --- a/src/adcp/types/domains/core/registry_event.py +++ b/src/adcp/types/domains/core/registry_event.py @@ -7,9 +7,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime, WireUuid +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime, WireUuid from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field, RootModel, StrictBool +from pydantic import ConfigDict, Field, StrictBool from ..enums import adcp_protocol, collection_kind, distribution_identifier_type from . import agent_signing_key @@ -268,15 +268,15 @@ class Domain(ScalarStr): } -class BadgeRole(RootModel[adcp_protocol.AdcpProtocol]): +class BadgeRole(AdCPRootModel[adcp_protocol.AdcpProtocol]): root: adcp_protocol.AdcpProtocol -class StringArray(RootModel[list[str]]): +class StringArray(AdCPRootModel[list[str]]): root: list[str] -class ChangedFields(RootModel[list[str]]): +class ChangedFields(AdCPRootModel[list[str]]): root: Annotated[ list[str], Field( @@ -359,7 +359,7 @@ class Country(Market): pass -class Countries(RootModel[list[Country]]): +class Countries(AdCPRootModel[list[Country]]): root: Annotated[list[Country], Field(min_length=1)] diff --git a/src/adcp/types/domains/core/reporting_verification_profile_set.py b/src/adcp/types/domains/core/reporting_verification_profile_set.py index 8b6ec2b01c..728434e482 100644 --- a/src/adcp/types/domains/core/reporting_verification_profile_set.py +++ b/src/adcp/types/domains/core/reporting_verification_profile_set.py @@ -7,7 +7,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from pydantic import Field, RootModel +from adcp.types.base import AdCPRootModel +from pydantic import Field class ReportingVerificationProfileSetEnum(StrEnum): @@ -16,7 +17,7 @@ class ReportingVerificationProfileSetEnum(StrEnum): canonical_digest = 'canonical_digest' -class ReportingVerificationProfileSet(RootModel[list[ReportingVerificationProfileSetEnum]]): +class ReportingVerificationProfileSet(AdCPRootModel[list[ReportingVerificationProfileSetEnum]]): root: Annotated[ list[ReportingVerificationProfileSetEnum], Field( diff --git a/src/adcp/types/domains/core/signal_pricing_option.py b/src/adcp/types/domains/core/signal_pricing_option.py index d42f10c562..be893f4dd7 100644 --- a/src/adcp/types/domains/core/signal_pricing_option.py +++ b/src/adcp/types/domains/core/signal_pricing_option.py @@ -6,12 +6,13 @@ from typing import Annotated -from pydantic import Field, RootModel +from adcp.types.base import AdCPRootModel +from pydantic import Field from . import vendor_pricing_option -class SignalPricingOption(RootModel[vendor_pricing_option.VendorPricingOption]): +class SignalPricingOption(AdCPRootModel[vendor_pricing_option.VendorPricingOption]): root: Annotated[ vendor_pricing_option.VendorPricingOption, Field( diff --git a/src/adcp/types/domains/core/targeting_overlay_requirements.py b/src/adcp/types/domains/core/targeting_overlay_requirements.py index 84add91f3e..c4a23057f9 100644 --- a/src/adcp/types/domains/core/targeting_overlay_requirements.py +++ b/src/adcp/types/domains/core/targeting_overlay_requirements.py @@ -6,8 +6,8 @@ from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import ConfigDict, Field, RootModel +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull +from pydantic import ConfigDict, Field from ..enums import browser_family, daypart_timezone_mode, match_type, metro_system, transport_mode from . import ext as ext_1 @@ -20,7 +20,7 @@ ) -class Required(RootModel[Literal[True]]): +class Required(AdCPRootModel[Literal[True]]): root: Literal[True] @@ -31,7 +31,7 @@ class MetroRequirement1(AdCPBaseModel): systems: Annotated[list[metro_system.MetroAreaSystem], Field(min_length=1)] -class MetroRequirement(RootModel[Required | MetroRequirement1]): +class MetroRequirement(AdCPRootModel[Required | MetroRequirement1]): root: Required | MetroRequirement1 def __getattr__(self, name: str) -> Any: """Proxy attribute access to the wrapped type.""" @@ -48,7 +48,7 @@ class KeywordRequirement1(AdCPBaseModel): supported_match_types: Annotated[list[match_type.MatchType], Field(min_length=1)] -class KeywordRequirement(RootModel[Required | KeywordRequirement1]): +class KeywordRequirement(AdCPRootModel[Required | KeywordRequirement1]): root: Required | KeywordRequirement1 def __getattr__(self, name: str) -> Any: """Proxy attribute access to the wrapped type.""" @@ -65,7 +65,7 @@ class BrowserRequirement1(AdCPBaseModel): families: Annotated[list[browser_family.BrowserFamily], Field(min_length=1)] -class BrowserRequirement(RootModel[Required | BrowserRequirement1]): +class BrowserRequirement(AdCPRootModel[Required | BrowserRequirement1]): root: Required | BrowserRequirement1 def __getattr__(self, name: str) -> Any: """Proxy attribute access to the wrapped type.""" @@ -90,7 +90,7 @@ class DaypartRequirement1(AdCPBaseModel): ] = None -class DaypartRequirement(RootModel[Required | DaypartRequirement1]): +class DaypartRequirement(AdCPRootModel[Required | DaypartRequirement1]): root: Required | DaypartRequirement1 def __getattr__(self, name: str) -> Any: """Proxy attribute access to the wrapped type.""" diff --git a/src/adcp/types/domains/core/targeting_overlay_support.py b/src/adcp/types/domains/core/targeting_overlay_support.py index 98d1f5da2c..720b10b971 100644 --- a/src/adcp/types/domains/core/targeting_overlay_support.py +++ b/src/adcp/types/domains/core/targeting_overlay_support.py @@ -6,9 +6,9 @@ from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, GeoPlaceSystemKey, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, AdCPRootModel, GeoPlaceSystemKey, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field, RootModel, StringConstraints +from pydantic import ConfigDict, Field, StringConstraints from ..enums import browser_family, daypart_timezone_mode, match_type, metro_system, transport_mode from . import ext as ext_1 @@ -31,7 +31,7 @@ class PlacementSelection(AdCPBaseModel): ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None -class Supported(RootModel[Literal[True]]): +class Supported(AdCPRootModel[Literal[True]]): root: Literal[True] @@ -49,7 +49,7 @@ class CountrySupport1(AdCPBaseModel): ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None -class CountrySupport(RootModel[Supported | CountrySupport1]): +class CountrySupport(AdCPRootModel[Supported | CountrySupport1]): root: Supported | CountrySupport1 def __getattr__(self, name: str) -> Any: """Proxy attribute access to the wrapped type.""" @@ -76,7 +76,7 @@ class MetroSupport1(AdCPBaseModel): ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None -class MetroSupport(RootModel[Supported | MetroSupport1]): +class MetroSupport(AdCPRootModel[Supported | MetroSupport1]): root: Supported | MetroSupport1 def __getattr__(self, name: str) -> Any: """Proxy attribute access to the wrapped type.""" @@ -143,7 +143,7 @@ class KeywordSupport1(AdCPBaseModel): ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None -class KeywordSupport(RootModel[Supported | KeywordSupport1]): +class KeywordSupport(AdCPRootModel[Supported | KeywordSupport1]): root: Supported | KeywordSupport1 def __getattr__(self, name: str) -> Any: """Proxy attribute access to the wrapped type.""" @@ -161,7 +161,7 @@ class BrowserSupport1(AdCPBaseModel): ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None -class BrowserSupport(RootModel[Supported | BrowserSupport1]): +class BrowserSupport(AdCPRootModel[Supported | BrowserSupport1]): root: Supported | BrowserSupport1 def __getattr__(self, name: str) -> Any: """Proxy attribute access to the wrapped type.""" @@ -171,7 +171,7 @@ def __getattr__(self, name: str) -> Any: -class IanaTimezones(RootModel[list[iana_timezone.IanaTimezoneIdentifier]]): +class IanaTimezones(AdCPRootModel[list[iana_timezone.IanaTimezoneIdentifier]]): root: Annotated[ list[iana_timezone.IanaTimezoneIdentifier], Field( @@ -196,7 +196,7 @@ class DaypartSupport1(AdCPBaseModel): ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None -class DaypartSupport(RootModel[Literal[True] | DaypartSupport1]): +class DaypartSupport(AdCPRootModel[Literal[True] | DaypartSupport1]): root: Literal[True] | DaypartSupport1 def __getattr__(self, name: str) -> Any: """Proxy attribute access to the wrapped type.""" diff --git a/src/adcp/types/domains/core/targeting_unknown_age_eligibility_constraint.py b/src/adcp/types/domains/core/targeting_unknown_age_eligibility_constraint.py index b28b93d9e6..3113743d1a 100644 --- a/src/adcp/types/domains/core/targeting_unknown_age_eligibility_constraint.py +++ b/src/adcp/types/domains/core/targeting_unknown_age_eligibility_constraint.py @@ -6,10 +6,11 @@ from typing import Annotated, Any -from pydantic import Field, RootModel +from adcp.types.base import AdCPRootModel +from pydantic import Field -class TargetingUnknownAgeEligibilityConstraint(RootModel[Any]): +class TargetingUnknownAgeEligibilityConstraint(AdCPRootModel[Any]): root: Annotated[ Any, Field( diff --git a/src/adcp/types/domains/core/targeting_verified_age_basis_constraint.py b/src/adcp/types/domains/core/targeting_verified_age_basis_constraint.py index 61a615b761..1472561d1d 100644 --- a/src/adcp/types/domains/core/targeting_verified_age_basis_constraint.py +++ b/src/adcp/types/domains/core/targeting_verified_age_basis_constraint.py @@ -6,10 +6,11 @@ from typing import Annotated, Any -from pydantic import Field, RootModel +from adcp.types.base import AdCPRootModel +from pydantic import Field -class TargetingVerifiedAgeBasisConstraint(RootModel[Any]): +class TargetingVerifiedAgeBasisConstraint(AdCPRootModel[Any]): root: Annotated[ Any, Field( diff --git a/src/adcp/types/domains/core/vast_tracker_constraints.py b/src/adcp/types/domains/core/vast_tracker_constraints.py index 21205d470e..fbac42fa2b 100644 --- a/src/adcp/types/domains/core/vast_tracker_constraints.py +++ b/src/adcp/types/domains/core/vast_tracker_constraints.py @@ -7,9 +7,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull from adcp.types._scalar import ScalarStr -from pydantic import Field, RootModel +from pydantic import Field from ..enums import vast_version @@ -55,7 +55,7 @@ class VastOffset(ScalarStr): _constraints = {'pattern': '^(\\d{2}:[0-5]\\d:[0-5]\\d(\\.\\d{3})?|(100|\\d{1,2})%)$'} -class VastVersions(RootModel[list[vast_version.VastVersion]]): +class VastVersions(AdCPRootModel[list[vast_version.VastVersion]]): root: Annotated[list[vast_version.VastVersion], Field(min_length=1)] diff --git a/src/adcp/types/domains/creative/list_creatives_response.py b/src/adcp/types/domains/creative/list_creatives_response.py index 22abb8b0ca..2b1738ff65 100644 --- a/src/adcp/types/domains/creative/list_creatives_response.py +++ b/src/adcp/types/domains/creative/list_creatives_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/list_creatives_response.json -# timestamp: 2026-10-25T19:32:54+00:00 +# timestamp: 2026-10-29T18:26:09+00:00 from __future__ import annotations @@ -10,8 +10,8 @@ from collections.abc import Sequence from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime -from pydantic import ConfigDict, Field, RootModel, StrictBool, StringConstraints, model_validator +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StringConstraints, model_validator from ..core import account as account_1 from ..core import context as context_1 @@ -317,7 +317,7 @@ class Assignments(AdCPBaseModel): ] = None -class Assets(RootModel[list[asset_union.AssetVariant]]): +class Assets(AdCPRootModel[list[asset_union.AssetVariant]]): root: Annotated[list[asset_union.AssetVariant], Field(min_length=1)] diff --git a/src/adcp/types/domains/enums/frequency_cap_scope.py b/src/adcp/types/domains/enums/frequency_cap_scope.py index ff99c43fe8..1fe399662d 100644 --- a/src/adcp/types/domains/enums/frequency_cap_scope.py +++ b/src/adcp/types/domains/enums/frequency_cap_scope.py @@ -6,10 +6,11 @@ from typing import Annotated, Literal -from pydantic import Field, RootModel +from adcp.types.base import AdCPRootModel +from pydantic import Field -class FrequencyCapScope(RootModel[Literal['package']]): +class FrequencyCapScope(AdCPRootModel[Literal['package']]): root: Annotated[ Literal['package'], Field(description='Scope for frequency cap application', title='Frequency Cap Scope'), diff --git a/src/adcp/types/domains/enums/media_buy_frequency_cap_control_mode.py b/src/adcp/types/domains/enums/media_buy_frequency_cap_control_mode.py index 3947843bbb..1eb4ec0cc2 100644 --- a/src/adcp/types/domains/enums/media_buy_frequency_cap_control_mode.py +++ b/src/adcp/types/domains/enums/media_buy_frequency_cap_control_mode.py @@ -6,10 +6,11 @@ from typing import Annotated, Literal -from pydantic import Field, RootModel +from adcp.types.base import AdCPRootModel +from pydantic import Field -class MediaBuyFrequencyCapControlMode(RootModel[Literal['max_impressions']]): +class MediaBuyFrequencyCapControlMode(AdCPRootModel[Literal['max_impressions']]): root: Annotated[ Literal['max_impressions'], Field( diff --git a/src/adcp/types/domains/error_details/requote_required.py b/src/adcp/types/domains/error_details/requote_required.py index ee75c1f392..d582035fa0 100644 --- a/src/adcp/types/domains/error_details/requote_required.py +++ b/src/adcp/types/domains/error_details/requote_required.py @@ -6,9 +6,9 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field, RootModel +from pydantic import ConfigDict, Field from ..core import media_buy_change_term_id from ..enums import seller_policy_decline_reason @@ -27,7 +27,7 @@ class EnvelopeField1Item(ScalarStr): _constraints = {'min_length': 1} -class EnvelopeField1(RootModel[list[EnvelopeField1Item]]): +class EnvelopeField1(AdCPRootModel[list[EnvelopeField1Item]]): root: Annotated[ list[EnvelopeField1Item], Field( diff --git a/src/adcp/types/domains/governance/accepted_governance_agents.py b/src/adcp/types/domains/governance/accepted_governance_agents.py index adac694cc4..1f213c8eb3 100644 --- a/src/adcp/types/domains/governance/accepted_governance_agents.py +++ b/src/adcp/types/domains/governance/accepted_governance_agents.py @@ -7,8 +7,8 @@ from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt, WireAnyUrl -from pydantic import ConfigDict, Field, RootModel +from adcp.types.base import AdCPBaseModel, AdCPRootModel, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field class AnyOf1(AdCPBaseModel): @@ -64,7 +64,7 @@ class AnyOf2(AdCPBaseModel): ] -class AnyOf(RootModel[AnyOf1 | AnyOf2]): +class AnyOf(AdCPRootModel[AnyOf1 | AnyOf2]): root: Annotated[AnyOf1 | AnyOf2, Field(discriminator='kind')] def __getattr__(self, name: str) -> Any: """Proxy attribute access to the wrapped type.""" diff --git a/src/adcp/types/domains/governance/reported_outcome_error.py b/src/adcp/types/domains/governance/reported_outcome_error.py index 4a76d4dba4..0e19ccdacb 100644 --- a/src/adcp/types/domains/governance/reported_outcome_error.py +++ b/src/adcp/types/domains/governance/reported_outcome_error.py @@ -7,9 +7,9 @@ from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Dict -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field, RootModel, StrictBool, StrictFloat, StringConstraints +from pydantic import ConfigDict, Field, StrictBool, StrictFloat, StringConstraints class Recovery(StrEnum): @@ -28,7 +28,7 @@ class BoundedScalar1(ScalarStr): _constraints = {'max_length': 4000} -class BoundedScalar(RootModel[StrictBool | StrictFloat | BoundedScalar1 | None]): +class BoundedScalar(AdCPRootModel[StrictBool | StrictFloat | BoundedScalar1 | None]): root: StrictBool | StrictFloat | BoundedScalar1 | None def __getattr__(self, name: str) -> Any: """Proxy attribute access to the wrapped type.""" @@ -38,7 +38,7 @@ def __getattr__(self, name: str) -> Any: -class BoundedValueLevel31(RootModel[list[BoundedScalar | None]]): +class BoundedValueLevel31(AdCPRootModel[list[BoundedScalar | None]]): root: Annotated[list[BoundedScalar | None], Field(max_length=32)] def __getattr__(self, name: str) -> Any: """Proxy attribute access to the wrapped type.""" @@ -51,21 +51,21 @@ def __getattr__(self, name: str) -> Any: BoundedValueLevel3 = BoundedScalar | BoundedValueLevel31 | dict[str, BoundedScalar | None] | None -class BoundedValueLevel21(RootModel[list[BoundedValueLevel3]]): +class BoundedValueLevel21(AdCPRootModel[list[BoundedValueLevel3]]): root: Annotated[list[BoundedValueLevel3], Field(max_length=32)] BoundedValueLevel2 = BoundedScalar | BoundedValueLevel21 | dict[str, BoundedValueLevel3] | None -class BoundedValue1(RootModel[list[BoundedValueLevel2]]): +class BoundedValue1(AdCPRootModel[list[BoundedValueLevel2]]): root: Annotated[list[BoundedValueLevel2], Field(max_length=32)] BoundedValue = BoundedScalar | BoundedValue1 | dict[str, BoundedValueLevel2] | None -class BoundedObject(RootModel[dict[Annotated[str, StringConstraints(max_length=128)], BoundedValue]]): +class BoundedObject(AdCPRootModel[dict[Annotated[str, StringConstraints(max_length=128)], BoundedValue]]): root: Annotated[dict[Annotated[str, StringConstraints(max_length=128)], BoundedValue], Field(max_length=32)] diff --git a/src/adcp/types/domains/manifest.py b/src/adcp/types/domains/manifest.py index 4c1f807649..a98c6cbe9c 100644 --- a/src/adcp/types/domains/manifest.py +++ b/src/adcp/types/domains/manifest.py @@ -6,10 +6,10 @@ from typing import Any -from pydantic import RootModel +from adcp.types.base import AdCPRootModel -class Model(RootModel[Any]): +class Model(AdCPRootModel[Any]): root: Any diff --git a/src/adcp/types/domains/media_buy/get_media_buy_delivery_request.py b/src/adcp/types/domains/media_buy/get_media_buy_delivery_request.py index a257083884..3c83ce347b 100644 --- a/src/adcp/types/domains/media_buy/get_media_buy_delivery_request.py +++ b/src/adcp/types/domains/media_buy/get_media_buy_delivery_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_media_buy_delivery_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-29T18:26:09+00:00 from __future__ import annotations @@ -11,8 +11,8 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import ConfigDict, Field, RootModel, StrictBool +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull +from pydantic import ConfigDict, Field, StrictBool from ..core import account_ref from ..core import context as context_1 @@ -70,7 +70,7 @@ class Spot(AdCPBaseModel): ] = None -class StatusFilter(RootModel[list[media_buy_status.MediaBuyStatus]]): +class StatusFilter(AdCPRootModel[list[media_buy_status.MediaBuyStatus]]): root: Annotated[ list[media_buy_status.MediaBuyStatus], Field( diff --git a/src/adcp/types/domains/media_buy/get_media_buys_request.py b/src/adcp/types/domains/media_buy/get_media_buys_request.py index d4f37b89fb..a1d4b40e65 100644 --- a/src/adcp/types/domains/media_buy/get_media_buys_request.py +++ b/src/adcp/types/domains/media_buy/get_media_buys_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_media_buys_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-29T18:26:09+00:00 from __future__ import annotations @@ -8,8 +8,8 @@ from typing import Annotated -from adcp.types.base import NoExplicitNull -from pydantic import ConfigDict, Field, RootModel, StrictBool +from adcp.types.base import AdCPRootModel, NoExplicitNull +from pydantic import ConfigDict, Field, StrictBool from ..core import account_ref from ..core import context as context_1 @@ -19,7 +19,7 @@ from ..enums import indicator_type, media_buy_status -class StatusFilter(RootModel[list[media_buy_status.MediaBuyStatus]]): +class StatusFilter(AdCPRootModel[list[media_buy_status.MediaBuyStatus]]): root: Annotated[ list[media_buy_status.MediaBuyStatus], Field( diff --git a/src/adcp/types/domains/media_buy/product_fields.py b/src/adcp/types/domains/media_buy/product_fields.py index 6d69a65efc..a3b9657e85 100644 --- a/src/adcp/types/domains/media_buy/product_fields.py +++ b/src/adcp/types/domains/media_buy/product_fields.py @@ -7,7 +7,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from pydantic import Field, RootModel +from adcp.types.base import AdCPRootModel +from pydantic import Field class ProductResponseField(StrEnum): @@ -51,7 +52,7 @@ class ProductResponseField(StrEnum): allowed_actions = 'allowed_actions' -class ProductResponseFields(RootModel[list[ProductResponseField]]): +class ProductResponseFields(AdCPRootModel[list[ProductResponseField]]): root: Annotated[ list[ProductResponseField], Field( diff --git a/src/adcp/types/domains/media_buy/product_refinement.py b/src/adcp/types/domains/media_buy/product_refinement.py index 8e46140639..20ffd95f1f 100644 --- a/src/adcp/types/domains/media_buy/product_refinement.py +++ b/src/adcp/types/domains/media_buy/product_refinement.py @@ -7,8 +7,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import ConfigDict, Field, RootModel +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull +from pydantic import ConfigDict, Field class ProductRefinementRequests2(AdCPBaseModel): @@ -79,7 +79,7 @@ class ProductRefinementRequests4(AdCPBaseModel): ] -class ProductRefinementRequests(RootModel[list[ProductRefinementRequests1]]): +class ProductRefinementRequests(AdCPRootModel[list[ProductRefinementRequests1]]): root: Annotated[ list[ProductRefinementRequests1], Field( diff --git a/src/adcp/types/domains/protocol/get_adcp_capabilities_response.py b/src/adcp/types/domains/protocol/get_adcp_capabilities_response.py index b170102f94..1abce7c440 100644 --- a/src/adcp/types/domains/protocol/get_adcp_capabilities_response.py +++ b/src/adcp/types/domains/protocol/get_adcp_capabilities_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/get_adcp_capabilities_response.json -# timestamp: 2026-10-25T19:32:54+00:00 +# timestamp: 2026-10-29T18:26:09+00:00 from __future__ import annotations @@ -10,15 +10,9 @@ from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Dict, Literal -from adcp.types.base import AdCPBaseModel, GeoPlaceSystemKey, NoExplicitNull, WireAnyUrl, WireDatetime +from adcp.types.base import AdCPBaseModel, AdCPRootModel, GeoPlaceSystemKey, NoExplicitNull, WireAnyUrl, WireDatetime from adcp.types._scalar import ScalarInt, ScalarStr -from pydantic import ( - ConfigDict, - Field, - RootModel, - StrictBool, - StrictFloat, -) +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import account_timezone_capability, attestation_capabilities, audience_activation_method from ..core import context as context_1 @@ -316,7 +310,7 @@ class Tasks9(AdCPBaseModel): class Tasks( - RootModel[Tasks1 | Tasks2 | Tasks3 | Tasks4 | Tasks5 | Tasks6 | Tasks7 | Tasks8 | Tasks9] + AdCPRootModel[Tasks1 | Tasks2 | Tasks3 | Tasks4 | Tasks5 | Tasks6 | Tasks7 | Tasks8 | Tasks9] ): root: Annotated[ Tasks1 | Tasks2 | Tasks3 | Tasks4 | Tasks5 | Tasks6 | Tasks7 | Tasks8 | Tasks9, diff --git a/src/adcp/types/domains/trusted_match/context_match_response.py b/src/adcp/types/domains/trusted_match/context_match_response.py index 73f7a2253b..27313d1e32 100644 --- a/src/adcp/types/domains/trusted_match/context_match_response.py +++ b/src/adcp/types/domains/trusted_match/context_match_response.py @@ -6,8 +6,8 @@ from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import ConfigDict, Field, RootModel, StringConstraints +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull +from pydantic import ConfigDict, Field, StringConstraints from ..core.protocol_envelope import ProtocolEnvelope from ..core.version_envelope import AdcpVersionEnvelope @@ -38,7 +38,7 @@ class TargetingKv(AdCPBaseModel): value: Annotated[str, Field(description='Targeting value preserved unchanged by the router.')] -class TargetingKvs(RootModel[list[TargetingKv]]): +class TargetingKvs(AdCPRootModel[list[TargetingKv]]): root: Annotated[ list[TargetingKv], Field( diff --git a/src/adcp/types/domains/trusted_match/provider_context_match_response.py b/src/adcp/types/domains/trusted_match/provider_context_match_response.py index c15bae21e8..4b75b2cb74 100644 --- a/src/adcp/types/domains/trusted_match/provider_context_match_response.py +++ b/src/adcp/types/domains/trusted_match/provider_context_match_response.py @@ -6,8 +6,8 @@ from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import ConfigDict, Field, RootModel +from adcp.types.base import AdCPBaseModel, AdCPRootModel, NoExplicitNull, SchemaInt +from pydantic import ConfigDict, Field from ..core.protocol_envelope import ProtocolEnvelope from ..core.version_envelope import AdcpVersionEnvelope @@ -27,7 +27,7 @@ class TargetingKv(AdCPBaseModel): value: Annotated[str, Field(description='Targeting value that the router preserves unchanged.')] -class TargetingKvs(RootModel[list[TargetingKv]]): +class TargetingKvs(AdCPRootModel[list[TargetingKv]]): root: Annotated[ list[TargetingKv], Field( diff --git a/tests/test_canonical_removed_fields.py b/tests/test_canonical_removed_fields.py index d0ad661b81..30c022a5bc 100644 --- a/tests/test_canonical_removed_fields.py +++ b/tests/test_canonical_removed_fields.py @@ -6,7 +6,7 @@ ``CanonicalBoundaryModel.__pydantic_init_subclass__`` removes those inherited declarations, and it does so through pydantic internals: it deletes from ``cls.model_fields``, pops ``__annotations__``, binds the name to ``None`` and -forces ``model_rebuild``. None of that is a supported pydantic API. It works on +defers the rebuild to first use. None of that is a supported pydantic API. It works on 2.13; a minor release could change any of it without saying so. This module is the alarm for that. For every canonical model that inherits a diff --git a/tests/test_import_memory.py b/tests/test_import_memory.py new file mode 100644 index 0000000000..f55dda5b94 --- /dev/null +++ b/tests/test_import_memory.py @@ -0,0 +1,78 @@ +"""Peak RSS of importing the SDK's entry modules stays under a budget (#1694). + +Importing ``adcp.client`` or ``adcp.server`` realizes the generated type +surface through ``adcp.types._eager``. On 9.3.0 that cost about 520 MiB of +resident memory — 3.4x the 6.6 figure — because the import-time patchers +forced ``model_rebuild`` on two dozen request and response roots and every +generated ``RootModel`` built its validator at class creation. A seller that +ran in a 512 MiB container was OOM-killed at startup. + +Each module is imported in a fresh ``python -I`` subprocess, as an adopter's +process would, and its own peak RSS is read back. The budget sits well above the +measured figure (about 115 MiB for ``adcp.types._eager`` and 185 MiB for +``adcp.server`` on Linux / CPython 3.12) so platform and interpreter variance +do not trip it, and well below the 512 MiB the issue was about, so a change +that reintroduces an import-time build of a large model subtree fails here +rather than in an adopter's container. +""" + +from __future__ import annotations + +import subprocess +import sys + +import pytest + +pytest.importorskip("resource", reason="peak RSS needs a POSIX platform") + +#: Peak-RSS budget for a bare ``import ``. +IMPORT_RSS_BUDGET_MIB = 320 + +ENTRY_MODULES = [ + "adcp.types._eager", + "adcp.client", + "adcp.server", + "adcp.server.test_controller", + "adcp.decisioning", + "adcp.compat.versions", +] + +_PROBE = """ +import sys +import {module} +if sys.platform == "linux": + # The address space's own high-water mark. ru_maxrss would not do: Linux + # carries the forking parent's peak across exec, so a probe spawned from a + # large pytest process would report pytest's peak rather than its own. + with open("/proc/self/status") as status: + line = next(line for line in status if line.startswith("VmHWM:")) + print(int(line.split()[1]) / 1024) +else: + import resource + rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + # ru_maxrss is KiB on most POSIX platforms and bytes on macOS. + print(rss / (1024 * 1024) if sys.platform == "darwin" else rss / 1024) +""" + + +def _import_peak_rss_mib(module: str) -> float: + result = subprocess.run( + [sys.executable, "-I", "-c", _PROBE.format(module=module)], + capture_output=True, + text=True, + check=True, + timeout=600, + ) + return float(result.stdout.strip().splitlines()[-1]) + + +@pytest.mark.slow +@pytest.mark.parametrize("module", ENTRY_MODULES) +def test_import_peak_rss_within_budget(module: str) -> None: + peak = _import_peak_rss_mib(module) + assert peak < IMPORT_RSS_BUDGET_MIB, ( + f"import {module} peaked at {peak:.0f} MiB RSS, over the {IMPORT_RSS_BUDGET_MIB} MiB " + "budget. Something now builds a large pydantic model subtree at import time: look " + "for a model_rebuild(force=True) that should be defer_model_rebuild(), or a " + "RootModel[...] that should parametrize adcp.types.base.AdCPRootModel (#1694)." + ) diff --git a/tests/test_lazy_rebuild.py b/tests/test_lazy_rebuild.py new file mode 100644 index 0000000000..8b2da2e364 --- /dev/null +++ b/tests/test_lazy_rebuild.py @@ -0,0 +1,267 @@ +"""The import-time patchers defer their rebuilds to first use (#1694). + +``_ergonomic``, ``_forward_compat``, ``canonical_creative`` and ``aliases`` +edit generated models' ``model_fields`` at import and then need the core +schema re-derived. They used to call ``model_rebuild(force=True)`` on the +spot, which walked each request or response root's whole subtree into +pydantic-core at import — the bulk of the 520 MiB ``adcp.client`` cost. +:func:`adcp.types.base.defer_model_rebuild` puts the model back into the +``defer_build`` state instead, so the patched fields take effect on first use. + +This module grades that contract on pydantic's private placeholders, which +the helper depends on: that the patched models are NOT built by importing +the surface, that they DO build on first use with the patches applied, and +that a generated ``RootModel`` parametrizes :class:`AdCPRootModel` without +building at class creation. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from typing import Literal + +import pytest +from pydantic import BaseModel, ConfigDict, Field, RootModel, ValidationError + +from adcp.types.base import AdCPBaseModel, AdCPRootModel, defer_model_rebuild + +# Models each patcher force-rebuilt at import before #1694. Read from a +# subprocess so the assertion is about the import itself, not about whatever +# an earlier test in this process already validated. +_PATCHED_MODELS = { + "adcp.types._forward_compat": [ + "CreateMediaBuyRequest", + "UpdateMediaBuyRequest", + "ReportingWebhook", + "McpWebhookPayload", + "GetCreativeDeliveryResponse", + ], + "adcp.types._ergonomic": [ + "GetProductsRequest", + "ListCreativesResponse", + "GetMediaBuyDeliveryResponse", + ], + "adcp.types.canonical_creative": ["CreativeManifest", "Format", "Product"], + "adcp.types.domains.adagents": ["AdcpAgentsAuthorization"], + "adcp.types.domains.core.format": ["CanonicalParameters"], +} + +_PROBE = """ +import adcp.types._eager +import importlib, json +out = {} +for module, names in SPEC.items(): + mod = importlib.import_module(module) + for name in names: + out[f"{module}.{name}"] = getattr(mod, name).__pydantic_complete__ +print(json.dumps(out)) +""" + + +def test_importing_the_surface_builds_no_patched_model() -> None: + result = subprocess.run( + [sys.executable, "-I", "-c", _PROBE.replace("SPEC", repr(_PATCHED_MODELS))], + capture_output=True, + text=True, + check=True, + timeout=600, + ) + built = {name for name, complete in json.loads(result.stdout).items() if complete} + assert not built, f"importing adcp.types._eager built {sorted(built)} at import time" + + +def test_forward_compat_patches_apply_on_first_use() -> None: + from adcp.types import CreateMediaBuyRequest, ReportingWebhook + + # The reporting_webhook extension (#1547) is a patched-in field. + registration = { + "url": "https://buyer.example/hook", + "authentication": { + "schemes": ["Bearer"], + "credentials": "buyer-reporting-token-1234567890", + }, + "reporting_frequency": "daily", + } + webhook = ReportingWebhook.model_validate({**registration, "operation_id": "op-1"}) + assert webhook.operation_id == "op-1" + with pytest.raises(ValidationError): + ReportingWebhook.model_validate({**registration, "operation_id": ""}) + # And the request parent sees it through its own lazily built schema. + schema = CreateMediaBuyRequest.model_json_schema() + assert "operation_id" in schema["$defs"]["ReportingWebhook"]["properties"] + + +def test_ergonomic_patches_apply_on_first_use() -> None: + from adcp.types.domains.media_buy.list_creative_formats_request import ( + ListCreativeFormatsRequest, + ) + + # The order-preserving unique-items validator is appended to field metadata + # at import and only takes effect once the schema is built. + request = ListCreativeFormatsRequest.model_validate( + {"disclosure_positions": ["overlay", "overlay"]} + ) + assert [p.value for p in request.disclosure_positions or []] == ["overlay"] + + +def test_defer_model_rebuild_rebuilds_from_patched_fields() -> None: + class Node(AdCPBaseModel): + model_config = ConfigDict(defer_build=True) + value: int = 0 + + assert Node.model_validate({"value": 1}).value == 1 + assert Node.__pydantic_complete__ + + # Widen the field the way the patchers do, then defer instead of forcing. + field = Node.model_fields["value"] + field.annotation = int | str + Node.__annotations__["value"] = int | str + defer_model_rebuild(Node) + assert not Node.__pydantic_complete__ + + assert Node.model_validate({"value": "x"}).value == "x" + assert Node.__pydantic_complete__ + assert Node(value=2).model_dump() == {"value": 2} + assert Node.model_json_schema()["properties"]["value"]["anyOf"] == [ + {"type": "integer"}, + {"type": "string"}, + ] + + +def test_defer_model_rebuild_on_a_never_built_model_is_harmless() -> None: + class Fresh(AdCPBaseModel): + model_config = ConfigDict(defer_build=True) + value: int = 0 + + assert not Fresh.__pydantic_complete__ + defer_model_rebuild(Fresh) + assert not Fresh.__pydantic_complete__ + assert Fresh.model_validate({"value": 3}).value == 3 + + +def test_adcp_root_model_parametrizes_deferred() -> None: + class Left(BaseModel): + kind: Literal["left"] = "left" + x: int + + class Right(BaseModel): + kind: Literal["right"] = "right" + y: str + + parametrized = AdCPRootModel[Left | Right] + assert not parametrized.__pydantic_complete__ + # pydantic's own RootModel builds at the subscript, which is the cost + # this base exists to avoid. + assert RootModel[Left | Right].__pydantic_complete__ + + class Either(AdCPRootModel[Left | Right]): + root: Left | Right = Field(discriminator="kind") + + assert not Either.__pydantic_complete__ + assert issubclass(Either, RootModel) + assert Either.model_validate({"kind": "right", "y": "q"}).root == Right(y="q") + assert Either.__pydantic_complete__ + assert Either(Left(x=1)).model_dump() == {"kind": "left", "x": 1} + assert set(Either.model_json_schema()["oneOf"][0]) == {"$ref"} + + +def test_generated_root_models_parametrize_adcp_root_model() -> None: + from adcp.types.domains.adagents import AdcpAgentsAuthorization + from adcp.types.domains.core.format import CanonicalParameters + + for root in (AdcpAgentsAuthorization, CanonicalParameters): + assert issubclass(root, AdCPRootModel), root + assert root.model_config.get("defer_build") is True + + +def test_a_canonical_subclass_may_forward_reference_a_later_model() -> None: + """The removal hook's deferred rebuild tolerates an unresolved forward reference. + + A subclass of a canonical model re-inherits the removed legacy identity, so + ``__pydantic_init_subclass__`` removes it again and used to force a rebuild + there -- which raised when a field named a model defined further down the + module, an ordinary pydantic pattern that plain ``model_rebuild()`` defers + (#1690). Deferring the rebuild leaves the class incomplete until the + caller's own ``model_rebuild()``, with the removal intact. + """ + from adcp.types import PackageRequest + from adcp.types.canonical_creative import is_legacy_creative_identity_key + + class WithForwardRef(PackageRequest): + later: list[Later] | None = None + + class Later(BaseModel): + name: str + + assert not WithForwardRef.__pydantic_complete__ + WithForwardRef.model_rebuild() + + assert WithForwardRef.__pydantic_complete__ + assert "later" in WithForwardRef.model_fields + assert not any(is_legacy_creative_identity_key(name) for name in WithForwardRef.model_fields) + payload = { + "buyer_ref": "p", + "product_id": "prod", + "pricing_option_id": "po", + "later": [{"name": "n"}], + } + assert WithForwardRef.model_validate(payload).later + + +_DUMP_PROBE = """ +from typing import Any +from pydantic import BaseModel +from adcp.types import CreateMediaBuyRequest, Placement +from adcp.types.domains.adagents import AdcpAgentsAuthorization +from adcp.types.domains.core.targeting import TargetingOverlay + + +class Parent(BaseModel): + item: Any + + +# A canonical model, unvalidated: the #1694 CI failure. +assert Parent(item=Placement.model_construct(name="n")).model_dump(mode="json") == { + "item": {"name": "n"} +} +# A config-deferred generated model, unvalidated: a TypeError on 9.3.0. +assert Parent(item=TargetingOverlay.model_construct()).model_dump(mode="json") == {"item": {}} +# A nested instance built through its parent's schema; its own class never built. +request = CreateMediaBuyRequest.model_validate( + { + "buyer_ref": "b", + "brand": {"domain": "brand.example"}, + "packages": [{"buyer_ref": "p", "product_id": "prod", "pricing_option_id": "po"}], + "start_time": "asap", + "end_time": "2030-01-01T00:00:00Z", + "idempotency_key": "k" * 20, + "account": {"account_id": "a"}, + } +) +package = request.packages[0] +assert not type(package).__pydantic_complete__ +assert Parent(item=package).model_dump(mode="json")["item"]["buyer_ref"] == "p" +assert type(package).__pydantic_complete__ +# A deferred generated root, unvalidated. +root = AdcpAgentsAuthorization.model_construct(root=None) +assert Parent(item=root).model_dump(mode="json") == {"item": None} +print("ok") +""" + + +def test_a_deferred_instance_dumps_through_a_plain_pydantic_parent() -> None: + """pydantic-core reads ``instance.__pydantic_serializer__`` when it serializes by + inference (an ``Any`` field, ``serialize_as_any``), and a placeholder there fails + with ``TypeError: 'MockValSer' object is not an instance of 'SchemaSerializer'``. + The SDK's placeholders build the model on that read instead. In a subprocess so + nothing in this process has built the models first.""" + result = subprocess.run( + [sys.executable, "-I", "-c", _DUMP_PROBE], + capture_output=True, + text=True, + timeout=600, + ) + assert result.returncode == 0, result.stderr[-2000:] + assert result.stdout.strip() == "ok" From 787cfb215cf9207bfef8c28cc34ff0b737cea39b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 18:17:02 -0400 Subject: [PATCH 04/28] feat(signing): accept any well-formed adcp_use purpose in keygen (#1710) generate_signing_keypair, pem_to_adcp_jwk and adcp-keygen --purpose took a closed Literal["request-signing", "webhook-signing"] and refused every other value, so the SDK could not mint or publish a key for response-signing (the designated-task response JWS profile, core/response-payload-jws-envelope.json), governance-signing (which governance.py already verifies), or a surface the vocabulary does not name yet. The helpers they delegate to already took a str. purpose is now validated by syntax instead of membership: a lowercase hyphenated token of at most 64 characters. Malformed values (empty, upper case, underscores, whitespace, leading/trailing/double hyphens, non-str) still raise ValueError, and the CLI exits 2 before writing a file. An open vocabulary is safe because purpose matching is fail-closed: a verifier accepts a key only when adcp_use equals the purpose of the surface it verifies, so a key under any other purpose verifies nothing there. A test pins that against the request and webhook verifiers. adcp.signing exports ADCP_USE_REQUEST, ADCP_USE_WEBHOOK (marked deprecated upstream), and the new ADCP_USE_RESPONSE and ADCP_USE_GOVERNANCE, so callers need not retype literals. The docstrings no longer say request- and webhook-signing keys must be distinct for every verifier: 3.3 webhook verifiers accept request-signing keys, and new webhook signers publish request-signing. Backport of #1708 to 9.x. Closes #1677 on main. Co-authored-by: Claude Opus 5.5 (1M context) --- src/adcp/signing/__init__.py | 8 ++ src/adcp/signing/constants.py | 7 ++ src/adcp/signing/keygen.py | 113 +++++++++++++----- .../signing/test_keygen_programmatic.py | 94 +++++++++++++-- .../signing/test_signing_provider.py | 10 +- 5 files changed, 188 insertions(+), 44 deletions(-) diff --git a/src/adcp/signing/__init__.py b/src/adcp/signing/__init__.py index 9b3c5bc8ba..2ae704f5b7 100644 --- a/src/adcp/signing/__init__.py +++ b/src/adcp/signing/__init__.py @@ -170,6 +170,10 @@ signing_operation, ) from adcp.signing.constants import ( + ADCP_USE_GOVERNANCE, + ADCP_USE_REQUEST, + ADCP_USE_RESPONSE, + ADCP_USE_WEBHOOK, DEFAULT_EXPIRES_IN_SECONDS, DEFAULT_SKEW_SECONDS, DEFAULT_TAG, @@ -368,6 +372,10 @@ def __init__(self, *args: object, **kwargs: object) -> None: "ResolvedSignerKey", "SignerKeyResolver", "StaticSignerKeys", + "ADCP_USE_GOVERNANCE", + "ADCP_USE_REQUEST", + "ADCP_USE_RESPONSE", + "ADCP_USE_WEBHOOK", "ALG_ED25519", "ALG_ES256", "ALLOWED_ALGS", diff --git a/src/adcp/signing/constants.py b/src/adcp/signing/constants.py index 6feddf4223..92aaf310ec 100644 --- a/src/adcp/signing/constants.py +++ b/src/adcp/signing/constants.py @@ -5,7 +5,12 @@ DEFAULT_TAG = "adcp/request-signing/v1" WEBHOOK_TAG = "adcp/webhook-signing/v1" ADCP_USE_REQUEST = "request-signing" +#: Deprecated upstream (adcp#5555): new webhook signers publish ``request-signing``. ADCP_USE_WEBHOOK = "webhook-signing" +#: Designated-task response JWS (``core/response-payload-jws-envelope.json``). +ADCP_USE_RESPONSE = "response-signing" +#: Governance attestation JWS (``check_governance`` ``governance_context``). +ADCP_USE_GOVERNANCE = "governance-signing" #: Current request keys and deprecated webhook keys are both valid for webhooks. WEBHOOK_ACCEPTED_ADCP_USES: frozenset[str] = frozenset({ADCP_USE_REQUEST, ADCP_USE_WEBHOOK}) MAX_WINDOW_SECONDS = 300 @@ -15,7 +20,9 @@ SIG_LABEL_DEFAULT = "sig1" __all__ = [ + "ADCP_USE_GOVERNANCE", "ADCP_USE_REQUEST", + "ADCP_USE_RESPONSE", "ADCP_USE_WEBHOOK", "DEFAULT_EXPIRES_IN_SECONDS", "DEFAULT_SKEW_SECONDS", diff --git a/src/adcp/signing/keygen.py b/src/adcp/signing/keygen.py index 6807b02a9c..a1043499f1 100644 --- a/src/adcp/signing/keygen.py +++ b/src/adcp/signing/keygen.py @@ -10,9 +10,10 @@ ergonomic. Both paths produce the same PEM and JWK. Publish the JWK (public half) -at your agent's ``jwks_uri`` — the ``adcp_use`` claim (``request-signing`` -or ``webhook-signing``) pins the key to one signing profile; verifiers -reject keys used in the wrong surface. +at your agent's ``jwks_uri`` — the ``adcp_use`` claim pins the key to one +signing purpose (``request-signing``, ``response-signing``, +``governance-signing``, the deprecated ``webhook-signing``, or any other +lowercase hyphenated token); verifiers reject keys used in the wrong surface. Usage (CLI): adcp-keygen --alg ed25519 --out private-key.pem @@ -43,6 +44,7 @@ import getpass import json import os +import re import secrets import sys import unicodedata @@ -53,6 +55,12 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec, ed25519 +from adcp.signing.constants import ( + ADCP_USE_GOVERNANCE, + ADCP_USE_REQUEST, + ADCP_USE_RESPONSE, + ADCP_USE_WEBHOOK, +) from adcp.signing.crypto import ALG_ED25519, ALG_ES256, b64url_encode, load_private_key_pem @@ -64,7 +72,11 @@ def _encryption_algorithm( return serialization.BestAvailableEncryption(passphrase) -_ADCP_USE_VALUES = ("request-signing", "webhook-signing") +#: Purposes the spec names today, for help text. The accepted vocabulary is +#: open: any token matching ``_ADCP_USE_PATTERN`` is a valid ``adcp_use``. +_KNOWN_ADCP_USES = (ADCP_USE_REQUEST, ADCP_USE_RESPONSE, ADCP_USE_GOVERNANCE, ADCP_USE_WEBHOOK) +_ADCP_USE_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") +_ADCP_USE_MAX_LENGTH = 64 def _public_jwk_ed25519( @@ -103,6 +115,25 @@ def _public_jwk_es256( } +def _check_purpose(purpose: object) -> str: + """``purpose`` if it is a lowercase hyphenated token, else ``ValueError``. + + Validation is syntactic, not by membership: an AdCP verifier accepts a + key only when ``adcp_use`` equals the purpose of the surface it verifies + (fail-closed), so a purpose no verifier implements authorizes nothing. + """ + if ( + not isinstance(purpose, str) + or len(purpose) > _ADCP_USE_MAX_LENGTH + or _ADCP_USE_PATTERN.fullmatch(purpose) is None + ): + raise ValueError( + "purpose must be a lowercase hyphenated token of at most " + f"{_ADCP_USE_MAX_LENGTH} characters (e.g. {ADCP_USE_REQUEST!r}), got {purpose!r}" + ) + return purpose + + def generate_ed25519( kid: str, passphrase: bytes | None = None, adcp_use: str = "request-signing" ) -> tuple[bytes, dict[str, Any]]: @@ -133,7 +164,7 @@ def pem_to_adcp_jwk( pem: bytes, *, kid: str, - purpose: Literal["request-signing", "webhook-signing"], + purpose: str, password: bytes | None = None, ) -> dict[str, Any]: """Derive the public JWK for an existing AdCP signing PEM. @@ -153,8 +184,8 @@ def pem_to_adcp_jwk( * ``alg`` — MUST be ``"EdDSA"`` for Ed25519, ``"ES256"`` for P-256 (NOT the RFC 9421 ``alg`` casing used in ``Signature-Input``). * ``adcp_use`` — required by AdCP #2423; verifiers reject keys - lacking it. MUST match the signing surface (``"request-signing"`` - vs. ``"webhook-signing"``). + lacking it. MUST match the signing surface (``"request-signing"``, + ``"response-signing"``, ``"governance-signing"``, ...). * ``key_ops`` — MUST be ``["verify"]`` (the public half cannot sign). @@ -165,22 +196,20 @@ def pem_to_adcp_jwk( the loader handles both forms. :param kid: JWK ``kid`` to embed. MUST match the value the signer will advertise via :meth:`SigningProvider.key_id`. - :param purpose: Which AdCP signing profile this key is for. Sets - ``adcp_use``. Generate distinct keys per purpose — sharing - material across request-signing and webhook-signing is a spec - violation, not just a convention. + :param purpose: Which AdCP signing purpose this key is for. Sets + ``adcp_use``. Any lowercase hyphenated token is accepted; see + :func:`generate_signing_keypair` for the vocabulary and why an + open one is safe. Generate distinct keys per purpose. :param password: Passphrase if ``pem`` is an encrypted private key. :returns: Public JWK ready to publish in the agent's ``jwks_uri``. The private scalar (``d``) is NEVER included in the output. - :raises ValueError: ``purpose`` is not in - ``("request-signing", "webhook-signing")``; the PEM is not - Ed25519 or ECDSA-P-256; the EC curve is not P-256. + :raises ValueError: ``purpose`` is not a lowercase hyphenated token; + the PEM is not Ed25519 or ECDSA-P-256; the EC curve is not P-256. """ - if purpose not in _ADCP_USE_VALUES: - raise ValueError(f"purpose must be one of {_ADCP_USE_VALUES}, got {purpose!r}") + purpose = _check_purpose(purpose) if not kid: raise ValueError("kid must be a non-empty string") @@ -234,7 +263,7 @@ def generate_signing_keypair( *, alg: Literal["ed25519", "es256"] = "ed25519", kid: str | None = None, - purpose: Literal["request-signing", "webhook-signing"] = "request-signing", + purpose: str = ADCP_USE_REQUEST, passphrase: bytes | None = None, ) -> tuple[bytes, dict[str, Any]]: """Generate an AdCP signing keypair. @@ -253,11 +282,20 @@ def generate_signing_keypair( collision-resistant within a single process but does not guarantee uniqueness across processes; rotation tooling needs its own identifier scheme to track retirement / revocation. - :param purpose: Which AdCP signing profile this key is for. Sets the - JWK ``adcp_use`` claim. **Request-signing and webhook-signing - keys MUST be distinct** — a signature from one surface cannot - replay as the other, and every conformant verifier enforces the - claim. Generate separate keys per purpose. + :param purpose: Which AdCP signing purpose this key is for. Sets the + JWK ``adcp_use`` claim. The spec names ``"request-signing"`` (RFC + 9421 requests, and webhooks from new signers), + ``"response-signing"`` (designated-task response JWS), + ``"governance-signing"`` and the deprecated ``"webhook-signing"``; + the constants are in :mod:`adcp.signing.constants` + (``ADCP_USE_REQUEST``, ...). Any other lowercase hyphenated token + is accepted for a surface the vocabulary does not name yet. + An open vocabulary is safe because purpose matching is + fail-closed: a verifier accepts a key only when ``adcp_use`` + equals the purpose of the surface it verifies (webhook verifiers + accept ``request-signing`` or ``webhook-signing``), so a key + published under any other purpose verifies nothing there. + Generate separate keys per purpose. :param passphrase: When provided, the PEM is encrypted with ``BestAvailableEncryption``. Typical only for dev-laptop keys; automated deployments usually leave the PEM unencrypted and @@ -275,7 +313,8 @@ def generate_signing_keypair( publish at your ``jwks_uri``. The private scalar is NOT in the JWK — only in the PEM. - :raises ValueError: ``alg`` or ``purpose`` is unsupported. + :raises ValueError: ``alg`` is unsupported, or ``purpose`` is not a + lowercase hyphenated token. Example: >>> pem, jwk = generate_signing_keypair( @@ -286,8 +325,7 @@ def generate_signing_keypair( """ if alg not in ("ed25519", "es256"): raise ValueError(f"alg must be 'ed25519' or 'es256', got {alg!r}") - if purpose not in _ADCP_USE_VALUES: - raise ValueError(f"purpose must be one of {_ADCP_USE_VALUES}, got {purpose!r}") + purpose = _check_purpose(purpose) resolved_kid = kid or _default_kid(alg) if alg == "ed25519": return generate_ed25519(resolved_kid, passphrase=passphrase, adcp_use=purpose) @@ -310,10 +348,17 @@ def _prompt_passphrase_bytes() -> bytes: return unicodedata.normalize("NFC", first).encode("utf-8") +def _purpose_arg(value: str) -> str: + try: + return _check_purpose(value) + except ValueError as exc: + raise argparse.ArgumentTypeError(str(exc)) from None + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="adcp-keygen", - description="Generate a signing keypair for the AdCP request-signing profile.", + description="Generate an AdCP signing keypair (PEM private key + public JWK).", ) parser.add_argument( "--alg", @@ -347,13 +392,17 @@ def main(argv: list[str] | None = None) -> int: ) parser.add_argument( "--purpose", - choices=list(_ADCP_USE_VALUES), - default="request-signing", + type=_purpose_arg, + default=ADCP_USE_REQUEST, + metavar="PURPOSE", help=( - "Which AdCP signing profile this key is for (sets the JWK `adcp_use` " - "claim). `request-signing` for outbound tool calls; `webhook-signing` " - "for keys a sender uses to sign outbound webhooks to buyers. Verifiers " - "enforce the claim, so mixing the two silently fails at first delivery." + "Which AdCP signing purpose this key is for (sets the JWK `adcp_use` " + f"claim): {', '.join(_KNOWN_ADCP_USES)}, or another lowercase " + "hyphenated token. `request-signing` (default) signs outbound tool " + "calls and, for new signers, webhooks; `response-signing` signs " + "designated-task response envelopes; `webhook-signing` is deprecated. " + "Verifiers enforce the claim, so a key under the wrong purpose fails " + "at first use." ), ) args = parser.parse_args(argv) diff --git a/tests/conformance/signing/test_keygen_programmatic.py b/tests/conformance/signing/test_keygen_programmatic.py index e7edddc215..4417f132a0 100644 --- a/tests/conformance/signing/test_keygen_programmatic.py +++ b/tests/conformance/signing/test_keygen_programmatic.py @@ -18,7 +18,15 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec, ed25519 -from adcp.signing import generate_signing_keypair, public_key_from_jwk +from adcp.signing import ( + ADCP_USE_GOVERNANCE, + ADCP_USE_REQUEST, + ADCP_USE_RESPONSE, + ADCP_USE_WEBHOOK, + generate_signing_keypair, + public_key_from_jwk, +) +from adcp.signing.constants import WEBHOOK_ACCEPTED_ADCP_USES # --------------------------------------------------------------------------- # Core contract — returns (pem_bytes, public_jwk) @@ -88,15 +96,81 @@ def test_webhook_signing_purpose_sets_adcp_use() -> None: assert jwk["adcp_use"] == "webhook-signing" -def test_invalid_purpose_raises_value_error() -> None: - """Typos in ``purpose`` should fail loud — a silent fallback would - silently mint a key with an unexpected ``adcp_use``.""" - with pytest.raises(ValueError, match="purpose must be one of"): - generate_signing_keypair( - alg="ed25519", - kid="test-kid", - purpose="requets-signing", # type: ignore[arg-type] # typo - ) +@pytest.mark.parametrize( + "purpose", + [ADCP_USE_RESPONSE, ADCP_USE_GOVERNANCE, "oauth-access-token-signing", "x1"], +) +def test_purpose_vocabulary_is_open(purpose: str) -> None: + """``response-signing`` and purposes the spec does not name yet are + expressible (#1677); only the token's syntax is checked.""" + _, jwk = generate_signing_keypair(alg="es256", kid="test-kid", purpose=purpose) + assert jwk["adcp_use"] == purpose + + +@pytest.mark.parametrize( + "purpose", + [ + "", + "Request-Signing", + "request_signing", + "request signing", + "-request", + "request-", + "request--signing", + "request-signing\n", + "a" * 65, + None, + b"request-signing", + ], +) +def test_malformed_purpose_raises_value_error(purpose: object) -> None: + """The vocabulary is open but the input is still validated: a value + that is not a lowercase hyphenated token fails loud rather than mint + a key with an unexpected ``adcp_use``.""" + with pytest.raises(ValueError, match="purpose must be a lowercase hyphenated token"): + generate_signing_keypair(alg="ed25519", kid="test-kid", purpose=purpose) # type: ignore[arg-type] + + +def test_unregistered_purpose_fails_closed_at_the_request_verifier() -> None: + """An open vocabulary is safe because purpose matching is fail-closed: + a key minted under any purpose other than the verified surface's is + refused with ``request_signature_key_purpose_invalid``.""" + from adcp.signing.errors import ( + REQUEST_SIGNATURE_KEY_PURPOSE_INVALID, + SignatureVerificationError, + ) + from adcp.signing.verifier import _check_key_purpose + + for purpose in (ADCP_USE_RESPONSE, ADCP_USE_GOVERNANCE, "oauth-access-token-signing"): + _, jwk = generate_signing_keypair(alg="ed25519", kid="k", purpose=purpose) + with pytest.raises(SignatureVerificationError) as excinfo: + _check_key_purpose(jwk, "ed25519", expected_adcp_use=ADCP_USE_REQUEST) + assert excinfo.value.code == REQUEST_SIGNATURE_KEY_PURPOSE_INVALID + with pytest.raises(SignatureVerificationError): + _check_key_purpose( + jwk, + "ed25519", + expected_adcp_use=ADCP_USE_WEBHOOK, + accepted_adcp_uses=WEBHOOK_ACCEPTED_ADCP_USES, + ) + + +def test_cli_accepts_open_purpose_and_rejects_malformed(tmp_path, capsys) -> None: + import json + + from adcp.signing import keygen + + out = tmp_path / "response.pem" + rc = keygen.main(["--out", str(out), "--kid", "resp", "--purpose", "response-signing"]) + assert rc == 0 + printed = json.loads(capsys.readouterr().out) + assert printed["keys"][0]["adcp_use"] == "response-signing" + + with pytest.raises(SystemExit) as excinfo: + keygen.main(["--out", str(tmp_path / "bad.pem"), "--purpose", "Response_Signing"]) + assert excinfo.value.code == 2 + assert "lowercase hyphenated token" in capsys.readouterr().err + assert not (tmp_path / "bad.pem").exists() def test_invalid_alg_raises_value_error() -> None: diff --git a/tests/conformance/signing/test_signing_provider.py b/tests/conformance/signing/test_signing_provider.py index 63e9d057d4..34cebc1286 100644 --- a/tests/conformance/signing/test_signing_provider.py +++ b/tests/conformance/signing/test_signing_provider.py @@ -303,8 +303,14 @@ def test_pem_to_adcp_jwk_accepts_spki_public_pem() -> None: def test_pem_to_adcp_jwk_rejects_invalid_purpose() -> None: pem, _ = generate_signing_keypair(alg="ed25519", kid="x", purpose="request-signing") - with pytest.raises(ValueError, match="purpose must be one of"): - pem_to_adcp_jwk(pem, kid="x", purpose="some-other-purpose") # type: ignore[arg-type] + with pytest.raises(ValueError, match="purpose must be a lowercase hyphenated token"): + pem_to_adcp_jwk(pem, kid="x", purpose="Request Signing") + + +def test_pem_to_adcp_jwk_accepts_response_signing() -> None: + pem, minted = generate_signing_keypair(alg="es256", kid="x", purpose="response-signing") + derived = pem_to_adcp_jwk(pem, kid="x", purpose="response-signing") + assert derived == minted def test_pem_to_adcp_jwk_rejects_empty_kid() -> None: From 7088ebf909852134521ba0ed4796b6f2896a4ad7 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 20:31:54 -0400 Subject: [PATCH 05/28] feat(server): seller helper for wholesale feed versions (#1718) Backport of #1706 to SDK 9.x (AdCP 3.2), whose get_products / get_signals wholesale fields, if_wholesale_feed_version / if_pricing_version rules and unchanged: true response are the same as 3.3's. adcp.server gains wholesale_feed_version and pricing_version (RFC 8785 canonical, SHA-256, order-independent across items, path-addressed freshness fields ignored, keyed on cache_scope), check_feed_preconditions (the mode and pairing rules, INVALID_REQUEST with field) and feed_unchanged. products_response / signals_response no longer write unchanged: false, and refuse unchanged=True with items or without wholesale_feed_version, both of which the schema already rejects. Differences from #1706: the extra= docs no longer name the 3.3-only collections / signal_pricing_group_rules, and one test compares a model with its own wire form, since SDK 9 dumps unset schema defaults. Refs #1676 Co-authored-by: Claude Opus 5.5 (1M context) --- docs/wholesale-feed-versions.md | 145 ++++++++ src/adcp/server/__init__.py | 15 + src/adcp/server/feed_version.py | 362 +++++++++++++++++++ src/adcp/server/responses.py | 37 +- tests/test_feed_version.py | 600 ++++++++++++++++++++++++++++++++ 5 files changed, 1154 insertions(+), 5 deletions(-) create mode 100644 docs/wholesale-feed-versions.md create mode 100644 src/adcp/server/feed_version.py create mode 100644 tests/test_feed_version.py diff --git a/docs/wholesale-feed-versions.md b/docs/wholesale-feed-versions.md new file mode 100644 index 0000000000..09a3d004db --- /dev/null +++ b/docs/wholesale-feed-versions.md @@ -0,0 +1,145 @@ +# Wholesale feed versions (seller side) + +A wholesale `get_products` (`buying_mode="wholesale"`) or `get_signals` +(`discovery_mode="wholesale"`) response carries an opaque +`wholesale_feed_version`. A buyer such as `adcp.FeedMirror` echoes it back as +`if_wholesale_feed_version` and expects `unchanged: true`, with the item list +omitted, when nothing it can see has changed. `adcp.server.feed_version` +provides the seller side: + +| Helper | Job | +| --- | --- | +| `wholesale_feed_version(items, *, cache_scope, ignore=FRESHNESS_FIELDS, extra=None)` | Content token for the feed | +| `pricing_version(items, *, cache_scope)` | Token for the pricing layer, for sellers that version pricing separately | +| `check_feed_preconditions(request)` | Mode and pairing rules for `if_*` tokens. Raises `AdcpError("INVALID_REQUEST", field=...)` | +| `feed_unchanged(request, *, feed_version, pricing_version=None)` | Returns whether the buyer's tokens are current | + +All four are exported from `adcp.server`. + +```python +from adcp.server import feed_unchanged, products_response, wholesale_feed_version + +async def get_products(params, context=None): + scope = "account" if account_has_overrides(params.account) else "public" + feed = await load_wholesale_feed(params, scope) # the whole feed, not one page + version = wholesale_feed_version(feed, cache_scope=scope) + if feed_unchanged(params, feed_version=version): # also runs check_feed_preconditions + return products_response( + unchanged=True, wholesale_feed_version=version, cache_scope=scope + ) + page, pagination = paginate(feed, params.pagination) + return products_response( + page, wholesale_feed_version=version, cache_scope=scope, pagination=pagination + ) +``` + +`get_signals` follows the same pattern with `signals_response`. + +## The token + +The token is `"sha256:"` followed by 64 hex digits. The schema places no +format or length constraint on it, and buyers must treat it as opaque. Equal +buyer-visible content always produces the same token. Any buyer-visible change +produces a different one. + +- **Wire form.** Each item, whether a dict or a Pydantic model, is serialized + the way `products_response` / `signals_response` puts it on the wire. It is + then RFC 8785 (JCS) canonicalized and hashed with SHA-256. +- **Order.** Only the order of top-level items is ignored, because a feed is a + set keyed by `product_id` / `signal_agent_segment_id`. Arrays inside an item + keep their order: some are ordered by contract (`forecast.points` ascend by + budget), and the schema declares no set semantics for the rest. A seller + that emits a stable order gets stable tokens. A reordering costs at most one spurious + refetch, while a missed change would leave the buyer with stale data. +- **Freshness fields.** `ignore` takes dot paths from the item root, and + arrays are traversed without a path segment. `FRESHNESS_FIELDS` is + `forecast.generated_at` (products) and `coverage_forecast.generated_at` + (signals): a regenerated forecast with the same numbers is not a change. + `valid_until` is still hashed, because buyers read it to decide whether a + forecast has expired. A bare key matches only at the item root, never by + name anywhere in the item. +- **Scope.** `cache_scope` is part of the hash. The spec keys the comparator + on `(cache_scope, wholesale_feed_version)`, so a public token never matches + an account-scoped read, even when the content is identical. +- **Pages.** `pagination.cursor` is outside the version's scope. Hash the + whole feed and put the same token on every page. `feed_unchanged` returns + `False` on continuation pages. Hashing is linear in the feed size, so a + seller with a large feed computes the token when the feed changes and + stores it alongside the feed. +- **Response-level content.** Pass `extra` for content outside the item list + that the buyer sees and that must move the version. AdCP 3.2 responses + carry none; it exists for AdCP 3.3's `collections` (SDK 10). An empty value + still produces a different token from no `extra`. + +## Separate pricing version + +`pricing_version` covers `PRICING_FIELDS`, which are top-level +`pricing_options` and `signal_targeting_options[].pricing_options`, the +fields `GetProductsResponse.pricing_version` names. It binds each price to its +item's ID. To separate pricing: + +```python +feed_token = wholesale_feed_version(feed, cache_scope=scope, + ignore=FRESHNESS_FIELDS | PRICING_FIELDS) +price_token = pricing_version(feed, cache_scope=scope) +if feed_unchanged(params, feed_version=feed_token, pricing_version=price_token): + return products_response(unchanged=True, wholesale_feed_version=feed_token, + pricing_version=price_token, cache_scope=scope) +``` + +`feed_unchanged` follows the spec's evaluation order: a feed mismatch returns +the full payload, and so does a matching feed with a pricing mismatch. Only +when both match is the answer `unchanged`. If the seller passes +`pricing_version` and the buyer did not send `if_pricing_version`, the answer +is the full payload, because the feed token alone cannot show that prices are +current. Separating pricing is optional. A wholesale feed webhook carries +only `wholesale_feed_version`, and with separate pricing that token does not +move on a price-only change. + +## Request rules + +`check_feed_preconditions` rejects the following with `INVALID_REQUEST`: + +- `if_pricing_version` without `if_wholesale_feed_version`, with + `field="if_pricing_version"`. +- Either token on a read that is not wholesale, with + `field="if_wholesale_feed_version"`. A `get_signals` request without + `discovery_mode` is a brief read. + +It accepts request models and wire dicts. It complements +`adcp.decisioning.refine.assert_buying_mode_consistent`, which checks `brief` +and `refine` but not these tokens. + +## The unchanged answer + +`products_response(unchanged=True, ...)` and `signals_response(unchanged=True, ...)` +omit the item list and require `wholesale_feed_version`. Pass `cache_scope` +too: the schema requires it, and the MCP dispatcher fills in `public` only +when the request has no account. Echo `pricing_version` when you use it. +Passing items with `unchanged=True`, or leaving out the version, raises +`ValueError`. `unchanged=False` +emits nothing, because the spec forbids `unchanged: false`: a response that +carries products simply has no `unchanged` field. + +## Webhooks + +After a change, pass the post-change token to the wholesale feed webhook: + +```python +await sender.send_wholesale_feed( + ..., + wholesale_feed_version=wholesale_feed_version(feed_after, cache_scope="public"), + previous_wholesale_feed_version=token_before, + cache_scope="public", +) +``` + +A mirror that applies the webhook stores this version and presents it on its +next conditional read. Since the read computes the same token from the same +feed, the seller answers `unchanged: true`. A buyer that uses the version as a +gap detector therefore sees no mismatch. + +`list_products` returns `feed_version` and accepts `if_feed_version`, the +same concept under a different name. The token functions work for it +unchanged. `feed_unchanged` and the response builders cover `get_products` +and `get_signals`. diff --git a/src/adcp/server/__init__.py b/src/adcp/server/__init__.py index b9ef2b5d6c..2428301ce0 100644 --- a/src/adcp/server/__init__.py +++ b/src/adcp/server/__init__.py @@ -103,6 +103,14 @@ async def get_products(params, context=None): build_manifest, make_discovery_route, ) +from adcp.server.feed_version import ( + FRESHNESS_FIELDS, + PRICING_FIELDS, + check_feed_preconditions, + feed_unchanged, + pricing_version, + wholesale_feed_version, +) from adcp.server.governance import GovernanceHandler from adcp.server.governance_enforcement import ( GovernanceEnforcementDecision, @@ -364,4 +372,11 @@ async def get_products(params, context=None): "sync_creatives_response", "sync_governance_response", "update_media_buy_response", + # Wholesale feed versions (conditional get_products / get_signals reads) + "FRESHNESS_FIELDS", + "PRICING_FIELDS", + "check_feed_preconditions", + "feed_unchanged", + "pricing_version", + "wholesale_feed_version", ] diff --git a/src/adcp/server/feed_version.py b/src/adcp/server/feed_version.py new file mode 100644 index 0000000000..892a4eace1 --- /dev/null +++ b/src/adcp/server/feed_version.py @@ -0,0 +1,362 @@ +"""Seller half of conditional wholesale feed reads. + +A wholesale ``get_products`` (``buying_mode='wholesale'``) or ``get_signals`` +(``discovery_mode='wholesale'``) response carries an opaque +``wholesale_feed_version`` (and optionally ``pricing_version``). A buyer echoes +it as ``if_wholesale_feed_version`` / ``if_pricing_version``; when nothing has +changed the seller answers ``unchanged: true`` with the item list omitted. +:mod:`adcp.feed_mirror` is the buyer half. This module gives the seller the +three pieces it otherwise hand-rolls: + +- :func:`wholesale_feed_version` / :func:`pricing_version` — content tokens. + Same buyer-visible content, same token; any buyer-visible change, a new one. +- :func:`check_feed_preconditions` — the request's mode and pairing rules. +- :func:`feed_unchanged` — whether the buyer's tokens are current. Answer + with ``products_response(unchanged=True, ...)`` / + ``signals_response(unchanged=True, ...)``. + +Example:: + + from adcp.server import feed_unchanged, products_response, wholesale_feed_version + + async def get_products(params, context=None): + scope = "account" if has_overrides(params.account) else "public" + feed = await load_wholesale_feed(params, scope) # the whole feed, every page + version = wholesale_feed_version(feed, cache_scope=scope) + if feed_unchanged(params, feed_version=version): + return products_response( + unchanged=True, wholesale_feed_version=version, cache_scope=scope + ) + page, pagination = paginate(feed, params.pagination) + return products_response( + page, wholesale_feed_version=version, cache_scope=scope, pagination=pagination + ) + +Pass the same token as ``wholesale_feed_version`` to +:meth:`adcp.webhooks.WebhookSender.send_wholesale_feed` after a change, so a +buyer that uses the webhook version as a gap detector sees the version its +next conditional read returns. +""" + +from __future__ import annotations + +import hashlib +from collections.abc import Iterable, Mapping +from typing import TYPE_CHECKING, Any + +import rfc8785 +from pydantic_core import to_jsonable_python + +from adcp.server.responses import _serialize + +if TYPE_CHECKING: + from adcp.types import GetProductsRequest, GetSignalsRequest + +__all__ = [ + "FRESHNESS_FIELDS", + "PRICING_FIELDS", + "check_feed_preconditions", + "feed_unchanged", + "pricing_version", + "wholesale_feed_version", +] + +# Item-relative paths of fields that record when a value was computed rather +# than what it is. A regenerated forecast with the same numbers is not a change +# a buyer needs to refetch for. ``valid_until`` stays in: a buyer reads it to +# decide whether its mirrored forecast has expired. +FRESHNESS_FIELDS: frozenset[str] = frozenset( + { + "forecast.generated_at", # Product.forecast (DeliveryForecast) + "coverage_forecast.generated_at", # get_signals signals[].coverage_forecast + } +) + +# The pricing layer, per GetProductsResponse.pricing_version: "product +# pricing_options and nested signal_targeting_options pricing_options". +# Signals carry ``pricing_options`` at the top level of each item. +PRICING_FIELDS: frozenset[str] = frozenset( + { + "pricing_options", + "signal_targeting_options.pricing_options", + } +) + +# Stable item identity: the keys FeedMirror indexes products and signals by. +_ITEM_ID_FIELDS = ("product_id", "signal_agent_segment_id") + +_CACHE_SCOPES = ("public", "account") + +_Path = tuple[str, ...] + + +def wholesale_feed_version( + items: Iterable[Any], + *, + cache_scope: str, + ignore: Iterable[str] = FRESHNESS_FIELDS, + extra: Mapping[str, Any] | None = None, +) -> str: + """Return a content token for a wholesale product or signal feed. + + ``items`` is the *whole* feed for the request's scoping tuple, not one + page: ``pagination.cursor`` is outside the version's scope, so every page + carries the same token. Items may be dicts or Pydantic models; each is + serialized exactly as :func:`~adcp.server.responses.products_response` + puts it on the wire, so the token follows what the buyer sees. + + Canonicalization: + + - Each item is RFC 8785 (JCS) canonical JSON — sorted keys, no + whitespace, ES6 number form — hashed with SHA-256. The token is the + SHA-256 of the sorted item digests, so the order of top-level items + does not matter (a feed is a set keyed by ``product_id`` / + ``signal_agent_segment_id``). Arrays *inside* an item keep their + order: some are ordered by contract (``forecast.points`` ascend by + budget), the schema declares no set semantics for the rest, and a + reordering costs at worst one spurious refetch. + - ``ignore`` names fields left out of the hash, as dot-separated paths + from the item root (``"forecast.generated_at"``). Arrays are traversed + without a path segment, so ``"pricing_options.price_guidance"`` + reaches every pricing option. A bare key matches only at the item root, + never by name anywhere. Default: :data:`FRESHNESS_FIELDS`. A seller that + versions pricing separately passes ``FRESHNESS_FIELDS | PRICING_FIELDS`` + and also returns :func:`pricing_version`. + - ``cache_scope`` is part of the hash. The spec keys the comparator on + ``(cache_scope, wholesale_feed_version)``; a public token can then never + match an account-scoped read with the same content, or vice versa. + - ``extra`` carries response-level content the buyer sees beside the + items and that must move the version (an empty value still differs + from ``None``). AdCP 3.2 responses have none; SDK 10 uses it for 3.3's + ``collections``. It is hashed as-is, without ``ignore``. + + :returns: ``"sha256:"`` followed by 64 hex digits. The schema puts no + format, length, or pattern constraint on the token; buyers treat it as + opaque. + :raises ValueError: an unknown ``cache_scope``, a malformed ``ignore`` + path, or a value outside I-JSON (for example a NaN or an integer + beyond 2**53). + """ + paths = _parse_paths(ignore) + digests = sorted(_digest(_without(item, paths)) for item in _wire_items(items)) + return _token(cache_scope, digests, extra) + + +def pricing_version(items: Iterable[Any], *, cache_scope: str) -> str: + """Return a token for the pricing layer of a wholesale feed. + + Covers :data:`PRICING_FIELDS` of every item, bound to the item by its + ``product_id`` / ``signal_agent_segment_id`` so moving a price from one + product to another is a change. Use it with + ``wholesale_feed_version(items, ignore=FRESHNESS_FIELDS | PRICING_FIELDS)``: + a re-price then moves only this token, and a structural change moves the + feed token (which forces a full payload on its own). Canonicalization and + ``cache_scope`` keying are as for :func:`wholesale_feed_version`. + + Separate pricing is optional; the spec lets a seller omit + ``pricing_version`` and let ``wholesale_feed_version`` cover both. Note + that a wholesale feed webhook carries only ``wholesale_feed_version``, + which a price-only change does not move when pricing is separated. + + :raises ValueError: an item with neither identity field, or as for + :func:`wholesale_feed_version`. + """ + keep = _parse_paths(PRICING_FIELDS) + digests = [] + for item in _wire_items(items): + if not isinstance(item, dict) or not any(key in item for key in _ITEM_ID_FIELDS): + raise ValueError( + "pricing_version items need product_id or signal_agent_segment_id " + "to bind each price to its item" + ) + projection = {key: item[key] for key in _ITEM_ID_FIELDS if key in item} + projection.update(_only(item, keep)) + digests.append(_digest(projection)) + return _token(cache_scope, sorted(digests), None) + + +def check_feed_preconditions(request: GetProductsRequest | GetSignalsRequest | Any) -> None: + """Enforce the conditional-read rules of a get_products / get_signals request. + + - ``if_pricing_version`` requires ``if_wholesale_feed_version`` (pricing + has no structural baseline on its own). + - Either token requires a wholesale read: ``buying_mode='wholesale'`` on + get_products, ``discovery_mode='wholesale'`` on get_signals. + + ``request`` is a request model or its wire dict. A request without + either token passes. Complements + :func:`adcp.decisioning.refine.assert_buying_mode_consistent`, which + checks ``brief`` / ``refine`` but not these tokens. + + :raises adcp.decisioning.AdcpError: ``INVALID_REQUEST`` with ``field`` + naming the offending token. + """ + from adcp.decisioning.types import AdcpError + + feed_token = _get(request, "if_wholesale_feed_version") + pricing_token = _get(request, "if_pricing_version") + if pricing_token is not None and feed_token is None: + raise AdcpError( + "INVALID_REQUEST", + message=( + "if_pricing_version must be sent together with " + "if_wholesale_feed_version; a pricing version has no " + "structural baseline to compare against on its own." + ), + recovery="correctable", + field="if_pricing_version", + ) + if feed_token is None: + return + mode_field, mode = _mode(request) + if mode != "wholesale": + raise AdcpError( + "INVALID_REQUEST", + message=( + f"if_wholesale_feed_version is only valid on a wholesale feed read " + f"({mode_field}='wholesale'); got {mode_field}={mode!r}." + ), + recovery="correctable", + field="if_wholesale_feed_version", + ) + + +def feed_unchanged( + request: GetProductsRequest | GetSignalsRequest | Any, + *, + feed_version: str, + pricing_version: str | None = None, +) -> bool: + """Return whether the buyer's cached feed is current. + + ``feed_version`` / ``pricing_version`` are the seller's current tokens + for the cache_scope it resolved *this* request to (from + :func:`wholesale_feed_version` / :func:`pricing_version`, which key the + token on that scope). Pass ``pricing_version`` only when the seller + versions pricing separately. + + True only when ``if_wholesale_feed_version`` matches and — when the + seller passes ``pricing_version`` — ``if_pricing_version`` is present and + matches. A buyer that omits ``if_pricing_version`` from a seller that + separates pricing gets the full payload: its feed token alone cannot show + that prices are current. A seller that does not track pricing (no + ``pricing_version``) ignores ``if_pricing_version``, as the spec allows. + A continuation page (``pagination.cursor`` set) is never unchanged — the + tokens version the feed's first page read. + + On True, answer with ``products_response(unchanged=True, + wholesale_feed_version=..., cache_scope=..., pricing_version=...)`` (or + :func:`~adcp.server.responses.signals_response`), echoing + ``pricing_version`` when used. + + :raises adcp.decisioning.AdcpError: as :func:`check_feed_preconditions`. + """ + check_feed_preconditions(request) + feed_token = _get(request, "if_wholesale_feed_version") + if feed_token is None or feed_token != feed_version: + return False + pagination = _get(request, "pagination") + if pagination is not None and _get(pagination, "cursor") is not None: + return False + if pricing_version is not None: + return bool(_get(request, "if_pricing_version") == pricing_version) + return True + + +# ---------------------------------------------------------------------- +# Internals +# ---------------------------------------------------------------------- + + +def _get(obj: Any, name: str) -> Any: + if isinstance(obj, Mapping): + return obj.get(name) + return getattr(obj, name, None) + + +def _enum_value(value: Any) -> Any: + return getattr(value, "value", value) + + +def _mode(request: Any) -> tuple[str, Any]: + """Return the request's wholesale-mode field and value.""" + from adcp.types import GetSignalsRequest + + if isinstance(request, GetSignalsRequest) or ( + isinstance(request, Mapping) and "buying_mode" not in request + ): + # get_signals defaults discovery_mode to 'brief' when absent. + return "discovery_mode", _enum_value(_get(request, "discovery_mode")) or "brief" + return "buying_mode", _enum_value(_get(request, "buying_mode")) + + +def _wire_items(items: Iterable[Any]) -> list[Any]: + # ``_serialize`` is the products_response / signals_response item + # serializer; to_jsonable_python then settles anything a plain dict item + # left as a Python object (a datetime, an enum) to its JSON form. + return [to_jsonable_python(item) for item in _serialize(list(items))] + + +def _parse_paths(paths: Iterable[str]) -> frozenset[_Path]: + if isinstance(paths, str): + raise ValueError("ignore must be a collection of paths, not a single string") + parsed = set() + for path in paths: + parts = tuple(path.split(".")) + if not all(parts): + raise ValueError(f"invalid field path {path!r}") + parsed.add(parts) + return frozenset(parsed) + + +def _descend(paths: frozenset[_Path], key: str) -> frozenset[_Path]: + return frozenset(path[1:] for path in paths if len(path) > 1 and path[0] == key) + + +def _without(value: Any, paths: frozenset[_Path]) -> Any: + """Copy ``value`` minus the fields at ``paths``.""" + if not paths: + return value + if isinstance(value, list): + return [_without(entry, paths) for entry in value] + if isinstance(value, dict): + return { + key: _without(entry, _descend(paths, key)) + for key, entry in value.items() + if (key,) not in paths + } + return value + + +def _only(value: Any, paths: frozenset[_Path]) -> Any: + """Copy only the fields at ``paths`` out of ``value``, keeping their structure.""" + if isinstance(value, list): + return [_only(entry, paths) for entry in value] + if isinstance(value, dict): + kept: dict[str, Any] = {} + for key, entry in value.items(): + if (key,) in paths: + kept[key] = entry + elif nested := _descend(paths, key): + kept[key] = _only(entry, nested) + return kept + return None + + +def _digest(value: Any) -> str: + return hashlib.sha256(rfc8785.dumps(value)).hexdigest() + + +def _token(cache_scope: str, digests: list[str], extra: Mapping[str, Any] | None) -> str: + scope = _enum_value(cache_scope) + if scope not in _CACHE_SCOPES: + raise ValueError(f"cache_scope must be 'public' or 'account', got {cache_scope!r}") + document: dict[str, Any] = {"cache_scope": scope, "items": digests} + if extra is not None: + document["extra"] = to_jsonable_python( + { + key: _serialize(v) if isinstance(v, list) else _serialize([v])[0] + for key, v in extra.items() + } + ) + return f"sha256:{_digest(document)}" diff --git a/src/adcp/server/responses.py b/src/adcp/server/responses.py index 6d10bc1b46..dcafb1827b 100644 --- a/src/adcp/server/responses.py +++ b/src/adcp/server/responses.py @@ -252,6 +252,25 @@ def _with_canonical_sources(payload: dict[str, Any], *sources: Any) -> dict[str, return _CanonicalResponse(payload, *sources) +def _check_unchanged( + unchanged: bool | None, + items_field: str, + items: list[Any] | None, + wholesale_feed_version: str | None, +) -> None: + """Reject an ``unchanged: true`` wholesale answer the schema would reject. + + ``cache_scope`` is not checked: the MCP dispatcher fills ``public`` for a + request without an account, as it does for a full response. + """ + if not unchanged: + return + if items is not None: + raise ValueError(f"an unchanged: true response must omit {items_field}") + if wholesale_feed_version is None: + raise ValueError("an unchanged: true response must echo wholesale_feed_version") + + # ============================================================================ # Protocol Discovery # ============================================================================ @@ -420,7 +439,13 @@ def products_response( metadata for cache/version-aware enumeration. Pass ``cache_scope`` explicitly for spec-valid wholesale responses; the dispatcher only infers ``public`` for request paths without an account. + + ``unchanged=True`` builds the conditional-read answer (see + :func:`adcp.server.feed_version.feed_unchanged`): ``products`` must be + omitted and ``wholesale_feed_version`` echoed, with ``cache_scope``. + ``unchanged=False`` emits nothing — the spec forbids ``unchanged: false``. """ + _check_unchanged(unchanged, "products", products, wholesale_feed_version) serialized = _serialize_canonical(products) if products is not None else None resp: dict[str, Any] = { "status": status, @@ -444,8 +469,8 @@ def products_response( resp["pricing_version"] = pricing_version if cache_scope is not None: resp["cache_scope"] = cache_scope - if unchanged is not None: - resp["unchanged"] = unchanged + if unchanged: + resp["unchanged"] = True return _with_canonical_sources(resp, products, proposals) @@ -805,8 +830,10 @@ def signals_response( Matches GetSignalsResponse schema, including beta 3 wholesale feed metadata for cache/version-aware enumeration. Pass ``cache_scope`` explicitly for spec-valid wholesale responses; the dispatcher only infers - ``public`` for request paths without an account. + ``public`` for request paths without an account. ``unchanged`` follows + :func:`products_response`. """ + _check_unchanged(unchanged, "signals", signals, wholesale_feed_version) resp: dict[str, Any] = {"status": status, "sandbox": sandbox} if signals is not None: resp["signals"] = _serialize(signals) @@ -820,8 +847,8 @@ def signals_response( resp["pricing_version"] = pricing_version if cache_scope is not None: resp["cache_scope"] = cache_scope - if unchanged is not None: - resp["unchanged"] = unchanged + if unchanged: + resp["unchanged"] = True return resp diff --git a/tests/test_feed_version.py b/tests/test_feed_version.py new file mode 100644 index 0000000000..4ee463fc9e --- /dev/null +++ b/tests/test_feed_version.py @@ -0,0 +1,600 @@ +"""Seller helper for wholesale feed versions (``adcp.server.feed_version``). + +Tokens are exercised against wire dicts and real generated models; the +request rules and the ``unchanged: true`` answer are checked against the +bundled JSON schemas through the SDK's own validators, and the round trip +runs the buyer's real :class:`~adcp.feed_mirror.FeedMirror` against a seller +built from these helpers. +""" + +from __future__ import annotations + +import copy +import json +from typing import Any + +import pytest + +from adcp import FeedMirror +from adcp.decisioning import AdcpError +from adcp.server import ( + FRESHNESS_FIELDS, + PRICING_FIELDS, + check_feed_preconditions, + feed_unchanged, + pricing_version, + products_response, + signals_response, + wholesale_feed_version, +) +from adcp.types import ( + GetProductsRequest, + GetProductsResponse, + GetSignalsRequest, + GetSignalsResponse, + Product, + WholesaleFeedWebhook, +) +from adcp.types.core import TaskResult, TaskStatus +from adcp.validation.schema_validator import validate_request, validate_response +from adcp.webhooks import WebhookSender + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def make_product(product_id: str, *, cpm: float = 18.5, name: str | None = None) -> dict[str, Any]: + return { + "product_id": product_id, + "name": name or f"Product {product_id}", + "description": f"Description for {product_id}", + "publisher_properties": [{"selection_type": "all", "publisher_domain": "pub.example.com"}], + "format_options": [ + {"format_kind": "image", "format_option_id": "image_default", "params": {}} + ], + "delivery_type": "guaranteed", + "pricing_options": [ + { + "pricing_option_id": "po_cpm", + "pricing_model": "cpm", + "currency": "USD", + "fixed_cpm": cpm, + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 60, + "timezone": "UTC", + "supports_webhooks": False, + "available_metrics": ["impressions"], + "date_range_support": "date_range", + }, + "forecast": { + "method": "modeled", + "currency": "USD", + "points": [{"metrics": {"impressions": {"mid": 100000}}}], + "generated_at": "2026-10-01T00:00:00Z", + "valid_until": "2026-10-02T00:00:00Z", + }, + } + + +def make_signal(segment_id: str, *, cpm: float = 2.5) -> dict[str, Any]: + return { + "signal_ref": {"scope": "product", "signal_id": segment_id}, + "signal_agent_segment_id": segment_id, + "name": f"Signal {segment_id}", + "description": f"Description for {segment_id}", + "signal_type": "marketplace", + "data_provider": "Acme Data", + "deployments": [{"type": "platform", "platform": "the-trade-desk", "is_live": True}], + "pricing_options": [ + {"pricing_option_id": "po_cpm", "model": "cpm", "cpm": cpm, "currency": "USD"} + ], + "value_type": "binary", + "coverage_forecast": { + "points": [ + { + "dimensions": [ + {"kind": "signal", "signal_id": segment_id, "presence": "present"} + ], + "metrics": {"coverage_rate": {"mid": 0.4}}, + } + ], + "forecast_range_unit": "availability", + "method": "modeled", + "scope": {"kind": "inventory", "label": "network inventory"}, + "bucket_semantics": "exclusive", + "bucket_completeness": "partial", + "generated_at": "2026-10-01T00:00:00Z", + }, + } + + +FEED = [make_product("p1"), make_product("p2", cpm=12.0), make_product("p3")] + + +def version(items: list[Any], **kwargs: Any) -> str: + kwargs.setdefault("cache_scope", "public") + return wholesale_feed_version(items, **kwargs) + + +# --------------------------------------------------------------------------- +# The token +# --------------------------------------------------------------------------- + + +def test_token_is_deterministic_and_opaque_hex() -> None: + token = version(FEED) + assert token == version(copy.deepcopy(FEED)) + assert token.startswith("sha256:") and len(token) == len("sha256:") + 64 + + +def test_model_and_wire_dict_items_give_the_same_token() -> None: + # SDK 9 writes a model's unset schema defaults (max_bid: false, ...), so a + # model hashes as its own wire form, which is what the buyer receives. + models = [Product.model_validate(item) for item in FEED] + assert version(models) == version(products_response(models)["products"]) + + +def test_top_level_order_does_not_matter() -> None: + assert version(list(reversed(FEED))) == version(FEED) + assert version([FEED[1], FEED[2], FEED[0]]) == version(FEED) + + +def test_key_order_does_not_matter() -> None: + shuffled = [dict(reversed(list(item.items()))) for item in FEED] + assert version(shuffled) == version(FEED) + + +def test_nested_array_order_is_content() -> None: + # forecast.points ascend by budget, pricing_options are presented in + # seller order: reordering inside an item is a (conservative) change. + item = make_product("p1") + item["pricing_options"].append( + {"pricing_option_id": "po_cpc", "pricing_model": "cpc", "currency": "USD", "fixed_cpc": 1} + ) + flipped = copy.deepcopy(item) + flipped["pricing_options"].reverse() + assert version([item]) != version([flipped]) + + +@pytest.mark.parametrize( + "mutate", + [ + lambda feed: feed[0].update(name="Renamed"), + lambda feed: feed[0]["pricing_options"][0].update(fixed_cpm=19.0), + lambda feed: feed[0]["forecast"]["points"][0]["metrics"]["impressions"].update(mid=1), + lambda feed: feed[0]["forecast"].update(valid_until="2026-10-03T00:00:00Z"), + lambda feed: feed.pop(), + lambda feed: feed.append(make_product("p4")), + lambda feed: feed.append(make_product("p1")), + ], + ids=["name", "price", "forecast-metric", "forecast-valid_until", "removed", "added", "dup"], +) +def test_any_buyer_visible_change_moves_the_token(mutate: Any) -> None: + changed = copy.deepcopy(FEED) + mutate(changed) + assert version(changed) != version(FEED) + + +def test_forecast_generated_at_is_ignored() -> None: + regenerated = copy.deepcopy(FEED) + for item in regenerated: + item["forecast"]["generated_at"] = "2026-10-29T09:30:00Z" + assert version(regenerated) == version(FEED) + # Opting out of the default makes it content again. + assert version(regenerated, ignore=()) != version(FEED, ignore=()) + + +def test_signal_coverage_forecast_generated_at_is_ignored() -> None: + signals = [make_signal("s1"), make_signal("s2")] + regenerated = copy.deepcopy(signals) + regenerated[0]["coverage_forecast"]["generated_at"] = "2026-10-29T09:30:00Z" + assert version(regenerated) == version(signals) + regenerated[0]["coverage_forecast"]["method"] = "measured" + assert version(regenerated) != version(signals) + + +def test_ignore_is_path_based_not_key_name_based() -> None: + # A root-level generated_at is not forecast.generated_at. + stamped = copy.deepcopy(FEED) + stamped[0]["generated_at"] = "2026-10-29T00:00:00Z" + assert version(stamped) != version(FEED) + ignore = FRESHNESS_FIELDS | {"generated_at"} + assert version(stamped, ignore=ignore) == version(FEED, ignore=ignore) + + +def test_ignore_paths_traverse_arrays() -> None: + repriced = copy.deepcopy(FEED) + repriced[0]["pricing_options"][0]["fixed_cpm"] = 99.0 + ignore = FRESHNESS_FIELDS | {"pricing_options.fixed_cpm"} + assert version(repriced, ignore=ignore) == version(FEED, ignore=ignore) + + +@pytest.mark.parametrize("ignore", ["forecast.generated_at", ["forecast..generated_at"], [""]]) +def test_malformed_ignore_paths_raise(ignore: Any) -> None: + with pytest.raises(ValueError): + version(FEED, ignore=ignore) + + +def test_cache_scope_keys_the_token() -> None: + assert version(FEED, cache_scope="public") != version(FEED, cache_scope="account") + with pytest.raises(ValueError, match="cache_scope"): + version(FEED, cache_scope="global") + + +def test_extra_response_content_moves_the_token() -> None: + # include_collections: the two settings get distinct tokens, even when + # no collection is returned. + base = version(FEED) + with_none = version(FEED, extra={"collections": []}) + with_one = version(FEED, extra={"collections": [{"collection_id": "c1", "name": "News"}]}) + assert len({base, with_none, with_one}) == 3 + + +def test_non_i_json_values_raise() -> None: + item = make_product("p1") + item["ext"] = {"big": 2**60} + with pytest.raises(ValueError): + version([item]) + + +# --------------------------------------------------------------------------- +# Separate pricing version +# --------------------------------------------------------------------------- + +STRUCTURE_ONLY = FRESHNESS_FIELDS | PRICING_FIELDS + + +def test_separated_pricing_moves_only_the_pricing_token() -> None: + repriced = copy.deepcopy(FEED) + repriced[0]["pricing_options"][0]["fixed_cpm"] = 25.0 + assert version(repriced, ignore=STRUCTURE_ONLY) == version(FEED, ignore=STRUCTURE_ONLY) + assert pricing_version(repriced, cache_scope="public") != pricing_version( + FEED, cache_scope="public" + ) + + +def test_structure_change_does_not_move_the_pricing_token() -> None: + renamed = copy.deepcopy(FEED) + renamed[0]["name"] = "Renamed" + renamed[1]["forecast"]["generated_at"] = "2026-10-29T00:00:00Z" + assert version(renamed, ignore=STRUCTURE_ONLY) != version(FEED, ignore=STRUCTURE_ONLY) + assert pricing_version(renamed, cache_scope="public") == pricing_version( + list(reversed(FEED)), cache_scope="public" + ) + + +def test_pricing_token_binds_prices_to_items() -> None: + swapped = copy.deepcopy(FEED) + swapped[0]["pricing_options"], swapped[1]["pricing_options"] = ( + swapped[1]["pricing_options"], + swapped[0]["pricing_options"], + ) + assert pricing_version(swapped, cache_scope="public") != pricing_version( + FEED, cache_scope="public" + ) + + +def test_pricing_token_covers_nested_signal_targeting_options() -> None: + def with_option(cpm: float) -> dict[str, Any]: + item = make_product("p1") + item["signal_targeting_options"] = [ + { + "signal_ref": {"scope": "product", "signal_id": "auto"}, + "name": "Auto intenders", + "pricing_options": [ + {"pricing_option_id": "s", "model": "cpm", "cpm": cpm, "currency": "USD"} + ], + } + ] + return item + + cheap, dear = [with_option(1.0)], [with_option(2.0)] + assert version(cheap, ignore=STRUCTURE_ONLY) == version(dear, ignore=STRUCTURE_ONLY) + assert pricing_version(cheap, cache_scope="public") != pricing_version( + dear, cache_scope="public" + ) + + +def test_pricing_token_covers_signals_and_needs_an_identity() -> None: + signals = [make_signal("s1"), make_signal("s2")] + repriced = [make_signal("s1", cpm=3.0), make_signal("s2")] + assert pricing_version(signals, cache_scope="account") != pricing_version( + repriced, cache_scope="account" + ) + with pytest.raises(ValueError, match="product_id or signal_agent_segment_id"): + pricing_version([{"name": "anonymous"}], cache_scope="public") + + +# --------------------------------------------------------------------------- +# Request preconditions +# --------------------------------------------------------------------------- + + +def _signals_brief(**extra: Any) -> dict[str, Any]: + return {"signal_spec": "auto intenders", **extra} + + +INVALID_REQUESTS = [ + # (tool, request, offending field) + ( + "get_products", + {"buying_mode": "wholesale", "if_pricing_version": "p"}, + "if_pricing_version", + ), + ( + "get_products", + {"buying_mode": "brief", "brief": "news", "if_wholesale_feed_version": "v"}, + "if_wholesale_feed_version", + ), + ( + "get_products", + { + "buying_mode": "refine", + "refine": [{"scope": "request", "ask": "cheaper"}], + "if_wholesale_feed_version": "v", + "if_pricing_version": "p", + }, + "if_wholesale_feed_version", + ), + ( + "get_signals", + {"discovery_mode": "wholesale", "if_pricing_version": "p"}, + "if_pricing_version", + ), + ("get_signals", _signals_brief(if_wholesale_feed_version="v"), "if_wholesale_feed_version"), +] + +VALID_REQUESTS = [ + ("get_products", {"buying_mode": "wholesale"}), + ("get_products", {"buying_mode": "wholesale", "if_wholesale_feed_version": "v"}), + ( + "get_products", + {"buying_mode": "wholesale", "if_wholesale_feed_version": "v", "if_pricing_version": "p"}, + ), + ("get_products", {"buying_mode": "brief", "brief": "news"}), + ("get_signals", {"discovery_mode": "wholesale", "if_wholesale_feed_version": "v"}), + ("get_signals", _signals_brief()), +] + +MODELS = {"get_products": GetProductsRequest, "get_signals": GetSignalsRequest} + + +@pytest.mark.parametrize("as_model", [False, True], ids=["dict", "model"]) +@pytest.mark.parametrize(("tool", "request_body", "field"), INVALID_REQUESTS) +def test_preconditions_reject_with_the_offending_field( + tool: str, request_body: dict[str, Any], field: str, as_model: bool +) -> None: + # The schema rejects the same request, so the helper and the wire agree. + assert not validate_request(tool, request_body).valid + request = MODELS[tool].model_validate(request_body) if as_model else request_body + with pytest.raises(AdcpError) as raised: + check_feed_preconditions(request) + assert raised.value.code == "INVALID_REQUEST" + assert raised.value.field == field + with pytest.raises(AdcpError): + feed_unchanged(request, feed_version="v") + + +@pytest.mark.parametrize("as_model", [False, True], ids=["dict", "model"]) +@pytest.mark.parametrize(("tool", "request_body"), VALID_REQUESTS) +def test_preconditions_accept_valid_requests( + tool: str, request_body: dict[str, Any], as_model: bool +) -> None: + assert validate_request(tool, request_body).valid + request = MODELS[tool].model_validate(request_body) if as_model else request_body + check_feed_preconditions(request) + + +# --------------------------------------------------------------------------- +# Comparator +# --------------------------------------------------------------------------- + + +def _wholesale(**tokens: Any) -> GetProductsRequest: + return GetProductsRequest(buying_mode="wholesale", **tokens) + + +def test_feed_unchanged_on_matching_token() -> None: + assert feed_unchanged(_wholesale(if_wholesale_feed_version="v1"), feed_version="v1") + assert not feed_unchanged(_wholesale(if_wholesale_feed_version="v0"), feed_version="v1") + assert not feed_unchanged(_wholesale(), feed_version="v1") + assert not feed_unchanged({"buying_mode": "brief", "brief": "news"}, feed_version="v1") + + +def test_feed_unchanged_is_false_on_continuation_pages() -> None: + request = _wholesale( + if_wholesale_feed_version="v1", pagination={"cursor": "page-2", "max_results": 10} + ) + assert not feed_unchanged(request, feed_version="v1") + first_page = _wholesale(if_wholesale_feed_version="v1", pagination={"max_results": 10}) + assert feed_unchanged(first_page, feed_version="v1") + + +def test_feed_unchanged_pricing_evaluation_order() -> None: + both = _wholesale(if_wholesale_feed_version="v1", if_pricing_version="p1") + assert feed_unchanged(both, feed_version="v1", pricing_version="p1") + # (2) feed matches, pricing does not -> full payload. + assert not feed_unchanged(both, feed_version="v1", pricing_version="p2") + # (1) feed mismatch -> full payload regardless of pricing. + assert not feed_unchanged(both, feed_version="v2", pricing_version="p1") + # A seller that separates pricing cannot vouch for prices from the feed + # token alone. + feed_only = _wholesale(if_wholesale_feed_version="v1") + assert not feed_unchanged(feed_only, feed_version="v1", pricing_version="p1") + # A seller that does not track pricing ignores if_pricing_version. + assert feed_unchanged(both, feed_version="v1") + + +# --------------------------------------------------------------------------- +# The unchanged answer +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("tool", "builder"), [("get_products", products_response), ("get_signals", signals_response)] +) +def test_unchanged_answer_validates_against_the_schema(tool: str, builder: Any) -> None: + body = builder( + unchanged=True, + wholesale_feed_version=version(FEED), + pricing_version=pricing_version(FEED, cache_scope="public"), + cache_scope="public", + ) + assert body["unchanged"] is True + assert "products" not in body and "signals" not in body + outcome = validate_response(tool, body) + assert outcome.valid, outcome.issues + + +def test_unchanged_false_is_never_emitted() -> None: + body = products_response( + FEED, wholesale_feed_version=version(FEED), cache_scope="public", unchanged=False + ) + assert "unchanged" not in body + assert validate_response("get_products", body).valid + signals = [make_signal("s1")] + body = signals_response( + signals, wholesale_feed_version=version(signals), cache_scope="public", unchanged=False + ) + assert "unchanged" not in body + assert validate_response("get_signals", body).valid + + +@pytest.mark.parametrize("builder", [products_response, signals_response]) +def test_malformed_unchanged_answers_raise(builder: Any) -> None: + with pytest.raises(ValueError, match="must omit"): + builder([], unchanged=True, wholesale_feed_version="v", cache_scope="public") + with pytest.raises(ValueError, match="must echo"): + builder(unchanged=True, cache_scope="public") + # cache_scope may be left to the dispatcher, which fills "public" for a + # request without an account. + assert builder(unchanged=True, wholesale_feed_version="v")["unchanged"] is True + + +# --------------------------------------------------------------------------- +# Round trip: buyer FeedMirror against a seller built from these helpers +# --------------------------------------------------------------------------- + + +class HelperSeller: + """A wholesale seller that answers through the helpers and the schema.""" + + def __init__(self, products: list[dict[str, Any]], *, separate_pricing: bool = False) -> None: + self.products = products + self.separate_pricing = separate_pricing + self.responses: list[dict[str, Any]] = [] + + def tokens(self) -> tuple[str, str | None]: + if not self.separate_pricing: + return version(self.products), None + return ( + version(self.products, ignore=STRUCTURE_ONLY), + pricing_version(self.products, cache_scope="public"), + ) + + async def get_products(self, request: GetProductsRequest) -> TaskResult[GetProductsResponse]: + assert validate_request("get_products", request).valid + feed_token, price_token = self.tokens() + if feed_unchanged(request, feed_version=feed_token, pricing_version=price_token): + body = products_response( + unchanged=True, + wholesale_feed_version=feed_token, + pricing_version=price_token, + cache_scope="public", + ) + else: + # Two-page walk: every page carries the whole feed's token. + cursor = request.pagination.cursor if request.pagination else None + start = int(cursor) if cursor else 0 + page = self.products[start : start + 2] + more = start + 2 < len(self.products) + body = products_response( + page, + wholesale_feed_version=feed_token, + pricing_version=price_token, + cache_scope="public", + pagination={"has_more": more, **({"cursor": str(start + 2)} if more else {})}, + ) + outcome = validate_response("get_products", body) + assert outcome.valid, outcome.issues + self.responses.append(body) + return TaskResult( + status=TaskStatus.COMPLETED, + success=True, + data=GetProductsResponse.model_validate(json.loads(json.dumps(body))), + ) + + async def get_signals(self, request: GetSignalsRequest) -> TaskResult[GetSignalsResponse]: + raise AssertionError("products only") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("separate_pricing", [False, True], ids=["single", "separate-pricing"]) +async def test_feed_mirror_round_trip_honours_unchanged(separate_pricing: bool) -> None: + seller = HelperSeller(copy.deepcopy(FEED), separate_pricing=separate_pricing) + mirror = FeedMirror(seller, strict_scope=True) + + await mirror.bootstrap("product") + assert set(mirror.products) == {"p1", "p2", "p3"} + assert len(seller.responses) == 2 # paginated full load + + # Same content, regenerated forecast: the mirror short-circuits. + for product in seller.products: + product["forecast"]["generated_at"] = "2026-10-29T09:30:00Z" + result = await mirror.refresh("product") + assert result.products_unchanged + assert seller.responses[-1]["unchanged"] is True + assert set(mirror.products) == {"p1", "p2", "p3"} + + # A re-price is a full payload, with or without a separate pricing token. + seller.products[0]["pricing_options"][0]["fixed_cpm"] = 30.0 + result = await mirror.refresh("product") + assert not result.products_unchanged + assert "unchanged" not in seller.responses[-1] + price = mirror.products["p1"].pricing_options[0].fixed_cpm + assert price == 30.0 + + result = await mirror.refresh("product") + assert result.products_unchanged + + +@pytest.mark.asyncio +async def test_webhook_version_matches_the_conditional_read_token() -> None: + seller = HelperSeller(copy.deepcopy(FEED)) + mirror = FeedMirror(seller, strict_scope=True) + await mirror.bootstrap("product") + previous = mirror.product_state.wholesale_feed_version + + # The seller removes p3 and announces it with the post-change token. + seller.products = [p for p in seller.products if p["product_id"] != "p3"] + current = version(seller.products) + prepared = WebhookSender.prepare_wholesale_feed( + url="https://buyer.example/webhooks/feed", + subscriber_id="buyer-primary", + account_id="acct_1", + notification_type="product.removed", + wholesale_feed_version=current, + previous_wholesale_feed_version=previous, + cache_scope="public", + event={ + "event_id": "018f13f8-7b40-7000-8000-000000000123", + "event_type": "product.removed", + "entity_type": "product", + "entity_id": "p3", + "created_at": "2026-10-29T12:00:00Z", + "payload": {"product_id": "p3", "applies_to": {"scope": "public"}}, + }, + ) + webhook = WholesaleFeedWebhook.model_validate_json(prepared.body) + assert webhook.wholesale_feed_version == current + await mirror.apply_webhook(webhook) + + # The buyer presents the webhook's version; the seller recognises it. + result = await mirror.refresh("product") + assert result.products_unchanged + assert seller.responses[-1]["wholesale_feed_version"] == current + assert set(mirror.products) == {"p1", "p2"} From 0c0ef92515ec5dfe9d1f045b976194d3d245d4e5 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 29 Oct 2026 21:05:32 -0400 Subject: [PATCH 06/28] feat(adagents): attribute and verify publisher property claims (#1719) * feat(adagents): attribute and verify publisher property claims * fix(adagents): explain invalid publisher claim exclusions --- AGENTS.md | 12 + docs/adagents-discovery.md | 117 +++ docs/adagents-fetch.md | 8 +- src/adcp/__init__.py | 6 + src/adcp/adagents.py | 300 +++++++- tests/fixtures/public_api_snapshot.json | 4 +- tests/test_adagents_property_resolution.py | 685 ++++++++++++++++++ .../adagents_property_resolution.py | 25 + 8 files changed, 1139 insertions(+), 18 deletions(-) create mode 100644 tests/test_adagents_property_resolution.py create mode 100644 tests/type_checks/adagents_property_resolution.py diff --git a/AGENTS.md b/AGENTS.md index da71320c53..2b2c9ac69c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -267,6 +267,18 @@ from adcp.testing import build_asgi_app, build_test_client, make_request_context ## Property Lists and Identifier Resolution +For publisher manifest sync, resolve the agent's selectors with +`get_properties_by_agent` or `resolve_properties_for_agent`, then call +`properties_for_publisher_domain(properties, publisher_domain)` to attribute +inventory. It keeps publisher subdomains and supports wildcard domain inventory. +Pass `include_unattributed=False` to exclude inventory without a publisher or +domain anchor. When using bare-entry permissive resolution, pass +`publisher_domain=` from the trusted fetch origin to require a matching domain +identifier. Use `await verify_cross_domain_properties(properties, source_domain, +agent_url)` before trusting external publisher claims; it follows secure +publisher discovery and checks strict inventory authorization and revocations. +All three helpers are exported from `adcp`. See `docs/adagents-discovery.md`. + Import `MCPPropertyListFetcher`, `ProductPropertyPolicy`, and `check_property_list_reference` from `adcp`. The default seller fetcher resolves lists with MCP `get_property_list`, follows pagination, and caches complete diff --git a/docs/adagents-discovery.md b/docs/adagents-discovery.md index 42dca3a668..45d67f929e 100644 --- a/docs/adagents-discovery.md +++ b/docs/adagents-discovery.md @@ -20,3 +20,120 @@ Malformed sibling entries and non-string or empty URL values are skipped during lookup. `validate_adagents_structure(document)` reports invalid URL types through its existing `missing_url` diagnostic. The listing helper returns matching entry objects in source order and does not modify the document. + +## Attributing properties to a publisher + +After resolving the agent's selectors, use `properties_for_publisher_domain` +to select a publisher's inventory from a shared manifest: + +```python +from adcp import get_properties_by_agent, properties_for_publisher_domain + +authorized = get_properties_by_agent(document, agent_url) +properties = properties_for_publisher_domain(authorized, "www.example.org") +``` + +An explicit property `publisher_domain` takes precedence over identifiers and +must name the requested publisher. Otherwise, a `type="domain"` or +`type="subdomain"` identifier must +name that publisher or any subdomain. For example, `shop.example.org` and +`ctv.example.org` both belong to `example.org`. Host case, trailing dots, IDNA +and a leading `www.` alias within the same registrable domain are normalized. +The alias rule respects both public and private suffixes, so `www.com` cannot +become `com` and `www.github.io` cannot become `github.io`. Domain matching uses +`domain_matches`, including an explicit `*.example.org` pattern. +Wildcard domain inventory such as `*.example.org` belongs to `example.org`; +publisher-domain arguments must be concrete hostnames. + +Properties with neither a publisher anchor nor a domain identifier, such as +bundle-only apps, are kept by default. Pass `include_unattributed=False` to +exclude them. Invalid domain claims are excluded. The helper returns original +objects in input order without changing the document. Attribution trusts the +manifest's claims; it does not establish cross-domain authority. + +## Gating bare-entry resolution + +When using permissive resolution, supply the domain from the fetch origin: + +```python +from adcp import fetch_adagents, resolve_properties_for_agent + +document = await fetch_adagents(domain, validate_structure=False) +properties = resolve_properties_for_agent( + document, agent_url, mode="permissive", publisher_domain=domain +) +``` + +For bare-entry fallback, each property must have a domain identifier naming +the publisher or a subdomain. The property's own `publisher_domain` cannot +satisfy this gate. Apps, venues and other inventory without a matching domain +identifier are excluded. Typed authorization selectors keep their existing +behavior in both modes. Omitting `publisher_domain` preserves the existing +permissive behavior; strict resolution remains the default. + +## Verifying cross-domain claims + +Use `verify_cross_domain_properties` on resolved inventory before trusting +claims about other domains: + +```python +from adcp import ( + fetch_adagents, + get_properties_by_agent, + properties_for_publisher_domain, + verify_cross_domain_properties, +) + +source_domain = "network.example" +document = await fetch_adagents(source_domain) +authorized = get_properties_by_agent(document, agent_url) +verified = await verify_cross_domain_properties( + authorized, source_domain, agent_url +) +publisher_properties = properties_for_publisher_domain(verified, "publisher.example") +``` + +The source domain must come from the trusted fetch origin. For every external +publisher anchor and domain or subdomain identifier, the helper fetches that domain's own +manifest and strictly resolves the same agent's selectors. The remote +authorization must cover the claimed inventory and be attributable to the +checked domain. A matching agent URL alone is insufficient. All claimed typed +identifiers must match an authorized property of the same type, using +`identifiers_match`; a matching property ID cannot override different domain +or bundle identifiers. When identifiers are absent, a publisher-scoped +property ID is used. + +A verified publisher anchor also covers that publisher's subdomain identifiers. +Parent domains and unrelated domains require their own verification. Wildcard +inventory is verified at its base hostname, and Unicode and IDNA domain +identities compare equivalently. The permissive gate continues to require +`type="domain"`; attribution and verification also recognize `type="subdomain"`. + +The default fetcher is `fetch_adagents`, which applies the existing URL/DNS, +response size and redirect protections, follows `authoritative_location`, and +discovers ads.txt `MANAGERDOMAIN` delegation. Delegation still requires the +resolved manifest to authorize inventory attributable to the checked publisher; +an unanchored app in a shared manager file is insufficient. Remote revocations +override authorization. A missing, invalid or unavailable manifest excludes +that domain's claims and logs the failure at debug level. Each external domain +is fetched once per call, sequentially, without recursively verifying its +other claims. There is no persistent verification cache, so later calls can +observe revocations. + +Local inventory, including subdomains of the source, passes through. Inventory +with no domain identifier or publisher anchor also passes through because +there is no remote domain to check. Use `include_unattributed=False` during +attribution to exclude it, and use the domain gate above for bare entries. + +For custom timeouts, deterministic tests or caller-controlled discovery, pass +an async `fetcher(domain) -> dict`. It must provide secure discovery and follow +publisher delegation. For example: + +```python +async def fetch_publisher(domain: str) -> dict: + return await fetch_adagents(domain, timeout=5.0) + +verified = await verify_cross_domain_properties( + authorized, source_domain, agent_url, fetcher=fetch_publisher +) +``` diff --git a/docs/adagents-fetch.md b/docs/adagents-fetch.md index c1c88728c1..1ce206040d 100644 --- a/docs/adagents-fetch.md +++ b/docs/adagents-fetch.md @@ -10,10 +10,16 @@ from adcp import fetch_adagents, validate_adagents_structure, resolve_properties async def inspect_publisher(domain: str, agent_url: str): document = await fetch_adagents(domain, validate_structure=False) report = validate_adagents_structure(document) - properties = resolve_properties_for_agent(document, agent_url, mode="permissive") + properties = resolve_properties_for_agent( + document, agent_url, mode="permissive", publisher_domain=domain + ) return report, properties ``` +The domain gate restricts bare-entry fallback to properties with a matching +domain identifier. See [publisher agent discovery](adagents-discovery.md) for +publisher attribution and cross-domain verification. + The opt-out preserves JSON-object and endpoint checks, authorization-array shape, renderer catalog trust checks, URL/DNS SSRF gates, response size caps, and redirect constraints. `fetch_adagents_with_cache` and `validate_adagents_domain` accept the diff --git a/src/adcp/__init__.py b/src/adcp/__init__.py index 1a354434f4..c0ae78440d 100644 --- a/src/adcp/__init__.py +++ b/src/adcp/__init__.py @@ -180,11 +180,13 @@ def _resolve_version() -> str: "get_properties_by_agent", "identifiers_match", "parse_managerdomains", + "properties_for_publisher_domain", "resolve_properties_for_agent", "validate_adagents_domain", "validate_adagents_structure", "verify_agent_authorization", "verify_agent_for_property", + "verify_cross_domain_properties", ), "adcp.canonical_formats": ( "format_is_supported", @@ -1600,10 +1602,12 @@ def get_adcp_version() -> str: "domain_matches", "identifiers_match", "parse_managerdomains", + "properties_for_publisher_domain", "get_all_properties", "get_all_tags", "get_properties_by_agent", "resolve_properties_for_agent", + "verify_cross_domain_properties", # Test helpers "test_agent", "test_agent_a2a", @@ -1851,11 +1855,13 @@ def get_adcp_version() -> str: get_properties_by_agent, identifiers_match, parse_managerdomains, + properties_for_publisher_domain, resolve_properties_for_agent, validate_adagents_domain, validate_adagents_structure, verify_agent_authorization, verify_agent_for_property, + verify_cross_domain_properties, ) from adcp.canonical_formats import ( format_is_supported, diff --git a/src/adcp/adagents.py b/src/adcp/adagents.py index 54a9101b82..c199f947f2 100644 --- a/src/adcp/adagents.py +++ b/src/adcp/adagents.py @@ -14,7 +14,7 @@ import logging import re import socket -from collections.abc import Callable +from collections.abc import Awaitable, Callable from contextlib import AbstractAsyncContextManager from dataclasses import dataclass, field from datetime import datetime @@ -34,7 +34,11 @@ AdagentsValidationError, ) from adcp.signing._strict_json import parse_strict_json -from adcp.signing.etld import registrable_domain, same_registrable_domain +from adcp.signing.etld import ( + registrable_domain, + same_development_brand_domain, + same_registrable_domain, +) from adcp.types.base import AdCPBaseModel from adcp.validation import ValidationError, validate_adagents @@ -616,6 +620,120 @@ def identifiers_match( return False +def _publisher_attribution_domain(domain: object) -> str: + """Canonical hostname for publisher attribution, including the www alias.""" + from adcp.identifiers import IdentifierValidationError, validate_publisher_domain + + if not isinstance(domain, str): + raise AdagentsValidationError("Invalid publisher domain for property attribution") + try: + canonical = validate_publisher_domain(domain) + except IdentifierValidationError as exc: + raise AdagentsValidationError("Invalid publisher domain for property attribution") from exc + if canonical.startswith("www."): + alias = canonical.removeprefix("www.") + if same_registrable_domain(canonical, alias) or same_development_brand_domain( + canonical, alias + ): + return alias + return canonical + + +def _domain_is_under_publisher(domain: object, publisher_domain: str) -> bool: + from adcp.identifiers import IdentifierValidationError, validate_publisher_domain + + if not isinstance(domain, str): + return False + try: + domain = validate_publisher_domain(domain) + except IdentifierValidationError: + return False + return domain_matches(domain, publisher_domain) or domain_matches( + domain, f"*.{publisher_domain}" + ) + + +def _property_domain_identifiers( + prop: dict[str, Any], *, include_subdomains: bool = False +) -> list[dict[str, Any]]: + identifiers = prop.get("identifiers") + if not isinstance(identifiers, list): + return [] + domain_types = {"domain", "subdomain"} if include_subdomains else {"domain"} + return [ + i + for i in identifiers + if isinstance(i, dict) and isinstance(i.get("type"), str) and i["type"] in domain_types + ] + + +def _property_identity_identifier(identifier: Any) -> dict[str, str] | None: + """Canonicalize domain identities while preserving opaque identifier values.""" + from adcp.identifiers import IdentifierValidationError, normalize_domain + + if ( + not isinstance(identifier, dict) + or not isinstance(identifier.get("type"), str) + or not isinstance(identifier.get("value"), str) + or not identifier["value"] + ): + return None + kind, value = identifier["type"], identifier["value"] + if kind in {"domain", "subdomain"}: + try: + value = normalize_domain(value, allow_wildcard=kind == "domain").value + except IdentifierValidationError: + return None + return {"type": kind, "value": value} + + +def _domain_identifier_is_under_publisher(identifier: dict[str, Any], domain: str) -> bool: + normalized = _property_identity_identifier(identifier) + return normalized is not None and _domain_is_under_publisher( + normalized["value"].removeprefix("*."), domain + ) + + +def properties_for_publisher_domain( + properties: list[dict[str, Any]], + publisher_domain: str, + *, + include_unattributed: bool = True, +) -> list[dict[str, Any]]: + """Attribute already-resolved properties to a publisher without network I/O. + + An explicit ``publisher_domain`` takes precedence and must name the same + publisher (case, trailing dots and a leading ``www.`` are normalized). + Otherwise, a domain or subdomain identifier must name the publisher or any subdomain, + using :func:`domain_matches`. Properties without either kind of domain, + such as bundle-only apps, are kept unless ``include_unattributed=False``. + Invalid domain claims are excluded rather than treated as unattributed. + + Returns the original property objects in input order without mutation. + This is attribution of trusted claims, not proof of authorization; use + :func:`verify_cross_domain_properties` to verify cross-domain claims. + + Raises AdagentsValidationError if ``publisher_domain`` is not a hostname. + """ + domain = _publisher_attribution_domain(publisher_domain) + selected: list[dict[str, Any]] = [] + for prop in properties: + if "publisher_domain" in prop: + try: + if _publisher_attribution_domain(prop["publisher_domain"]) == domain: + selected.append(prop) + except AdagentsValidationError: + # An invalid explicit publisher claim cannot fall back to identifiers. + logger.debug("Skipping property with invalid publisher_domain") + continue + domain_ids = _property_domain_identifiers(prop, include_subdomains=True) + if any(_domain_identifier_is_under_publisher(i, domain) for i in domain_ids) or ( + not domain_ids and include_unattributed + ): + selected.append(prop) + return selected + + def verify_agent_authorization( adagents_data: dict[str, Any], agent_url: str, @@ -2099,6 +2217,7 @@ def resolve_properties_for_agent( agent_url: str, *, mode: PropertyResolutionMode = "strict", + publisher_domain: str | None = None, ) -> list[dict[str, Any]]: """Resolve properties for an agent with an explicit strict/permissive mode. @@ -2115,11 +2234,18 @@ def resolve_properties_for_agent( explicit or unknown selector field, or has multiple same-URL entries, the resolver still returns the strict result. + ``publisher_domain`` gates only bare-entry fallback: a property must have + a domain identifier naming that publisher or a subdomain. Self-declared + ``publisher_domain`` values cannot satisfy the gate; apps and venues with + no matching domain identifier are excluded. Typed selectors are unchanged. + Args: adagents_data: Parsed adagents.json data agent_url: URL of the agent to filter by mode: ``"strict"`` for spec-conformant resolution, ``"permissive"`` to opt into bare-entry top-level property fallback. + publisher_domain: Optional hostname attested by the caller's fetch + origin, used to restrict permissive bare-entry fallback. Returns: List of properties for the specified agent. @@ -2128,19 +2254,17 @@ def resolve_properties_for_agent( AdagentsValidationError: If adagents_data is malformed ValueError: If mode is not ``"strict"`` or ``"permissive"`` """ - if mode == "strict": - return _resolve_properties_for_agent( - adagents_data, - agent_url, - permissive_bare_top_level=False, - ) - if mode == "permissive": - return _resolve_properties_for_agent( - adagents_data, - agent_url, - permissive_bare_top_level=True, - ) - raise ValueError("mode must be 'strict' or 'permissive'") + if mode not in {"strict", "permissive"}: + raise ValueError("mode must be 'strict' or 'permissive'") + domain = ( + _publisher_attribution_domain(publisher_domain) if publisher_domain is not None else None + ) + return _resolve_properties_for_agent( + adagents_data, + agent_url, + permissive_bare_top_level=mode == "permissive", + publisher_domain=domain, + ) def _resolve_properties_for_agent( @@ -2148,6 +2272,7 @@ def _resolve_properties_for_agent( agent_url: str, *, permissive_bare_top_level: bool, + publisher_domain: str | None = None, ) -> list[dict[str, Any]]: """Implementation shared by strict and opt-in permissive property resolution.""" matches = find_authorized_agent_entries(adagents_data, agent_url) @@ -2185,11 +2310,154 @@ def _resolve_properties_for_agent( and len(matches) == 1 and _is_bare_authorized_agent_entry(matches[0]) ): - return [p for p in revoked_top_level if isinstance(p, dict)] + return [ + p + for p in revoked_top_level + if isinstance(p, dict) + and ( + publisher_domain is None + or any( + _domain_identifier_is_under_publisher(i, publisher_domain) + for i in _property_domain_identifiers(p) + ) + ) + ] return [] +def _same_authorized_property(claim: dict[str, Any], authorized: dict[str, Any]) -> bool: + """Compare inventory identity without trusting file-local IDs over identifiers.""" + if claim.get("property_type") != authorized.get("property_type"): + return False + identifiers = claim.get("identifiers") + if identifiers is not None and not isinstance(identifiers, list): + return False + if isinstance(identifiers, list) and identifiers: + authorized_ids = authorized.get("identifiers") + if not isinstance(authorized_ids, list): + return False + valid_ids = [ + normalized + for i in authorized_ids + if (normalized := _property_identity_identifier(i)) is not None + ] + return all( + (normalized := _property_identity_identifier(i)) is not None + and identifiers_match([normalized], valid_ids) + for i in identifiers + ) + property_id = claim.get("property_id") + return ( + isinstance(property_id, str) + and bool(property_id) + and property_id == authorized.get("property_id") + ) + + +async def verify_cross_domain_properties( + properties: list[dict[str, Any]], + publisher_domain: str, + agent_url: str, + *, + fetcher: Callable[[str], Awaitable[dict[str, Any]]] | None = None, +) -> list[dict[str, Any]]: + """Return the subset of resolved properties vouched for by their domains. + + ``publisher_domain`` is the trusted origin of the source manifest, not a + value read from that manifest. First resolve its agent selectors with + :func:`get_properties_by_agent` or :func:`resolve_properties_for_agent`. + + Every external ``publisher_domain``, domain or subdomain identifier must + authorize the same inventory for ``agent_url`` in strict resolution. Domain + identifiers take precedence over file-local property IDs when comparing + inventory. Inventory with no identifiers uses its publisher anchor and + property ID. Remote authorizations must be attributable to the checked + domain; unanchored apps in a shared manager file cannot vouch for it. + Local properties (including subdomains) and unanchored non-domain inventory + pass through; the latter cannot be verified by this helper. + + The default fetcher is :func:`fetch_adagents`, including its SSRF gates, + ``authoritative_location`` following and ads.txt MANAGERDOMAIN discovery. + Delegated manifests must still authorize the particular property; merely + listing an agent or delegating to a manager is insufficient. A custom async + ``fetcher(domain)`` must provide the same secure discovery semantics. + + Each external domain is fetched once per call, sequentially. Missing, + invalid, revoked or unavailable authorizations are excluded, with failures + logged at debug level. No recursive property verification is performed. + Returns original objects in input order without mutation. + """ + source = _publisher_attribution_domain(publisher_domain) + fetch = fetcher if fetcher is not None else fetch_adagents + authorizations: dict[str, list[dict[str, Any]]] = {} + verified: list[dict[str, Any]] = [] + + for prop in properties: + try: + anchor = ( + _publisher_attribution_domain(prop["publisher_domain"]) + if "publisher_domain" in prop + else None + ) + domains = [anchor] if anchor is not None else [] + for identifier in _property_domain_identifiers(prop, include_subdomains=True): + normalized = _property_identity_identifier(identifier) + if normalized is None: + raise AdagentsValidationError("Invalid property domain identifier") + domain = _publisher_attribution_domain(normalized["value"].removeprefix("*.")) + if domain not in domains: + domains.append(domain) + except AdagentsValidationError: + continue + + for domain in domains: + if _domain_is_under_publisher(domain, source): + continue + # The anchor is checked first. Its verified authorization covers + # its own subdomains, matching the source-domain trust boundary. + if ( + anchor is not None + and domain != anchor + and _domain_is_under_publisher(domain, anchor) + ): + continue + if domain not in authorizations: + try: + data = await fetch(domain) + revoked = { + _publisher_attribution_domain(d) + for d in _get_revoked_publisher_domains(data) + } + try: + candidates = get_properties_by_agent(data, agent_url) + except (TypeError, ValueError) as exc: + raise AdagentsValidationError( + "Malformed remote property authorization" + ) from exc + authorizations[domain] = ( + [] + if domain in revoked + else properties_for_publisher_domain( + candidates, domain, include_unattributed=False + ) + ) + except ( + AdagentsNotFoundError, + AdagentsValidationError, + AdagentsTimeoutError, + httpx.HTTPError, + OSError, + ) as exc: + logger.debug("Cross-domain authorization failed for %s: %s", domain, exc) + authorizations[domain] = [] + if not any(_same_authorized_property(prop, p) for p in authorizations[domain]): + break + else: + verified.append(prop) + return verified + + def _reference_renderer_catalog_errors( adagents_data: dict[str, Any], *, source_url: str | None ) -> list[AdagentsEntryError]: diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index 6e9c3d0ff2..c176447468 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -719,6 +719,7 @@ "normalize_identifier": "adcp.identifiers", "parse_managerdomains": "adcp.adagents", "project_available_actions": "adcp.media_buy_actions", + "properties_for_publisher_domain": "adcp.adagents", "reassess_media_buy_action": "adcp.media_buy_actions", "resolve_properties_for_agent": "adcp.adagents", "route_media_buy_action": "adcp.media_buy_actions", @@ -746,7 +747,8 @@ "validate_publisher_properties_item": "adcp.validation.legacy", "validate_webhook_challenge_response": "adcp.webhooks", "verify_agent_authorization": "adcp.adagents", - "verify_agent_for_property": "adcp.adagents" + "verify_agent_for_property": "adcp.adagents", + "verify_cross_domain_properties": "adcp.adagents" } }, "adcp.reporting": { diff --git a/tests/test_adagents_property_resolution.py b/tests/test_adagents_property_resolution.py new file mode 100644 index 0000000000..450f75133c --- /dev/null +++ b/tests/test_adagents_property_resolution.py @@ -0,0 +1,685 @@ +"""Publisher attribution and verification without live network dependencies.""" + +import asyncio +import subprocess +import sys +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from copy import deepcopy +from typing import Any +from unittest.mock import AsyncMock + +import httpx +import pytest + +from adcp import ( + fetch_adagents, + get_properties_by_agent, + properties_for_publisher_domain, + resolve_properties_for_agent, + verify_cross_domain_properties, +) +from adcp.exceptions import ( + AdagentsNotFoundError, + AdagentsTimeoutError, + AdagentsValidationError, +) + +AGENT = "https://sales.example.com/mcp" + + +def site(domain: str, property_id: str = "site", **extra: Any) -> dict[str, Any]: + return { + "property_id": property_id, + "property_type": "website", + "name": domain, + "identifiers": [{"type": "domain", "value": domain}], + **extra, + } + + +def manifest(properties: list[dict[str, Any]], *, bare: bool = False) -> dict[str, Any]: + entry: dict[str, Any] = {"url": AGENT, "authorized_for": "Sales"} + if not bare: + entry.update( + authorization_type="property_ids", property_ids=[p["property_id"] for p in properties] + ) + return {"authorized_agents": [entry], "properties": properties} + + +@pytest.mark.parametrize("publisher", ["example.org", "www.example.org", " WWW.EXAMPLE.ORG. "]) +def test_attribution_keeps_authorized_subdomains(publisher: str) -> None: + props = [ + site(d, d) + for d in [ + "example.org", + "www.example.org", + "m.example.org", + "mobile.example.org", + "shop.example.org", + "ctv.example.org", + "deep.shop.example.org", + "other.org", + "notexample.org", + "example.org.attacker.org", + ] + ] + document = manifest(props) + before = deepcopy(document) + resolved = get_properties_by_agent(document, AGENT) + selected = properties_for_publisher_domain(resolved, publisher) + assert selected == props[:7] + assert all(a is b for a, b in zip(selected, props)) + assert document == before + + +def test_attribution_explicit_publisher_takes_precedence() -> None: + selected = site("elsewhere.org", publisher_domain="WWW.EXAMPLE.ORG.") + wrong = site("example.org", publisher_domain="other.org") + child_publisher = site("example.org", publisher_domain="shop.example.org") + app = { + "property_type": "mobile_app", + "identifiers": [{"type": "bundle_id", "value": "org.app"}], + } + props = [wrong, selected, child_publisher, app] + assert properties_for_publisher_domain(props, "example.org") == [selected, app] + assert properties_for_publisher_domain(props, "example.org", include_unattributed=False) == [ + selected + ] + + +@pytest.mark.parametrize("bad", [None, "", 1, [], "https://example.org", "bad..org"]) +def test_invalid_claims_are_not_unattributed(bad: Any) -> None: + assert ( + properties_for_publisher_domain([site("example.org", publisher_domain=bad)], "example.org") + == [] + ) + assert properties_for_publisher_domain([site(bad)], "example.org") == [] + + +@pytest.mark.parametrize( + "bad", ["", "https://example.org", "*.example.org", "example.org:443", "bad..org"] +) +def test_invalid_publisher_arguments_raise(bad: str) -> None: + with pytest.raises(AdagentsValidationError): + properties_for_publisher_domain([], bad) + with pytest.raises(AdagentsValidationError): + resolve_properties_for_agent( + manifest([], bare=True), AGENT, mode="permissive", publisher_domain=bad + ) + + +def test_attribution_handles_multi_label_suffixes_and_idna() -> None: + uk = site("shop.publisher.co.uk") + idn = site("shop.xn--bcher-kva.example") + assert properties_for_publisher_domain([uk], "www.publisher.co.uk") == [uk] + assert properties_for_publisher_domain([idn], "bücher.example") == [idn] + + +def test_permissive_gate_uses_only_domain_identifiers() -> None: + good = site("ctv.example.org", publisher_domain="other.org") + forged = site("victim.org", publisher_domain="example.org") + app = { + "property_id": "app", + "publisher_domain": "example.org", + "identifiers": [{"type": "bundle_id", "value": "org.app"}], + } + venue = {"property_id": "venue", "identifiers": [{"type": "venue_id", "value": "example.org"}]} + props = [ + good, + forged, + app, + venue, + site("example.org.attacker.org"), + site("https://shop.example.org"), + ] + document = manifest(props, bare=True) + before = deepcopy(document) + assert resolve_properties_for_agent(document, AGENT, mode="permissive") == props + assert resolve_properties_for_agent( + document, AGENT, mode="permissive", publisher_domain="www.example.org" + ) == [good] + assert document == before + + +@pytest.mark.parametrize("mode", ["strict", "permissive"]) +def test_domain_gate_does_not_change_typed_selectors(mode: str) -> None: + props = [site("other.org")] + assert ( + resolve_properties_for_agent( + manifest(props), AGENT, mode=mode, publisher_domain="example.org" + ) + == props + ) + + +def test_gate_does_not_expand_unlisted_or_ambiguous_authorizations() -> None: + document = manifest([site("example.org")], bare=True) + assert ( + resolve_properties_for_agent( + document, AGENT + "/other", mode="permissive", publisher_domain="example.org" + ) + == [] + ) + document["authorized_agents"] *= 2 + assert ( + resolve_properties_for_agent( + document, AGENT, mode="permissive", publisher_domain="example.org" + ) + == [] + ) + document["authorized_agents"] = [ + {"url": AGENT, "authorized_for": "Sales", "authorization_type": "unknown"} + ] + assert ( + resolve_properties_for_agent( + document, AGENT, mode="permissive", publisher_domain="example.org" + ) + == [] + ) + + +def test_gate_honors_existing_revocations() -> None: + prop = site("ctv.example.org", publisher_domain="example.org") + document = manifest([prop], bare=True) + document["revoked_publisher_domains"] = [{"publisher_domain": "example.org"}] + assert ( + resolve_properties_for_agent( + document, AGENT, mode="permissive", publisher_domain="example.org" + ) + == [] + ) + + +async def test_verification_keeps_local_and_unanchored_inventory_without_fetching() -> None: + props = [ + site("example.org"), + site("ctv.example.org"), + {"property_id": "app", "identifiers": [{"type": "bundle_id", "value": "org.app"}]}, + ] + fetcher = AsyncMock() + assert ( + await verify_cross_domain_properties(props, "WWW.EXAMPLE.ORG.", AGENT, fetcher=fetcher) + == props + ) + fetcher.assert_not_awaited() + + +async def test_verification_fetches_once_per_domain_and_retains_input_order() -> None: + first = site("remote.example", "first") + second = site("remote.example", "second") + local = site("publisher.example", "local") + props = [first, local, second] + before = deepcopy(props) + fetcher = AsyncMock(return_value=manifest([first, second])) + result = await verify_cross_domain_properties( + props, "publisher.example", AGENT, fetcher=fetcher + ) + assert result == props + assert all(a is b for a, b in zip(result, props)) + fetcher.assert_awaited_once_with("remote.example") + assert props == before + + +@pytest.mark.parametrize( + "authorization_type", + ["property_ids", "property_tags", "inline_properties", "publisher_properties"], +) +async def test_remote_selectors_resolve_inventory_independently_of_file_local_ids( + authorization_type: str, +) -> None: + claim = site("remote.example", "parent-id") + remote_property = site( + "remote.example", "child-id", publisher_domain="remote.example", tags=["sales"] + ) + remote = manifest([remote_property]) + entry = remote["authorized_agents"][0] + entry.pop("property_ids") + entry["authorization_type"] = authorization_type + entry.update( + { + "property_ids": {"property_ids": ["child-id"]}, + "property_tags": {"property_tags": ["sales"]}, + "inline_properties": {"properties": [remote_property]}, + "publisher_properties": { + "publisher_properties": [ + {"publisher_domain": "remote.example", "selection_type": "all"} + ] + }, + }[authorization_type] + ) + assert await verify_cross_domain_properties( + [claim], "publisher.example", AGENT, fetcher=AsyncMock(return_value=remote) + ) == [claim] + + +async def test_verification_refreshes_between_calls_to_observe_revocation() -> None: + prop = site("remote.example") + remote = manifest([prop]) + revoked = {**remote, "revoked_publisher_domains": [{"publisher_domain": "remote.example"}]} + fetcher = AsyncMock(side_effect=[remote, revoked]) + assert await verify_cross_domain_properties( + [prop], "publisher.example", AGENT, fetcher=fetcher + ) == [prop] + assert ( + await verify_cross_domain_properties([prop], "publisher.example", AGENT, fetcher=fetcher) + == [] + ) + assert fetcher.await_count == 2 + + +@pytest.mark.parametrize( + "remote", + [ + {"authorized_agents": []}, + manifest([site("remote.example")], bare=True), + manifest([site("elsewhere.example")]), + manifest([site("remote.example", property_type="mobile_app")]), + { + "authorized_agents": [ + { + "url": AGENT, + "authorized_for": "Sales", + "authorization_type": "property_ids", + "property_ids": [], + } + ] + }, + { + "authorized_agents": [ + { + "url": "https://other.example", + "authorization_type": "inline_properties", + "properties": [site("remote.example")], + } + ] + }, + ], +) +async def test_listing_agent_or_reusing_property_id_is_insufficient(remote: dict[str, Any]) -> None: + fetcher = AsyncMock(return_value=remote) + assert ( + await verify_cross_domain_properties( + [site("remote.example")], "publisher.example", AGENT, fetcher=fetcher + ) + == [] + ) + + +async def test_every_claimed_identifier_must_be_authorized() -> None: + claim = site("remote.example") + claim["identifiers"].append({"type": "bundle_id", "value": "org.forged"}) + assert ( + await verify_cross_domain_properties( + [claim], + "publisher.example", + AGENT, + fetcher=AsyncMock(return_value=manifest([site("remote.example")])), + ) + == [] + ) + + +async def test_forged_local_publisher_anchor_does_not_bypass_external_domain_check() -> None: + prop = site("victim.example", publisher_domain="publisher.example") + fetcher = AsyncMock(return_value=manifest([], bare=True)) + assert ( + await verify_cross_domain_properties([prop], "publisher.example", AGENT, fetcher=fetcher) + == [] + ) + fetcher.assert_awaited_once_with("victim.example") + + +async def test_every_external_domain_must_vouch() -> None: + prop = site("first.example") + prop["identifiers"].append({"type": "domain", "value": "second.example"}) + fetcher = AsyncMock(side_effect=[manifest([prop]), {"authorized_agents": []}]) + assert ( + await verify_cross_domain_properties([prop], "publisher.example", AGENT, fetcher=fetcher) + == [] + ) + assert [c.args[0] for c in fetcher.await_args_list] == ["first.example", "second.example"] + + +async def test_bundle_only_inventory_uses_explicit_publisher_anchor() -> None: + prop = { + "property_id": "app", + "property_type": "mobile_app", + "publisher_domain": "remote.example", + "identifiers": [{"type": "bundle_id", "value": "org.app"}], + } + fetcher = AsyncMock(return_value=manifest([prop])) + assert await verify_cross_domain_properties( + [prop], "publisher.example", AGENT, fetcher=fetcher + ) == [prop] + fetcher.assert_awaited_once_with("remote.example") + # Matching a file-local ID cannot override a different bundle identifier. + remote = {**prop, "identifiers": [{"type": "bundle_id", "value": "org.other"}]} + assert ( + await verify_cross_domain_properties( + [prop], "publisher.example", AGENT, fetcher=AsyncMock(return_value=manifest([remote])) + ) + == [] + ) + + # An app with no publisher anchor in a shared manager file cannot vouch for B. + unanchored = {k: v for k, v in prop.items() if k != "publisher_domain"} + assert ( + await verify_cross_domain_properties( + [prop], + "publisher.example", + AGENT, + fetcher=AsyncMock(return_value=manifest([unanchored])), + ) + == [] + ) + + +async def test_publisher_scoped_property_id_fallback() -> None: + prop = {"property_id": "venue", "publisher_domain": "remote.example"} + assert await verify_cross_domain_properties( + [prop], "publisher.example", AGENT, fetcher=AsyncMock(return_value=manifest([prop])) + ) == [prop] + other = {**prop, "publisher_domain": "elsewhere.example"} + assert ( + await verify_cross_domain_properties( + [prop], "publisher.example", AGENT, fetcher=AsyncMock(return_value=manifest([other])) + ) + == [] + ) + + +@pytest.mark.parametrize("inline", [False, True]) +async def test_remote_revocation_overrides_authorization(inline: bool) -> None: + prop = site("remote.example", publisher_domain="remote.example") + remote = manifest([prop]) + if inline: + remote["authorized_agents"][0] = { + "url": AGENT, + "authorization_type": "inline_properties", + "properties": [prop], + } + remote["revoked_publisher_domains"] = [{"publisher_domain": "WWW.REMOTE.EXAMPLE."}] + assert ( + await verify_cross_domain_properties( + [prop], "publisher.example", AGENT, fetcher=AsyncMock(return_value=remote) + ) + == [] + ) + + +@pytest.mark.parametrize( + "error", + [ + AdagentsNotFoundError("remote.example"), + AdagentsTimeoutError("remote.example", 10), + AdagentsValidationError("bad manifest"), + httpx.ConnectError("unavailable"), + OSError("DNS unavailable"), + ], +) +async def test_fetch_failure_is_closed_and_cached(error: Exception) -> None: + props = [ + site("remote.example", "one"), + site("remote.example", "two"), + site("publisher.example", "local"), + ] + fetcher = AsyncMock(side_effect=error) + assert ( + await verify_cross_domain_properties(props, "publisher.example", AGENT, fetcher=fetcher) + == props[-1:] + ) + fetcher.assert_awaited_once() + + +@pytest.mark.parametrize("selector", ["property_ids", "property_tags"]) +async def test_malformed_remote_inventory_excludes_only_that_origin(selector: str) -> None: + from adcp import validate_adagents_structure + + remote = manifest([site("remote.example")]) + if selector == "property_ids": + remote["properties"][0]["property_id"] = [] + else: + remote["authorized_agents"][0].pop("property_ids") + remote["authorized_agents"][0].update( + authorization_type="property_tags", property_tags=["sales"] + ) + remote["properties"][0]["tags"] = None + # Fetch-time structural validation checks entries, not the inventory catalog. + assert validate_adagents_structure(remote).schema_valid + props = [site("remote.example"), site("publisher.example", "local")] + assert ( + await verify_cross_domain_properties( + props, "publisher.example", AGENT, fetcher=AsyncMock(return_value=remote) + ) + == props[-1:] + ) + + +async def test_fetcher_programming_errors_are_not_hidden() -> None: + with pytest.raises(TypeError, match="fetcher bug"): + await verify_cross_domain_properties( + [site("remote.example")], + "publisher.example", + AGENT, + fetcher=AsyncMock(side_effect=TypeError("fetcher bug")), + ) + + +async def test_cancellation_is_propagated() -> None: + with pytest.raises(asyncio.CancelledError): + await verify_cross_domain_properties( + [site("remote.example")], + "publisher.example", + AGENT, + fetcher=AsyncMock(side_effect=asyncio.CancelledError), + ) + + +@pytest.mark.parametrize("bad", [None, "https://remote.example", "", []]) +async def test_invalid_cross_domain_claims_are_excluded_without_fetch(bad: Any) -> None: + fetcher = AsyncMock() + assert ( + await verify_cross_domain_properties( + [site(bad)], "publisher.example", AGENT, fetcher=fetcher + ) + == [] + ) + assert ( + await verify_cross_domain_properties( + [site("publisher.example", publisher_domain=bad)], + "publisher.example", + AGENT, + fetcher=fetcher, + ) + == [] + ) + fetcher.assert_not_awaited() + + +@pytest.mark.parametrize("module", ["adcp", "adcp.adagents"]) +def test_helpers_import_in_a_fresh_interpreter(module: str) -> None: + code = ( + f"from {module} import fetch_adagents, properties_for_publisher_domain, " + "verify_cross_domain_properties; " + "assert properties_for_publisher_domain([], 'publisher.example') == []" + ) + result = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True, timeout=60 + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + ("publisher", "victim"), + [ + ("www.com", "victim.com"), + ("www.co.uk", "victim.co.uk"), + ("www.github.io", "victim.github.io"), + ], +) +async def test_www_alias_never_broadens_authority_to_public_or_private_suffix( + publisher: str, victim: str +) -> None: + local = site(publisher, "local") + remote = site(victim, "victim") + assert properties_for_publisher_domain([local, remote], publisher) == [local] + assert resolve_properties_for_agent( + manifest([local, remote], bare=True), AGENT, mode="permissive", publisher_domain=publisher + ) == [local] + fetcher = AsyncMock(return_value={"authorized_agents": []}) + assert await verify_cross_domain_properties( + [local, remote], publisher, AGENT, fetcher=fetcher + ) == [local] + fetcher.assert_awaited_once_with(victim) + + +async def test_verified_publisher_anchor_covers_its_own_subdomain_inventory() -> None: + prop = site("ctv.publisher.example", publisher_domain="publisher.example") + fetcher = AsyncMock(return_value=manifest([prop])) + assert await verify_cross_domain_properties( + [prop], "network.example", AGENT, fetcher=fetcher + ) == [prop] + fetcher.assert_awaited_once_with("publisher.example") + assert ( + await verify_cross_domain_properties( + [prop], + "network.example", + AGENT, + fetcher=AsyncMock(return_value={"authorized_agents": []}), + ) + == [] + ) + + +async def test_child_publisher_cannot_vouch_for_parent_domain() -> None: + prop = site("publisher.example", publisher_domain="ctv.publisher.example") + fetcher = AsyncMock(side_effect=[manifest([prop]), {"authorized_agents": []}]) + assert ( + await verify_cross_domain_properties([prop], "network.example", AGENT, fetcher=fetcher) + == [] + ) + assert [c.args[0] for c in fetcher.await_args_list] == [ + "ctv.publisher.example", + "publisher.example", + ] + + +async def test_subdomain_identifier_requires_external_authorization() -> None: + prop = site("ctv.victim.example") + prop["identifiers"][0]["type"] = "subdomain" + fetcher = AsyncMock(return_value={"authorized_agents": []}) + assert ( + await verify_cross_domain_properties([prop], "attacker.example", AGENT, fetcher=fetcher) + == [] + ) + fetcher.assert_awaited_once_with("ctv.victim.example") + assert properties_for_publisher_domain([prop], "victim.example") == [prop] + assert properties_for_publisher_domain([prop], "attacker.example") == [] + assert await verify_cross_domain_properties( + [prop], "network.example", AGENT, fetcher=AsyncMock(return_value=manifest([prop])) + ) == [prop] + # The bare-entry gate continues to require the documented domain type. + assert ( + resolve_properties_for_agent( + manifest([prop], bare=True), AGENT, mode="permissive", publisher_domain="victim.example" + ) + == [] + ) + + +@pytest.mark.parametrize("kind", ["domain", "subdomain"]) +async def test_unicode_and_idna_inventory_identities_match(kind: str) -> None: + prop = site("bücher.example") + prop["identifiers"][0]["type"] = kind + remote = site("xn--bcher-kva.example") + remote["identifiers"][0]["type"] = kind + fetcher = AsyncMock(return_value=manifest([remote])) + assert await verify_cross_domain_properties( + [prop], "network.example", AGENT, fetcher=fetcher + ) == [prop] + fetcher.assert_awaited_once_with("xn--bcher-kva.example") + + +async def test_wildcard_inventory_is_attributed_gated_and_verified_at_its_base() -> None: + prop = site("*.publisher.example") + assert properties_for_publisher_domain([prop], "publisher.example") == [prop] + assert resolve_properties_for_agent( + manifest([prop], bare=True), AGENT, mode="permissive", publisher_domain="publisher.example" + ) == [prop] + fetcher = AsyncMock(return_value=manifest([prop])) + assert await verify_cross_domain_properties( + [prop], "network.example", AGENT, fetcher=fetcher + ) == [prop] + fetcher.assert_awaited_once_with("publisher.example") + # A single authorized subdomain cannot vouch for every subdomain in a wildcard. + assert ( + await verify_cross_domain_properties( + [prop], + "network.example", + AGENT, + fetcher=AsyncMock(return_value=manifest([site("ctv.publisher.example")])), + ) + == [] + ) + with pytest.raises(AdagentsValidationError): + properties_for_publisher_domain([prop], "*.publisher.example") + + +async def test_default_fetcher_is_sdk_discovery(monkeypatch: pytest.MonkeyPatch) -> None: + prop = site("remote.example") + fetcher = AsyncMock(return_value=manifest([prop])) + monkeypatch.setattr("adcp.adagents.fetch_adagents", fetcher) + assert await verify_cross_domain_properties([prop], "publisher.example", AGENT) == [prop] + fetcher.assert_awaited_once_with("remote.example") + + +@pytest.mark.parametrize( + "delegation", ["direct", "authoritative_location", "ads_txt_managerdomain"] +) +@pytest.mark.parametrize("authorized", [True, False]) +async def test_real_discovery_requires_publisher_vouching( + delegation: str, authorized: bool +) -> None: + prop = site("remote.example", publisher_domain="remote.example") + parent = manifest([prop]) + no_vouch = manifest([site("unrelated.example")]) + requested: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + requested.append(url) + if url == "https://remote.example/.well-known/adagents.json": + if delegation == "authoritative_location": + return httpx.Response( + 200, + json={ + "authoritative_location": "https://manager.example/.well-known/adagents.json" + }, + ) + if delegation == "ads_txt_managerdomain": + return httpx.Response(404) + return httpx.Response(200, json=parent if authorized else no_vouch) + if url == "https://remote.example/ads.txt": + return httpx.Response(200, text="MANAGERDOMAIN=manager.example\n") + if url == "https://manager.example/.well-known/adagents.json": + return httpx.Response(200, json=parent if authorized else no_vouch) + raise AssertionError(f"Unexpected fetch: {url}") + + @asynccontextmanager + async def transport(url: str, timeout: float) -> AsyncIterator[httpx.AsyncClient]: + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + yield client + + async def fetcher(domain: str) -> dict[str, Any]: + return await fetch_adagents(domain, transport_factory=transport) + + resolved = get_properties_by_agent(parent, AGENT) + assert await verify_cross_domain_properties( + resolved, "manager.example", AGENT, fetcher=fetcher + ) == ([prop] if authorized else []) + assert requested[0] == "https://remote.example/.well-known/adagents.json" + if delegation == "ads_txt_managerdomain": + assert "https://remote.example/ads.txt" in requested + if delegation != "direct": + assert "https://manager.example/.well-known/adagents.json" in requested diff --git a/tests/type_checks/adagents_property_resolution.py b/tests/type_checks/adagents_property_resolution.py new file mode 100644 index 0000000000..54b3ef80ac --- /dev/null +++ b/tests/type_checks/adagents_property_resolution.py @@ -0,0 +1,25 @@ +"""Publisher sync helpers accept typed custom discovery and preserve dictionaries.""" + +from typing import Any + +from adcp import ( + fetch_adagents, + properties_for_publisher_domain, + resolve_properties_for_agent, + verify_cross_domain_properties, +) + + +async def fetch_publisher(domain: str) -> dict[str, Any]: + return await fetch_adagents(domain, timeout=5.0) + + +async def sync_publisher(domain: str, agent_url: str) -> list[dict[str, Any]]: + document = await fetch_adagents(domain, validate_structure=False) + authorized = resolve_properties_for_agent( + document, agent_url, mode="permissive", publisher_domain=domain + ) + verified = await verify_cross_domain_properties( + authorized, domain, agent_url, fetcher=fetch_publisher + ) + return properties_for_publisher_domain(verified, domain, include_unattributed=False) From 8635115db545a7fdb23e6173bc217c8873afac1a Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 21:50:41 -0400 Subject: [PATCH 07/28] ci: pin pypa/gh-action-pypi-publish to v1.14.3 for GHSA-5xjx-3w67-w9x9 (#1752) The advisory, critical, covers every version up to 1.14.2, after revocation of a CA in the Sigstore chain used for Trusted Publishing and PyPI attestations. Same change as #1748 on next. Co-authored-by: Claude Opus 5.5 (1M context) --- .github/workflows/release-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 61e9de55f2..8f60b6a11b 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -106,6 +106,6 @@ jobs: with: name: release-dist path: dist/ - - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 with: packages-dir: dist/ From bf6d53095886e1200340afb6e14bd4727c487f04 Mon Sep 17 00:00:00 2001 From: "aao-ipr-bot[bot]" <279263549+aao-ipr-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:58:45 -0400 Subject: [PATCH 08/28] chore(main): release 9.4.0 (#1714) Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 14 ++++++++++++++ pyproject.toml | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 5005692797..a6992a9dda 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { - ".": "9.3.1", + ".": "9.4.0", "packages/adcp-reporting-storage": "0.1.0-beta.4" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ee3e69aa..ab17f677f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -192,6 +192,20 @@ change public consumer defaults. See [format-kind migration](docs/canonical-format-kinds-migration.md). +## [9.4.0](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.3.1...v9.4.0) (2026-10-31) + + +### Features + +* **adagents:** attribute and verify publisher property claims ([#1719](https://github.com/adcontextprotocol/adcp-client-python/issues/1719)) ([0c0ef92](https://github.com/adcontextprotocol/adcp-client-python/commit/0c0ef92515ec5dfe9d1f045b976194d3d245d4e5)) +* **server:** seller helper for wholesale feed versions ([#1718](https://github.com/adcontextprotocol/adcp-client-python/issues/1718)) ([7088ebf](https://github.com/adcontextprotocol/adcp-client-python/commit/7088ebf909852134521ba0ed4796b6f2896a4ad7)), closes [#1676](https://github.com/adcontextprotocol/adcp-client-python/issues/1676) +* **signing:** accept any well-formed adcp_use purpose in keygen ([#1710](https://github.com/adcontextprotocol/adcp-client-python/issues/1710)) ([787cfb2](https://github.com/adcontextprotocol/adcp-client-python/commit/787cfb215cf9207bfef8c28cc34ff0b737cea39b)) + + +### Bug Fixes + +* **types:** defer the import-time model rebuilds that cost adcp.client 560 MiB RSS ([#1704](https://github.com/adcontextprotocol/adcp-client-python/issues/1704)) ([aac52ca](https://github.com/adcontextprotocol/adcp-client-python/commit/aac52cac66ecae2d1c2c4e11be23294335a3edd8)), closes [#1694](https://github.com/adcontextprotocol/adcp-client-python/issues/1694) + ## [9.3.1](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.3.0...v9.3.1) (2026-10-29) diff --git a/pyproject.toml b/pyproject.toml index 7593bd1947..d438e88a03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "adcp" -version = "9.3.1" +version = "9.4.0" description = "Official Python client for the Ad Context Protocol (AdCP)" authors = [ {name = "AdCP Community", email = "maintainers@adcontextprotocol.org"} From 1fcc3319a41082a8b9ea4fcbe356b3234e412135 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 1 Nov 2026 22:52:04 -0500 Subject: [PATCH 09/28] test(reporting): run the row-upload lease test at the wall clock (#1813) Backport of #1767 (c1c6d6eb) from `next`. Since 2026-11-01 every `main`-based CI run fails `test_the_producer_stages_object_uploads_outside_its_publication_transaction` with `STATE_UNAVAILABLE: no live worker lease covers this row upload` (#1792): the settling harness pins its clock at 2026-11-01T02:10Z, the producer stamps `lease_expires_at` from that clock, and `row_write.py` checks the lease against the database's `clock_timestamp()`. Once real time passed the frozen instant plus the lease, no upload was covered. Run the producer's turn at the wall clock so the stamped lease lies in the database's real future. The lease check keeps its production semantics; only the test's clock moves. Refs #1792 Co-authored-by: Claude Fable 5.1 --- .../reporting/test_reporting_row_storage_object_pg.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/conformance/reporting/test_reporting_row_storage_object_pg.py b/tests/conformance/reporting/test_reporting_row_storage_object_pg.py index aa2410045c..f4cab40174 100644 --- a/tests/conformance/reporting/test_reporting_row_storage_object_pg.py +++ b/tests/conformance/reporting/test_reporting_row_storage_object_pg.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -442,13 +443,18 @@ async def test_the_producer_stages_object_uploads_outside_its_publication_transa from tests.test_reporting_settling import _capabilities, _harness, _revisions async with isolated_reporting_pool() as pool: - producer, store, _, _ = await _harness( + producer, store, _, clock = await _harness( _capabilities(restatement_window="P3D"), store_factory=lambda clock: PgReportingLedgerStore( pool=pool, clock=clock, row_storage=options(tmp_path) ), ) assert isinstance(producer, ReportingProducer) + # The row upload checks the worker lease against the database's + # clock_timestamp(), so the lease the injected clock stamps must lie in + # the real future: run the turn at the wall clock, not the harness's + # fixed instant, which is already in the past. + clock[0] = datetime.now(timezone.utc) turn = await producer.run_worker() assert turn.revisions_committed (revision,) = await _revisions(store) From 126f9052ab79399f1e7bab8a53f34e24999ef95e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 2 Nov 2026 00:22:03 -0500 Subject: [PATCH 10/28] fix(server): return task and workflow handoffs uncached from IdempotencyStore.wrap (#1795) A method that may hand off to a workflow could not use the SDK's idempotency: IdempotencyStore.wrap coerced every completed result to a dict for the replay cache, and a TaskHandoff or WorkflowHandoff is a control marker for the dispatcher, not a response, so the call raised "Cannot coerce WorkflowHandoff to dict for idempotency caching" (#1781). Adopters copied the private wrapper to work around it. wrap now returns a handoff uncached, after the key, conflict, lock and cancellation handling, so the dispatcher allocates the task as usual. It also accepts cache_response=, a predicate a seller can use to keep its own control results out of the cache: @store.wrap(cache_response=...) caches a result only when the predicate returns True, and a handoff is never cached. Both forms register the wrapper for validate_idempotency_wiring. An uncached result stores no replay entry, so a retry with the same key runs the method again; the docs say to deduplicate the enqueued work on the key. Replaying the task id the dispatcher allocated needs dispatcher support and is left for a follow-up. Closes #1781 Co-authored-by: Claude Opus 5.5 --- docs/handler-authoring.md | 9 ++ src/adcp/server/idempotency/store.py | 57 ++++++- tests/test_idempotency_handoff.py | 230 +++++++++++++++++++++++++++ 3 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 tests/test_idempotency_handoff.py diff --git a/docs/handler-authoring.md b/docs/handler-authoring.md index 74fa0de0b2..28adb7f5a4 100644 --- a/docs/handler-authoring.md +++ b/docs/handler-authoring.md @@ -704,6 +704,15 @@ class MySeller(ADCPHandler): return capabilities_response(["media_buy"], idempotency=idempotency.capability()) ``` +A wrapped method may hand off: a `TaskHandoff` or `WorkflowHandoff` +(`ctx.handoff_to_task(...)` / `ctx.handoff_to_workflow(...)`) is returned to +the dispatcher uncached, because it is a control marker rather than a +response. Nothing is stored for that key, so a retry with the same +`idempotency_key` runs the method again; make the work the handoff enqueues +deduplicate on the key. To keep other results out of the replay cache, pass a +predicate: `@idempotency.wrap(cache_response=lambda result: ...)` caches a +result only when it returns `True`. + For production, swap `MemoryBackend()` for `PgBackend` (note the import path — `PgBackend` lives in `adcp.server.idempotency`, not the top-level `adcp.server`): diff --git a/src/adcp/server/idempotency/store.py b/src/adcp/server/idempotency/store.py index 11c139b45e..f256280269 100644 --- a/src/adcp/server/idempotency/store.py +++ b/src/adcp/server/idempotency/store.py @@ -39,7 +39,7 @@ from contextlib import AbstractAsyncContextManager, asynccontextmanager from contextvars import ContextVar from functools import wraps -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, overload from pydantic import BaseModel @@ -123,6 +123,17 @@ def is_wrapped(fn: Any) -> bool: _MAX_TTL_SECONDS = 604800 HandlerFn = Callable[..., Awaitable[Any]] +#: Predicate deciding whether a completed handler result is cached for replay. +CacheResponsePredicate = Callable[[Any], bool] + + +def _is_handoff(response: Any) -> bool: + """A ``TaskHandoff`` / ``WorkflowHandoff`` control marker, not a response.""" + # Local import: the server middleware must not import the decisioning + # package at module import time. + from adcp.decisioning.types import is_task_handoff, is_workflow_handoff + + return is_task_handoff(response) or is_workflow_handoff(response) class IdempotencyStore: @@ -237,7 +248,22 @@ def reserve( operation=operation, ) - def wrap(self, handler: HandlerFn) -> HandlerFn: + @overload + def wrap( + self, handler: HandlerFn, *, cache_response: CacheResponsePredicate | None = None + ) -> HandlerFn: ... + + @overload + def wrap( + self, handler: None = None, *, cache_response: CacheResponsePredicate | None = None + ) -> Callable[[HandlerFn], HandlerFn]: ... + + def wrap( + self, + handler: HandlerFn | None = None, + *, + cache_response: CacheResponsePredicate | None = None, + ) -> HandlerFn | Callable[[HandlerFn], HandlerFn]: """Decorator that adds idempotency semantics to an AdCP handler method. Supports three calling conventions the framework dispatches with: @@ -265,7 +291,24 @@ def wrap(self, handler: HandlerFn) -> HandlerFn: re-validation on a hit (when the handler's declared return type exposes ``model_validate``). Callers that return raw dicts get dicts back. + + **Uncached results.** A :class:`~adcp.decisioning.TaskHandoff` or + :class:`~adcp.decisioning.WorkflowHandoff` is a control marker for + the dispatcher, not a response, so it is returned uncached: the + dispatcher allocates the task and owns what happens next. Use + ``@store.wrap(cache_response=predicate)`` to mark other results + as not cacheable; ``predicate(result)`` returning ``False`` + returns the result uncached (a handoff is never cached, whatever + the predicate says). An uncached result holds no replay entry, so + a retry with the same ``idempotency_key`` runs the method again: + deduplicate the work a handoff enqueues on that key. """ + if handler is None: + + def _decorate(fn: HandlerFn) -> HandlerFn: + return self.wrap(fn, cache_response=cache_response) + + return _decorate @wraps(handler) async def _wrapped(*args: Any, **kwargs: Any) -> Any: @@ -343,6 +386,16 @@ async def _execute_locked() -> Any: return _replay_cached(cached) response = await handler(*args, **kwargs) + if _is_handoff(response) or ( + cache_response is not None and not cache_response(response) + ): + logger.debug( + "idempotency: %s result not cached: scope=%s key_prefix=%s", + operation, + _scope_log_id(scope_key), + idempotency_key[:8], + ) + return response # Deep-copy when caching so post-return mutation of the caller's # copy can't poison future replays. `_clone_response` also deep- # copies on the hit path, giving independent objects per replay. diff --git a/tests/test_idempotency_handoff.py b/tests/test_idempotency_handoff.py new file mode 100644 index 0000000000..316af3a914 --- /dev/null +++ b/tests/test_idempotency_handoff.py @@ -0,0 +1,230 @@ +"""``IdempotencyStore.wrap`` returns task and workflow handoffs uncached (#1781). + +A handoff is a control marker for the dispatcher, not a response, so the +store cannot cache it. ``cache_response=`` lets a seller mark other results +as not cacheable. +""" + +from __future__ import annotations + +import secrets +from typing import Any + +import pytest + +from adcp.decisioning import DecisioningCapabilities, DecisioningPlatform, SingletonAccounts +from adcp.decisioning.types import TaskHandoff, WorkflowHandoff +from adcp.decisioning.validate_idempotency import is_wrapped +from adcp.server import IdempotencyStore, MemoryBackend, ToolContext +from adcp.testing import build_test_client +from adcp.types import CreateMediaBuySuccessResponse, Package + +CONTEXT = ToolContext(caller_identity="buyer_1") + + +def _params(key: str | None = None, **extra: Any) -> dict[str, Any]: + return {"idempotency_key": key or "k" * 16, "media_buy_id": "mb_1", **extra} + + +@pytest.mark.parametrize( + "make_handoff", + [lambda: WorkflowHandoff(lambda task_ctx: None), lambda: TaskHandoff(lambda task_ctx: {})], + ids=["workflow", "task"], +) +async def test_handoff_is_returned_uncached(make_handoff: Any) -> None: + store = IdempotencyStore(MemoryBackend()) + returned: list[Any] = [] + + class Platform: + @store.wrap + async def update_media_buy(self, params: Any, context: Any = None) -> Any: + handoff = make_handoff() + returned.append(handoff) + return handoff + + platform = Platform() + first = await platform.update_media_buy(_params(), CONTEXT) + assert first is returned[0] + # Nothing was cached, so a retry with the same key runs the method again + # rather than replaying a marker the dispatcher already consumed. + second = await platform.update_media_buy(_params(), CONTEXT) + assert second is returned[1] + assert len(returned) == 2 + + +async def test_a_completed_response_after_a_handoff_is_cached() -> None: + store = IdempotencyStore(MemoryBackend()) + calls = 0 + + class Platform: + @store.wrap + async def update_media_buy(self, params: Any, context: Any = None) -> Any: + nonlocal calls + calls += 1 + if calls == 1: + return WorkflowHandoff(lambda task_ctx: None) + return {"media_buy_id": "mb_1", "revision": calls} + + platform = Platform() + assert isinstance(await platform.update_media_buy(_params(), CONTEXT), WorkflowHandoff) + assert await platform.update_media_buy(_params(), CONTEXT) == { + "media_buy_id": "mb_1", + "revision": 2, + } + replay = await platform.update_media_buy(_params(), CONTEXT) + assert replay == {"media_buy_id": "mb_1", "revision": 2, "replayed": True} + assert calls == 2 + + +async def test_cache_response_predicate_marks_results_uncached() -> None: + store = IdempotencyStore(MemoryBackend()) + calls = 0 + + class Pending: + """A seller's own control result, not a wire response.""" + + class Platform: + @store.wrap(cache_response=lambda result: not isinstance(result, Pending)) + async def update_media_buy(self, params: Any, context: Any = None) -> Any: + nonlocal calls + calls += 1 + return Pending() if params.get("paused") else {"media_buy_id": "mb_1"} + + platform = Platform() + assert is_wrapped(platform.update_media_buy) + assert isinstance(await platform.update_media_buy(_params(paused=True), CONTEXT), Pending) + assert isinstance(await platform.update_media_buy(_params(paused=True), CONTEXT), Pending) + assert calls == 2 + + key = "c" * 16 + assert await platform.update_media_buy(_params(key), CONTEXT) == {"media_buy_id": "mb_1"} + assert (await platform.update_media_buy(_params(key), CONTEXT))["replayed"] is True + assert calls == 3 + + +async def test_predicate_cannot_cache_a_handoff() -> None: + store = IdempotencyStore(MemoryBackend()) + calls = 0 + + class Platform: + @store.wrap(cache_response=lambda result: True) + async def update_media_buy(self, params: Any, context: Any = None) -> Any: + nonlocal calls + calls += 1 + return WorkflowHandoff(lambda task_ctx: None) + + platform = Platform() + await platform.update_media_buy(_params(), CONTEXT) + await platform.update_media_buy(_params(), CONTEXT) + assert calls == 2 + + +async def test_uncached_result_still_checks_conflicts_against_cached_ones() -> None: + """A key first used for a cached response keeps its conflict detection.""" + from adcp.exceptions import IdempotencyConflictError + + store = IdempotencyStore(MemoryBackend()) + + class Platform: + @store.wrap + async def update_media_buy(self, params: Any, context: Any = None) -> Any: + if params.get("paused"): + return WorkflowHandoff(lambda task_ctx: None) + return {"media_buy_id": "mb_1"} + + platform = Platform() + await platform.update_media_buy(_params(), CONTEXT) + with pytest.raises(IdempotencyConflictError): + await platform.update_media_buy(_params(paused=True), CONTEXT) + + +# End to end: a decisioning method that may hand off to a workflow keeps the +# SDK's idempotency, and the buyer gets the framework's Submitted envelope. + +store = IdempotencyStore(backend=MemoryBackend(), ttl_seconds=86_400) + + +class Seller(DecisioningPlatform): + capabilities = DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], channels=["display"], pricing_models=["cpm"] + ) + accounts = SingletonAccounts(account_id="acct_1") + + def __init__(self) -> None: + self.enqueued: list[str] = [] + self.created = 0 + + @store.wrap + async def create_media_buy(self, req: Any, ctx: Any) -> Any: + if req.packages[0].budget > 5000: + # Needs a person: hand the call to an external review workflow. + return ctx.handoff_to_workflow(lambda task_ctx: self.enqueued.append(task_ctx.id)) + self.created += 1 + return CreateMediaBuySuccessResponse( + media_buy_id=f"mb_{self.created}", + confirmed_at="2026-05-01T00:00:00Z", + revision=1, + status="active", + packages=[Package(package_id="pkg_1", product_id="prod_1")], + ) + + def get_products(self, req: Any, ctx: Any) -> Any: + raise NotImplementedError + + def update_media_buy(self, media_buy_id: Any, patch: Any, ctx: Any) -> Any: + raise NotImplementedError + + def sync_creatives(self, req: Any, ctx: Any) -> Any: + raise NotImplementedError + + def get_media_buy_delivery(self, req: Any, ctx: Any) -> Any: + raise NotImplementedError + + def get_media_buys(self, req: Any, ctx: Any) -> Any: + raise NotImplementedError + + +def _create_request(key: str, budget: int) -> dict[str, Any]: + return { + "idempotency_key": key, + "account": {"account_id": "acct_1"}, + "brand": {"domain": "brand.example"}, + "start_time": "2026-10-01T00:00:00Z", + "end_time": "2026-10-31T00:00:00Z", + "packages": [{"product_id": "prod_1", "pricing_option_id": "cpm_1", "budget": budget}], + } + + +async def _create(client: Any, arguments: dict[str, Any]) -> dict[str, Any]: + response = await client.post( + "/mcp/", + headers={"accept": "application/json, text/event-stream"}, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "create_media_buy", "arguments": arguments}, + }, + ) + assert response.status_code == 200, response.text + return response.json()["result"]["structuredContent"] # type: ignore[no-any-return] + + +async def test_wrapped_decisioning_method_can_hand_off_to_a_workflow() -> None: + seller = Seller() + async with build_test_client( + seller, + transport="mcp", + stateless_http=True, + context_factory=lambda meta: ToolContext(caller_identity="buyer_1"), + ) as client: + submitted = await _create(client, _create_request(f"idem-{secrets.token_hex(8)}", 9000)) + assert submitted["status"] == "submitted", submitted + assert seller.enqueued == [submitted["task_id"]] + + # The synchronous path on the same wrapper still replays. + key = f"idem-{secrets.token_hex(8)}" + first = await _create(client, _create_request(key, 1000)) + replay = await _create(client, _create_request(key, 1000)) + assert first["media_buy_id"] == replay["media_buy_id"] == "mb_1" + assert seller.created == 1 From 7447d00ceba97490109c46f0cf2f0ca9d6f8e5f3 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 2 Nov 2026 00:28:27 -0500 Subject: [PATCH 11/28] fix(types): make the domain package roots lazy so a types package imports first in any order (#1798) Backport of the SDK 10 fix for #1784 to the 9.x line, where the cycle reproduces identically: ``from adcp.types.domains.formats import Slot`` raised ImportError when it was the first adcp import in a process, because the domain roots imported every schema module eagerly and ``formats/canonical/_base.py`` re-entered ``formats/canonical`` through ``core/__init__`` while half built. Each domain root is now a PEP 562 module: ``__all__`` and a TYPE_CHECKING import block keep the static surface, ``_DECLARED_IN`` names each name's schema module, and ``__getattr__`` imports the declaring module on first read and caches the binding. Nothing is renamed and every name still resolves. Co-authored-by: Claude Fable 5.1 --- scripts/consolidate_exports.py | 40 +- src/adcp/types/domains/a2ui/__init__.py | 132 +- src/adcp/types/domains/aao/__init__.py | 47 +- src/adcp/types/domains/account/__init__.py | 155 +- src/adcp/types/domains/brand/__init__.py | 304 +- src/adcp/types/domains/collection/__init__.py | 145 +- src/adcp/types/domains/compliance/__init__.py | 148 +- .../domains/content_standards/__init__.py | 245 +- src/adcp/types/domains/core/__init__.py | 4998 +++++++++++------ src/adcp/types/domains/creative/__init__.py | 417 +- src/adcp/types/domains/enums/__init__.py | 665 ++- .../types/domains/error_details/__init__.py | 228 +- src/adcp/types/domains/extensions/__init__.py | 34 +- src/adcp/types/domains/formats/__init__.py | 247 +- src/adcp/types/domains/governance/__init__.py | 332 +- src/adcp/types/domains/media_buy/__init__.py | 1458 +++-- .../types/domains/pricing_options/__init__.py | 79 +- src/adcp/types/domains/property/__init__.py | 172 +- src/adcp/types/domains/protocol/__init__.py | 585 +- src/adcp/types/domains/registries/__init__.py | 62 +- src/adcp/types/domains/signals/__init__.py | 145 +- .../sponsored_intelligence/__init__.py | 227 +- .../types/domains/trusted_match/__init__.py | 167 +- .../test_domain_roots_import_in_any_order.py | 91 + 24 files changed, 7479 insertions(+), 3644 deletions(-) create mode 100644 tests/test_domain_roots_import_in_any_order.py diff --git a/scripts/consolidate_exports.py b/scripts/consolidate_exports.py index fddc793c54..fd66ffe468 100644 --- a/scripts/consolidate_exports.py +++ b/scripts/consolidate_exports.py @@ -956,6 +956,12 @@ def generate_domain_exports( ] lines.extend( [ + "", + "This root is lazy (PEP 562): importing it, or a schema module under it,", + "does not import every schema in the domain. A name is bound from its", + "declaring module on first access and cached here. That is what keeps a", + "schema module's ``from ... import `` from re-entering a", + "half-initialized module through this package (#1784).", "", "Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY.", f"Generation date: {generation_date}", @@ -963,14 +969,46 @@ def generate_domain_exports( "# ruff: noqa: E501, I001", "from __future__ import annotations", "", + "from typing import TYPE_CHECKING", + "", + "if TYPE_CHECKING:", ] ) for module_name in sorted(by_module): lines.append( - f"from adcp.types.domains.{module_name} import " + f" from adcp.types.domains.{module_name} import " f"{', '.join(sorted(by_module[module_name]))}" ) lines.extend(_format_all_block(sorted(rows))) + lines.extend( + [ + "", + "#: The schema module that declares each name, for the lazy binding below.", + "_DECLARED_IN = {", + ] + ) + for type_name, module_name in sorted(rows.items()): + lines.append(f' "{type_name}": "adcp.types.domains.{module_name}",') + lines.extend( + [ + "}", + "", + "if not TYPE_CHECKING:", + " import importlib as _importlib", + "", + " def __getattr__(name: str) -> object:", + " module_name = _DECLARED_IN.get(name)", + " if module_name is None:", + " # Not a declared name: let the import system try a submodule, or fail.", + ' raise AttributeError(f"module {__name__!r} has no attribute {name!r}")', + " value = getattr(_importlib.import_module(module_name), name)", + " globals()[name] = value", + " return value", + "", + " def __dir__() -> list[str]:", + " return sorted(set(globals()) | set(__all__))", + ] + ) modules[domain] = "\n".join(lines) root = [ diff --git a/src/adcp/types/domains/a2ui/__init__.py b/src/adcp/types/domains/a2ui/__init__.py index 6645b8324b..32822c9147 100644 --- a/src/adcp/types/domains/a2ui/__init__.py +++ b/src/adcp/types/domains/a2ui/__init__.py @@ -9,48 +9,57 @@ it from its own schema's module, ``adcp.types.domains.a2ui.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.a2ui.bound_value import ( - A2UiBoundValue, - A2UiBoundValue1, - A2UiBoundValue2, - A2UiBoundValue3, - A2UiBoundValue4, - A2UiBoundValue5, -) -from adcp.types.domains.a2ui.component import A2UiComponent -from adcp.types.domains.a2ui.si_catalog import ( - Action23, - Align, - AppHandoff, - Apps, - Button, - Card, - Column, - Component, - Image, - IntegrationAction, - Justify, - Layout, - Link, - List, - ProductCard, - Row, - SiComponentCatalog, - Template, - Text, - Type, - Variant, - Variant4, -) -from adcp.types.domains.a2ui.surface import A2UiSurface -from adcp.types.domains.a2ui.user_action import A2UiUserAction +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.a2ui.bound_value import ( + A2UiBoundValue, + A2UiBoundValue1, + A2UiBoundValue2, + A2UiBoundValue3, + A2UiBoundValue4, + A2UiBoundValue5, + ) + from adcp.types.domains.a2ui.component import A2UiComponent + from adcp.types.domains.a2ui.si_catalog import ( + Action23, + Align, + AppHandoff, + Apps, + Button, + Card, + Column, + Component, + Image, + IntegrationAction, + Justify, + Layout, + Link, + List, + ProductCard, + Row, + SiComponentCatalog, + Template, + Text, + Type, + Variant, + Variant4, + ) + from adcp.types.domains.a2ui.surface import A2UiSurface + from adcp.types.domains.a2ui.user_action import A2UiUserAction # Explicit exports __all__ = [ @@ -86,3 +95,54 @@ "Variant", "Variant4", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "A2UiBoundValue": "adcp.types.domains.a2ui.bound_value", + "A2UiBoundValue1": "adcp.types.domains.a2ui.bound_value", + "A2UiBoundValue2": "adcp.types.domains.a2ui.bound_value", + "A2UiBoundValue3": "adcp.types.domains.a2ui.bound_value", + "A2UiBoundValue4": "adcp.types.domains.a2ui.bound_value", + "A2UiBoundValue5": "adcp.types.domains.a2ui.bound_value", + "A2UiComponent": "adcp.types.domains.a2ui.component", + "A2UiSurface": "adcp.types.domains.a2ui.surface", + "A2UiUserAction": "adcp.types.domains.a2ui.user_action", + "Action23": "adcp.types.domains.a2ui.si_catalog", + "Align": "adcp.types.domains.a2ui.si_catalog", + "AppHandoff": "adcp.types.domains.a2ui.si_catalog", + "Apps": "adcp.types.domains.a2ui.si_catalog", + "Button": "adcp.types.domains.a2ui.si_catalog", + "Card": "adcp.types.domains.a2ui.si_catalog", + "Column": "adcp.types.domains.a2ui.si_catalog", + "Component": "adcp.types.domains.a2ui.si_catalog", + "Image": "adcp.types.domains.a2ui.si_catalog", + "IntegrationAction": "adcp.types.domains.a2ui.si_catalog", + "Justify": "adcp.types.domains.a2ui.si_catalog", + "Layout": "adcp.types.domains.a2ui.si_catalog", + "Link": "adcp.types.domains.a2ui.si_catalog", + "List": "adcp.types.domains.a2ui.si_catalog", + "ProductCard": "adcp.types.domains.a2ui.si_catalog", + "Row": "adcp.types.domains.a2ui.si_catalog", + "SiComponentCatalog": "adcp.types.domains.a2ui.si_catalog", + "Template": "adcp.types.domains.a2ui.si_catalog", + "Text": "adcp.types.domains.a2ui.si_catalog", + "Type": "adcp.types.domains.a2ui.si_catalog", + "Variant": "adcp.types.domains.a2ui.si_catalog", + "Variant4": "adcp.types.domains.a2ui.si_catalog", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/aao/__init__.py b/src/adcp/types/domains/aao/__init__.py index d084865886..01d2314818 100644 --- a/src/adcp/types/domains/aao/__init__.py +++ b/src/adcp/types/domains/aao/__init__.py @@ -9,19 +9,28 @@ it from its own schema's module, ``adcp.types.domains.aao.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.aao.agent_publishers import ( - AaoDirectoryAgentPublishersInverseLookupResponse, - DiscoveryMethod, - PublisherEntry, - Status, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.aao.agent_publishers import ( + AaoDirectoryAgentPublishersInverseLookupResponse, + DiscoveryMethod, + PublisherEntry, + Status, + ) # Explicit exports __all__ = [ @@ -30,3 +39,27 @@ "PublisherEntry", "Status", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "AaoDirectoryAgentPublishersInverseLookupResponse": "adcp.types.domains.aao.agent_publishers", + "DiscoveryMethod": "adcp.types.domains.aao.agent_publishers", + "PublisherEntry": "adcp.types.domains.aao.agent_publishers", + "Status": "adcp.types.domains.aao.agent_publishers", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/account/__init__.py b/src/adcp/types/domains/account/__init__.py index 0d3483af03..60beabfbf6 100644 --- a/src/adcp/types/domains/account/__init__.py +++ b/src/adcp/types/domains/account/__init__.py @@ -9,56 +9,70 @@ it from its own schema's module, ``adcp.types.domains.account.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.account.get_account_financials_request import GetAccountFinancialsRequest -from adcp.types.domains.account.get_account_financials_response import ( - Balance, - Credit, - GetAccountFinancialsResponse, - GetAccountFinancialsResponse1, - GetAccountFinancialsResponse2, - Invoice, - LastTopUp, - Spend, -) -from adcp.types.domains.account.list_account_changes_request import ( - ListAccountChangesRequest, - StartingPosition, -) -from adcp.types.domains.account.list_account_changes_response import ( - Kind, - ListAccountChangesResponse, - SourceCoverageItem, - Status22, -) -from adcp.types.domains.account.list_accounts_request import ListAccountsRequest -from adcp.types.domains.account.list_accounts_response import ListAccountsResponse -from adcp.types.domains.account.report_usage_request import ReportUsageRequest, UsageItem -from adcp.types.domains.account.report_usage_response import ReportUsageResponse -from adcp.types.domains.account.sync_accounts_request import ( - Accounts, - Accounts1, - SyncAccountsRequest, -) -from adcp.types.domains.account.sync_accounts_response import ( - CreditLimit, - Setup, - SyncAccountsResponse, - SyncAccountsResponse1, - SyncAccountsResponse2, -) -from adcp.types.domains.account.sync_governance_request import Authentication, SyncGovernanceRequest -from adcp.types.domains.account.sync_governance_response import ( - SyncGovernanceResponse, - SyncGovernanceResponse1, - SyncGovernanceResponse2, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.account.get_account_financials_request import ( + GetAccountFinancialsRequest, + ) + from adcp.types.domains.account.get_account_financials_response import ( + Balance, + Credit, + GetAccountFinancialsResponse, + GetAccountFinancialsResponse1, + GetAccountFinancialsResponse2, + Invoice, + LastTopUp, + Spend, + ) + from adcp.types.domains.account.list_account_changes_request import ( + ListAccountChangesRequest, + StartingPosition, + ) + from adcp.types.domains.account.list_account_changes_response import ( + Kind, + ListAccountChangesResponse, + SourceCoverageItem, + Status22, + ) + from adcp.types.domains.account.list_accounts_request import ListAccountsRequest + from adcp.types.domains.account.list_accounts_response import ListAccountsResponse + from adcp.types.domains.account.report_usage_request import ReportUsageRequest, UsageItem + from adcp.types.domains.account.report_usage_response import ReportUsageResponse + from adcp.types.domains.account.sync_accounts_request import ( + Accounts, + Accounts1, + SyncAccountsRequest, + ) + from adcp.types.domains.account.sync_accounts_response import ( + CreditLimit, + Setup, + SyncAccountsResponse, + SyncAccountsResponse1, + SyncAccountsResponse2, + ) + from adcp.types.domains.account.sync_governance_request import ( + Authentication, + SyncGovernanceRequest, + ) + from adcp.types.domains.account.sync_governance_response import ( + SyncGovernanceResponse, + SyncGovernanceResponse1, + SyncGovernanceResponse2, + ) # Explicit exports __all__ = [ @@ -96,3 +110,56 @@ "SyncGovernanceResponse2", "UsageItem", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "Accounts": "adcp.types.domains.account.sync_accounts_request", + "Accounts1": "adcp.types.domains.account.sync_accounts_request", + "Authentication": "adcp.types.domains.account.sync_governance_request", + "Balance": "adcp.types.domains.account.get_account_financials_response", + "Credit": "adcp.types.domains.account.get_account_financials_response", + "CreditLimit": "adcp.types.domains.account.sync_accounts_response", + "GetAccountFinancialsRequest": "adcp.types.domains.account.get_account_financials_request", + "GetAccountFinancialsResponse": "adcp.types.domains.account.get_account_financials_response", + "GetAccountFinancialsResponse1": "adcp.types.domains.account.get_account_financials_response", + "GetAccountFinancialsResponse2": "adcp.types.domains.account.get_account_financials_response", + "Invoice": "adcp.types.domains.account.get_account_financials_response", + "Kind": "adcp.types.domains.account.list_account_changes_response", + "LastTopUp": "adcp.types.domains.account.get_account_financials_response", + "ListAccountChangesRequest": "adcp.types.domains.account.list_account_changes_request", + "ListAccountChangesResponse": "adcp.types.domains.account.list_account_changes_response", + "ListAccountsRequest": "adcp.types.domains.account.list_accounts_request", + "ListAccountsResponse": "adcp.types.domains.account.list_accounts_response", + "ReportUsageRequest": "adcp.types.domains.account.report_usage_request", + "ReportUsageResponse": "adcp.types.domains.account.report_usage_response", + "Setup": "adcp.types.domains.account.sync_accounts_response", + "SourceCoverageItem": "adcp.types.domains.account.list_account_changes_response", + "Spend": "adcp.types.domains.account.get_account_financials_response", + "StartingPosition": "adcp.types.domains.account.list_account_changes_request", + "Status22": "adcp.types.domains.account.list_account_changes_response", + "SyncAccountsRequest": "adcp.types.domains.account.sync_accounts_request", + "SyncAccountsResponse": "adcp.types.domains.account.sync_accounts_response", + "SyncAccountsResponse1": "adcp.types.domains.account.sync_accounts_response", + "SyncAccountsResponse2": "adcp.types.domains.account.sync_accounts_response", + "SyncGovernanceRequest": "adcp.types.domains.account.sync_governance_request", + "SyncGovernanceResponse": "adcp.types.domains.account.sync_governance_response", + "SyncGovernanceResponse1": "adcp.types.domains.account.sync_governance_response", + "SyncGovernanceResponse2": "adcp.types.domains.account.sync_governance_response", + "UsageItem": "adcp.types.domains.account.report_usage_request", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/brand/__init__.py b/src/adcp/types/domains/brand/__init__.py index a37d39fdc8..0266066790 100644 --- a/src/adcp/types/domains/brand/__init__.py +++ b/src/adcp/types/domains/brand/__init__.py @@ -9,109 +9,118 @@ it from its own schema's module, ``adcp.types.domains.brand.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.brand.acquire_rights_request import AcquireRightsRequest, Campaign -from adcp.types.domains.brand.acquire_rights_response import ( - AcquireRightsResponse, - AcquireRightsResponse1, - AcquireRightsResponse2, - AcquireRightsResponse3, - AcquireRightsResponse4, - Disclosure, -) -from adcp.types.domains.brand.creative_approval_request import CreativeApprovalRequest -from adcp.types.domains.brand.creative_approval_response import ( - CreativeApprovalResponse, - CreativeApprovalResponse1, - CreativeApprovalResponse2, - CreativeApprovalResponse3, - CreativeApprovalResponse4, -) -from adcp.types.domains.brand.get_brand_identity_request import Field1, GetBrandIdentityRequest -from adcp.types.domains.brand.get_brand_identity_response import ( - Asset, - Colors, - File, - FontRole2, - Fonts, - GetBrandIdentityResponse, - GetBrandIdentityResponse1, - GetBrandIdentityResponse2, - Tone, - VoiceSynthesis, -) -from adcp.types.domains.brand.get_rights_request import GetRightsRequest -from adcp.types.domains.brand.get_rights_response import ( - Excluded, - ExclusivityStatus, - GetRightsResponse, - GetRightsResponse1, - GetRightsResponse2, - PreviewAsset, - Right, -) -from adcp.types.domains.brand.revocation_notification import RevocationNotification -from adcp.types.domains.brand.rights_pricing_option import RightsPricingOption -from adcp.types.domains.brand.rights_terms import Exclusivity, RightsTerms -from adcp.types.domains.brand.search_brands_request import SearchBrandsRequest -from adcp.types.domains.brand.search_brands_response import ( - Background, - ExcludedCountry, - KellerType, - Orientation, - RelationshipTrust, - SearchBrandResult, - SearchBrandsResponse, - Variant, -) -from adcp.types.domains.brand.update_rights_request import UpdateRightsRequest -from adcp.types.domains.brand.update_rights_response import ( - UpdateRightsResponse, - UpdateRightsResponse1, - UpdateRightsResponse2, -) -from adcp.types.domains.brand.verification_status import VerificationStatus -from adcp.types.domains.brand.verify_brand_claim_request import VerifyBrandClaimRequest -from adcp.types.domains.brand.verify_brand_claim_response import ( - VerifyBrandClaimErrorResponse, - VerifyBrandClaimPayload, - VerifyBrandClaimResponse, - VerifyBrandClaimSignedResponse, - VerifyBrandClaimSignedSuccessPayload, - VerifyBrandClaimSuccessResponse, -) -from adcp.types.domains.brand.verify_brand_claims_request import ( - Claim, - Claim1, - Claim2, - Claim3, - ClaimEntry, - ClaimEntry1, - ClaimEntry2, - ClaimEntry3, - ClaimEntry4, - Property, - Store, - VerifyBrandClaimsRequest, - VerifyBrandClaimsRequestBulk, -) -from adcp.types.domains.brand.verify_brand_claims_response import ( - ResultEntry, - ResultEntry1, - ResultEntry2, - VerifyBrandClaimsErrorResponse, - VerifyBrandClaimsPayload, - VerifyBrandClaimsResponse, - VerifyBrandClaimsResponseBulk, - VerifyBrandClaimsSignedResponse, - VerifyBrandClaimsSignedSuccessPayload, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.brand.acquire_rights_request import AcquireRightsRequest, Campaign + from adcp.types.domains.brand.acquire_rights_response import ( + AcquireRightsResponse, + AcquireRightsResponse1, + AcquireRightsResponse2, + AcquireRightsResponse3, + AcquireRightsResponse4, + Disclosure, + ) + from adcp.types.domains.brand.creative_approval_request import CreativeApprovalRequest + from adcp.types.domains.brand.creative_approval_response import ( + CreativeApprovalResponse, + CreativeApprovalResponse1, + CreativeApprovalResponse2, + CreativeApprovalResponse3, + CreativeApprovalResponse4, + ) + from adcp.types.domains.brand.get_brand_identity_request import Field1, GetBrandIdentityRequest + from adcp.types.domains.brand.get_brand_identity_response import ( + Asset, + Colors, + File, + FontRole2, + Fonts, + GetBrandIdentityResponse, + GetBrandIdentityResponse1, + GetBrandIdentityResponse2, + Tone, + VoiceSynthesis, + ) + from adcp.types.domains.brand.get_rights_request import GetRightsRequest + from adcp.types.domains.brand.get_rights_response import ( + Excluded, + ExclusivityStatus, + GetRightsResponse, + GetRightsResponse1, + GetRightsResponse2, + PreviewAsset, + Right, + ) + from adcp.types.domains.brand.revocation_notification import RevocationNotification + from adcp.types.domains.brand.rights_pricing_option import RightsPricingOption + from adcp.types.domains.brand.rights_terms import Exclusivity, RightsTerms + from adcp.types.domains.brand.search_brands_request import SearchBrandsRequest + from adcp.types.domains.brand.search_brands_response import ( + Background, + ExcludedCountry, + KellerType, + Orientation, + RelationshipTrust, + SearchBrandResult, + SearchBrandsResponse, + Variant, + ) + from adcp.types.domains.brand.update_rights_request import UpdateRightsRequest + from adcp.types.domains.brand.update_rights_response import ( + UpdateRightsResponse, + UpdateRightsResponse1, + UpdateRightsResponse2, + ) + from adcp.types.domains.brand.verification_status import VerificationStatus + from adcp.types.domains.brand.verify_brand_claim_request import VerifyBrandClaimRequest + from adcp.types.domains.brand.verify_brand_claim_response import ( + VerifyBrandClaimErrorResponse, + VerifyBrandClaimPayload, + VerifyBrandClaimResponse, + VerifyBrandClaimSignedResponse, + VerifyBrandClaimSignedSuccessPayload, + VerifyBrandClaimSuccessResponse, + ) + from adcp.types.domains.brand.verify_brand_claims_request import ( + Claim, + Claim1, + Claim2, + Claim3, + ClaimEntry, + ClaimEntry1, + ClaimEntry2, + ClaimEntry3, + ClaimEntry4, + Property, + Store, + VerifyBrandClaimsRequest, + VerifyBrandClaimsRequestBulk, + ) + from adcp.types.domains.brand.verify_brand_claims_response import ( + ResultEntry, + ResultEntry1, + ResultEntry2, + VerifyBrandClaimsErrorResponse, + VerifyBrandClaimsPayload, + VerifyBrandClaimsResponse, + VerifyBrandClaimsResponseBulk, + VerifyBrandClaimsSignedResponse, + VerifyBrandClaimsSignedSuccessPayload, + ) # Explicit exports __all__ = [ @@ -197,3 +206,104 @@ "VerifyBrandClaimsSignedSuccessPayload", "VoiceSynthesis", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "AcquireRightsRequest": "adcp.types.domains.brand.acquire_rights_request", + "AcquireRightsResponse": "adcp.types.domains.brand.acquire_rights_response", + "AcquireRightsResponse1": "adcp.types.domains.brand.acquire_rights_response", + "AcquireRightsResponse2": "adcp.types.domains.brand.acquire_rights_response", + "AcquireRightsResponse3": "adcp.types.domains.brand.acquire_rights_response", + "AcquireRightsResponse4": "adcp.types.domains.brand.acquire_rights_response", + "Asset": "adcp.types.domains.brand.get_brand_identity_response", + "Background": "adcp.types.domains.brand.search_brands_response", + "Campaign": "adcp.types.domains.brand.acquire_rights_request", + "Claim": "adcp.types.domains.brand.verify_brand_claims_request", + "Claim1": "adcp.types.domains.brand.verify_brand_claims_request", + "Claim2": "adcp.types.domains.brand.verify_brand_claims_request", + "Claim3": "adcp.types.domains.brand.verify_brand_claims_request", + "ClaimEntry": "adcp.types.domains.brand.verify_brand_claims_request", + "ClaimEntry1": "adcp.types.domains.brand.verify_brand_claims_request", + "ClaimEntry2": "adcp.types.domains.brand.verify_brand_claims_request", + "ClaimEntry3": "adcp.types.domains.brand.verify_brand_claims_request", + "ClaimEntry4": "adcp.types.domains.brand.verify_brand_claims_request", + "Colors": "adcp.types.domains.brand.get_brand_identity_response", + "CreativeApprovalRequest": "adcp.types.domains.brand.creative_approval_request", + "CreativeApprovalResponse": "adcp.types.domains.brand.creative_approval_response", + "CreativeApprovalResponse1": "adcp.types.domains.brand.creative_approval_response", + "CreativeApprovalResponse2": "adcp.types.domains.brand.creative_approval_response", + "CreativeApprovalResponse3": "adcp.types.domains.brand.creative_approval_response", + "CreativeApprovalResponse4": "adcp.types.domains.brand.creative_approval_response", + "Disclosure": "adcp.types.domains.brand.acquire_rights_response", + "Excluded": "adcp.types.domains.brand.get_rights_response", + "ExcludedCountry": "adcp.types.domains.brand.search_brands_response", + "Exclusivity": "adcp.types.domains.brand.rights_terms", + "ExclusivityStatus": "adcp.types.domains.brand.get_rights_response", + "Field1": "adcp.types.domains.brand.get_brand_identity_request", + "File": "adcp.types.domains.brand.get_brand_identity_response", + "FontRole2": "adcp.types.domains.brand.get_brand_identity_response", + "Fonts": "adcp.types.domains.brand.get_brand_identity_response", + "GetBrandIdentityRequest": "adcp.types.domains.brand.get_brand_identity_request", + "GetBrandIdentityResponse": "adcp.types.domains.brand.get_brand_identity_response", + "GetBrandIdentityResponse1": "adcp.types.domains.brand.get_brand_identity_response", + "GetBrandIdentityResponse2": "adcp.types.domains.brand.get_brand_identity_response", + "GetRightsRequest": "adcp.types.domains.brand.get_rights_request", + "GetRightsResponse": "adcp.types.domains.brand.get_rights_response", + "GetRightsResponse1": "adcp.types.domains.brand.get_rights_response", + "GetRightsResponse2": "adcp.types.domains.brand.get_rights_response", + "KellerType": "adcp.types.domains.brand.search_brands_response", + "Orientation": "adcp.types.domains.brand.search_brands_response", + "PreviewAsset": "adcp.types.domains.brand.get_rights_response", + "Property": "adcp.types.domains.brand.verify_brand_claims_request", + "RelationshipTrust": "adcp.types.domains.brand.search_brands_response", + "ResultEntry": "adcp.types.domains.brand.verify_brand_claims_response", + "ResultEntry1": "adcp.types.domains.brand.verify_brand_claims_response", + "ResultEntry2": "adcp.types.domains.brand.verify_brand_claims_response", + "RevocationNotification": "adcp.types.domains.brand.revocation_notification", + "Right": "adcp.types.domains.brand.get_rights_response", + "RightsPricingOption": "adcp.types.domains.brand.rights_pricing_option", + "RightsTerms": "adcp.types.domains.brand.rights_terms", + "SearchBrandResult": "adcp.types.domains.brand.search_brands_response", + "SearchBrandsRequest": "adcp.types.domains.brand.search_brands_request", + "SearchBrandsResponse": "adcp.types.domains.brand.search_brands_response", + "Store": "adcp.types.domains.brand.verify_brand_claims_request", + "Tone": "adcp.types.domains.brand.get_brand_identity_response", + "UpdateRightsRequest": "adcp.types.domains.brand.update_rights_request", + "UpdateRightsResponse": "adcp.types.domains.brand.update_rights_response", + "UpdateRightsResponse1": "adcp.types.domains.brand.update_rights_response", + "UpdateRightsResponse2": "adcp.types.domains.brand.update_rights_response", + "Variant": "adcp.types.domains.brand.search_brands_response", + "VerificationStatus": "adcp.types.domains.brand.verification_status", + "VerifyBrandClaimErrorResponse": "adcp.types.domains.brand.verify_brand_claim_response", + "VerifyBrandClaimPayload": "adcp.types.domains.brand.verify_brand_claim_response", + "VerifyBrandClaimRequest": "adcp.types.domains.brand.verify_brand_claim_request", + "VerifyBrandClaimResponse": "adcp.types.domains.brand.verify_brand_claim_response", + "VerifyBrandClaimSignedResponse": "adcp.types.domains.brand.verify_brand_claim_response", + "VerifyBrandClaimSignedSuccessPayload": "adcp.types.domains.brand.verify_brand_claim_response", + "VerifyBrandClaimSuccessResponse": "adcp.types.domains.brand.verify_brand_claim_response", + "VerifyBrandClaimsErrorResponse": "adcp.types.domains.brand.verify_brand_claims_response", + "VerifyBrandClaimsPayload": "adcp.types.domains.brand.verify_brand_claims_response", + "VerifyBrandClaimsRequest": "adcp.types.domains.brand.verify_brand_claims_request", + "VerifyBrandClaimsRequestBulk": "adcp.types.domains.brand.verify_brand_claims_request", + "VerifyBrandClaimsResponse": "adcp.types.domains.brand.verify_brand_claims_response", + "VerifyBrandClaimsResponseBulk": "adcp.types.domains.brand.verify_brand_claims_response", + "VerifyBrandClaimsSignedResponse": "adcp.types.domains.brand.verify_brand_claims_response", + "VerifyBrandClaimsSignedSuccessPayload": "adcp.types.domains.brand.verify_brand_claims_response", + "VoiceSynthesis": "adcp.types.domains.brand.get_brand_identity_response", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/collection/__init__.py b/src/adcp/types/domains/collection/__init__.py index cde3db016b..6c146d79c4 100644 --- a/src/adcp/types/domains/collection/__init__.py +++ b/src/adcp/types/domains/collection/__init__.py @@ -9,53 +9,72 @@ it from its own schema's module, ``adcp.types.domains.collection.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.collection.base_collection_source import ( - BaseCollectionSource, - BaseCollectionSource1, - BaseCollectionSource2, - BaseCollectionSource3, - Identifier, -) -from adcp.types.domains.collection.collection_list import CollectionList -from adcp.types.domains.collection.collection_list_changed_webhook import ( - ChangeSummary, - CollectionListChangedWebhook, -) -from adcp.types.domains.collection.collection_list_filters import ( - CollectionListFilters, - ExcludeDistributionId, -) -from adcp.types.domains.collection.create_collection_list_request import CreateCollectionListRequest -from adcp.types.domains.collection.create_collection_list_response import ( - CreateCollectionListResponse, -) -from adcp.types.domains.collection.delete_collection_list_request import DeleteCollectionListRequest -from adcp.types.domains.collection.delete_collection_list_response import ( - DeleteCollectionListResponse, -) -from adcp.types.domains.collection.get_collection_list_request import ( - GetCollectionListRequest, - Pagination, -) -from adcp.types.domains.collection.get_collection_list_response import ( - Collection, - CoverageGap, - DistributionId, - GetCollectionListResponse, -) -from adcp.types.domains.collection.list_collection_lists_request import ListCollectionListsRequest -from adcp.types.domains.collection.list_collection_lists_response import ListCollectionListsResponse -from adcp.types.domains.collection.update_collection_list_request import UpdateCollectionListRequest -from adcp.types.domains.collection.update_collection_list_response import ( - UpdateCollectionListResponse, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.collection.base_collection_source import ( + BaseCollectionSource, + BaseCollectionSource1, + BaseCollectionSource2, + BaseCollectionSource3, + Identifier, + ) + from adcp.types.domains.collection.collection_list import CollectionList + from adcp.types.domains.collection.collection_list_changed_webhook import ( + ChangeSummary, + CollectionListChangedWebhook, + ) + from adcp.types.domains.collection.collection_list_filters import ( + CollectionListFilters, + ExcludeDistributionId, + ) + from adcp.types.domains.collection.create_collection_list_request import ( + CreateCollectionListRequest, + ) + from adcp.types.domains.collection.create_collection_list_response import ( + CreateCollectionListResponse, + ) + from adcp.types.domains.collection.delete_collection_list_request import ( + DeleteCollectionListRequest, + ) + from adcp.types.domains.collection.delete_collection_list_response import ( + DeleteCollectionListResponse, + ) + from adcp.types.domains.collection.get_collection_list_request import ( + GetCollectionListRequest, + Pagination, + ) + from adcp.types.domains.collection.get_collection_list_response import ( + Collection, + CoverageGap, + DistributionId, + GetCollectionListResponse, + ) + from adcp.types.domains.collection.list_collection_lists_request import ( + ListCollectionListsRequest, + ) + from adcp.types.domains.collection.list_collection_lists_response import ( + ListCollectionListsResponse, + ) + from adcp.types.domains.collection.update_collection_list_request import ( + UpdateCollectionListRequest, + ) + from adcp.types.domains.collection.update_collection_list_response import ( + UpdateCollectionListResponse, + ) # Explicit exports __all__ = [ @@ -84,3 +103,47 @@ "UpdateCollectionListRequest", "UpdateCollectionListResponse", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "BaseCollectionSource": "adcp.types.domains.collection.base_collection_source", + "BaseCollectionSource1": "adcp.types.domains.collection.base_collection_source", + "BaseCollectionSource2": "adcp.types.domains.collection.base_collection_source", + "BaseCollectionSource3": "adcp.types.domains.collection.base_collection_source", + "ChangeSummary": "adcp.types.domains.collection.collection_list_changed_webhook", + "Collection": "adcp.types.domains.collection.get_collection_list_response", + "CollectionList": "adcp.types.domains.collection.collection_list", + "CollectionListChangedWebhook": "adcp.types.domains.collection.collection_list_changed_webhook", + "CollectionListFilters": "adcp.types.domains.collection.collection_list_filters", + "CoverageGap": "adcp.types.domains.collection.get_collection_list_response", + "CreateCollectionListRequest": "adcp.types.domains.collection.create_collection_list_request", + "CreateCollectionListResponse": "adcp.types.domains.collection.create_collection_list_response", + "DeleteCollectionListRequest": "adcp.types.domains.collection.delete_collection_list_request", + "DeleteCollectionListResponse": "adcp.types.domains.collection.delete_collection_list_response", + "DistributionId": "adcp.types.domains.collection.get_collection_list_response", + "ExcludeDistributionId": "adcp.types.domains.collection.collection_list_filters", + "GetCollectionListRequest": "adcp.types.domains.collection.get_collection_list_request", + "GetCollectionListResponse": "adcp.types.domains.collection.get_collection_list_response", + "Identifier": "adcp.types.domains.collection.base_collection_source", + "ListCollectionListsRequest": "adcp.types.domains.collection.list_collection_lists_request", + "ListCollectionListsResponse": "adcp.types.domains.collection.list_collection_lists_response", + "Pagination": "adcp.types.domains.collection.get_collection_list_request", + "UpdateCollectionListRequest": "adcp.types.domains.collection.update_collection_list_request", + "UpdateCollectionListResponse": "adcp.types.domains.collection.update_collection_list_response", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/compliance/__init__.py b/src/adcp/types/domains/compliance/__init__.py index 63727ff151..b969a6e751 100644 --- a/src/adcp/types/domains/compliance/__init__.py +++ b/src/adcp/types/domains/compliance/__init__.py @@ -9,54 +9,63 @@ it from its own schema's module, ``adcp.types.domains.compliance.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.compliance.comply_test_controller_request import ( - Account, - AdvanceTo, - Arm, - ComplyTestControllerRequest, - Kind, - Metric, - NotYetMeasurableVendorMetric, - NotYetMeasurableVendorMetricsByPackageItem, - Operation, - Params, - PurgeKind, - ReachWindow, - ReportedSpend, - TargetHealth, -) -from adcp.types.domains.compliance.comply_test_controller_response import ( - AttestationMode, - ComplyResponseArm, - ComplyTestControllerResponse, - ComplyTestControllerResponse1, - ComplyTestControllerResponse2, - ComplyTestControllerResponse3, - ComplyTestControllerResponse4, - ComplyTestControllerResponse5, - ComplyTestControllerResponse6, - ComplyTestControllerResponse7, - ComplyTestControllerResponse8, - Error, - Forced, - IdentifierMatchProof, - Method, - Purpose, - RecordedCalls, - RecordedCalls1, - RecordedCalls2, -) -from adcp.types.domains.compliance.get_creative_features_completion import ( - GetCreativeFeaturesComplianceCompletion, -) -from adcp.types.domains.compliance.task_completion_data import ComplianceTaskCompletionData +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.compliance.comply_test_controller_request import ( + Account, + AdvanceTo, + Arm, + ComplyTestControllerRequest, + Kind, + Metric, + NotYetMeasurableVendorMetric, + NotYetMeasurableVendorMetricsByPackageItem, + Operation, + Params, + PurgeKind, + ReachWindow, + ReportedSpend, + TargetHealth, + ) + from adcp.types.domains.compliance.comply_test_controller_response import ( + AttestationMode, + ComplyResponseArm, + ComplyTestControllerResponse, + ComplyTestControllerResponse1, + ComplyTestControllerResponse2, + ComplyTestControllerResponse3, + ComplyTestControllerResponse4, + ComplyTestControllerResponse5, + ComplyTestControllerResponse6, + ComplyTestControllerResponse7, + ComplyTestControllerResponse8, + Error, + Forced, + IdentifierMatchProof, + Method, + Purpose, + RecordedCalls, + RecordedCalls1, + RecordedCalls2, + ) + from adcp.types.domains.compliance.get_creative_features_completion import ( + GetCreativeFeaturesComplianceCompletion, + ) + from adcp.types.domains.compliance.task_completion_data import ComplianceTaskCompletionData # Explicit exports __all__ = [ @@ -96,3 +105,58 @@ "ReportedSpend", "TargetHealth", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "Account": "adcp.types.domains.compliance.comply_test_controller_request", + "AdvanceTo": "adcp.types.domains.compliance.comply_test_controller_request", + "Arm": "adcp.types.domains.compliance.comply_test_controller_request", + "AttestationMode": "adcp.types.domains.compliance.comply_test_controller_response", + "ComplianceTaskCompletionData": "adcp.types.domains.compliance.task_completion_data", + "ComplyResponseArm": "adcp.types.domains.compliance.comply_test_controller_response", + "ComplyTestControllerRequest": "adcp.types.domains.compliance.comply_test_controller_request", + "ComplyTestControllerResponse": "adcp.types.domains.compliance.comply_test_controller_response", + "ComplyTestControllerResponse1": "adcp.types.domains.compliance.comply_test_controller_response", + "ComplyTestControllerResponse2": "adcp.types.domains.compliance.comply_test_controller_response", + "ComplyTestControllerResponse3": "adcp.types.domains.compliance.comply_test_controller_response", + "ComplyTestControllerResponse4": "adcp.types.domains.compliance.comply_test_controller_response", + "ComplyTestControllerResponse5": "adcp.types.domains.compliance.comply_test_controller_response", + "ComplyTestControllerResponse6": "adcp.types.domains.compliance.comply_test_controller_response", + "ComplyTestControllerResponse7": "adcp.types.domains.compliance.comply_test_controller_response", + "ComplyTestControllerResponse8": "adcp.types.domains.compliance.comply_test_controller_response", + "Error": "adcp.types.domains.compliance.comply_test_controller_response", + "Forced": "adcp.types.domains.compliance.comply_test_controller_response", + "GetCreativeFeaturesComplianceCompletion": "adcp.types.domains.compliance.get_creative_features_completion", + "IdentifierMatchProof": "adcp.types.domains.compliance.comply_test_controller_response", + "Kind": "adcp.types.domains.compliance.comply_test_controller_request", + "Method": "adcp.types.domains.compliance.comply_test_controller_response", + "Metric": "adcp.types.domains.compliance.comply_test_controller_request", + "NotYetMeasurableVendorMetric": "adcp.types.domains.compliance.comply_test_controller_request", + "NotYetMeasurableVendorMetricsByPackageItem": "adcp.types.domains.compliance.comply_test_controller_request", + "Operation": "adcp.types.domains.compliance.comply_test_controller_request", + "Params": "adcp.types.domains.compliance.comply_test_controller_request", + "PurgeKind": "adcp.types.domains.compliance.comply_test_controller_request", + "Purpose": "adcp.types.domains.compliance.comply_test_controller_response", + "ReachWindow": "adcp.types.domains.compliance.comply_test_controller_request", + "RecordedCalls": "adcp.types.domains.compliance.comply_test_controller_response", + "RecordedCalls1": "adcp.types.domains.compliance.comply_test_controller_response", + "RecordedCalls2": "adcp.types.domains.compliance.comply_test_controller_response", + "ReportedSpend": "adcp.types.domains.compliance.comply_test_controller_request", + "TargetHealth": "adcp.types.domains.compliance.comply_test_controller_request", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/content_standards/__init__.py b/src/adcp/types/domains/content_standards/__init__.py index aaf3561dec..e546e021f6 100644 --- a/src/adcp/types/domains/content_standards/__init__.py +++ b/src/adcp/types/domains/content_standards/__init__.py @@ -9,93 +9,104 @@ it from its own schema's module, ``adcp.types.domains.content_standards.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.content_standards.artifact import ( - AssetAccess, - AssetAccess1, - AssetAccess2, - AssetAccess3, - Assets, - Assets1, - Assets2, - Assets3, - Assets4, - ContentFormat, - Identifiers, - Metadata, - Provider, - Role, - TranscriptFormat, - TranscriptSource, - TranscriptSource1, -) -from adcp.types.domains.content_standards.artifact_webhook_payload import ArtifactWebhookPayload -from adcp.types.domains.content_standards.calibrate_content_request import CalibrateContentRequest -from adcp.types.domains.content_standards.calibrate_content_response import ( - CalibrateContentResponse, - CalibrateContentResponse1, - CalibrateContentResponse2, -) -from adcp.types.domains.content_standards.content_standards import ContentStandards -from adcp.types.domains.content_standards.create_content_standards_request import ( - CreateContentStandardsRequest, -) -from adcp.types.domains.content_standards.create_content_standards_response import ( - CreateContentStandardsResponse, - CreateContentStandardsResponse1, - CreateContentStandardsResponse2, -) -from adcp.types.domains.content_standards.get_content_standards_request import ( - GetContentStandardsRequest, -) -from adcp.types.domains.content_standards.get_content_standards_response import ( - GetContentStandardsResponse, - GetContentStandardsResponse1, - GetContentStandardsResponse2, -) -from adcp.types.domains.content_standards.get_media_buy_artifacts_request import ( - GetMediaBuyArtifactsRequest, - TimeRange, -) -from adcp.types.domains.content_standards.get_media_buy_artifacts_response import ( - CollectionInfo, - GetMediaBuyArtifactsResponse, - GetMediaBuyArtifactsResponse1, - GetMediaBuyArtifactsResponse2, -) -from adcp.types.domains.content_standards.list_content_standards_request import ( - ListContentStandardsRequest, -) -from adcp.types.domains.content_standards.list_content_standards_response import ( - ListContentStandardsResponse, - ListContentStandardsResponse1, - ListContentStandardsResponse2, -) -from adcp.types.domains.content_standards.update_content_standards_request import ( - UpdateContentStandardsRequest, -) -from adcp.types.domains.content_standards.update_content_standards_response import ( - UpdateContentStandardsResponse, - UpdateContentStandardsResponse1, - UpdateContentStandardsResponse2, -) -from adcp.types.domains.content_standards.validate_content_delivery_request import ( - Record, - ValidateContentDeliveryRequest, -) -from adcp.types.domains.content_standards.validate_content_delivery_response import ( - Result, - Summary, - ValidateContentDeliveryResponse, - ValidateContentDeliveryResponse1, - ValidateContentDeliveryResponse2, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.content_standards.artifact import ( + AssetAccess, + AssetAccess1, + AssetAccess2, + AssetAccess3, + Assets, + Assets1, + Assets2, + Assets3, + Assets4, + ContentFormat, + Identifiers, + Metadata, + Provider, + Role, + TranscriptFormat, + TranscriptSource, + TranscriptSource1, + ) + from adcp.types.domains.content_standards.artifact_webhook_payload import ArtifactWebhookPayload + from adcp.types.domains.content_standards.calibrate_content_request import ( + CalibrateContentRequest, + ) + from adcp.types.domains.content_standards.calibrate_content_response import ( + CalibrateContentResponse, + CalibrateContentResponse1, + CalibrateContentResponse2, + ) + from adcp.types.domains.content_standards.content_standards import ContentStandards + from adcp.types.domains.content_standards.create_content_standards_request import ( + CreateContentStandardsRequest, + ) + from adcp.types.domains.content_standards.create_content_standards_response import ( + CreateContentStandardsResponse, + CreateContentStandardsResponse1, + CreateContentStandardsResponse2, + ) + from adcp.types.domains.content_standards.get_content_standards_request import ( + GetContentStandardsRequest, + ) + from adcp.types.domains.content_standards.get_content_standards_response import ( + GetContentStandardsResponse, + GetContentStandardsResponse1, + GetContentStandardsResponse2, + ) + from adcp.types.domains.content_standards.get_media_buy_artifacts_request import ( + GetMediaBuyArtifactsRequest, + TimeRange, + ) + from adcp.types.domains.content_standards.get_media_buy_artifacts_response import ( + CollectionInfo, + GetMediaBuyArtifactsResponse, + GetMediaBuyArtifactsResponse1, + GetMediaBuyArtifactsResponse2, + ) + from adcp.types.domains.content_standards.list_content_standards_request import ( + ListContentStandardsRequest, + ) + from adcp.types.domains.content_standards.list_content_standards_response import ( + ListContentStandardsResponse, + ListContentStandardsResponse1, + ListContentStandardsResponse2, + ) + from adcp.types.domains.content_standards.update_content_standards_request import ( + UpdateContentStandardsRequest, + ) + from adcp.types.domains.content_standards.update_content_standards_response import ( + UpdateContentStandardsResponse, + UpdateContentStandardsResponse1, + UpdateContentStandardsResponse2, + ) + from adcp.types.domains.content_standards.validate_content_delivery_request import ( + Record, + ValidateContentDeliveryRequest, + ) + from adcp.types.domains.content_standards.validate_content_delivery_response import ( + Result, + Summary, + ValidateContentDeliveryResponse, + ValidateContentDeliveryResponse1, + ValidateContentDeliveryResponse2, + ) # Explicit exports __all__ = [ @@ -152,3 +163,75 @@ "ValidateContentDeliveryResponse1", "ValidateContentDeliveryResponse2", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "ArtifactWebhookPayload": "adcp.types.domains.content_standards.artifact_webhook_payload", + "AssetAccess": "adcp.types.domains.content_standards.artifact", + "AssetAccess1": "adcp.types.domains.content_standards.artifact", + "AssetAccess2": "adcp.types.domains.content_standards.artifact", + "AssetAccess3": "adcp.types.domains.content_standards.artifact", + "Assets": "adcp.types.domains.content_standards.artifact", + "Assets1": "adcp.types.domains.content_standards.artifact", + "Assets2": "adcp.types.domains.content_standards.artifact", + "Assets3": "adcp.types.domains.content_standards.artifact", + "Assets4": "adcp.types.domains.content_standards.artifact", + "CalibrateContentRequest": "adcp.types.domains.content_standards.calibrate_content_request", + "CalibrateContentResponse": "adcp.types.domains.content_standards.calibrate_content_response", + "CalibrateContentResponse1": "adcp.types.domains.content_standards.calibrate_content_response", + "CalibrateContentResponse2": "adcp.types.domains.content_standards.calibrate_content_response", + "CollectionInfo": "adcp.types.domains.content_standards.get_media_buy_artifacts_response", + "ContentFormat": "adcp.types.domains.content_standards.artifact", + "ContentStandards": "adcp.types.domains.content_standards.content_standards", + "CreateContentStandardsRequest": "adcp.types.domains.content_standards.create_content_standards_request", + "CreateContentStandardsResponse": "adcp.types.domains.content_standards.create_content_standards_response", + "CreateContentStandardsResponse1": "adcp.types.domains.content_standards.create_content_standards_response", + "CreateContentStandardsResponse2": "adcp.types.domains.content_standards.create_content_standards_response", + "GetContentStandardsRequest": "adcp.types.domains.content_standards.get_content_standards_request", + "GetContentStandardsResponse": "adcp.types.domains.content_standards.get_content_standards_response", + "GetContentStandardsResponse1": "adcp.types.domains.content_standards.get_content_standards_response", + "GetContentStandardsResponse2": "adcp.types.domains.content_standards.get_content_standards_response", + "GetMediaBuyArtifactsRequest": "adcp.types.domains.content_standards.get_media_buy_artifacts_request", + "GetMediaBuyArtifactsResponse": "adcp.types.domains.content_standards.get_media_buy_artifacts_response", + "GetMediaBuyArtifactsResponse1": "adcp.types.domains.content_standards.get_media_buy_artifacts_response", + "GetMediaBuyArtifactsResponse2": "adcp.types.domains.content_standards.get_media_buy_artifacts_response", + "Identifiers": "adcp.types.domains.content_standards.artifact", + "ListContentStandardsRequest": "adcp.types.domains.content_standards.list_content_standards_request", + "ListContentStandardsResponse": "adcp.types.domains.content_standards.list_content_standards_response", + "ListContentStandardsResponse1": "adcp.types.domains.content_standards.list_content_standards_response", + "ListContentStandardsResponse2": "adcp.types.domains.content_standards.list_content_standards_response", + "Metadata": "adcp.types.domains.content_standards.artifact", + "Provider": "adcp.types.domains.content_standards.artifact", + "Record": "adcp.types.domains.content_standards.validate_content_delivery_request", + "Result": "adcp.types.domains.content_standards.validate_content_delivery_response", + "Role": "adcp.types.domains.content_standards.artifact", + "Summary": "adcp.types.domains.content_standards.validate_content_delivery_response", + "TimeRange": "adcp.types.domains.content_standards.get_media_buy_artifacts_request", + "TranscriptFormat": "adcp.types.domains.content_standards.artifact", + "TranscriptSource": "adcp.types.domains.content_standards.artifact", + "TranscriptSource1": "adcp.types.domains.content_standards.artifact", + "UpdateContentStandardsRequest": "adcp.types.domains.content_standards.update_content_standards_request", + "UpdateContentStandardsResponse": "adcp.types.domains.content_standards.update_content_standards_response", + "UpdateContentStandardsResponse1": "adcp.types.domains.content_standards.update_content_standards_response", + "UpdateContentStandardsResponse2": "adcp.types.domains.content_standards.update_content_standards_response", + "ValidateContentDeliveryRequest": "adcp.types.domains.content_standards.validate_content_delivery_request", + "ValidateContentDeliveryResponse": "adcp.types.domains.content_standards.validate_content_delivery_response", + "ValidateContentDeliveryResponse1": "adcp.types.domains.content_standards.validate_content_delivery_response", + "ValidateContentDeliveryResponse2": "adcp.types.domains.content_standards.validate_content_delivery_response", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/core/__init__.py b/src/adcp/types/domains/core/__init__.py index 60f8a9c319..3da17fb7c9 100644 --- a/src/adcp/types/domains/core/__init__.py +++ b/src/adcp/types/domains/core/__init__.py @@ -9,1750 +9,1812 @@ it from its own schema's module, ``adcp.types.domains.core.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-19 19:54:10 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.core.acceptance_policy_profile_ids import ( - AcceptancePolicyProfileId, - AcceptancePolicyProfileIds, -) -from adcp.types.domains.core.account import ( - Account, - Compression, - CreditLimit, - GovernanceAgent, - ReportingBucket, -) -from adcp.types.domains.core.account_authorization import ( - AccountAuthorization, - AllowedTask, - ScopeName, -) -from adcp.types.domains.core.account_change import AccountChange, Actor, Repair, UnavailableReason -from adcp.types.domains.core.account_change_recorded_webhook import AccountChangeRecordedWebhook -from adcp.types.domains.core.account_identity_change import ( - AccountIdentityChange, - AccountIdentityChange1, - AccountIdentityChange2, -) -from adcp.types.domains.core.account_identity_change_preview import ( - AccountIdentityChangePreview, - AccountIdentityChangePreview1, - AccountIdentityChangePreview2, - AccountIdentityChangePreview3, - BlockedImpacts, - Blocker, - Effect1, - Impact, - NonblockingImpact, - NonblockingImpacts, -) -from adcp.types.domains.core.account_ref import ( - AccountReference, - AccountReference1, - AccountReference2, -) -from adcp.types.domains.core.account_status_changed_webhook import AccountStatusChangedWebhook -from adcp.types.domains.core.account_timezone_capability import ( - AccountSelection, - AccountTimezoneCapability, - SupportedTimezone, -) -from adcp.types.domains.core.account_with_authorization import AccountWithAuthorization -from adcp.types.domains.core.activation_key import ActivationKey, ActivationKey1, ActivationKey2 -from adcp.types.domains.core.ad_inventory_config import AdInventoryConfiguration -from adcp.types.domains.core.agent_encryption_key import AgentEncryptionKey -from adcp.types.domains.core.agent_notification_config import AgentNotificationConfig -from adcp.types.domains.core.agent_notification_config_state import AgentNotificationConfigState -from adcp.types.domains.core.agent_reporting_destination import ( - AcceptedFormat, - AgentReportingDestination, - AgentReportingDestination1, - AgentReportingDestination2, - AgentReportingDestination3, -) -from adcp.types.domains.core.agent_reporting_destination_state import ( - AgentReportingDestinationState, - PriorDestinationRef, -) -from adcp.types.domains.core.agent_signing_key import AgentSigningKey -from adcp.types.domains.core.agent_webhook_challenge import AgentWebhookChallenge -from adcp.types.domains.core.app_item import AppItem, Platform -from adcp.types.domains.core.applicable_package_id import ApplicablePackageId -from adcp.types.domains.core.asset_group_vocabulary import AdcpAssetGroupVocabularyRegistry -from adcp.types.domains.core.assets.asset_union import ( - AssetVariant, - AudioChannelLayout, - C2paWatermarkAction, - CatalogType, - ContentIdType, - DaastAsset1, - DaastAsset2, - DaastTrackingEvent, - DaastVersion, - DigitalSourceType, - DisclosurePersistence, - DisclosurePosition, - DisplayTagAsset1, - DisplayTagAsset2, - DisplayTagAsset3, - EmbeddedProvenanceMethod, - Ext, - FeedFormat, - FieldModel, - FrameRateType, - GopType, - HttpMethod, - JavascriptModuleType, - Jurisdiction2, - Location1, - Location2, - Location4, - Location5, - Location6, - Location8, - Location9, - MacroBearingUrl1, - MacroBearingUrl2, - MacroDeclaration1, - MacroDeclaration10, - MacroDeclaration2, - MacroDeclaration3, - MacroDeclaration4, - MacroDeclaration5, - MacroDeclaration6, - MacroDeclaration7, - MacroDeclaration8, - MacroDeclaration9, - MacroDeclarationModel, - MacroDialect, - MacroMappingStatus, - MacroProcessingOperation, - MacroResolver, - MacroValueContext, - MarkdownFlavor, - MoovAtomPosition, - PixelTrackingEvent, - PlatformExtensionRef, - PublishedPostAsset1, - PublishedPostAsset2, - ReferenceAuthorization1, - Role2, - ScanType, - Target1, - UniversalMacro, - UpdateFrequency, - UrlAssetType, - VastAsset1, - VastAsset2, - VastTrackingEvent, - VastVersion, - VerifyAgent1, - WatermarkMediaType, - WebhookResponseType, - WebhookSecurityMethod, -) -from adcp.types.domains.core.assets.audio_asset import BitDepth -from adcp.types.domains.core.assets.daast_asset import ( - DaastAsset3, - DaastAsset4, - Location13, - MacroDeclaration12, -) -from adcp.types.domains.core.assets.display_tag_asset import ( - DisplayTagAsset4, - DisplayTagAsset5, - DisplayTagAsset6, - Location17, - Location18, - MacroDeclaration15, - MacroDeclaration16, -) -from adcp.types.domains.core.assets.vast_asset import ( - Location26, - MacroDeclaration20, - VastAsset3, - VastAsset4, -) -from adcp.types.domains.core.async_response_data import AdcpAsyncResponseData -from adcp.types.domains.core.async_response_refs.creative.get_creative_features_async_response_submitted import ( - GetCreativeFeaturesSubmitted, -) -from adcp.types.domains.core.async_response_refs.creative.sync_creatives_async_response_input_required import ( - SyncCreativesInputRequired, -) -from adcp.types.domains.core.async_response_refs.creative.sync_creatives_async_response_submitted import ( - SyncCreativesSubmitted, -) -from adcp.types.domains.core.async_response_refs.creative.sync_creatives_async_response_working import ( - SyncCreativesWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.accept_proposal_async_response_input_required import ( - AcceptProposalInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.accept_proposal_async_response_submitted import ( - AcceptProposalSubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.accept_proposal_async_response_working import ( - AcceptProposalWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.build_creative_async_response_input_required import ( - BuildCreativeInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.build_creative_async_response_submitted import ( - BuildCreativeSubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.build_creative_async_response_working import ( - BuildCreativeWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.buy_products_async_response_input_required import ( - BuyProductsInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.buy_products_async_response_submitted import ( - BuyProductsSubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.buy_products_async_response_working import ( - BuyProductsWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.control_media_buy_async_response_input_required import ( - ControlMediaBuyInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.control_media_buy_async_response_submitted import ( - ControlMediaBuySubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.control_media_buy_async_response_working import ( - ControlMediaBuyWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.create_media_buy_async_response_input_required import ( - CreateMediaBuyInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.create_media_buy_async_response_submitted import ( - CreateMediaBuySubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.create_media_buy_async_response_working import ( - CreateMediaBuyWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.decline_proposals_async_response_input_required import ( - DeclineProposalsInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.decline_proposals_async_response_submitted import ( - DeclineProposalsSubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.decline_proposals_async_response_working import ( - DeclineProposalsWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.get_products_async_response_input_required import ( - GetProductsInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.get_products_async_response_submitted import ( - GetProductsSubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.get_products_async_response_working import ( - GetProductsWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.refine_proposals_async_response_input_required import ( - RefineProposalsInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.refine_proposals_async_response_submitted import ( - RefineProposalsSubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.refine_proposals_async_response_working import ( - RefineProposalsWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.request_proposals_async_response_input_required import ( - RequestProposalsInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.request_proposals_async_response_submitted import ( - RequestProposalsSubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.request_proposals_async_response_working import ( - RequestProposalsWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.sync_catalogs_async_response_input_required import ( - SyncCatalogsInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.sync_catalogs_async_response_submitted import ( - SyncCatalogsSubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.sync_catalogs_async_response_working import ( - SyncCatalogsWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.update_media_buy_async_response_input_required import ( - UpdateMediaBuyInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.update_media_buy_async_response_submitted import ( - UpdateMediaBuySubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.update_media_buy_async_response_working import ( - UpdateMediaBuyWorking, -) -from adcp.types.domains.core.async_response_refs.signals.get_signals_async_response_submitted import ( - GetSignalsSubmitted, -) -from adcp.types.domains.core.async_response_refs.signals.get_signals_async_response_working import ( - GetSignalsWorking, -) -from adcp.types.domains.core.attestation_capabilities import ( - AcceptedIssuer, - AttestationCapabilities, - CredentialOrigin, - Resolver, - SupportedDeliveryMethod, -) -from adcp.types.domains.core.attestation_evaluation import Outcome -from adcp.types.domains.core.attestation_issuer import ( - AttestationIssuer, - AttestationIssuer1, - AttestationIssuer2, - AttestationIssuer3, -) -from adcp.types.domains.core.attestation_reference import ( - AttestationReference, - EmbeddedCredential, - Locator, - Locator1, - ValidityHint, -) -from adcp.types.domains.core.attestation_subject import ( - AttestationSubject, - AttestationSubject1, - AttestationSubject2, - AttestationSubject3, -) -from adcp.types.domains.core.attribution_window import AttributionWindow -from adcp.types.domains.core.audience_activation_method import ( - AudienceActivationMethod, - AudienceActivationMethod1, - AudienceActivationMethod2, - AudienceActivationMethod3, - AudienceActivationMethod4, - AudienceActivationMethod5, - AudienceActivationMethod6, - Cloud, - ConsumerIdentity, -) -from adcp.types.domains.core.audience_characteristic import AudienceCharacteristic -from adcp.types.domains.core.audience_evidence import ( - AudienceEvidence, - Subject11, - Subject12, - Subject13, - Subject14, - Subject15, - Subject16, - Subject17, - Subject2, - Subject21, - Subject22, - Subject23, - Subject24, - Subject25, - Subject26, - Subject27, - Subject3, - Subject31, - Subject32, - Subject33, - Subject34, - Subject35, - Subject36, - Subject37, -) -from adcp.types.domains.core.audience_evidence_pin import AudienceEvidencePin -from adcp.types.domains.core.audience_evidence_requirements import AudienceEvidenceRequirements -from adcp.types.domains.core.audience_evidence_selection import AudienceEvidenceSelection -from adcp.types.domains.core.audience_member import AudienceMember -from adcp.types.domains.core.audience_selector import ( - AudienceSelector, - AudienceSelector1, - AudienceSelector2, - AudienceSelector3, - AudienceSelector4, -) -from adcp.types.domains.core.audience_source import AudienceSource, AudienceSource1, AudienceSource2 -from adcp.types.domains.core.authorized_agent_base import AuthorizedAgentBaseFields -from adcp.types.domains.core.bidding_policy import BiddingPolicy, CostPer, Roas, Strength, Strength1 -from adcp.types.domains.core.bidding_policy_capability import ( - BiddingPolicyCapability, - CostPerStrength, - MaxBidWithCostPer, - MaxBidWithRoas, - PolicyProfile, - RoasStrength, - ScopeCapability, -) -from adcp.types.domains.core.brand_id import BrandId -from adcp.types.domains.core.brand_key import BrandKey -from adcp.types.domains.core.brand_ref import ( - BrandKitOverride, - BrandReference, - Colors, - DataSubjectContestation, -) -from adcp.types.domains.core.brand_response_authorization_result import ( - BrandResponseAuthorizationResult, - BrandResponseAuthorizationResult1, - BrandResponseAuthorizationResult2, - Trust, -) -from adcp.types.domains.core.budget_allocation import ( - BudgetAllocation, - BudgetAllocation1, - BudgetAllocation2, - OptimizationGoal1, - OptimizationGoal2, - OptimizationGoal3, - OptimizationGoal4, - OptimizationGoal5, - OptimizationGoal6, - OptimizationGoal7, - Target3, - Target4, - Target5, - Target6, - Target7, - Target8, -) -from adcp.types.domains.core.business_entity import Bank, BusinessEntity, Contact -from adcp.types.domains.core.cancellation_policy import CancellationFee, CancellationPolicy -from adcp.types.domains.core.canonical_account_ref import ( - CanonicalAccountReference, - CanonicalAccountReference1, - CanonicalAccountReference2, -) -from adcp.types.domains.core.canonical_audience_evidence import ( - AttestationDigest, - CanonicalAudienceEvidence, -) -from adcp.types.domains.core.canonical_audience_evidence_selection import ( - CanonicalAudienceEvidenceSelection, - VerifiedAttestationDigest, -) -from adcp.types.domains.core.canonical_budget_allocation import ( - CanonicalBudgetAllocation, - CanonicalBudgetAllocation1, - CanonicalBudgetAllocation2, -) -from adcp.types.domains.core.canonical_delivery_forecast import CanonicalDeliveryForecast -from adcp.types.domains.core.canonical_forecast_point import CanonicalForecastPoint -from adcp.types.domains.core.canonical_forecast_vendor_metric_value import ( - CanonicalForecastVendorMetricValue, -) -from adcp.types.domains.core.canonical_format_kind import CanonicalFormatKind -from adcp.types.domains.core.canonical_format_option import CanonicalFormatOption, FormatKind -from adcp.types.domains.core.canonical_measurement_terms import CanonicalMeasurementTerms -from adcp.types.domains.core.canonical_media_buy_action import ( - Action3, - Action4, - CanonicalMediaBuyAction, - CanonicalMediaBuyAction1, - CanonicalMediaBuyAction2, - CanonicalMediaBuyAction3, -) -from adcp.types.domains.core.canonical_media_buy_action_fields import CanonicalMediaBuyActionFields -from adcp.types.domains.core.canonical_media_buy_features import CanonicalMediaBuyFeatures -from adcp.types.domains.core.canonical_metric_qualifier import CanonicalMetricQualifier -from adcp.types.domains.core.canonical_optimization_goal import ( - CanonicalOptimizationGoal, - CanonicalOptimizationGoal1, - CanonicalOptimizationGoal2, - CanonicalOptimizationGoal3, - Target10, - Target11, -) -from adcp.types.domains.core.canonical_performance_standard import CanonicalPerformanceStandard -from adcp.types.domains.core.canonical_placement import ( - CanonicalDoohPlacementAttributes, - CanonicalDoohScreenResolution, - CanonicalProductPlacement, - CanonicalProductPlacement1, - CanonicalProductPlacement2, -) -from adcp.types.domains.core.canonical_pricing_option import CanonicalPricingOption, PricingModel -from adcp.types.domains.core.canonical_product import ( - CanonicalProduct, - PublisherProperty1, - PublisherProperty2, - PublisherProperty3, - PublisherProperty4, - PublisherProperty5, - PublisherProperty6, - PublisherProperty7, -) -from adcp.types.domains.core.canonical_product_action import CanonicalProductAction -from adcp.types.domains.core.canonical_projection_ref import ( - AssetSource, - CanonicalProjectionReference, -) -from adcp.types.domains.core.canonical_projection_slot_override import ( - CanonicalProjectionSlotOverride, -) -from adcp.types.domains.core.canonical_proposal import CanonicalProposal, ProposalKind -from adcp.types.domains.core.canonical_reporting_capabilities import CanonicalReportingCapabilities -from adcp.types.domains.core.canonical_reporting_commitment import ( - CanonicalReportingCommitment, - CanonicalReportingCommitment1, - CanonicalReportingCommitment2, -) -from adcp.types.domains.core.canvas_constraint import CanvasConstraint, Constraint -from adcp.types.domains.core.capabilities_changed_webhook import CapabilitiesChangedWebhook -from adcp.types.domains.core.catalog_item_availability_error import CatalogItemAvailabilityError -from adcp.types.domains.core.catalog_item_availability_ref import CatalogItemAvailabilityReference -from adcp.types.domains.core.catalog_item_availability_state import CatalogItemAvailabilityState -from adcp.types.domains.core.catalog_item_availability_update import CatalogItemAvailabilityUpdate -from adcp.types.domains.core.catalog_item_availability_update_result import ( - CatalogItemAvailabilityUpdateResult, -) -from adcp.types.domains.core.catalog_item_delivery_metrics import CatalogItemDeliveryMetrics -from adcp.types.domains.core.catalog_item_reference_not_found_error import ( - CatalogItemReferenceNotFoundError, -) -from adcp.types.domains.core.catalog_selection import CatalogSelection -from adcp.types.domains.core.catchment import Catchment -from adcp.types.domains.core.collection import Collection, RelatedCollection -from adcp.types.domains.core.collection_delivery_metrics import CollectionDeliveryMetrics -from adcp.types.domains.core.collection_distribution import CollectionDistribution -from adcp.types.domains.core.collection_list_ref import CollectionListReference -from adcp.types.domains.core.collection_property_delivery_metrics import ( - CollectionPropertyDeliveryMetrics, -) -from adcp.types.domains.core.collection_ref import CollectionReference -from adcp.types.domains.core.collection_selection import ( - CollectionSelection, - CollectionSelection1, - CollectionSelection2, -) -from adcp.types.domains.core.collection_selector import CollectionSelector -from adcp.types.domains.core.committed_metric import ( - CommittedMetric, - CommittedMetric1, - CommittedMetric2, - Qualifier1, - QualifierModel, -) -from adcp.types.domains.core.compact_task_input_required import CompactTaskInputRequired -from adcp.types.domains.core.compact_task_submitted import CompactTaskSubmitted -from adcp.types.domains.core.compact_task_working import CompactTaskWorking -from adcp.types.domains.core.content_rating import ContentRating -from adcp.types.domains.core.context import ContextObject -from adcp.types.domains.core.creative_approval_scope import ScopedCreativeApproval -from adcp.types.domains.core.creative_asset import CreativeAsset, Input -from adcp.types.domains.core.creative_assets import CreativeAssets, CreativeAssets1 -from adcp.types.domains.core.creative_assignment import CreativeAssignment, RotationMode -from adcp.types.domains.core.creative_consumption import CreativeConsumption -from adcp.types.domains.core.creative_delivery_metrics import CreativeDeliveryMetrics -from adcp.types.domains.core.creative_filters import CreativeFilters -from adcp.types.domains.core.creative_item import CreativeItem, CreativeItem1, CreativeItem2 -from adcp.types.domains.core.creative_locale_policy import CreativeLocalePolicy -from adcp.types.domains.core.creative_localization import CreativeLocalization, TargetVariant -from adcp.types.domains.core.creative_localization_readback import ( - CreativeLocalizationReadback, - ResolvedAssets, - ResolvedAssets1, - Variants, - Variants1, - Variants2, -) -from adcp.types.domains.core.creative_manifest import CreativeManifest -from adcp.types.domains.core.creative_operation_format_declaration import ( - CreativeOperationFormatDeclaration, -) -from adcp.types.domains.core.creative_policy import CreativePolicy, ProvenanceRequirements -from adcp.types.domains.core.creative_representation import CreativeRepresentation -from adcp.types.domains.core.creative_representation_set import CreativeRepresentationSet -from adcp.types.domains.core.creative_revision_id import CreativeRevisionId -from adcp.types.domains.core.creative_variable import CreativeVariable, VariableType -from adcp.types.domains.core.creative_variant import Artifact, CreativeVariant, GenerationContext -from adcp.types.domains.core.daast_tracker_constraints import ( - DaastEvent, - DaastOffset, - DaastTarget, - DaastTrackerConstraints, - DaastVersions, -) -from adcp.types.domains.core.data_provider_signal_selector import ( - DataProviderSignalSelector, - DataProviderSignalSelector1, - DataProviderSignalSelector2, - DataProviderSignalSelector3, - SignalTag, -) -from adcp.types.domains.core.date_range import DateRange -from adcp.types.domains.core.datetime_range import DatetimeRange -from adcp.types.domains.core.daypart_target import DaypartTarget -from adcp.types.domains.core.deadline_policy import DeadlinePolicy, MaterialStage -from adcp.types.domains.core.delivery_breakdown_controls import DeliveryBreakdownControls -from adcp.types.domains.core.delivery_forecast import DeliveryForecast -from adcp.types.domains.core.delivery_metric_aggregate import ( - DeliveryMetricAggregate, - DeliveryMetricAggregate1, - DeliveryMetricAggregate2, - Field0, - Qualifier3, -) -from adcp.types.domains.core.delivery_metrics import ( - ByActionSourceItem, - ByEventTypeItem, - DeliveryMetrics, - DoohMetrics, - DoohMetrics1, - EstimationBasis, - OohMetrics, - Panel, - Posting, - QuartileData, - ReachWindow, - TimeBasedView, - VenueBreakdownItem, - Viewability1, - ViewedSecondsHistogramItem, - ViewedSecondsPercentiles, -) -from adcp.types.domains.core.delivery_provider import DeliveryProvider -from adcp.types.domains.core.delivery_recipient import DeliveryRecipient -from adcp.types.domains.core.demographic_age_range import DemographicAgeRange -from adcp.types.domains.core.demographic_predicate import DemographicPredicate -from adcp.types.domains.core.demographic_reporting_capability import ( - DemographicReportingCapability, - ReportingMode, -) -from adcp.types.domains.core.demographic_targeting_capability import ( - DemographicTargetingCapability, - ExecutionMode, - UnknownHandling, -) -from adcp.types.domains.core.demographic_targeting_intent import DemographicTargetingIntent -from adcp.types.domains.core.demographic_targeting_resolution import ( - DemographicTargetingResolution, - Execution, - Execution1, - Execution2, - IntervalId, -) -from adcp.types.domains.core.deployment import Deployment, Deployment1, Deployment2 -from adcp.types.domains.core.destination import Destination1, Destination2 -from adcp.types.domains.core.destination_item import DestinationItem, DestinationType -from adcp.types.domains.core.diagnostic_issue import DiagnosticIssue, Severity -from adcp.types.domains.core.downstream_connection_requirement import ( - ConnectionType, - DownstreamConnectionRequirement, - ResourceRef, -) -from adcp.types.domains.core.duration import Duration -from adcp.types.domains.core.education_item import DegreeType, EducationItem, Level, Modality -from adcp.types.domains.core.error import BuyerReason, DiscriminatorItem, Issue, Recovery -from adcp.types.domains.core.evaluator_spec import ( - EvalBudget, - EvaluatorSpec, - EvaluatorSpec1, - EvaluatorSpec2, - EvaluatorSpec3, - Exemplars, - FeatureAgent, - RankByItem, - RankByItem1, - RankByItem2, -) -from adcp.types.domains.core.event import Event -from adcp.types.domains.core.event_custom_data import Content, EventCustomData -from adcp.types.domains.core.event_source_health import Detail, EventSourceHealth -from adcp.types.domains.core.event_surface import Category, EventSurface -from adcp.types.domains.core.experimental_feature_id import ExperimentalFeatureId -from adcp.types.domains.core.ext import ExtensionObject -from adcp.types.domains.core.feature_requirement import FeatureRequirement, IfNotCovered -from adcp.types.domains.core.flight_item import FlightItem -from adcp.types.domains.core.forecast_dimension_audience import AudienceForecastDimension -from adcp.types.domains.core.forecast_dimension_device_platform import ( - DevicePlatformForecastDimension, -) -from adcp.types.domains.core.forecast_dimension_device_type import DeviceTypeForecastDimension -from adcp.types.domains.core.forecast_dimension_geo import GeoForecastDimension -from adcp.types.domains.core.forecast_dimension_placement import PlacementForecastDimension -from adcp.types.domains.core.forecast_dimension_signal import Presence, SignalForecastDimension -from adcp.types.domains.core.forecast_dimension_time import TimeForecastDimension -from adcp.types.domains.core.forecast_point import ForecastPoint, ViewableRate -from adcp.types.domains.core.forecast_point_dimensions import ForecastPointDimensions -from adcp.types.domains.core.forecast_range import ForecastRange -from adcp.types.domains.core.forecast_rate_range import ForecastRateRange -from adcp.types.domains.core.forecast_vendor_metric_value import ForecastVendorMetricValue -from adcp.types.domains.core.format import ( - Assets10, - Assets11, - Assets12, - Assets13, - Assets14, - Assets15, - Assets16, - Assets17, - Assets18, - Assets19, - Assets20, - Assets21, - Assets22, - Assets23, - Assets24, - Assets25, - Assets26, - Assets27, - Assets28, - Assets29, - Assets30, - Assets31, - Assets32, - Assets33, - Assets34, - Assets35, - Assets36, - Assets37, - Assets38, - Assets39, - Assets40, - Assets41, - Assets42, - Assets43, - Assets44, - Assets45, - Assets46, - Assets47, - Assets48, - Assets49, - Assets50, - Assets51, - Assets9, - Assets94, - BaseGroupAsset, - BaseIndividualAsset, - CanonicalParameters, - CanonicalParameters1, - CanonicalParameters10, - CanonicalParameters11, - CanonicalParameters12, - CanonicalParameters13, - CanonicalParameters14, - CanonicalParameters15, - CanonicalParameters16, - CanonicalParameters17, - CanonicalParameters18, - CanonicalParameters19, - CanonicalParameters2, - CanonicalParameters20, - CanonicalParameters21, - CanonicalParameters22, - CanonicalParameters23, - CanonicalParameters24, - CanonicalParameters25, - CanonicalParameters26, - CanonicalParameters27, - CanonicalParameters28, - CanonicalParameters29, - CanonicalParameters3, - CanonicalParameters30, - CanonicalParameters31, - CanonicalParameters32, - CanonicalParameters33, - CanonicalParameters4, - CanonicalParameters5, - CanonicalParameters6, - CanonicalParameters7, - CanonicalParameters8, - CanonicalParameters9, - Dimensions, - Dimensions1, - DisclosureCapability, - FormatAssetUnion, - FormatCard, - FormatCardDetailed, - GroupFormatAssetUnion, - Renders, - Renders1, - Responsive, - UnknownFormatAsset, - UnknownGroupAsset, -) -from adcp.types.domains.core.format_id import FormatReferenceStructuredObject -from adcp.types.domains.core.format_option_ref import ( - FormatOptionReference, - FormatOptionReference1, - FormatOptionReference2, -) -from adcp.types.domains.core.format_shape_vocabulary import AdcpFormatShapeVocabularyRegistry -from adcp.types.domains.core.frequency_cap import FrequencyCap -from adcp.types.domains.core.frequency_cap_constraints import FrequencyCapConstraints -from adcp.types.domains.core.frequency_cap_duration_unit import FrequencyCapDurationUnit -from adcp.types.domains.core.frequency_cap_impression_constraints import ( - AllowedValue, - FrequencyCapImpressionConstraints, -) -from adcp.types.domains.core.frequency_cap_interval_constraints import ( - AllowedInterval, - FrequencyCapIntervalConstraints, -) -from adcp.types.domains.core.frequency_cap_requirements import FrequencyCapRequirements -from adcp.types.domains.core.generation_credential import GenerationCredential -from adcp.types.domains.core.geo_breakdown_support import GeographicBreakdownSupport -from adcp.types.domains.core.geo_delivery_metrics import GeoDeliveryMetrics -from adcp.types.domains.core.geo_metro import GeoMetro -from adcp.types.domains.core.geo_place_area import GeographicPlaceArea -from adcp.types.domains.core.geo_place_catalog_capability import ( - GeographicPlaceCatalogCapability, - SupportedVersion, -) -from adcp.types.domains.core.geo_place_catalog_entry import ( - GeographicPlaceCatalogEntry, - ParentLabel, - ReplacedByValue, -) -from adcp.types.domains.core.geo_place_requirement import ( - CatalogRequirement, - GeographicPlaceRequirement, -) -from adcp.types.domains.core.geo_place_resolver import Auth, GeographicPlaceResolver -from adcp.types.domains.core.geo_place_support import GeographicPlaceSystemSupport -from adcp.types.domains.core.geo_place_system import ( - GeographicPlaceIdentifierSystem, - GeographicPlaceIdentifierSystem1, - GeographicPlaceIdentifierSystem2, -) -from adcp.types.domains.core.geo_place_type import ( - GeographicPlaceType, - GeographicPlaceType1, - GeographicPlaceType2, -) -from adcp.types.domains.core.geo_region_requirement import Countries1, GeographicRegionRequirement -from adcp.types.domains.core.geo_region_support import Countries3, GeographicRegionSupport -from adcp.types.domains.core.get_geo_place_resolution_request import ( - GetGeographicPlaceResolutionRequest, -) -from adcp.types.domains.core.get_geo_place_resolution_response import ( - GetGeographicPlaceResolutionResponse, -) -from adcp.types.domains.core.hotel_item import HotelItem -from adcp.types.domains.core.iana_timezone import IanaTimezoneIdentifier -from adcp.types.domains.core.impairment import Impairment, Transition -from adcp.types.domains.core.indicator import Indicator -from adcp.types.domains.core.indicator_bearing import IndicatorBearingResourceState -from adcp.types.domains.core.indicator_scope import IndicatorScope -from adcp.types.domains.core.indicators_changed_webhook import ( - IndicatorsChangedWebhook, - RelationshipKind, -) -from adcp.types.domains.core.industry_identifier import IndustryIdentifier -from adcp.types.domains.core.insertion_order import InsertionOrder, PaymentTerms, Terms, TotalBudget -from adcp.types.domains.core.installment import DerivativeOf, Installment -from adcp.types.domains.core.installment_deadlines import InstallmentDeadlines -from adcp.types.domains.core.installment_delivery_metrics import InstallmentDeliveryMetrics -from adcp.types.domains.core.installment_property_delivery_metrics import ( - InstallmentPropertyDeliveryMetrics, -) -from adcp.types.domains.core.installment_ref import InstallmentReference -from adcp.types.domains.core.inventory_list_application import ( - InventoryListApplication, - InventoryListApplication1, - InventoryListApplication2, - Summary, - Summary2, -) -from adcp.types.domains.core.job_item import EmploymentType, ExperienceLevel, JobItem, Salary -from adcp.types.domains.core.keyword_delivery_metrics import KeywordDeliveryMetrics -from adcp.types.domains.core.limited_series import LimitedSeries -from adcp.types.domains.core.locale_tag import LanguageTag -from adcp.types.domains.core.localized_creative_asset import ( - LocalizedCreativeAsset, - LocalizedCreativeAsset1, - LocalizedCreativeAsset10, - LocalizedCreativeAsset11, - LocalizedCreativeAsset12, - LocalizedCreativeAsset13, - LocalizedCreativeAsset14, - LocalizedCreativeAsset15, - LocalizedCreativeAsset16, - LocalizedCreativeAsset17, - LocalizedCreativeAsset18, - LocalizedCreativeAsset19, - LocalizedCreativeAsset2, - LocalizedCreativeAsset20, - LocalizedCreativeAsset21, - LocalizedCreativeAsset22, - LocalizedCreativeAsset3, - LocalizedCreativeAsset4, - LocalizedCreativeAsset5, - LocalizedCreativeAsset7, - LocalizedCreativeAsset8, - LocalizedCreativeAsset9, -) -from adcp.types.domains.core.macro_bearing_url import MacroBearingUrl3, MacroBearingUrl4 -from adcp.types.domains.core.macro_resolution_capability import ( - MacroProcessingCapability, - MappingStatus, - Operation, -) -from adcp.types.domains.core.macro_resolution_result import MacroResolutionResult -from adcp.types.domains.core.material_deadline import MaterialDeadline -from adcp.types.domains.core.mcp_webhook_payload import McpWebhookPayload -from adcp.types.domains.core.measurement_readiness import MeasurementReadiness -from adcp.types.domains.core.measurement_terms import MeasurementTerms -from adcp.types.domains.core.measurement_window import MeasurementWindow -from adcp.types.domains.core.media_buy import MediaBuy -from adcp.types.domains.core.media_buy_available_action import MediaBuyAvailableAction -from adcp.types.domains.core.media_buy_available_action_id import MediaBuyAvailableActionId -from adcp.types.domains.core.media_buy_change_term_id import MediaBuyChangeTermId -from adcp.types.domains.core.media_buy_features import MediaBuyFeatures -from adcp.types.domains.core.media_buy_frequency_cap import MediaBuyFrequencyCap -from adcp.types.domains.core.media_buy_frequency_cap_capability import ( - MediaBuyFrequencyCapCapability, -) -from adcp.types.domains.core.media_buy_frequency_cap_requirement import ( - MediaBuyFrequencyCapRequirement, -) -from adcp.types.domains.core.media_buy_frequency_cap_support import MediaBuyFrequencyCapSupport -from adcp.types.domains.core.media_buy_legacy_terms_ref import MediaBuyTermsReference -from adcp.types.domains.core.media_buy_support import ProductMediaBuySupport -from adcp.types.domains.core.media_buy_support_requirements import ( - ProductMediaBuySupportRequirements, -) -from adcp.types.domains.core.missing_metric import MissingMetric, MissingMetric1, MissingMetric2 -from adcp.types.domains.core.negative_keyword import NegativeKeyword -from adcp.types.domains.core.notification_config import NotificationConfig -from adcp.types.domains.core.offering import GeoTargets, Offering -from adcp.types.domains.core.offering_asset_group import Items, OfferingAssetGroup -from adcp.types.domains.core.operator_identity import OperatorIdentity -from adcp.types.domains.core.operator_unit import OperatorUnit -from adcp.types.domains.core.opportunity_context import ( - CloseReason, - Intent, - OpportunityContext, - Phase, -) -from adcp.types.domains.core.optimization_goal import ( - OptimizationGoal10, - OptimizationGoal8, - OptimizationGoal9, - Target14, - Target15, - Target16, - Target17, - Target18, - Target19, -) -from adcp.types.domains.core.outcome_measurement import OutcomeMeasurement -from adcp.types.domains.core.outcome_target_cost_per import OutcomeTargetCostPer -from adcp.types.domains.core.overlay import Bounds, Overlay, Visual -from adcp.types.domains.core.package import Package -from adcp.types.domains.core.package_delivery_metric_value import PackageDeliveryMetricValue -from adcp.types.domains.core.package_format_snapshot import ( - PackageFormatSnapshot, - PackageFormatSnapshot1, - PackageFormatSnapshot10, - PackageFormatSnapshot11, - PackageFormatSnapshot12, - PackageFormatSnapshot13, - PackageFormatSnapshot14, - PackageFormatSnapshot15, - PackageFormatSnapshot16, - PackageFormatSnapshot17, - PackageFormatSnapshot18, - PackageFormatSnapshot19, - PackageFormatSnapshot2, - PackageFormatSnapshot20, - PackageFormatSnapshot21, - PackageFormatSnapshot22, - PackageFormatSnapshot23, - PackageFormatSnapshot24, - PackageFormatSnapshot25, - PackageFormatSnapshot26, - PackageFormatSnapshot27, - PackageFormatSnapshot28, - PackageFormatSnapshot29, - PackageFormatSnapshot3, - PackageFormatSnapshot30, - PackageFormatSnapshot31, - PackageFormatSnapshot32, - PackageFormatSnapshot33, - PackageFormatSnapshot4, - PackageFormatSnapshot5, - PackageFormatSnapshot6, - PackageFormatSnapshot7, - PackageFormatSnapshot8, - PackageFormatSnapshot9, -) -from adcp.types.domains.core.package_signal_targeting import ( - PackageSignalTargeting, - PackageSignalTargeting1, - PackageSignalTargeting2, - PackageSignalTargeting3, - PackageSignalTargeting4, - PackageSignalTargeting5, - PackageSignalTargeting6, - PackageSignalTargeting7, -) -from adcp.types.domains.core.package_signal_targeting_group import ( - Operator, - PackageSignalTargetingGroup, -) -from adcp.types.domains.core.package_signal_targeting_groups import PackageSignalTargetingGroups -from adcp.types.domains.core.package_targeting_resolution import PackageTargetingResolution -from adcp.types.domains.core.pagination_request import PaginationRequest -from adcp.types.domains.core.pagination_response import PaginationResponse -from adcp.types.domains.core.performance_feedback import ( - MeasurementPeriod, - Metric7, - PerformanceFeedback, -) -from adcp.types.domains.core.performance_feedback_assertion import ( - ConfidenceInterval, - PerformanceFeedbackAssertion, -) -from adcp.types.domains.core.performance_feedback_metric import ( - PerformanceFeedbackMetric, - PerformanceFeedbackMetric1, - PerformanceFeedbackMetric2, -) -from adcp.types.domains.core.performance_standard import PerformanceStandard -from adcp.types.domains.core.placement import ( - Placement, - ProductDoohPlacementAttributes, - ProductDoohScreenResolution, -) -from adcp.types.domains.core.placement_definition import ( - FormatOptions, - PlacementDefinition, - PublisherDoohPlacementAttributes, - PublisherDoohScreenResolution, -) -from adcp.types.domains.core.placement_delivery_metrics import PlacementDeliveryMetrics -from adcp.types.domains.core.placement_evidence import PlacementEvidence -from adcp.types.domains.core.placement_identity import ( - PlacementIdentity, - PlacementIdentity1, - PlacementIdentity2, -) -from adcp.types.domains.core.placement_presentation import ( - BoxDecoration, - Canvas, - Color, - CreativeSlot, - Fit, - ImageDecoration, - ImageRef, - Layer, - PlacementPresentationDocument, - Rectangle, - TextDecoration, -) -from adcp.types.domains.core.placement_property_delivery_metrics import ( - PlacementPropertyDeliveryMetrics, -) -from adcp.types.domains.core.placement_ref import PlacementReference -from adcp.types.domains.core.placement_selection import PlacementSelection1, PlacementSelection2 -from adcp.types.domains.core.planned_delivery import Geo, PlannedDelivery -from adcp.types.domains.core.platform_extension_ref import PlatformExtensionReference -from adcp.types.domains.core.positive_postal_area_support import PositivePostalAreaSupport -from adcp.types.domains.core.postal_area import ( - PostalArea, - PostalArea1, - PostalArea11, - PostalArea110, - PostalArea111, - PostalArea112, - PostalArea113, - PostalArea114, - PostalArea115, - PostalArea116, - PostalArea117, - PostalArea118, - PostalArea119, - PostalArea12, - PostalArea120, - PostalArea121, - PostalArea13, - PostalArea14, - PostalArea15, - PostalArea16, - PostalArea17, - PostalArea18, - PostalArea19, - PostalArea2, - System1, - System2, - System3, - System9, -) -from adcp.types.domains.core.postal_area_support import ( - CAEnum, - GBEnum, - ME, - PostalAreaSupport, - PostalAreaSupportAdditionalPropertyEnum, -) -from adcp.types.domains.core.postal_country_system import ( - PostalCountrySystem, - PostalCountrySystem1, - PostalCountrySystem10, - PostalCountrySystem2, - PostalCountrySystem3, - PostalCountrySystem4, - PostalCountrySystem5, - PostalCountrySystem6, - PostalCountrySystem7, - PostalCountrySystem8, - PostalCountrySystem9, - System11, - System12, - System13, - System19, -) -from adcp.types.domains.core.presentation_ref import PlacementPresentationReference -from adcp.types.domains.core.preview_provider import PublisherDesignatedPreviewProvider, Route -from adcp.types.domains.core.preview_renderer_metadata import ( - PreviewRendererMetadata, - RenderingOrigin, -) -from adcp.types.domains.core.price import Price -from adcp.types.domains.core.pricing_option import PricingOption -from adcp.types.domains.core.principal_changed_webhook import PrincipalChangedWebhook -from adcp.types.domains.core.principal_declarations import ( - AgentDeclarations, - AsyncAdcpVersion, - WebhookSigningAlgorithm, -) -from adcp.types.domains.core.principal_declarations_state import ( - Axis, - Exclusion, - PrincipalDeclarationsState, -) -from adcp.types.domains.core.principal_state import ( - DestinationRef, - PrincipalState, - RetiredDestination, -) -from adcp.types.domains.core.product import ( - AudienceActivation, - ConversionTracking, - DeliveryMeasurement, - MaterialSubmission, - MetricOptimization, - Product, - ProductCard, - ProductCardDetailed, - PublisherProperty81, - PublisherProperty82, - PublisherProperty83, - PublisherProperty84, - PublisherProperty85, - PublisherProperty86, - PublisherProperty87, - Specification, - SupportedMetric, - SupportedTarget5, - SupportedViewDuration, -) -from adcp.types.domains.core.product_allocation import ProductAllocation -from adcp.types.domains.core.product_allowed_action import ProductAllowedAction -from adcp.types.domains.core.product_audience_evidence_requirements import ( - AcceptedAttestationIssuers, - AcceptedAttestationIssuers1, - AcceptedAttestationIssuers2, - AcceptedAttestationIssuers3, - ProductAudienceEvidenceRequirements, -) -from adcp.types.domains.core.product_card_reference_asset import ProductCardReferenceAsset -from adcp.types.domains.core.product_change_map import ProductChangeMap, ProductChangeMap1 -from adcp.types.domains.core.product_execution_requirement import ( - Connection, - ProductExecutionRequirement, - ProductExecutionRequirement1, - ProductExecutionRequirement2, - ProductExecutionRequirement3, -) -from adcp.types.domains.core.product_filters import ( - AudienceActivationMethods, - AudienceActivationMethods1, - AudienceActivationMethods2, - AudienceActivationMethods3, - AudienceActivationMethods4, - AudienceActivationMethods5, - Keyword, - ProductFilters, - RequiredGeoTargetingItem, - SignalTargetingItem, - SignalTargetingItem1, - SignalTargetingItem2, - SignalTargetingItem3, - SignalTargetingItem4, - SignalTargetingItem5, - SignalTargetingItem6, - SignalTargetingItem7, -) -from adcp.types.domains.core.product_format_declaration import ( - ProductFormatDeclaration, - ProductFormatDeclaration1, - ProductFormatDeclaration10, - ProductFormatDeclaration11, - ProductFormatDeclaration12, - ProductFormatDeclaration13, - ProductFormatDeclaration14, - ProductFormatDeclaration15, - ProductFormatDeclaration16, - ProductFormatDeclaration2, - ProductFormatDeclaration3, - ProductFormatDeclaration4, - ProductFormatDeclaration5, - ProductFormatDeclaration6, - ProductFormatDeclaration7, - ProductFormatDeclaration8, - ProductFormatDeclaration9, -) -from adcp.types.domains.core.product_identity import ProductIdentity -from adcp.types.domains.core.product_offer_filters import ( - AvailabilityHorizon, - ProductOfferFilters, - RequiredPerformanceStandard, -) -from adcp.types.domains.core.product_signal_targeting_option import ( - ActivationStatus, - AllowedTargetingMode, - ProductSignalTargetingOption, -) -from adcp.types.domains.core.product_targeting_resolution import ProductTargetingResolution -from adcp.types.domains.core.property import Property -from adcp.types.domains.core.property_delivery_metrics import PropertyDeliveryMetrics -from adcp.types.domains.core.property_id import PropertyId -from adcp.types.domains.core.property_list_ref import PropertyListReference -from adcp.types.domains.core.property_ref import PropertyReference -from adcp.types.domains.core.property_tag import PropertyTag -from adcp.types.domains.core.proposal import Proposal -from adcp.types.domains.core.protocol_envelope import ProtocolEnvelope -from adcp.types.domains.core.provenance import VerifyAgent10 -from adcp.types.domains.core.publisher_property_selector import ( - PublisherPropertySelector, - PublisherPropertySelector1, - PublisherPropertySelector2, - PublisherPropertySelector3, -) -from adcp.types.domains.core.push_notification_config import PushNotificationConfig -from adcp.types.domains.core.real_estate_item import ListingType, PropertyType, RealEstateItem -from adcp.types.domains.core.reference_renderer import ReferenceRenderer -from adcp.types.domains.core.registry_event import ( - AgentProfilePayload, - AuthorizationPayload, - AuthorizationType, - BadgeRole, - ChangedFields, - Classification, - CollectionIdentifier, - CollectionPayload, - CompliancePayload, - ComplianceStatus, - DelegationType, - GradingProfile, - Market, - Payload1, - Payload10, - Payload11, - Payload12, - Payload13, - Payload14, - Payload2, - Payload3, - Payload4, - Payload5, - Payload6, - Payload7, - Payload8, - Payload9, - PropertyPayload, - PropertySource, - PublisherAdagentsPayload, - RegistryEvent, - RegistryEvent1, - RegistryEvent10, - RegistryEvent11, - RegistryEvent12, - RegistryEvent13, - RegistryEvent14, - RegistryEvent15, - RegistryEvent16, - RegistryEvent17, - RegistryEvent18, - RegistryEvent19, - RegistryEvent2, - RegistryEvent20, - RegistryEvent3, - RegistryEvent4, - RegistryEvent5, - RegistryEvent6, - RegistryEvent7, - RegistryEvent8, - RegistryEvent9, - Storyboard, - StoryboardStatus, - StringArray, - Tracks, -) -from adcp.types.domains.core.registry_feed_response import Freshness, RegistryFeedResponse -from adcp.types.domains.core.reporting_adjustment import AccountingPeriod, ReportingAdjustment -from adcp.types.domains.core.reporting_adjustment_receipt import ( - ReportingAdjustmentReceipt, - ReportingAdjustmentRejectionCode, -) -from adcp.types.domains.core.reporting_canonical_content_digest import ( - ReportingCanonicalContentDigest, -) -from adcp.types.domains.core.reporting_canonicalization_contract import ( - AdditionalItem, - EmptyReport, - GoldenVectors, - OrderingEncoding, - ReportingCanonicalizationContract, - ReportingPrimaryKey, -) -from adcp.types.domains.core.reporting_capabilities import ReportingCapabilities -from adcp.types.domains.core.reporting_consumer_status import ( - ConsumerStatus, - FailureCode, - MismatchCode, - ReportingConsumerStatus, -) -from adcp.types.domains.core.reporting_control_total import ( - ReportingControlTotal, - ReportingControlTotal1, - ReportingControlTotal2, -) -from adcp.types.domains.core.reporting_coverage import ( - Limitation, - ReportingCoverage, - ReportingMediaBuyId, - ReportingPackageId, -) -from adcp.types.domains.core.reporting_dataset_share_destination import ( - Recipient, - ReportingCloud, - ReportingDatasetShareDestination, - ReportingDatasetShareDestination1, - ReportingDatasetShareDestination2, -) -from adcp.types.domains.core.reporting_delivery_capabilities import ( - OperationsContact, - ReportingDeliveryCapabilities, -) -from adcp.types.domains.core.reporting_delivery_config import ( - AuthoritativeParty, - CoverageRequirement, - ReportingDeliveryConfiguration, -) -from adcp.types.domains.core.reporting_delivery_config_state import ( - ReportingDeliveryConfigLifecycleState, - ReportingDeliveryConfigurationState, -) -from adcp.types.domains.core.reporting_delivery_method import ( - ReportingDeliveryMethod, - ReportingDeliveryMethod1, - ReportingDeliveryMethod2, - ReportingDeliveryMethod3, - ReportingOrchestration, -) -from adcp.types.domains.core.reporting_delivery_offering import ( - DestinationMode, - ProducerIdentity, - ReportingDeliveryOffering, - ReportingDeliveryPattern, - ReportingFeedPurpose, - ReportingProfile, -) -from adcp.types.domains.core.reporting_delivery_offering_id import ReportingDeliveryOfferingId -from adcp.types.domains.core.reporting_delivery_ready_webhook import ( - Readiness, - ReportingDeliveryReadyWebhook, -) -from adcp.types.domains.core.reporting_file_compression import ReportingFileCompression -from adcp.types.domains.core.reporting_file_entry import ReportingFileEntry -from adcp.types.domains.core.reporting_file_manifest import ReportingFileManifest -from adcp.types.domains.core.reporting_file_object_ref import ReportingFileObjectReference -from adcp.types.domains.core.reporting_ledger_changed_webhook import ReportingLedgerChangedWebhook -from adcp.types.domains.core.reporting_materialization import ReportingMaterialization -from adcp.types.domains.core.reporting_native_version_ref import ReportingNativeVersionReference -from adcp.types.domains.core.reporting_obligation import ( - ProductionStatus, - ReconciliationStatus, - ReportingObligation, -) -from adcp.types.domains.core.reporting_receipt import RejectionCode, ReportingReceipt -from adcp.types.domains.core.reporting_reconciliation_mode import ReportingReconciliationMode -from adcp.types.domains.core.reporting_reliability_statistics import ( - AdjustmentMagnitudeItem, - Basis, - LatencyPercentiles, - ReportingReliabilityMeasurementPeriod, - ReportingReliabilityStatistics, -) -from adcp.types.domains.core.reporting_report_definition import ( - Aggregation, - Calendar, - ContractVersion, - FinalityPolicies, - FinalityPolicies1, - FinalityPolicies2, - ReportCalendarTimezoneBasis, - ReportingReportDefinition, - RestatementPolicy, -) -from adcp.types.domains.core.reporting_resource import ( - Immutability, - ReportingReaderCompatibilityItem, - ReportingResource, -) -from adcp.types.domains.core.reporting_revision import ( - DataThroughPrecision, - FinalityBasis, - ReportingRevision, -) -from adcp.types.domains.core.reporting_schedule import ReportingSchedule, ReportingScheduleAlignment -from adcp.types.domains.core.reporting_schedule_offering import ( - PeriodAnchorPolicy, - PeriodTimezonePolicy, - ReportingScheduleOffering, -) -from adcp.types.domains.core.reporting_status_changed_webhook import ( - IssueId, - ReportingStatusChangedWebhook, -) -from adcp.types.domains.core.reporting_status_issue import ( - IssueState, - RecommendedAction, - ReportingStatusIssue, - ReportingStatusSeverity, - ResponsibleParty, -) -from adcp.types.domains.core.reporting_verification import ( - NativeCommitEvidence, - ObservedThrough, - PhysicalChecksums, - PhysicalChecksums1, - ReportingVerification, - VerificationPath, -) -from adcp.types.domains.core.reporting_verification_profile import ReportingVerificationProfile -from adcp.types.domains.core.reporting_verification_profile_set import ( - ReportingVerificationProfileSet, - ReportingVerificationProfileSetEnum, -) -from adcp.types.domains.core.reporting_webhook import ReportingFrequency, ReportingWebhook -from adcp.types.domains.core.reporting_write_destination import ( - ReportingWriteDestination, - ReportingWriteDestination1, - ReportingWriteDestination2, -) -from adcp.types.domains.core.representation_destination import RepresentationDestination -from adcp.types.domains.core.representation_rejection import RepresentationRejection -from adcp.types.domains.core.representation_selection import RepresentationSelection, ResolvedBy -from adcp.types.domains.core.requirements.asset_requirements import AssetRequirements -from adcp.types.domains.core.requirements.audio_asset_requirements import ( - AudioAssetRequirements, - SampleRate, -) -from adcp.types.domains.core.requirements.catalog_field_binding import ( - AssetPoolBinding, - CatalogFieldBinding, - CatalogFieldBinding1, - PerItemBindings, - ScalarBinding, -) -from adcp.types.domains.core.requirements.catalog_requirements import CatalogRequirements -from adcp.types.domains.core.requirements.css_asset_requirements import CssAssetRequirements -from adcp.types.domains.core.requirements.daast_asset_requirements import DaastAssetRequirements -from adcp.types.domains.core.requirements.html_asset_requirements import ( - HtmlAssetRequirements, - Sandbox, -) -from adcp.types.domains.core.requirements.image_asset_requirements import ( - Bleed, - Bleed1, - ImageAssetRequirements, - PixelRatio, -) -from adcp.types.domains.core.requirements.javascript_asset_requirements import ( - JavascriptAssetRequirements, - ModuleType, -) -from adcp.types.domains.core.requirements.markdown_asset_requirements import ( - MarkdownAssetRequirements, -) -from adcp.types.domains.core.requirements.offering_asset_constraint import OfferingAssetConstraint -from adcp.types.domains.core.requirements.text_asset_requirements import TextAssetRequirements -from adcp.types.domains.core.requirements.url_asset_requirements import ( - Protocol, - UrlAssetRequirements, -) -from adcp.types.domains.core.requirements.vast_asset_requirements import VastAssetRequirements -from adcp.types.domains.core.requirements.video_asset_requirements import ( - AudioCodec, - AudioSampleRate, - FrameRate, - VideoAssetRequirements, -) -from adcp.types.domains.core.requirements.webhook_asset_requirements import WebhookAssetRequirements -from adcp.types.domains.core.response import ProtocolResponse -from adcp.types.domains.core.response_payload_jws_envelope import ( - ResponsePayload, - ResponsePayloadJwsEnvelope, -) -from adcp.types.domains.core.rights_attestation_evaluation import ( - Issuer5, - Issuer6, - Reference, - RightsAttestationEvaluation, - Subject10, - Subject19, -) -from adcp.types.domains.core.rights_constraint import ( - ApprovalStatus, - ExcludedCountry, - GrantStatus, - Issuer8, - Issuer9, - Restriction, - RightsAgent, - RightsConstraint, - Subject20, - Subject29, -) -from adcp.types.domains.core.seller_agent_ref import SellerAgentReference -from adcp.types.domains.core.signal_coverage_forecast import ( - BucketCompleteness, - BucketSemantics, - Point, - SignalCoverageForecast, -) -from adcp.types.domains.core.signal_definition import AudienceScope, SignalDefinition, Tag -from adcp.types.domains.core.signal_definition_enrichment import SignalDefinitionEnrichment -from adcp.types.domains.core.signal_filters import SignalFilters -from adcp.types.domains.core.signal_id import SignalId8, SignalId9 -from adcp.types.domains.core.signal_listing import SignalListing -from adcp.types.domains.core.signal_modeling_disclosure import Audience, SignalModelingDisclosure -from adcp.types.domains.core.signal_pricing import ( - VendorPricing, - VendorPricing1, - VendorPricing2, - VendorPricing3, - VendorPricing4, - VendorPricing5, -) -from adcp.types.domains.core.signal_pricing_option import SignalPricingOption -from adcp.types.domains.core.signal_ref import SignalRef, SignalRef1, SignalRef2, SignalRef3 -from adcp.types.domains.core.signal_selection_group_rule import SignalSelectionGroupRule -from adcp.types.domains.core.signal_targeting import ( - SignalTargeting, - SignalTargeting1, - SignalTargeting2, - SignalTargeting3, -) -from adcp.types.domains.core.signal_targeting_expression import ( - SignalTargetingExpression, - SignalTargetingExpression1, - SignalTargetingExpression2, - SignalTargetingExpression3, -) -from adcp.types.domains.core.signal_targeting_rules import ResolutionModel, SignalTargetingRules -from adcp.types.domains.core.sla_window import SlaWindow -from adcp.types.domains.core.special import Special -from adcp.types.domains.core.spot_reporting_capability import SpotReportingCapability -from adcp.types.domains.core.start_timing import StartTiming -from adcp.types.domains.core.store_item import StoreItem -from adcp.types.domains.core.talent import Talent -from adcp.types.domains.core.targeting import AgeRestriction, TargetingOverlay -from adcp.types.domains.core.targeting_input import TargetingOverlayInput -from adcp.types.domains.core.targeting_modification import ( - Path, - Selector, - TargetingModification, - TargetingModification1, - TargetingModification2, -) -from adcp.types.domains.core.targeting_overlay_requirements import ( - BrowserRequirement, - BrowserRequirement1, - DaypartRequirement, - DaypartRequirement1, - KeywordRequirement, - KeywordRequirement1, - MetroRequirement, - MetroRequirement1, - Required, - TargetingOverlayRequirements, -) -from adcp.types.domains.core.targeting_overlay_support import ( - BrowserSupport, - BrowserSupport1, - CountrySupport, - CountrySupport1, - DaypartSupport, - DaypartSupport1, - IanaTimezones, - KeywordSupport, - KeywordSupport1, - MetroSupport, - MetroSupport1, - PlaceCatalogSupport, - PlaceSupport, - Supported, - TargetingOverlaySupport, -) -from adcp.types.domains.core.targeting_unknown_age_eligibility_constraint import ( - TargetingUnknownAgeEligibilityConstraint, -) -from adcp.types.domains.core.targeting_verified_age_basis_constraint import ( - TargetingVerifiedAgeBasisConstraint, -) -from adcp.types.domains.core.tasks_get_request import TasksGetRequest -from adcp.types.domains.core.tasks_get_response import ( - Details, - HistoryItem, - Progress, - TasksGetResponse, -) -from adcp.types.domains.core.tasks_list_request import Filters, Sort, TasksListRequest -from adcp.types.domains.core.tasks_list_response import ( - DomainBreakdown, - QuerySummary, - SortApplied, - TasksListResponse, -) -from adcp.types.domains.core.tracker_execution_contract import TrackerExecutionContract -from adcp.types.domains.core.tracker_execution_selector import ( - TrackerExecutionSelector, - TrackerExecutionSelector1, - TrackerExecutionSelector2, - TrackerExecutionSelector3, -) -from adcp.types.domains.core.transformer import ( - BrandAgent, - InputFormat, - InputFormat1, - InputFormat10, - InputFormat11, - InputFormat12, - InputFormat13, - InputFormat14, - InputFormat15, - InputFormat16, - InputFormat17, - InputFormat18, - InputFormat19, - InputFormat2, - InputFormat20, - InputFormat21, - InputFormat22, - InputFormat23, - InputFormat24, - InputFormat25, - InputFormat26, - InputFormat27, - InputFormat28, - InputFormat29, - InputFormat3, - InputFormat30, - InputFormat31, - InputFormat32, - InputFormat33, - InputFormat4, - InputFormat5, - InputFormat6, - InputFormat7, - InputFormat8, - InputFormat9, - Multiplicity, - OutputCapabilityId, - Transformer, - VariantDimension, - VoiceSynthesisRefItem, -) -from adcp.types.domains.core.transformer_param import Option, TransformerParam, ValueSource -from adcp.types.domains.core.truncation_sentinel import FieldTruncation, TruncationSentinel -from adcp.types.domains.core.user_match import UserMatch -from adcp.types.domains.core.vast_media_file_requirements import MimeType, VastMediafileRequirements -from adcp.types.domains.core.vast_tracker_constraints import ( - VastEvent, - VastOffset, - VastTarget, - VastTrackerConstraints, - VastVersions, -) -from adcp.types.domains.core.vehicle_item import ( - BodyStyle, - Condition, - FuelType, - Mileage, - Transmission, - VehicleItem, -) -from adcp.types.domains.core.vendor_metric_id import VendorMetricId -from adcp.types.domains.core.vendor_metric_optimization import VendorMetricOptimization -from adcp.types.domains.core.vendor_metric_optimization_supported_metric import ( - VendorMetricOptimizationSupportedMetric, -) -from adcp.types.domains.core.vendor_metric_value import VendorMetricValue -from adcp.types.domains.core.vendor_pricing_option import ( - AppliesToOutputCapabilityId, - VendorPricingOption, - VendorPricingOption1, - VendorPricingOption10, - VendorPricingOption11, - VendorPricingOption2, - VendorPricingOption3, - VendorPricingOption4, - VendorPricingOption5, - VendorPricingOption6, - VendorPricingOption7, - VendorPricingOption8, - VendorPricingOption9, -) -from adcp.types.domains.core.verification_token_claims import ( - AgenticadvertisingOrgVerificationTokenClaims, - VerificationTokenGradingProfile, - VerificationTokenMode, -) -from adcp.types.domains.core.version_envelope import AdcpVersionEnvelope -from adcp.types.domains.core.warning import Warning -from adcp.types.domains.core.warning_resource import WarningAffectedResource -from adcp.types.domains.core.webhook_activity_record import WebhookActivityRecord -from adcp.types.domains.core.webhook_challenge import WebhookChallenge -from adcp.types.domains.core.webhook_challenge_response import WebhookChallengeResponse -from adcp.types.domains.core.wholesale_feed_event import ( - AffectedEntityType, - AppliesTo, - AppliesTo1, - AppliesTo2, - Payload17, - Payload18, - Payload20, - Payload21, - Payload22, - Payload23, - Payload24, - Payload25, - RemovalReason, - Signal, - WholesaleFeedEvent, - WholesaleFeedEvent1, - WholesaleFeedEvent2, - WholesaleFeedEvent3, - WholesaleFeedEvent4, - WholesaleFeedEvent5, - WholesaleFeedEvent6, - WholesaleFeedEvent7, - WholesaleFeedEvent8, - WholesaleFeedEvent9, -) -from adcp.types.domains.core.wholesale_feed_webhook import ( - CacheScope, - NotificationType, - WholesaleFeedWebhook, -) -from adcp.types.domains.core.x_entity_types import XEntityTypes +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.core.acceptance_policy_profile_ids import ( + AcceptancePolicyProfileId, + AcceptancePolicyProfileIds, + ) + from adcp.types.domains.core.account import ( + Account, + Compression, + CreditLimit, + GovernanceAgent, + ReportingBucket, + ) + from adcp.types.domains.core.account_authorization import ( + AccountAuthorization, + AllowedTask, + ScopeName, + ) + from adcp.types.domains.core.account_change import ( + AccountChange, + Actor, + Repair, + UnavailableReason, + ) + from adcp.types.domains.core.account_change_recorded_webhook import AccountChangeRecordedWebhook + from adcp.types.domains.core.account_identity_change import ( + AccountIdentityChange, + AccountIdentityChange1, + AccountIdentityChange2, + ) + from adcp.types.domains.core.account_identity_change_preview import ( + AccountIdentityChangePreview, + AccountIdentityChangePreview1, + AccountIdentityChangePreview2, + AccountIdentityChangePreview3, + BlockedImpacts, + Blocker, + Effect1, + Impact, + NonblockingImpact, + NonblockingImpacts, + ) + from adcp.types.domains.core.account_ref import ( + AccountReference, + AccountReference1, + AccountReference2, + ) + from adcp.types.domains.core.account_status_changed_webhook import AccountStatusChangedWebhook + from adcp.types.domains.core.account_timezone_capability import ( + AccountSelection, + AccountTimezoneCapability, + SupportedTimezone, + ) + from adcp.types.domains.core.account_with_authorization import AccountWithAuthorization + from adcp.types.domains.core.activation_key import ActivationKey, ActivationKey1, ActivationKey2 + from adcp.types.domains.core.ad_inventory_config import AdInventoryConfiguration + from adcp.types.domains.core.agent_encryption_key import AgentEncryptionKey + from adcp.types.domains.core.agent_notification_config import AgentNotificationConfig + from adcp.types.domains.core.agent_notification_config_state import AgentNotificationConfigState + from adcp.types.domains.core.agent_reporting_destination import ( + AcceptedFormat, + AgentReportingDestination, + AgentReportingDestination1, + AgentReportingDestination2, + AgentReportingDestination3, + ) + from adcp.types.domains.core.agent_reporting_destination_state import ( + AgentReportingDestinationState, + PriorDestinationRef, + ) + from adcp.types.domains.core.agent_signing_key import AgentSigningKey + from adcp.types.domains.core.agent_webhook_challenge import AgentWebhookChallenge + from adcp.types.domains.core.app_item import AppItem, Platform + from adcp.types.domains.core.applicable_package_id import ApplicablePackageId + from adcp.types.domains.core.asset_group_vocabulary import AdcpAssetGroupVocabularyRegistry + from adcp.types.domains.core.assets.asset_union import ( + AssetVariant, + AudioChannelLayout, + C2paWatermarkAction, + CatalogType, + ContentIdType, + DaastAsset1, + DaastAsset2, + DaastTrackingEvent, + DaastVersion, + DigitalSourceType, + DisclosurePersistence, + DisclosurePosition, + DisplayTagAsset1, + DisplayTagAsset2, + DisplayTagAsset3, + EmbeddedProvenanceMethod, + Ext, + FeedFormat, + FieldModel, + FrameRateType, + GopType, + HttpMethod, + JavascriptModuleType, + Jurisdiction2, + Location1, + Location2, + Location4, + Location5, + Location6, + Location8, + Location9, + MacroBearingUrl1, + MacroBearingUrl2, + MacroDeclaration1, + MacroDeclaration10, + MacroDeclaration2, + MacroDeclaration3, + MacroDeclaration4, + MacroDeclaration5, + MacroDeclaration6, + MacroDeclaration7, + MacroDeclaration8, + MacroDeclaration9, + MacroDeclarationModel, + MacroDialect, + MacroMappingStatus, + MacroProcessingOperation, + MacroResolver, + MacroValueContext, + MarkdownFlavor, + MoovAtomPosition, + PixelTrackingEvent, + PlatformExtensionRef, + PublishedPostAsset1, + PublishedPostAsset2, + ReferenceAuthorization1, + Role2, + ScanType, + Target1, + UniversalMacro, + UpdateFrequency, + UrlAssetType, + VastAsset1, + VastAsset2, + VastTrackingEvent, + VastVersion, + VerifyAgent1, + WatermarkMediaType, + WebhookResponseType, + WebhookSecurityMethod, + ) + from adcp.types.domains.core.assets.audio_asset import BitDepth + from adcp.types.domains.core.assets.daast_asset import ( + DaastAsset3, + DaastAsset4, + Location13, + MacroDeclaration12, + ) + from adcp.types.domains.core.assets.display_tag_asset import ( + DisplayTagAsset4, + DisplayTagAsset5, + DisplayTagAsset6, + Location17, + Location18, + MacroDeclaration15, + MacroDeclaration16, + ) + from adcp.types.domains.core.assets.vast_asset import ( + Location26, + MacroDeclaration20, + VastAsset3, + VastAsset4, + ) + from adcp.types.domains.core.async_response_data import AdcpAsyncResponseData + from adcp.types.domains.core.async_response_refs.creative.get_creative_features_async_response_submitted import ( + GetCreativeFeaturesSubmitted, + ) + from adcp.types.domains.core.async_response_refs.creative.sync_creatives_async_response_input_required import ( + SyncCreativesInputRequired, + ) + from adcp.types.domains.core.async_response_refs.creative.sync_creatives_async_response_submitted import ( + SyncCreativesSubmitted, + ) + from adcp.types.domains.core.async_response_refs.creative.sync_creatives_async_response_working import ( + SyncCreativesWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.accept_proposal_async_response_input_required import ( + AcceptProposalInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.accept_proposal_async_response_submitted import ( + AcceptProposalSubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.accept_proposal_async_response_working import ( + AcceptProposalWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.build_creative_async_response_input_required import ( + BuildCreativeInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.build_creative_async_response_submitted import ( + BuildCreativeSubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.build_creative_async_response_working import ( + BuildCreativeWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.buy_products_async_response_input_required import ( + BuyProductsInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.buy_products_async_response_submitted import ( + BuyProductsSubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.buy_products_async_response_working import ( + BuyProductsWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.control_media_buy_async_response_input_required import ( + ControlMediaBuyInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.control_media_buy_async_response_submitted import ( + ControlMediaBuySubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.control_media_buy_async_response_working import ( + ControlMediaBuyWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.create_media_buy_async_response_input_required import ( + CreateMediaBuyInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.create_media_buy_async_response_submitted import ( + CreateMediaBuySubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.create_media_buy_async_response_working import ( + CreateMediaBuyWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.decline_proposals_async_response_input_required import ( + DeclineProposalsInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.decline_proposals_async_response_submitted import ( + DeclineProposalsSubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.decline_proposals_async_response_working import ( + DeclineProposalsWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.get_products_async_response_input_required import ( + GetProductsInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.get_products_async_response_submitted import ( + GetProductsSubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.get_products_async_response_working import ( + GetProductsWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.refine_proposals_async_response_input_required import ( + RefineProposalsInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.refine_proposals_async_response_submitted import ( + RefineProposalsSubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.refine_proposals_async_response_working import ( + RefineProposalsWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.request_proposals_async_response_input_required import ( + RequestProposalsInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.request_proposals_async_response_submitted import ( + RequestProposalsSubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.request_proposals_async_response_working import ( + RequestProposalsWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.sync_catalogs_async_response_input_required import ( + SyncCatalogsInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.sync_catalogs_async_response_submitted import ( + SyncCatalogsSubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.sync_catalogs_async_response_working import ( + SyncCatalogsWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.update_media_buy_async_response_input_required import ( + UpdateMediaBuyInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.update_media_buy_async_response_submitted import ( + UpdateMediaBuySubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.update_media_buy_async_response_working import ( + UpdateMediaBuyWorking, + ) + from adcp.types.domains.core.async_response_refs.signals.get_signals_async_response_submitted import ( + GetSignalsSubmitted, + ) + from adcp.types.domains.core.async_response_refs.signals.get_signals_async_response_working import ( + GetSignalsWorking, + ) + from adcp.types.domains.core.attestation_capabilities import ( + AcceptedIssuer, + AttestationCapabilities, + CredentialOrigin, + Resolver, + SupportedDeliveryMethod, + ) + from adcp.types.domains.core.attestation_evaluation import Outcome + from adcp.types.domains.core.attestation_issuer import ( + AttestationIssuer, + AttestationIssuer1, + AttestationIssuer2, + AttestationIssuer3, + ) + from adcp.types.domains.core.attestation_reference import ( + AttestationReference, + EmbeddedCredential, + Locator, + Locator1, + ValidityHint, + ) + from adcp.types.domains.core.attestation_subject import ( + AttestationSubject, + AttestationSubject1, + AttestationSubject2, + AttestationSubject3, + ) + from adcp.types.domains.core.attribution_window import AttributionWindow + from adcp.types.domains.core.audience_activation_method import ( + AudienceActivationMethod, + AudienceActivationMethod1, + AudienceActivationMethod2, + AudienceActivationMethod3, + AudienceActivationMethod4, + AudienceActivationMethod5, + AudienceActivationMethod6, + Cloud, + ConsumerIdentity, + ) + from adcp.types.domains.core.audience_characteristic import AudienceCharacteristic + from adcp.types.domains.core.audience_evidence import ( + AudienceEvidence, + Subject11, + Subject12, + Subject13, + Subject14, + Subject15, + Subject16, + Subject17, + Subject2, + Subject21, + Subject22, + Subject23, + Subject24, + Subject25, + Subject26, + Subject27, + Subject3, + Subject31, + Subject32, + Subject33, + Subject34, + Subject35, + Subject36, + Subject37, + ) + from adcp.types.domains.core.audience_evidence_pin import AudienceEvidencePin + from adcp.types.domains.core.audience_evidence_requirements import AudienceEvidenceRequirements + from adcp.types.domains.core.audience_evidence_selection import AudienceEvidenceSelection + from adcp.types.domains.core.audience_member import AudienceMember + from adcp.types.domains.core.audience_selector import ( + AudienceSelector, + AudienceSelector1, + AudienceSelector2, + AudienceSelector3, + AudienceSelector4, + ) + from adcp.types.domains.core.audience_source import ( + AudienceSource, + AudienceSource1, + AudienceSource2, + ) + from adcp.types.domains.core.authorized_agent_base import AuthorizedAgentBaseFields + from adcp.types.domains.core.bidding_policy import ( + BiddingPolicy, + CostPer, + Roas, + Strength, + Strength1, + ) + from adcp.types.domains.core.bidding_policy_capability import ( + BiddingPolicyCapability, + CostPerStrength, + MaxBidWithCostPer, + MaxBidWithRoas, + PolicyProfile, + RoasStrength, + ScopeCapability, + ) + from adcp.types.domains.core.brand_id import BrandId + from adcp.types.domains.core.brand_key import BrandKey + from adcp.types.domains.core.brand_ref import ( + BrandKitOverride, + BrandReference, + Colors, + DataSubjectContestation, + ) + from adcp.types.domains.core.brand_response_authorization_result import ( + BrandResponseAuthorizationResult, + BrandResponseAuthorizationResult1, + BrandResponseAuthorizationResult2, + Trust, + ) + from adcp.types.domains.core.budget_allocation import ( + BudgetAllocation, + BudgetAllocation1, + BudgetAllocation2, + OptimizationGoal1, + OptimizationGoal2, + OptimizationGoal3, + OptimizationGoal4, + OptimizationGoal5, + OptimizationGoal6, + OptimizationGoal7, + Target3, + Target4, + Target5, + Target6, + Target7, + Target8, + ) + from adcp.types.domains.core.business_entity import Bank, BusinessEntity, Contact + from adcp.types.domains.core.cancellation_policy import CancellationFee, CancellationPolicy + from adcp.types.domains.core.canonical_account_ref import ( + CanonicalAccountReference, + CanonicalAccountReference1, + CanonicalAccountReference2, + ) + from adcp.types.domains.core.canonical_audience_evidence import ( + AttestationDigest, + CanonicalAudienceEvidence, + ) + from adcp.types.domains.core.canonical_audience_evidence_selection import ( + CanonicalAudienceEvidenceSelection, + VerifiedAttestationDigest, + ) + from adcp.types.domains.core.canonical_budget_allocation import ( + CanonicalBudgetAllocation, + CanonicalBudgetAllocation1, + CanonicalBudgetAllocation2, + ) + from adcp.types.domains.core.canonical_delivery_forecast import CanonicalDeliveryForecast + from adcp.types.domains.core.canonical_forecast_point import CanonicalForecastPoint + from adcp.types.domains.core.canonical_forecast_vendor_metric_value import ( + CanonicalForecastVendorMetricValue, + ) + from adcp.types.domains.core.canonical_format_kind import CanonicalFormatKind + from adcp.types.domains.core.canonical_format_option import CanonicalFormatOption, FormatKind + from adcp.types.domains.core.canonical_measurement_terms import CanonicalMeasurementTerms + from adcp.types.domains.core.canonical_media_buy_action import ( + Action3, + Action4, + CanonicalMediaBuyAction, + CanonicalMediaBuyAction1, + CanonicalMediaBuyAction2, + CanonicalMediaBuyAction3, + ) + from adcp.types.domains.core.canonical_media_buy_action_fields import ( + CanonicalMediaBuyActionFields, + ) + from adcp.types.domains.core.canonical_media_buy_features import CanonicalMediaBuyFeatures + from adcp.types.domains.core.canonical_metric_qualifier import CanonicalMetricQualifier + from adcp.types.domains.core.canonical_optimization_goal import ( + CanonicalOptimizationGoal, + CanonicalOptimizationGoal1, + CanonicalOptimizationGoal2, + CanonicalOptimizationGoal3, + Target10, + Target11, + ) + from adcp.types.domains.core.canonical_performance_standard import CanonicalPerformanceStandard + from adcp.types.domains.core.canonical_placement import ( + CanonicalDoohPlacementAttributes, + CanonicalDoohScreenResolution, + CanonicalProductPlacement, + CanonicalProductPlacement1, + CanonicalProductPlacement2, + ) + from adcp.types.domains.core.canonical_pricing_option import ( + CanonicalPricingOption, + PricingModel, + ) + from adcp.types.domains.core.canonical_product import ( + CanonicalProduct, + PublisherProperty1, + PublisherProperty2, + PublisherProperty3, + PublisherProperty4, + PublisherProperty5, + PublisherProperty6, + PublisherProperty7, + ) + from adcp.types.domains.core.canonical_product_action import CanonicalProductAction + from adcp.types.domains.core.canonical_projection_ref import ( + AssetSource, + CanonicalProjectionReference, + ) + from adcp.types.domains.core.canonical_projection_slot_override import ( + CanonicalProjectionSlotOverride, + ) + from adcp.types.domains.core.canonical_proposal import CanonicalProposal, ProposalKind + from adcp.types.domains.core.canonical_reporting_capabilities import ( + CanonicalReportingCapabilities, + ) + from adcp.types.domains.core.canonical_reporting_commitment import ( + CanonicalReportingCommitment, + CanonicalReportingCommitment1, + CanonicalReportingCommitment2, + ) + from adcp.types.domains.core.canvas_constraint import CanvasConstraint, Constraint + from adcp.types.domains.core.capabilities_changed_webhook import CapabilitiesChangedWebhook + from adcp.types.domains.core.catalog_item_availability_error import CatalogItemAvailabilityError + from adcp.types.domains.core.catalog_item_availability_ref import ( + CatalogItemAvailabilityReference, + ) + from adcp.types.domains.core.catalog_item_availability_state import CatalogItemAvailabilityState + from adcp.types.domains.core.catalog_item_availability_update import ( + CatalogItemAvailabilityUpdate, + ) + from adcp.types.domains.core.catalog_item_availability_update_result import ( + CatalogItemAvailabilityUpdateResult, + ) + from adcp.types.domains.core.catalog_item_delivery_metrics import CatalogItemDeliveryMetrics + from adcp.types.domains.core.catalog_item_reference_not_found_error import ( + CatalogItemReferenceNotFoundError, + ) + from adcp.types.domains.core.catalog_selection import CatalogSelection + from adcp.types.domains.core.catchment import Catchment + from adcp.types.domains.core.collection import Collection, RelatedCollection + from adcp.types.domains.core.collection_delivery_metrics import CollectionDeliveryMetrics + from adcp.types.domains.core.collection_distribution import CollectionDistribution + from adcp.types.domains.core.collection_list_ref import CollectionListReference + from adcp.types.domains.core.collection_property_delivery_metrics import ( + CollectionPropertyDeliveryMetrics, + ) + from adcp.types.domains.core.collection_ref import CollectionReference + from adcp.types.domains.core.collection_selection import ( + CollectionSelection, + CollectionSelection1, + CollectionSelection2, + ) + from adcp.types.domains.core.collection_selector import CollectionSelector + from adcp.types.domains.core.committed_metric import ( + CommittedMetric, + CommittedMetric1, + CommittedMetric2, + Qualifier1, + QualifierModel, + ) + from adcp.types.domains.core.compact_task_input_required import CompactTaskInputRequired + from adcp.types.domains.core.compact_task_submitted import CompactTaskSubmitted + from adcp.types.domains.core.compact_task_working import CompactTaskWorking + from adcp.types.domains.core.content_rating import ContentRating + from adcp.types.domains.core.context import ContextObject + from adcp.types.domains.core.creative_approval_scope import ScopedCreativeApproval + from adcp.types.domains.core.creative_asset import CreativeAsset, Input + from adcp.types.domains.core.creative_assets import CreativeAssets, CreativeAssets1 + from adcp.types.domains.core.creative_assignment import CreativeAssignment, RotationMode + from adcp.types.domains.core.creative_consumption import CreativeConsumption + from adcp.types.domains.core.creative_delivery_metrics import CreativeDeliveryMetrics + from adcp.types.domains.core.creative_filters import CreativeFilters + from adcp.types.domains.core.creative_item import CreativeItem, CreativeItem1, CreativeItem2 + from adcp.types.domains.core.creative_locale_policy import CreativeLocalePolicy + from adcp.types.domains.core.creative_localization import CreativeLocalization, TargetVariant + from adcp.types.domains.core.creative_localization_readback import ( + CreativeLocalizationReadback, + ResolvedAssets, + ResolvedAssets1, + Variants, + Variants1, + Variants2, + ) + from adcp.types.domains.core.creative_manifest import CreativeManifest + from adcp.types.domains.core.creative_operation_format_declaration import ( + CreativeOperationFormatDeclaration, + ) + from adcp.types.domains.core.creative_policy import CreativePolicy, ProvenanceRequirements + from adcp.types.domains.core.creative_representation import CreativeRepresentation + from adcp.types.domains.core.creative_representation_set import CreativeRepresentationSet + from adcp.types.domains.core.creative_revision_id import CreativeRevisionId + from adcp.types.domains.core.creative_variable import CreativeVariable, VariableType + from adcp.types.domains.core.creative_variant import ( + Artifact, + CreativeVariant, + GenerationContext, + ) + from adcp.types.domains.core.daast_tracker_constraints import ( + DaastEvent, + DaastOffset, + DaastTarget, + DaastTrackerConstraints, + DaastVersions, + ) + from adcp.types.domains.core.data_provider_signal_selector import ( + DataProviderSignalSelector, + DataProviderSignalSelector1, + DataProviderSignalSelector2, + DataProviderSignalSelector3, + SignalTag, + ) + from adcp.types.domains.core.date_range import DateRange + from adcp.types.domains.core.datetime_range import DatetimeRange + from adcp.types.domains.core.daypart_target import DaypartTarget + from adcp.types.domains.core.deadline_policy import DeadlinePolicy, MaterialStage + from adcp.types.domains.core.delivery_breakdown_controls import DeliveryBreakdownControls + from adcp.types.domains.core.delivery_forecast import DeliveryForecast + from adcp.types.domains.core.delivery_metric_aggregate import ( + DeliveryMetricAggregate, + DeliveryMetricAggregate1, + DeliveryMetricAggregate2, + Field0, + Qualifier3, + ) + from adcp.types.domains.core.delivery_metrics import ( + ByActionSourceItem, + ByEventTypeItem, + DeliveryMetrics, + DoohMetrics, + DoohMetrics1, + EstimationBasis, + OohMetrics, + Panel, + Posting, + QuartileData, + ReachWindow, + TimeBasedView, + VenueBreakdownItem, + Viewability1, + ViewedSecondsHistogramItem, + ViewedSecondsPercentiles, + ) + from adcp.types.domains.core.delivery_provider import DeliveryProvider + from adcp.types.domains.core.delivery_recipient import DeliveryRecipient + from adcp.types.domains.core.demographic_age_range import DemographicAgeRange + from adcp.types.domains.core.demographic_predicate import DemographicPredicate + from adcp.types.domains.core.demographic_reporting_capability import ( + DemographicReportingCapability, + ReportingMode, + ) + from adcp.types.domains.core.demographic_targeting_capability import ( + DemographicTargetingCapability, + ExecutionMode, + UnknownHandling, + ) + from adcp.types.domains.core.demographic_targeting_intent import DemographicTargetingIntent + from adcp.types.domains.core.demographic_targeting_resolution import ( + DemographicTargetingResolution, + Execution, + Execution1, + Execution2, + IntervalId, + ) + from adcp.types.domains.core.deployment import Deployment, Deployment1, Deployment2 + from adcp.types.domains.core.destination import Destination1, Destination2 + from adcp.types.domains.core.destination_item import DestinationItem, DestinationType + from adcp.types.domains.core.diagnostic_issue import DiagnosticIssue, Severity + from adcp.types.domains.core.downstream_connection_requirement import ( + ConnectionType, + DownstreamConnectionRequirement, + ResourceRef, + ) + from adcp.types.domains.core.duration import Duration + from adcp.types.domains.core.education_item import DegreeType, EducationItem, Level, Modality + from adcp.types.domains.core.error import BuyerReason, DiscriminatorItem, Issue, Recovery + from adcp.types.domains.core.evaluator_spec import ( + EvalBudget, + EvaluatorSpec, + EvaluatorSpec1, + EvaluatorSpec2, + EvaluatorSpec3, + Exemplars, + FeatureAgent, + RankByItem, + RankByItem1, + RankByItem2, + ) + from adcp.types.domains.core.event import Event + from adcp.types.domains.core.event_custom_data import Content, EventCustomData + from adcp.types.domains.core.event_source_health import Detail, EventSourceHealth + from adcp.types.domains.core.event_surface import Category, EventSurface + from adcp.types.domains.core.experimental_feature_id import ExperimentalFeatureId + from adcp.types.domains.core.ext import ExtensionObject + from adcp.types.domains.core.feature_requirement import FeatureRequirement, IfNotCovered + from adcp.types.domains.core.flight_item import FlightItem + from adcp.types.domains.core.forecast_dimension_audience import AudienceForecastDimension + from adcp.types.domains.core.forecast_dimension_device_platform import ( + DevicePlatformForecastDimension, + ) + from adcp.types.domains.core.forecast_dimension_device_type import DeviceTypeForecastDimension + from adcp.types.domains.core.forecast_dimension_geo import GeoForecastDimension + from adcp.types.domains.core.forecast_dimension_placement import PlacementForecastDimension + from adcp.types.domains.core.forecast_dimension_signal import Presence, SignalForecastDimension + from adcp.types.domains.core.forecast_dimension_time import TimeForecastDimension + from adcp.types.domains.core.forecast_point import ForecastPoint, ViewableRate + from adcp.types.domains.core.forecast_point_dimensions import ForecastPointDimensions + from adcp.types.domains.core.forecast_range import ForecastRange + from adcp.types.domains.core.forecast_rate_range import ForecastRateRange + from adcp.types.domains.core.forecast_vendor_metric_value import ForecastVendorMetricValue + from adcp.types.domains.core.format import ( + Assets10, + Assets11, + Assets12, + Assets13, + Assets14, + Assets15, + Assets16, + Assets17, + Assets18, + Assets19, + Assets20, + Assets21, + Assets22, + Assets23, + Assets24, + Assets25, + Assets26, + Assets27, + Assets28, + Assets29, + Assets30, + Assets31, + Assets32, + Assets33, + Assets34, + Assets35, + Assets36, + Assets37, + Assets38, + Assets39, + Assets40, + Assets41, + Assets42, + Assets43, + Assets44, + Assets45, + Assets46, + Assets47, + Assets48, + Assets49, + Assets50, + Assets51, + Assets9, + Assets94, + BaseGroupAsset, + BaseIndividualAsset, + CanonicalParameters, + CanonicalParameters1, + CanonicalParameters10, + CanonicalParameters11, + CanonicalParameters12, + CanonicalParameters13, + CanonicalParameters14, + CanonicalParameters15, + CanonicalParameters16, + CanonicalParameters17, + CanonicalParameters18, + CanonicalParameters19, + CanonicalParameters2, + CanonicalParameters20, + CanonicalParameters21, + CanonicalParameters22, + CanonicalParameters23, + CanonicalParameters24, + CanonicalParameters25, + CanonicalParameters26, + CanonicalParameters27, + CanonicalParameters28, + CanonicalParameters29, + CanonicalParameters3, + CanonicalParameters30, + CanonicalParameters31, + CanonicalParameters32, + CanonicalParameters33, + CanonicalParameters4, + CanonicalParameters5, + CanonicalParameters6, + CanonicalParameters7, + CanonicalParameters8, + CanonicalParameters9, + Dimensions, + Dimensions1, + DisclosureCapability, + FormatAssetUnion, + FormatCard, + FormatCardDetailed, + GroupFormatAssetUnion, + Renders, + Renders1, + Responsive, + UnknownFormatAsset, + UnknownGroupAsset, + ) + from adcp.types.domains.core.format_id import FormatReferenceStructuredObject + from adcp.types.domains.core.format_option_ref import ( + FormatOptionReference, + FormatOptionReference1, + FormatOptionReference2, + ) + from adcp.types.domains.core.format_shape_vocabulary import AdcpFormatShapeVocabularyRegistry + from adcp.types.domains.core.frequency_cap import FrequencyCap + from adcp.types.domains.core.frequency_cap_constraints import FrequencyCapConstraints + from adcp.types.domains.core.frequency_cap_duration_unit import FrequencyCapDurationUnit + from adcp.types.domains.core.frequency_cap_impression_constraints import ( + AllowedValue, + FrequencyCapImpressionConstraints, + ) + from adcp.types.domains.core.frequency_cap_interval_constraints import ( + AllowedInterval, + FrequencyCapIntervalConstraints, + ) + from adcp.types.domains.core.frequency_cap_requirements import FrequencyCapRequirements + from adcp.types.domains.core.generation_credential import GenerationCredential + from adcp.types.domains.core.geo_breakdown_support import GeographicBreakdownSupport + from adcp.types.domains.core.geo_delivery_metrics import GeoDeliveryMetrics + from adcp.types.domains.core.geo_metro import GeoMetro + from adcp.types.domains.core.geo_place_area import GeographicPlaceArea + from adcp.types.domains.core.geo_place_catalog_capability import ( + GeographicPlaceCatalogCapability, + SupportedVersion, + ) + from adcp.types.domains.core.geo_place_catalog_entry import ( + GeographicPlaceCatalogEntry, + ParentLabel, + ReplacedByValue, + ) + from adcp.types.domains.core.geo_place_requirement import ( + CatalogRequirement, + GeographicPlaceRequirement, + ) + from adcp.types.domains.core.geo_place_resolver import Auth, GeographicPlaceResolver + from adcp.types.domains.core.geo_place_support import GeographicPlaceSystemSupport + from adcp.types.domains.core.geo_place_system import ( + GeographicPlaceIdentifierSystem, + GeographicPlaceIdentifierSystem1, + GeographicPlaceIdentifierSystem2, + ) + from adcp.types.domains.core.geo_place_type import ( + GeographicPlaceType, + GeographicPlaceType1, + GeographicPlaceType2, + ) + from adcp.types.domains.core.geo_region_requirement import ( + Countries1, + GeographicRegionRequirement, + ) + from adcp.types.domains.core.geo_region_support import Countries3, GeographicRegionSupport + from adcp.types.domains.core.get_geo_place_resolution_request import ( + GetGeographicPlaceResolutionRequest, + ) + from adcp.types.domains.core.get_geo_place_resolution_response import ( + GetGeographicPlaceResolutionResponse, + ) + from adcp.types.domains.core.hotel_item import HotelItem + from adcp.types.domains.core.iana_timezone import IanaTimezoneIdentifier + from adcp.types.domains.core.impairment import Impairment, Transition + from adcp.types.domains.core.indicator import Indicator + from adcp.types.domains.core.indicator_bearing import IndicatorBearingResourceState + from adcp.types.domains.core.indicator_scope import IndicatorScope + from adcp.types.domains.core.indicators_changed_webhook import ( + IndicatorsChangedWebhook, + RelationshipKind, + ) + from adcp.types.domains.core.industry_identifier import IndustryIdentifier + from adcp.types.domains.core.insertion_order import ( + InsertionOrder, + PaymentTerms, + Terms, + TotalBudget, + ) + from adcp.types.domains.core.installment import DerivativeOf, Installment + from adcp.types.domains.core.installment_deadlines import InstallmentDeadlines + from adcp.types.domains.core.installment_delivery_metrics import InstallmentDeliveryMetrics + from adcp.types.domains.core.installment_property_delivery_metrics import ( + InstallmentPropertyDeliveryMetrics, + ) + from adcp.types.domains.core.installment_ref import InstallmentReference + from adcp.types.domains.core.inventory_list_application import ( + InventoryListApplication, + InventoryListApplication1, + InventoryListApplication2, + Summary, + Summary2, + ) + from adcp.types.domains.core.job_item import EmploymentType, ExperienceLevel, JobItem, Salary + from adcp.types.domains.core.keyword_delivery_metrics import KeywordDeliveryMetrics + from adcp.types.domains.core.limited_series import LimitedSeries + from adcp.types.domains.core.locale_tag import LanguageTag + from adcp.types.domains.core.localized_creative_asset import ( + LocalizedCreativeAsset, + LocalizedCreativeAsset1, + LocalizedCreativeAsset10, + LocalizedCreativeAsset11, + LocalizedCreativeAsset12, + LocalizedCreativeAsset13, + LocalizedCreativeAsset14, + LocalizedCreativeAsset15, + LocalizedCreativeAsset16, + LocalizedCreativeAsset17, + LocalizedCreativeAsset18, + LocalizedCreativeAsset19, + LocalizedCreativeAsset2, + LocalizedCreativeAsset20, + LocalizedCreativeAsset21, + LocalizedCreativeAsset22, + LocalizedCreativeAsset3, + LocalizedCreativeAsset4, + LocalizedCreativeAsset5, + LocalizedCreativeAsset7, + LocalizedCreativeAsset8, + LocalizedCreativeAsset9, + ) + from adcp.types.domains.core.macro_bearing_url import MacroBearingUrl3, MacroBearingUrl4 + from adcp.types.domains.core.macro_resolution_capability import ( + MacroProcessingCapability, + MappingStatus, + Operation, + ) + from adcp.types.domains.core.macro_resolution_result import MacroResolutionResult + from adcp.types.domains.core.material_deadline import MaterialDeadline + from adcp.types.domains.core.mcp_webhook_payload import McpWebhookPayload + from adcp.types.domains.core.measurement_readiness import MeasurementReadiness + from adcp.types.domains.core.measurement_terms import MeasurementTerms + from adcp.types.domains.core.measurement_window import MeasurementWindow + from adcp.types.domains.core.media_buy import MediaBuy + from adcp.types.domains.core.media_buy_available_action import MediaBuyAvailableAction + from adcp.types.domains.core.media_buy_available_action_id import MediaBuyAvailableActionId + from adcp.types.domains.core.media_buy_change_term_id import MediaBuyChangeTermId + from adcp.types.domains.core.media_buy_features import MediaBuyFeatures + from adcp.types.domains.core.media_buy_frequency_cap import MediaBuyFrequencyCap + from adcp.types.domains.core.media_buy_frequency_cap_capability import ( + MediaBuyFrequencyCapCapability, + ) + from adcp.types.domains.core.media_buy_frequency_cap_requirement import ( + MediaBuyFrequencyCapRequirement, + ) + from adcp.types.domains.core.media_buy_frequency_cap_support import MediaBuyFrequencyCapSupport + from adcp.types.domains.core.media_buy_legacy_terms_ref import MediaBuyTermsReference + from adcp.types.domains.core.media_buy_support import ProductMediaBuySupport + from adcp.types.domains.core.media_buy_support_requirements import ( + ProductMediaBuySupportRequirements, + ) + from adcp.types.domains.core.missing_metric import MissingMetric, MissingMetric1, MissingMetric2 + from adcp.types.domains.core.negative_keyword import NegativeKeyword + from adcp.types.domains.core.notification_config import NotificationConfig + from adcp.types.domains.core.offering import GeoTargets, Offering + from adcp.types.domains.core.offering_asset_group import Items, OfferingAssetGroup + from adcp.types.domains.core.operator_identity import OperatorIdentity + from adcp.types.domains.core.operator_unit import OperatorUnit + from adcp.types.domains.core.opportunity_context import ( + CloseReason, + Intent, + OpportunityContext, + Phase, + ) + from adcp.types.domains.core.optimization_goal import ( + OptimizationGoal10, + OptimizationGoal8, + OptimizationGoal9, + Target14, + Target15, + Target16, + Target17, + Target18, + Target19, + ) + from adcp.types.domains.core.outcome_measurement import OutcomeMeasurement + from adcp.types.domains.core.outcome_target_cost_per import OutcomeTargetCostPer + from adcp.types.domains.core.overlay import Bounds, Overlay, Visual + from adcp.types.domains.core.package import Package + from adcp.types.domains.core.package_delivery_metric_value import PackageDeliveryMetricValue + from adcp.types.domains.core.package_format_snapshot import ( + PackageFormatSnapshot, + PackageFormatSnapshot1, + PackageFormatSnapshot10, + PackageFormatSnapshot11, + PackageFormatSnapshot12, + PackageFormatSnapshot13, + PackageFormatSnapshot14, + PackageFormatSnapshot15, + PackageFormatSnapshot16, + PackageFormatSnapshot17, + PackageFormatSnapshot18, + PackageFormatSnapshot19, + PackageFormatSnapshot2, + PackageFormatSnapshot20, + PackageFormatSnapshot21, + PackageFormatSnapshot22, + PackageFormatSnapshot23, + PackageFormatSnapshot24, + PackageFormatSnapshot25, + PackageFormatSnapshot26, + PackageFormatSnapshot27, + PackageFormatSnapshot28, + PackageFormatSnapshot29, + PackageFormatSnapshot3, + PackageFormatSnapshot30, + PackageFormatSnapshot31, + PackageFormatSnapshot32, + PackageFormatSnapshot33, + PackageFormatSnapshot4, + PackageFormatSnapshot5, + PackageFormatSnapshot6, + PackageFormatSnapshot7, + PackageFormatSnapshot8, + PackageFormatSnapshot9, + ) + from adcp.types.domains.core.package_signal_targeting import ( + PackageSignalTargeting, + PackageSignalTargeting1, + PackageSignalTargeting2, + PackageSignalTargeting3, + PackageSignalTargeting4, + PackageSignalTargeting5, + PackageSignalTargeting6, + PackageSignalTargeting7, + ) + from adcp.types.domains.core.package_signal_targeting_group import ( + Operator, + PackageSignalTargetingGroup, + ) + from adcp.types.domains.core.package_signal_targeting_groups import PackageSignalTargetingGroups + from adcp.types.domains.core.package_targeting_resolution import PackageTargetingResolution + from adcp.types.domains.core.pagination_request import PaginationRequest + from adcp.types.domains.core.pagination_response import PaginationResponse + from adcp.types.domains.core.performance_feedback import ( + MeasurementPeriod, + Metric7, + PerformanceFeedback, + ) + from adcp.types.domains.core.performance_feedback_assertion import ( + ConfidenceInterval, + PerformanceFeedbackAssertion, + ) + from adcp.types.domains.core.performance_feedback_metric import ( + PerformanceFeedbackMetric, + PerformanceFeedbackMetric1, + PerformanceFeedbackMetric2, + ) + from adcp.types.domains.core.performance_standard import PerformanceStandard + from adcp.types.domains.core.placement import ( + Placement, + ProductDoohPlacementAttributes, + ProductDoohScreenResolution, + ) + from adcp.types.domains.core.placement_definition import ( + FormatOptions, + PlacementDefinition, + PublisherDoohPlacementAttributes, + PublisherDoohScreenResolution, + ) + from adcp.types.domains.core.placement_delivery_metrics import PlacementDeliveryMetrics + from adcp.types.domains.core.placement_evidence import PlacementEvidence + from adcp.types.domains.core.placement_identity import ( + PlacementIdentity, + PlacementIdentity1, + PlacementIdentity2, + ) + from adcp.types.domains.core.placement_presentation import ( + BoxDecoration, + Canvas, + Color, + CreativeSlot, + Fit, + ImageDecoration, + ImageRef, + Layer, + PlacementPresentationDocument, + Rectangle, + TextDecoration, + ) + from adcp.types.domains.core.placement_property_delivery_metrics import ( + PlacementPropertyDeliveryMetrics, + ) + from adcp.types.domains.core.placement_ref import PlacementReference + from adcp.types.domains.core.placement_selection import PlacementSelection1, PlacementSelection2 + from adcp.types.domains.core.planned_delivery import Geo, PlannedDelivery + from adcp.types.domains.core.platform_extension_ref import PlatformExtensionReference + from adcp.types.domains.core.positive_postal_area_support import PositivePostalAreaSupport + from adcp.types.domains.core.postal_area import ( + PostalArea, + PostalArea1, + PostalArea11, + PostalArea110, + PostalArea111, + PostalArea112, + PostalArea113, + PostalArea114, + PostalArea115, + PostalArea116, + PostalArea117, + PostalArea118, + PostalArea119, + PostalArea12, + PostalArea120, + PostalArea121, + PostalArea13, + PostalArea14, + PostalArea15, + PostalArea16, + PostalArea17, + PostalArea18, + PostalArea19, + PostalArea2, + System1, + System2, + System3, + System9, + ) + from adcp.types.domains.core.postal_area_support import ( + CAEnum, + GBEnum, + ME, + PostalAreaSupport, + PostalAreaSupportAdditionalPropertyEnum, + ) + from adcp.types.domains.core.postal_country_system import ( + PostalCountrySystem, + PostalCountrySystem1, + PostalCountrySystem10, + PostalCountrySystem2, + PostalCountrySystem3, + PostalCountrySystem4, + PostalCountrySystem5, + PostalCountrySystem6, + PostalCountrySystem7, + PostalCountrySystem8, + PostalCountrySystem9, + System11, + System12, + System13, + System19, + ) + from adcp.types.domains.core.presentation_ref import PlacementPresentationReference + from adcp.types.domains.core.preview_provider import PublisherDesignatedPreviewProvider, Route + from adcp.types.domains.core.preview_renderer_metadata import ( + PreviewRendererMetadata, + RenderingOrigin, + ) + from adcp.types.domains.core.price import Price + from adcp.types.domains.core.pricing_option import PricingOption + from adcp.types.domains.core.principal_changed_webhook import PrincipalChangedWebhook + from adcp.types.domains.core.principal_declarations import ( + AgentDeclarations, + AsyncAdcpVersion, + WebhookSigningAlgorithm, + ) + from adcp.types.domains.core.principal_declarations_state import ( + Axis, + Exclusion, + PrincipalDeclarationsState, + ) + from adcp.types.domains.core.principal_state import ( + DestinationRef, + PrincipalState, + RetiredDestination, + ) + from adcp.types.domains.core.product import ( + AudienceActivation, + ConversionTracking, + DeliveryMeasurement, + MaterialSubmission, + MetricOptimization, + Product, + ProductCard, + ProductCardDetailed, + PublisherProperty81, + PublisherProperty82, + PublisherProperty83, + PublisherProperty84, + PublisherProperty85, + PublisherProperty86, + PublisherProperty87, + Specification, + SupportedMetric, + SupportedTarget5, + SupportedViewDuration, + ) + from adcp.types.domains.core.product_allocation import ProductAllocation + from adcp.types.domains.core.product_allowed_action import ProductAllowedAction + from adcp.types.domains.core.product_audience_evidence_requirements import ( + AcceptedAttestationIssuers, + AcceptedAttestationIssuers1, + AcceptedAttestationIssuers2, + AcceptedAttestationIssuers3, + ProductAudienceEvidenceRequirements, + ) + from adcp.types.domains.core.product_card_reference_asset import ProductCardReferenceAsset + from adcp.types.domains.core.product_change_map import ProductChangeMap, ProductChangeMap1 + from adcp.types.domains.core.product_execution_requirement import ( + Connection, + ProductExecutionRequirement, + ProductExecutionRequirement1, + ProductExecutionRequirement2, + ProductExecutionRequirement3, + ) + from adcp.types.domains.core.product_filters import ( + AudienceActivationMethods, + AudienceActivationMethods1, + AudienceActivationMethods2, + AudienceActivationMethods3, + AudienceActivationMethods4, + AudienceActivationMethods5, + Keyword, + ProductFilters, + RequiredGeoTargetingItem, + SignalTargetingItem, + SignalTargetingItem1, + SignalTargetingItem2, + SignalTargetingItem3, + SignalTargetingItem4, + SignalTargetingItem5, + SignalTargetingItem6, + SignalTargetingItem7, + ) + from adcp.types.domains.core.product_format_declaration import ( + ProductFormatDeclaration, + ProductFormatDeclaration1, + ProductFormatDeclaration10, + ProductFormatDeclaration11, + ProductFormatDeclaration12, + ProductFormatDeclaration13, + ProductFormatDeclaration14, + ProductFormatDeclaration15, + ProductFormatDeclaration16, + ProductFormatDeclaration2, + ProductFormatDeclaration3, + ProductFormatDeclaration4, + ProductFormatDeclaration5, + ProductFormatDeclaration6, + ProductFormatDeclaration7, + ProductFormatDeclaration8, + ProductFormatDeclaration9, + ) + from adcp.types.domains.core.product_identity import ProductIdentity + from adcp.types.domains.core.product_offer_filters import ( + AvailabilityHorizon, + ProductOfferFilters, + RequiredPerformanceStandard, + ) + from adcp.types.domains.core.product_signal_targeting_option import ( + ActivationStatus, + AllowedTargetingMode, + ProductSignalTargetingOption, + ) + from adcp.types.domains.core.product_targeting_resolution import ProductTargetingResolution + from adcp.types.domains.core.property import Property + from adcp.types.domains.core.property_delivery_metrics import PropertyDeliveryMetrics + from adcp.types.domains.core.property_id import PropertyId + from adcp.types.domains.core.property_list_ref import PropertyListReference + from adcp.types.domains.core.property_ref import PropertyReference + from adcp.types.domains.core.property_tag import PropertyTag + from adcp.types.domains.core.proposal import Proposal + from adcp.types.domains.core.protocol_envelope import ProtocolEnvelope + from adcp.types.domains.core.provenance import VerifyAgent10 + from adcp.types.domains.core.publisher_property_selector import ( + PublisherPropertySelector, + PublisherPropertySelector1, + PublisherPropertySelector2, + PublisherPropertySelector3, + ) + from adcp.types.domains.core.push_notification_config import PushNotificationConfig + from adcp.types.domains.core.real_estate_item import ListingType, PropertyType, RealEstateItem + from adcp.types.domains.core.reference_renderer import ReferenceRenderer + from adcp.types.domains.core.registry_event import ( + AgentProfilePayload, + AuthorizationPayload, + AuthorizationType, + BadgeRole, + ChangedFields, + Classification, + CollectionIdentifier, + CollectionPayload, + CompliancePayload, + ComplianceStatus, + DelegationType, + GradingProfile, + Market, + Payload1, + Payload10, + Payload11, + Payload12, + Payload13, + Payload14, + Payload2, + Payload3, + Payload4, + Payload5, + Payload6, + Payload7, + Payload8, + Payload9, + PropertyPayload, + PropertySource, + PublisherAdagentsPayload, + RegistryEvent, + RegistryEvent1, + RegistryEvent10, + RegistryEvent11, + RegistryEvent12, + RegistryEvent13, + RegistryEvent14, + RegistryEvent15, + RegistryEvent16, + RegistryEvent17, + RegistryEvent18, + RegistryEvent19, + RegistryEvent2, + RegistryEvent20, + RegistryEvent3, + RegistryEvent4, + RegistryEvent5, + RegistryEvent6, + RegistryEvent7, + RegistryEvent8, + RegistryEvent9, + Storyboard, + StoryboardStatus, + StringArray, + Tracks, + ) + from adcp.types.domains.core.registry_feed_response import Freshness, RegistryFeedResponse + from adcp.types.domains.core.reporting_adjustment import AccountingPeriod, ReportingAdjustment + from adcp.types.domains.core.reporting_adjustment_receipt import ( + ReportingAdjustmentReceipt, + ReportingAdjustmentRejectionCode, + ) + from adcp.types.domains.core.reporting_canonical_content_digest import ( + ReportingCanonicalContentDigest, + ) + from adcp.types.domains.core.reporting_canonicalization_contract import ( + AdditionalItem, + EmptyReport, + GoldenVectors, + OrderingEncoding, + ReportingCanonicalizationContract, + ReportingPrimaryKey, + ) + from adcp.types.domains.core.reporting_capabilities import ReportingCapabilities + from adcp.types.domains.core.reporting_consumer_status import ( + ConsumerStatus, + FailureCode, + MismatchCode, + ReportingConsumerStatus, + ) + from adcp.types.domains.core.reporting_control_total import ( + ReportingControlTotal, + ReportingControlTotal1, + ReportingControlTotal2, + ) + from adcp.types.domains.core.reporting_coverage import ( + Limitation, + ReportingCoverage, + ReportingMediaBuyId, + ReportingPackageId, + ) + from adcp.types.domains.core.reporting_dataset_share_destination import ( + Recipient, + ReportingCloud, + ReportingDatasetShareDestination, + ReportingDatasetShareDestination1, + ReportingDatasetShareDestination2, + ) + from adcp.types.domains.core.reporting_delivery_capabilities import ( + OperationsContact, + ReportingDeliveryCapabilities, + ) + from adcp.types.domains.core.reporting_delivery_config import ( + AuthoritativeParty, + CoverageRequirement, + ReportingDeliveryConfiguration, + ) + from adcp.types.domains.core.reporting_delivery_config_state import ( + ReportingDeliveryConfigLifecycleState, + ReportingDeliveryConfigurationState, + ) + from adcp.types.domains.core.reporting_delivery_method import ( + ReportingDeliveryMethod, + ReportingDeliveryMethod1, + ReportingDeliveryMethod2, + ReportingDeliveryMethod3, + ReportingOrchestration, + ) + from adcp.types.domains.core.reporting_delivery_offering import ( + DestinationMode, + ProducerIdentity, + ReportingDeliveryOffering, + ReportingDeliveryPattern, + ReportingFeedPurpose, + ReportingProfile, + ) + from adcp.types.domains.core.reporting_delivery_offering_id import ReportingDeliveryOfferingId + from adcp.types.domains.core.reporting_delivery_ready_webhook import ( + Readiness, + ReportingDeliveryReadyWebhook, + ) + from adcp.types.domains.core.reporting_file_compression import ReportingFileCompression + from adcp.types.domains.core.reporting_file_entry import ReportingFileEntry + from adcp.types.domains.core.reporting_file_manifest import ReportingFileManifest + from adcp.types.domains.core.reporting_file_object_ref import ReportingFileObjectReference + from adcp.types.domains.core.reporting_ledger_changed_webhook import ( + ReportingLedgerChangedWebhook, + ) + from adcp.types.domains.core.reporting_materialization import ReportingMaterialization + from adcp.types.domains.core.reporting_native_version_ref import ReportingNativeVersionReference + from adcp.types.domains.core.reporting_obligation import ( + ProductionStatus, + ReconciliationStatus, + ReportingObligation, + ) + from adcp.types.domains.core.reporting_receipt import RejectionCode, ReportingReceipt + from adcp.types.domains.core.reporting_reconciliation_mode import ReportingReconciliationMode + from adcp.types.domains.core.reporting_reliability_statistics import ( + AdjustmentMagnitudeItem, + Basis, + LatencyPercentiles, + ReportingReliabilityMeasurementPeriod, + ReportingReliabilityStatistics, + ) + from adcp.types.domains.core.reporting_report_definition import ( + Aggregation, + Calendar, + ContractVersion, + FinalityPolicies, + FinalityPolicies1, + FinalityPolicies2, + ReportCalendarTimezoneBasis, + ReportingReportDefinition, + RestatementPolicy, + ) + from adcp.types.domains.core.reporting_resource import ( + Immutability, + ReportingReaderCompatibilityItem, + ReportingResource, + ) + from adcp.types.domains.core.reporting_revision import ( + DataThroughPrecision, + FinalityBasis, + ReportingRevision, + ) + from adcp.types.domains.core.reporting_schedule import ( + ReportingSchedule, + ReportingScheduleAlignment, + ) + from adcp.types.domains.core.reporting_schedule_offering import ( + PeriodAnchorPolicy, + PeriodTimezonePolicy, + ReportingScheduleOffering, + ) + from adcp.types.domains.core.reporting_status_changed_webhook import ( + IssueId, + ReportingStatusChangedWebhook, + ) + from adcp.types.domains.core.reporting_status_issue import ( + IssueState, + RecommendedAction, + ReportingStatusIssue, + ReportingStatusSeverity, + ResponsibleParty, + ) + from adcp.types.domains.core.reporting_verification import ( + NativeCommitEvidence, + ObservedThrough, + PhysicalChecksums, + PhysicalChecksums1, + ReportingVerification, + VerificationPath, + ) + from adcp.types.domains.core.reporting_verification_profile import ReportingVerificationProfile + from adcp.types.domains.core.reporting_verification_profile_set import ( + ReportingVerificationProfileSet, + ReportingVerificationProfileSetEnum, + ) + from adcp.types.domains.core.reporting_webhook import ReportingFrequency, ReportingWebhook + from adcp.types.domains.core.reporting_write_destination import ( + ReportingWriteDestination, + ReportingWriteDestination1, + ReportingWriteDestination2, + ) + from adcp.types.domains.core.representation_destination import RepresentationDestination + from adcp.types.domains.core.representation_rejection import RepresentationRejection + from adcp.types.domains.core.representation_selection import RepresentationSelection, ResolvedBy + from adcp.types.domains.core.requirements.asset_requirements import AssetRequirements + from adcp.types.domains.core.requirements.audio_asset_requirements import ( + AudioAssetRequirements, + SampleRate, + ) + from adcp.types.domains.core.requirements.catalog_field_binding import ( + AssetPoolBinding, + CatalogFieldBinding, + CatalogFieldBinding1, + PerItemBindings, + ScalarBinding, + ) + from adcp.types.domains.core.requirements.catalog_requirements import CatalogRequirements + from adcp.types.domains.core.requirements.css_asset_requirements import CssAssetRequirements + from adcp.types.domains.core.requirements.daast_asset_requirements import DaastAssetRequirements + from adcp.types.domains.core.requirements.html_asset_requirements import ( + HtmlAssetRequirements, + Sandbox, + ) + from adcp.types.domains.core.requirements.image_asset_requirements import ( + Bleed, + Bleed1, + ImageAssetRequirements, + PixelRatio, + ) + from adcp.types.domains.core.requirements.javascript_asset_requirements import ( + JavascriptAssetRequirements, + ModuleType, + ) + from adcp.types.domains.core.requirements.markdown_asset_requirements import ( + MarkdownAssetRequirements, + ) + from adcp.types.domains.core.requirements.offering_asset_constraint import ( + OfferingAssetConstraint, + ) + from adcp.types.domains.core.requirements.text_asset_requirements import TextAssetRequirements + from adcp.types.domains.core.requirements.url_asset_requirements import ( + Protocol, + UrlAssetRequirements, + ) + from adcp.types.domains.core.requirements.vast_asset_requirements import VastAssetRequirements + from adcp.types.domains.core.requirements.video_asset_requirements import ( + AudioCodec, + AudioSampleRate, + FrameRate, + VideoAssetRequirements, + ) + from adcp.types.domains.core.requirements.webhook_asset_requirements import ( + WebhookAssetRequirements, + ) + from adcp.types.domains.core.response import ProtocolResponse + from adcp.types.domains.core.response_payload_jws_envelope import ( + ResponsePayload, + ResponsePayloadJwsEnvelope, + ) + from adcp.types.domains.core.rights_attestation_evaluation import ( + Issuer5, + Issuer6, + Reference, + RightsAttestationEvaluation, + Subject10, + Subject19, + ) + from adcp.types.domains.core.rights_constraint import ( + ApprovalStatus, + ExcludedCountry, + GrantStatus, + Issuer8, + Issuer9, + Restriction, + RightsAgent, + RightsConstraint, + Subject20, + Subject29, + ) + from adcp.types.domains.core.seller_agent_ref import SellerAgentReference + from adcp.types.domains.core.signal_coverage_forecast import ( + BucketCompleteness, + BucketSemantics, + Point, + SignalCoverageForecast, + ) + from adcp.types.domains.core.signal_definition import AudienceScope, SignalDefinition, Tag + from adcp.types.domains.core.signal_definition_enrichment import SignalDefinitionEnrichment + from adcp.types.domains.core.signal_filters import SignalFilters + from adcp.types.domains.core.signal_id import SignalId8, SignalId9 + from adcp.types.domains.core.signal_listing import SignalListing + from adcp.types.domains.core.signal_modeling_disclosure import ( + Audience, + SignalModelingDisclosure, + ) + from adcp.types.domains.core.signal_pricing import ( + VendorPricing, + VendorPricing1, + VendorPricing2, + VendorPricing3, + VendorPricing4, + VendorPricing5, + ) + from adcp.types.domains.core.signal_pricing_option import SignalPricingOption + from adcp.types.domains.core.signal_ref import SignalRef, SignalRef1, SignalRef2, SignalRef3 + from adcp.types.domains.core.signal_selection_group_rule import SignalSelectionGroupRule + from adcp.types.domains.core.signal_targeting import ( + SignalTargeting, + SignalTargeting1, + SignalTargeting2, + SignalTargeting3, + ) + from adcp.types.domains.core.signal_targeting_expression import ( + SignalTargetingExpression, + SignalTargetingExpression1, + SignalTargetingExpression2, + SignalTargetingExpression3, + ) + from adcp.types.domains.core.signal_targeting_rules import ResolutionModel, SignalTargetingRules + from adcp.types.domains.core.sla_window import SlaWindow + from adcp.types.domains.core.special import Special + from adcp.types.domains.core.spot_reporting_capability import SpotReportingCapability + from adcp.types.domains.core.start_timing import StartTiming + from adcp.types.domains.core.store_item import StoreItem + from adcp.types.domains.core.talent import Talent + from adcp.types.domains.core.targeting import AgeRestriction, TargetingOverlay + from adcp.types.domains.core.targeting_input import TargetingOverlayInput + from adcp.types.domains.core.targeting_modification import ( + Path, + Selector, + TargetingModification, + TargetingModification1, + TargetingModification2, + ) + from adcp.types.domains.core.targeting_overlay_requirements import ( + BrowserRequirement, + BrowserRequirement1, + DaypartRequirement, + DaypartRequirement1, + KeywordRequirement, + KeywordRequirement1, + MetroRequirement, + MetroRequirement1, + Required, + TargetingOverlayRequirements, + ) + from adcp.types.domains.core.targeting_overlay_support import ( + BrowserSupport, + BrowserSupport1, + CountrySupport, + CountrySupport1, + DaypartSupport, + DaypartSupport1, + IanaTimezones, + KeywordSupport, + KeywordSupport1, + MetroSupport, + MetroSupport1, + PlaceCatalogSupport, + PlaceSupport, + Supported, + TargetingOverlaySupport, + ) + from adcp.types.domains.core.targeting_unknown_age_eligibility_constraint import ( + TargetingUnknownAgeEligibilityConstraint, + ) + from adcp.types.domains.core.targeting_verified_age_basis_constraint import ( + TargetingVerifiedAgeBasisConstraint, + ) + from adcp.types.domains.core.tasks_get_request import TasksGetRequest + from adcp.types.domains.core.tasks_get_response import ( + Details, + HistoryItem, + Progress, + TasksGetResponse, + ) + from adcp.types.domains.core.tasks_list_request import Filters, Sort, TasksListRequest + from adcp.types.domains.core.tasks_list_response import ( + DomainBreakdown, + QuerySummary, + SortApplied, + TasksListResponse, + ) + from adcp.types.domains.core.tracker_execution_contract import TrackerExecutionContract + from adcp.types.domains.core.tracker_execution_selector import ( + TrackerExecutionSelector, + TrackerExecutionSelector1, + TrackerExecutionSelector2, + TrackerExecutionSelector3, + ) + from adcp.types.domains.core.transformer import ( + BrandAgent, + InputFormat, + InputFormat1, + InputFormat10, + InputFormat11, + InputFormat12, + InputFormat13, + InputFormat14, + InputFormat15, + InputFormat16, + InputFormat17, + InputFormat18, + InputFormat19, + InputFormat2, + InputFormat20, + InputFormat21, + InputFormat22, + InputFormat23, + InputFormat24, + InputFormat25, + InputFormat26, + InputFormat27, + InputFormat28, + InputFormat29, + InputFormat3, + InputFormat30, + InputFormat31, + InputFormat32, + InputFormat33, + InputFormat4, + InputFormat5, + InputFormat6, + InputFormat7, + InputFormat8, + InputFormat9, + Multiplicity, + OutputCapabilityId, + Transformer, + VariantDimension, + VoiceSynthesisRefItem, + ) + from adcp.types.domains.core.transformer_param import Option, TransformerParam, ValueSource + from adcp.types.domains.core.truncation_sentinel import FieldTruncation, TruncationSentinel + from adcp.types.domains.core.user_match import UserMatch + from adcp.types.domains.core.vast_media_file_requirements import ( + MimeType, + VastMediafileRequirements, + ) + from adcp.types.domains.core.vast_tracker_constraints import ( + VastEvent, + VastOffset, + VastTarget, + VastTrackerConstraints, + VastVersions, + ) + from adcp.types.domains.core.vehicle_item import ( + BodyStyle, + Condition, + FuelType, + Mileage, + Transmission, + VehicleItem, + ) + from adcp.types.domains.core.vendor_metric_id import VendorMetricId + from adcp.types.domains.core.vendor_metric_optimization import VendorMetricOptimization + from adcp.types.domains.core.vendor_metric_optimization_supported_metric import ( + VendorMetricOptimizationSupportedMetric, + ) + from adcp.types.domains.core.vendor_metric_value import VendorMetricValue + from adcp.types.domains.core.vendor_pricing_option import ( + AppliesToOutputCapabilityId, + VendorPricingOption, + VendorPricingOption1, + VendorPricingOption10, + VendorPricingOption11, + VendorPricingOption2, + VendorPricingOption3, + VendorPricingOption4, + VendorPricingOption5, + VendorPricingOption6, + VendorPricingOption7, + VendorPricingOption8, + VendorPricingOption9, + ) + from adcp.types.domains.core.verification_token_claims import ( + AgenticadvertisingOrgVerificationTokenClaims, + VerificationTokenGradingProfile, + VerificationTokenMode, + ) + from adcp.types.domains.core.version_envelope import AdcpVersionEnvelope + from adcp.types.domains.core.warning import Warning + from adcp.types.domains.core.warning_resource import WarningAffectedResource + from adcp.types.domains.core.webhook_activity_record import WebhookActivityRecord + from adcp.types.domains.core.webhook_challenge import WebhookChallenge + from adcp.types.domains.core.webhook_challenge_response import WebhookChallengeResponse + from adcp.types.domains.core.wholesale_feed_event import ( + AffectedEntityType, + AppliesTo, + AppliesTo1, + AppliesTo2, + Payload17, + Payload18, + Payload20, + Payload21, + Payload22, + Payload23, + Payload24, + Payload25, + RemovalReason, + Signal, + WholesaleFeedEvent, + WholesaleFeedEvent1, + WholesaleFeedEvent2, + WholesaleFeedEvent3, + WholesaleFeedEvent4, + WholesaleFeedEvent5, + WholesaleFeedEvent6, + WholesaleFeedEvent7, + WholesaleFeedEvent8, + WholesaleFeedEvent9, + ) + from adcp.types.domains.core.wholesale_feed_webhook import ( + CacheScope, + NotificationType, + WholesaleFeedWebhook, + ) + from adcp.types.domains.core.x_entity_types import XEntityTypes # Explicit exports __all__ = [ @@ -3197,3 +3259,1463 @@ "WholesaleFeedWebhook", "XEntityTypes", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "AcceptProposalInputRequired": "adcp.types.domains.core.async_response_refs.media_buy.accept_proposal_async_response_input_required", + "AcceptProposalSubmitted": "adcp.types.domains.core.async_response_refs.media_buy.accept_proposal_async_response_submitted", + "AcceptProposalWorking": "adcp.types.domains.core.async_response_refs.media_buy.accept_proposal_async_response_working", + "AcceptancePolicyProfileId": "adcp.types.domains.core.acceptance_policy_profile_ids", + "AcceptancePolicyProfileIds": "adcp.types.domains.core.acceptance_policy_profile_ids", + "AcceptedAttestationIssuers": "adcp.types.domains.core.product_audience_evidence_requirements", + "AcceptedAttestationIssuers1": "adcp.types.domains.core.product_audience_evidence_requirements", + "AcceptedAttestationIssuers2": "adcp.types.domains.core.product_audience_evidence_requirements", + "AcceptedAttestationIssuers3": "adcp.types.domains.core.product_audience_evidence_requirements", + "AcceptedFormat": "adcp.types.domains.core.agent_reporting_destination", + "AcceptedIssuer": "adcp.types.domains.core.attestation_capabilities", + "Account": "adcp.types.domains.core.account", + "AccountAuthorization": "adcp.types.domains.core.account_authorization", + "AccountChange": "adcp.types.domains.core.account_change", + "AccountChangeRecordedWebhook": "adcp.types.domains.core.account_change_recorded_webhook", + "AccountIdentityChange": "adcp.types.domains.core.account_identity_change", + "AccountIdentityChange1": "adcp.types.domains.core.account_identity_change", + "AccountIdentityChange2": "adcp.types.domains.core.account_identity_change", + "AccountIdentityChangePreview": "adcp.types.domains.core.account_identity_change_preview", + "AccountIdentityChangePreview1": "adcp.types.domains.core.account_identity_change_preview", + "AccountIdentityChangePreview2": "adcp.types.domains.core.account_identity_change_preview", + "AccountIdentityChangePreview3": "adcp.types.domains.core.account_identity_change_preview", + "AccountReference": "adcp.types.domains.core.account_ref", + "AccountReference1": "adcp.types.domains.core.account_ref", + "AccountReference2": "adcp.types.domains.core.account_ref", + "AccountSelection": "adcp.types.domains.core.account_timezone_capability", + "AccountStatusChangedWebhook": "adcp.types.domains.core.account_status_changed_webhook", + "AccountTimezoneCapability": "adcp.types.domains.core.account_timezone_capability", + "AccountWithAuthorization": "adcp.types.domains.core.account_with_authorization", + "AccountingPeriod": "adcp.types.domains.core.reporting_adjustment", + "Action3": "adcp.types.domains.core.canonical_media_buy_action", + "Action4": "adcp.types.domains.core.canonical_media_buy_action", + "ActivationKey": "adcp.types.domains.core.activation_key", + "ActivationKey1": "adcp.types.domains.core.activation_key", + "ActivationKey2": "adcp.types.domains.core.activation_key", + "ActivationStatus": "adcp.types.domains.core.product_signal_targeting_option", + "Actor": "adcp.types.domains.core.account_change", + "AdInventoryConfiguration": "adcp.types.domains.core.ad_inventory_config", + "AdcpAssetGroupVocabularyRegistry": "adcp.types.domains.core.asset_group_vocabulary", + "AdcpAsyncResponseData": "adcp.types.domains.core.async_response_data", + "AdcpFormatShapeVocabularyRegistry": "adcp.types.domains.core.format_shape_vocabulary", + "AdcpVersionEnvelope": "adcp.types.domains.core.version_envelope", + "AdditionalItem": "adcp.types.domains.core.reporting_canonicalization_contract", + "AdjustmentMagnitudeItem": "adcp.types.domains.core.reporting_reliability_statistics", + "AffectedEntityType": "adcp.types.domains.core.wholesale_feed_event", + "AgeRestriction": "adcp.types.domains.core.targeting", + "AgentDeclarations": "adcp.types.domains.core.principal_declarations", + "AgentEncryptionKey": "adcp.types.domains.core.agent_encryption_key", + "AgentNotificationConfig": "adcp.types.domains.core.agent_notification_config", + "AgentNotificationConfigState": "adcp.types.domains.core.agent_notification_config_state", + "AgentProfilePayload": "adcp.types.domains.core.registry_event", + "AgentReportingDestination": "adcp.types.domains.core.agent_reporting_destination", + "AgentReportingDestination1": "adcp.types.domains.core.agent_reporting_destination", + "AgentReportingDestination2": "adcp.types.domains.core.agent_reporting_destination", + "AgentReportingDestination3": "adcp.types.domains.core.agent_reporting_destination", + "AgentReportingDestinationState": "adcp.types.domains.core.agent_reporting_destination_state", + "AgentSigningKey": "adcp.types.domains.core.agent_signing_key", + "AgentWebhookChallenge": "adcp.types.domains.core.agent_webhook_challenge", + "AgenticadvertisingOrgVerificationTokenClaims": "adcp.types.domains.core.verification_token_claims", + "Aggregation": "adcp.types.domains.core.reporting_report_definition", + "AllowedInterval": "adcp.types.domains.core.frequency_cap_interval_constraints", + "AllowedTargetingMode": "adcp.types.domains.core.product_signal_targeting_option", + "AllowedTask": "adcp.types.domains.core.account_authorization", + "AllowedValue": "adcp.types.domains.core.frequency_cap_impression_constraints", + "AppItem": "adcp.types.domains.core.app_item", + "ApplicablePackageId": "adcp.types.domains.core.applicable_package_id", + "AppliesTo": "adcp.types.domains.core.wholesale_feed_event", + "AppliesTo1": "adcp.types.domains.core.wholesale_feed_event", + "AppliesTo2": "adcp.types.domains.core.wholesale_feed_event", + "AppliesToOutputCapabilityId": "adcp.types.domains.core.vendor_pricing_option", + "ApprovalStatus": "adcp.types.domains.core.rights_constraint", + "Artifact": "adcp.types.domains.core.creative_variant", + "AssetPoolBinding": "adcp.types.domains.core.requirements.catalog_field_binding", + "AssetRequirements": "adcp.types.domains.core.requirements.asset_requirements", + "AssetSource": "adcp.types.domains.core.canonical_projection_ref", + "AssetVariant": "adcp.types.domains.core.assets.asset_union", + "Assets10": "adcp.types.domains.core.format", + "Assets11": "adcp.types.domains.core.format", + "Assets12": "adcp.types.domains.core.format", + "Assets13": "adcp.types.domains.core.format", + "Assets14": "adcp.types.domains.core.format", + "Assets15": "adcp.types.domains.core.format", + "Assets16": "adcp.types.domains.core.format", + "Assets17": "adcp.types.domains.core.format", + "Assets18": "adcp.types.domains.core.format", + "Assets19": "adcp.types.domains.core.format", + "Assets20": "adcp.types.domains.core.format", + "Assets21": "adcp.types.domains.core.format", + "Assets22": "adcp.types.domains.core.format", + "Assets23": "adcp.types.domains.core.format", + "Assets24": "adcp.types.domains.core.format", + "Assets25": "adcp.types.domains.core.format", + "Assets26": "adcp.types.domains.core.format", + "Assets27": "adcp.types.domains.core.format", + "Assets28": "adcp.types.domains.core.format", + "Assets29": "adcp.types.domains.core.format", + "Assets30": "adcp.types.domains.core.format", + "Assets31": "adcp.types.domains.core.format", + "Assets32": "adcp.types.domains.core.format", + "Assets33": "adcp.types.domains.core.format", + "Assets34": "adcp.types.domains.core.format", + "Assets35": "adcp.types.domains.core.format", + "Assets36": "adcp.types.domains.core.format", + "Assets37": "adcp.types.domains.core.format", + "Assets38": "adcp.types.domains.core.format", + "Assets39": "adcp.types.domains.core.format", + "Assets40": "adcp.types.domains.core.format", + "Assets41": "adcp.types.domains.core.format", + "Assets42": "adcp.types.domains.core.format", + "Assets43": "adcp.types.domains.core.format", + "Assets44": "adcp.types.domains.core.format", + "Assets45": "adcp.types.domains.core.format", + "Assets46": "adcp.types.domains.core.format", + "Assets47": "adcp.types.domains.core.format", + "Assets48": "adcp.types.domains.core.format", + "Assets49": "adcp.types.domains.core.format", + "Assets50": "adcp.types.domains.core.format", + "Assets51": "adcp.types.domains.core.format", + "Assets9": "adcp.types.domains.core.format", + "Assets94": "adcp.types.domains.core.format", + "AsyncAdcpVersion": "adcp.types.domains.core.principal_declarations", + "AttestationCapabilities": "adcp.types.domains.core.attestation_capabilities", + "AttestationDigest": "adcp.types.domains.core.canonical_audience_evidence", + "AttestationIssuer": "adcp.types.domains.core.attestation_issuer", + "AttestationIssuer1": "adcp.types.domains.core.attestation_issuer", + "AttestationIssuer2": "adcp.types.domains.core.attestation_issuer", + "AttestationIssuer3": "adcp.types.domains.core.attestation_issuer", + "AttestationReference": "adcp.types.domains.core.attestation_reference", + "AttestationSubject": "adcp.types.domains.core.attestation_subject", + "AttestationSubject1": "adcp.types.domains.core.attestation_subject", + "AttestationSubject2": "adcp.types.domains.core.attestation_subject", + "AttestationSubject3": "adcp.types.domains.core.attestation_subject", + "AttributionWindow": "adcp.types.domains.core.attribution_window", + "Audience": "adcp.types.domains.core.signal_modeling_disclosure", + "AudienceActivation": "adcp.types.domains.core.product", + "AudienceActivationMethod": "adcp.types.domains.core.audience_activation_method", + "AudienceActivationMethod1": "adcp.types.domains.core.audience_activation_method", + "AudienceActivationMethod2": "adcp.types.domains.core.audience_activation_method", + "AudienceActivationMethod3": "adcp.types.domains.core.audience_activation_method", + "AudienceActivationMethod4": "adcp.types.domains.core.audience_activation_method", + "AudienceActivationMethod5": "adcp.types.domains.core.audience_activation_method", + "AudienceActivationMethod6": "adcp.types.domains.core.audience_activation_method", + "AudienceActivationMethods": "adcp.types.domains.core.product_filters", + "AudienceActivationMethods1": "adcp.types.domains.core.product_filters", + "AudienceActivationMethods2": "adcp.types.domains.core.product_filters", + "AudienceActivationMethods3": "adcp.types.domains.core.product_filters", + "AudienceActivationMethods4": "adcp.types.domains.core.product_filters", + "AudienceActivationMethods5": "adcp.types.domains.core.product_filters", + "AudienceCharacteristic": "adcp.types.domains.core.audience_characteristic", + "AudienceEvidence": "adcp.types.domains.core.audience_evidence", + "AudienceEvidencePin": "adcp.types.domains.core.audience_evidence_pin", + "AudienceEvidenceRequirements": "adcp.types.domains.core.audience_evidence_requirements", + "AudienceEvidenceSelection": "adcp.types.domains.core.audience_evidence_selection", + "AudienceForecastDimension": "adcp.types.domains.core.forecast_dimension_audience", + "AudienceMember": "adcp.types.domains.core.audience_member", + "AudienceScope": "adcp.types.domains.core.signal_definition", + "AudienceSelector": "adcp.types.domains.core.audience_selector", + "AudienceSelector1": "adcp.types.domains.core.audience_selector", + "AudienceSelector2": "adcp.types.domains.core.audience_selector", + "AudienceSelector3": "adcp.types.domains.core.audience_selector", + "AudienceSelector4": "adcp.types.domains.core.audience_selector", + "AudienceSource": "adcp.types.domains.core.audience_source", + "AudienceSource1": "adcp.types.domains.core.audience_source", + "AudienceSource2": "adcp.types.domains.core.audience_source", + "AudioAssetRequirements": "adcp.types.domains.core.requirements.audio_asset_requirements", + "AudioChannelLayout": "adcp.types.domains.core.assets.asset_union", + "AudioCodec": "adcp.types.domains.core.requirements.video_asset_requirements", + "AudioSampleRate": "adcp.types.domains.core.requirements.video_asset_requirements", + "Auth": "adcp.types.domains.core.geo_place_resolver", + "AuthoritativeParty": "adcp.types.domains.core.reporting_delivery_config", + "AuthorizationPayload": "adcp.types.domains.core.registry_event", + "AuthorizationType": "adcp.types.domains.core.registry_event", + "AuthorizedAgentBaseFields": "adcp.types.domains.core.authorized_agent_base", + "AvailabilityHorizon": "adcp.types.domains.core.product_offer_filters", + "Axis": "adcp.types.domains.core.principal_declarations_state", + "BadgeRole": "adcp.types.domains.core.registry_event", + "Bank": "adcp.types.domains.core.business_entity", + "BaseGroupAsset": "adcp.types.domains.core.format", + "BaseIndividualAsset": "adcp.types.domains.core.format", + "Basis": "adcp.types.domains.core.reporting_reliability_statistics", + "BiddingPolicy": "adcp.types.domains.core.bidding_policy", + "BiddingPolicyCapability": "adcp.types.domains.core.bidding_policy_capability", + "BitDepth": "adcp.types.domains.core.assets.audio_asset", + "Bleed": "adcp.types.domains.core.requirements.image_asset_requirements", + "Bleed1": "adcp.types.domains.core.requirements.image_asset_requirements", + "BlockedImpacts": "adcp.types.domains.core.account_identity_change_preview", + "Blocker": "adcp.types.domains.core.account_identity_change_preview", + "BodyStyle": "adcp.types.domains.core.vehicle_item", + "Bounds": "adcp.types.domains.core.overlay", + "BoxDecoration": "adcp.types.domains.core.placement_presentation", + "BrandAgent": "adcp.types.domains.core.transformer", + "BrandId": "adcp.types.domains.core.brand_id", + "BrandKey": "adcp.types.domains.core.brand_key", + "BrandKitOverride": "adcp.types.domains.core.brand_ref", + "BrandReference": "adcp.types.domains.core.brand_ref", + "BrandResponseAuthorizationResult": "adcp.types.domains.core.brand_response_authorization_result", + "BrandResponseAuthorizationResult1": "adcp.types.domains.core.brand_response_authorization_result", + "BrandResponseAuthorizationResult2": "adcp.types.domains.core.brand_response_authorization_result", + "BrowserRequirement": "adcp.types.domains.core.targeting_overlay_requirements", + "BrowserRequirement1": "adcp.types.domains.core.targeting_overlay_requirements", + "BrowserSupport": "adcp.types.domains.core.targeting_overlay_support", + "BrowserSupport1": "adcp.types.domains.core.targeting_overlay_support", + "BucketCompleteness": "adcp.types.domains.core.signal_coverage_forecast", + "BucketSemantics": "adcp.types.domains.core.signal_coverage_forecast", + "BudgetAllocation": "adcp.types.domains.core.budget_allocation", + "BudgetAllocation1": "adcp.types.domains.core.budget_allocation", + "BudgetAllocation2": "adcp.types.domains.core.budget_allocation", + "BuildCreativeInputRequired": "adcp.types.domains.core.async_response_refs.media_buy.build_creative_async_response_input_required", + "BuildCreativeSubmitted": "adcp.types.domains.core.async_response_refs.media_buy.build_creative_async_response_submitted", + "BuildCreativeWorking": "adcp.types.domains.core.async_response_refs.media_buy.build_creative_async_response_working", + "BusinessEntity": "adcp.types.domains.core.business_entity", + "BuyProductsInputRequired": "adcp.types.domains.core.async_response_refs.media_buy.buy_products_async_response_input_required", + "BuyProductsSubmitted": "adcp.types.domains.core.async_response_refs.media_buy.buy_products_async_response_submitted", + "BuyProductsWorking": "adcp.types.domains.core.async_response_refs.media_buy.buy_products_async_response_working", + "BuyerReason": "adcp.types.domains.core.error", + "ByActionSourceItem": "adcp.types.domains.core.delivery_metrics", + "ByEventTypeItem": "adcp.types.domains.core.delivery_metrics", + "C2paWatermarkAction": "adcp.types.domains.core.assets.asset_union", + "CAEnum": "adcp.types.domains.core.postal_area_support", + "CacheScope": "adcp.types.domains.core.wholesale_feed_webhook", + "Calendar": "adcp.types.domains.core.reporting_report_definition", + "CancellationFee": "adcp.types.domains.core.cancellation_policy", + "CancellationPolicy": "adcp.types.domains.core.cancellation_policy", + "CanonicalAccountReference": "adcp.types.domains.core.canonical_account_ref", + "CanonicalAccountReference1": "adcp.types.domains.core.canonical_account_ref", + "CanonicalAccountReference2": "adcp.types.domains.core.canonical_account_ref", + "CanonicalAudienceEvidence": "adcp.types.domains.core.canonical_audience_evidence", + "CanonicalAudienceEvidenceSelection": "adcp.types.domains.core.canonical_audience_evidence_selection", + "CanonicalBudgetAllocation": "adcp.types.domains.core.canonical_budget_allocation", + "CanonicalBudgetAllocation1": "adcp.types.domains.core.canonical_budget_allocation", + "CanonicalBudgetAllocation2": "adcp.types.domains.core.canonical_budget_allocation", + "CanonicalDeliveryForecast": "adcp.types.domains.core.canonical_delivery_forecast", + "CanonicalDoohPlacementAttributes": "adcp.types.domains.core.canonical_placement", + "CanonicalDoohScreenResolution": "adcp.types.domains.core.canonical_placement", + "CanonicalForecastPoint": "adcp.types.domains.core.canonical_forecast_point", + "CanonicalForecastVendorMetricValue": "adcp.types.domains.core.canonical_forecast_vendor_metric_value", + "CanonicalFormatKind": "adcp.types.domains.core.canonical_format_kind", + "CanonicalFormatOption": "adcp.types.domains.core.canonical_format_option", + "CanonicalMeasurementTerms": "adcp.types.domains.core.canonical_measurement_terms", + "CanonicalMediaBuyAction": "adcp.types.domains.core.canonical_media_buy_action", + "CanonicalMediaBuyAction1": "adcp.types.domains.core.canonical_media_buy_action", + "CanonicalMediaBuyAction2": "adcp.types.domains.core.canonical_media_buy_action", + "CanonicalMediaBuyAction3": "adcp.types.domains.core.canonical_media_buy_action", + "CanonicalMediaBuyActionFields": "adcp.types.domains.core.canonical_media_buy_action_fields", + "CanonicalMediaBuyFeatures": "adcp.types.domains.core.canonical_media_buy_features", + "CanonicalMetricQualifier": "adcp.types.domains.core.canonical_metric_qualifier", + "CanonicalOptimizationGoal": "adcp.types.domains.core.canonical_optimization_goal", + "CanonicalOptimizationGoal1": "adcp.types.domains.core.canonical_optimization_goal", + "CanonicalOptimizationGoal2": "adcp.types.domains.core.canonical_optimization_goal", + "CanonicalOptimizationGoal3": "adcp.types.domains.core.canonical_optimization_goal", + "CanonicalParameters": "adcp.types.domains.core.format", + "CanonicalParameters1": "adcp.types.domains.core.format", + "CanonicalParameters10": "adcp.types.domains.core.format", + "CanonicalParameters11": "adcp.types.domains.core.format", + "CanonicalParameters12": "adcp.types.domains.core.format", + "CanonicalParameters13": "adcp.types.domains.core.format", + "CanonicalParameters14": "adcp.types.domains.core.format", + "CanonicalParameters15": "adcp.types.domains.core.format", + "CanonicalParameters16": "adcp.types.domains.core.format", + "CanonicalParameters17": "adcp.types.domains.core.format", + "CanonicalParameters18": "adcp.types.domains.core.format", + "CanonicalParameters19": "adcp.types.domains.core.format", + "CanonicalParameters2": "adcp.types.domains.core.format", + "CanonicalParameters20": "adcp.types.domains.core.format", + "CanonicalParameters21": "adcp.types.domains.core.format", + "CanonicalParameters22": "adcp.types.domains.core.format", + "CanonicalParameters23": "adcp.types.domains.core.format", + "CanonicalParameters24": "adcp.types.domains.core.format", + "CanonicalParameters25": "adcp.types.domains.core.format", + "CanonicalParameters26": "adcp.types.domains.core.format", + "CanonicalParameters27": "adcp.types.domains.core.format", + "CanonicalParameters28": "adcp.types.domains.core.format", + "CanonicalParameters29": "adcp.types.domains.core.format", + "CanonicalParameters3": "adcp.types.domains.core.format", + "CanonicalParameters30": "adcp.types.domains.core.format", + "CanonicalParameters31": "adcp.types.domains.core.format", + "CanonicalParameters32": "adcp.types.domains.core.format", + "CanonicalParameters33": "adcp.types.domains.core.format", + "CanonicalParameters4": "adcp.types.domains.core.format", + "CanonicalParameters5": "adcp.types.domains.core.format", + "CanonicalParameters6": "adcp.types.domains.core.format", + "CanonicalParameters7": "adcp.types.domains.core.format", + "CanonicalParameters8": "adcp.types.domains.core.format", + "CanonicalParameters9": "adcp.types.domains.core.format", + "CanonicalPerformanceStandard": "adcp.types.domains.core.canonical_performance_standard", + "CanonicalPricingOption": "adcp.types.domains.core.canonical_pricing_option", + "CanonicalProduct": "adcp.types.domains.core.canonical_product", + "CanonicalProductAction": "adcp.types.domains.core.canonical_product_action", + "CanonicalProductPlacement": "adcp.types.domains.core.canonical_placement", + "CanonicalProductPlacement1": "adcp.types.domains.core.canonical_placement", + "CanonicalProductPlacement2": "adcp.types.domains.core.canonical_placement", + "CanonicalProjectionReference": "adcp.types.domains.core.canonical_projection_ref", + "CanonicalProjectionSlotOverride": "adcp.types.domains.core.canonical_projection_slot_override", + "CanonicalProposal": "adcp.types.domains.core.canonical_proposal", + "CanonicalReportingCapabilities": "adcp.types.domains.core.canonical_reporting_capabilities", + "CanonicalReportingCommitment": "adcp.types.domains.core.canonical_reporting_commitment", + "CanonicalReportingCommitment1": "adcp.types.domains.core.canonical_reporting_commitment", + "CanonicalReportingCommitment2": "adcp.types.domains.core.canonical_reporting_commitment", + "Canvas": "adcp.types.domains.core.placement_presentation", + "CanvasConstraint": "adcp.types.domains.core.canvas_constraint", + "CapabilitiesChangedWebhook": "adcp.types.domains.core.capabilities_changed_webhook", + "CatalogFieldBinding": "adcp.types.domains.core.requirements.catalog_field_binding", + "CatalogFieldBinding1": "adcp.types.domains.core.requirements.catalog_field_binding", + "CatalogItemAvailabilityError": "adcp.types.domains.core.catalog_item_availability_error", + "CatalogItemAvailabilityReference": "adcp.types.domains.core.catalog_item_availability_ref", + "CatalogItemAvailabilityState": "adcp.types.domains.core.catalog_item_availability_state", + "CatalogItemAvailabilityUpdate": "adcp.types.domains.core.catalog_item_availability_update", + "CatalogItemAvailabilityUpdateResult": "adcp.types.domains.core.catalog_item_availability_update_result", + "CatalogItemDeliveryMetrics": "adcp.types.domains.core.catalog_item_delivery_metrics", + "CatalogItemReferenceNotFoundError": "adcp.types.domains.core.catalog_item_reference_not_found_error", + "CatalogRequirement": "adcp.types.domains.core.geo_place_requirement", + "CatalogRequirements": "adcp.types.domains.core.requirements.catalog_requirements", + "CatalogSelection": "adcp.types.domains.core.catalog_selection", + "CatalogType": "adcp.types.domains.core.assets.asset_union", + "Catchment": "adcp.types.domains.core.catchment", + "Category": "adcp.types.domains.core.event_surface", + "ChangedFields": "adcp.types.domains.core.registry_event", + "Classification": "adcp.types.domains.core.registry_event", + "CloseReason": "adcp.types.domains.core.opportunity_context", + "Cloud": "adcp.types.domains.core.audience_activation_method", + "Collection": "adcp.types.domains.core.collection", + "CollectionDeliveryMetrics": "adcp.types.domains.core.collection_delivery_metrics", + "CollectionDistribution": "adcp.types.domains.core.collection_distribution", + "CollectionIdentifier": "adcp.types.domains.core.registry_event", + "CollectionListReference": "adcp.types.domains.core.collection_list_ref", + "CollectionPayload": "adcp.types.domains.core.registry_event", + "CollectionPropertyDeliveryMetrics": "adcp.types.domains.core.collection_property_delivery_metrics", + "CollectionReference": "adcp.types.domains.core.collection_ref", + "CollectionSelection": "adcp.types.domains.core.collection_selection", + "CollectionSelection1": "adcp.types.domains.core.collection_selection", + "CollectionSelection2": "adcp.types.domains.core.collection_selection", + "CollectionSelector": "adcp.types.domains.core.collection_selector", + "Color": "adcp.types.domains.core.placement_presentation", + "Colors": "adcp.types.domains.core.brand_ref", + "CommittedMetric": "adcp.types.domains.core.committed_metric", + "CommittedMetric1": "adcp.types.domains.core.committed_metric", + "CommittedMetric2": "adcp.types.domains.core.committed_metric", + "CompactTaskInputRequired": "adcp.types.domains.core.compact_task_input_required", + "CompactTaskSubmitted": "adcp.types.domains.core.compact_task_submitted", + "CompactTaskWorking": "adcp.types.domains.core.compact_task_working", + "CompliancePayload": "adcp.types.domains.core.registry_event", + "ComplianceStatus": "adcp.types.domains.core.registry_event", + "Compression": "adcp.types.domains.core.account", + "Condition": "adcp.types.domains.core.vehicle_item", + "ConfidenceInterval": "adcp.types.domains.core.performance_feedback_assertion", + "Connection": "adcp.types.domains.core.product_execution_requirement", + "ConnectionType": "adcp.types.domains.core.downstream_connection_requirement", + "Constraint": "adcp.types.domains.core.canvas_constraint", + "ConsumerIdentity": "adcp.types.domains.core.audience_activation_method", + "ConsumerStatus": "adcp.types.domains.core.reporting_consumer_status", + "Contact": "adcp.types.domains.core.business_entity", + "Content": "adcp.types.domains.core.event_custom_data", + "ContentIdType": "adcp.types.domains.core.assets.asset_union", + "ContentRating": "adcp.types.domains.core.content_rating", + "ContextObject": "adcp.types.domains.core.context", + "ContractVersion": "adcp.types.domains.core.reporting_report_definition", + "ControlMediaBuyInputRequired": "adcp.types.domains.core.async_response_refs.media_buy.control_media_buy_async_response_input_required", + "ControlMediaBuySubmitted": "adcp.types.domains.core.async_response_refs.media_buy.control_media_buy_async_response_submitted", + "ControlMediaBuyWorking": "adcp.types.domains.core.async_response_refs.media_buy.control_media_buy_async_response_working", + "ConversionTracking": "adcp.types.domains.core.product", + "CostPer": "adcp.types.domains.core.bidding_policy", + "CostPerStrength": "adcp.types.domains.core.bidding_policy_capability", + "Countries1": "adcp.types.domains.core.geo_region_requirement", + "Countries3": "adcp.types.domains.core.geo_region_support", + "CountrySupport": "adcp.types.domains.core.targeting_overlay_support", + "CountrySupport1": "adcp.types.domains.core.targeting_overlay_support", + "CoverageRequirement": "adcp.types.domains.core.reporting_delivery_config", + "CreateMediaBuyInputRequired": "adcp.types.domains.core.async_response_refs.media_buy.create_media_buy_async_response_input_required", + "CreateMediaBuySubmitted": "adcp.types.domains.core.async_response_refs.media_buy.create_media_buy_async_response_submitted", + "CreateMediaBuyWorking": "adcp.types.domains.core.async_response_refs.media_buy.create_media_buy_async_response_working", + "CreativeAsset": "adcp.types.domains.core.creative_asset", + "CreativeAssets": "adcp.types.domains.core.creative_assets", + "CreativeAssets1": "adcp.types.domains.core.creative_assets", + "CreativeAssignment": "adcp.types.domains.core.creative_assignment", + "CreativeConsumption": "adcp.types.domains.core.creative_consumption", + "CreativeDeliveryMetrics": "adcp.types.domains.core.creative_delivery_metrics", + "CreativeFilters": "adcp.types.domains.core.creative_filters", + "CreativeItem": "adcp.types.domains.core.creative_item", + "CreativeItem1": "adcp.types.domains.core.creative_item", + "CreativeItem2": "adcp.types.domains.core.creative_item", + "CreativeLocalePolicy": "adcp.types.domains.core.creative_locale_policy", + "CreativeLocalization": "adcp.types.domains.core.creative_localization", + "CreativeLocalizationReadback": "adcp.types.domains.core.creative_localization_readback", + "CreativeManifest": "adcp.types.domains.core.creative_manifest", + "CreativeOperationFormatDeclaration": "adcp.types.domains.core.creative_operation_format_declaration", + "CreativePolicy": "adcp.types.domains.core.creative_policy", + "CreativeRepresentation": "adcp.types.domains.core.creative_representation", + "CreativeRepresentationSet": "adcp.types.domains.core.creative_representation_set", + "CreativeRevisionId": "adcp.types.domains.core.creative_revision_id", + "CreativeSlot": "adcp.types.domains.core.placement_presentation", + "CreativeVariable": "adcp.types.domains.core.creative_variable", + "CreativeVariant": "adcp.types.domains.core.creative_variant", + "CredentialOrigin": "adcp.types.domains.core.attestation_capabilities", + "CreditLimit": "adcp.types.domains.core.account", + "CssAssetRequirements": "adcp.types.domains.core.requirements.css_asset_requirements", + "DaastAsset1": "adcp.types.domains.core.assets.asset_union", + "DaastAsset2": "adcp.types.domains.core.assets.asset_union", + "DaastAsset3": "adcp.types.domains.core.assets.daast_asset", + "DaastAsset4": "adcp.types.domains.core.assets.daast_asset", + "DaastAssetRequirements": "adcp.types.domains.core.requirements.daast_asset_requirements", + "DaastEvent": "adcp.types.domains.core.daast_tracker_constraints", + "DaastOffset": "adcp.types.domains.core.daast_tracker_constraints", + "DaastTarget": "adcp.types.domains.core.daast_tracker_constraints", + "DaastTrackerConstraints": "adcp.types.domains.core.daast_tracker_constraints", + "DaastTrackingEvent": "adcp.types.domains.core.assets.asset_union", + "DaastVersion": "adcp.types.domains.core.assets.asset_union", + "DaastVersions": "adcp.types.domains.core.daast_tracker_constraints", + "DataProviderSignalSelector": "adcp.types.domains.core.data_provider_signal_selector", + "DataProviderSignalSelector1": "adcp.types.domains.core.data_provider_signal_selector", + "DataProviderSignalSelector2": "adcp.types.domains.core.data_provider_signal_selector", + "DataProviderSignalSelector3": "adcp.types.domains.core.data_provider_signal_selector", + "DataSubjectContestation": "adcp.types.domains.core.brand_ref", + "DataThroughPrecision": "adcp.types.domains.core.reporting_revision", + "DateRange": "adcp.types.domains.core.date_range", + "DatetimeRange": "adcp.types.domains.core.datetime_range", + "DaypartRequirement": "adcp.types.domains.core.targeting_overlay_requirements", + "DaypartRequirement1": "adcp.types.domains.core.targeting_overlay_requirements", + "DaypartSupport": "adcp.types.domains.core.targeting_overlay_support", + "DaypartSupport1": "adcp.types.domains.core.targeting_overlay_support", + "DaypartTarget": "adcp.types.domains.core.daypart_target", + "DeadlinePolicy": "adcp.types.domains.core.deadline_policy", + "DeclineProposalsInputRequired": "adcp.types.domains.core.async_response_refs.media_buy.decline_proposals_async_response_input_required", + "DeclineProposalsSubmitted": "adcp.types.domains.core.async_response_refs.media_buy.decline_proposals_async_response_submitted", + "DeclineProposalsWorking": "adcp.types.domains.core.async_response_refs.media_buy.decline_proposals_async_response_working", + "DegreeType": "adcp.types.domains.core.education_item", + "DelegationType": "adcp.types.domains.core.registry_event", + "DeliveryBreakdownControls": "adcp.types.domains.core.delivery_breakdown_controls", + "DeliveryForecast": "adcp.types.domains.core.delivery_forecast", + "DeliveryMeasurement": "adcp.types.domains.core.product", + "DeliveryMetricAggregate": "adcp.types.domains.core.delivery_metric_aggregate", + "DeliveryMetricAggregate1": "adcp.types.domains.core.delivery_metric_aggregate", + "DeliveryMetricAggregate2": "adcp.types.domains.core.delivery_metric_aggregate", + "DeliveryMetrics": "adcp.types.domains.core.delivery_metrics", + "DeliveryProvider": "adcp.types.domains.core.delivery_provider", + "DeliveryRecipient": "adcp.types.domains.core.delivery_recipient", + "DemographicAgeRange": "adcp.types.domains.core.demographic_age_range", + "DemographicPredicate": "adcp.types.domains.core.demographic_predicate", + "DemographicReportingCapability": "adcp.types.domains.core.demographic_reporting_capability", + "DemographicTargetingCapability": "adcp.types.domains.core.demographic_targeting_capability", + "DemographicTargetingIntent": "adcp.types.domains.core.demographic_targeting_intent", + "DemographicTargetingResolution": "adcp.types.domains.core.demographic_targeting_resolution", + "Deployment": "adcp.types.domains.core.deployment", + "Deployment1": "adcp.types.domains.core.deployment", + "Deployment2": "adcp.types.domains.core.deployment", + "DerivativeOf": "adcp.types.domains.core.installment", + "Destination1": "adcp.types.domains.core.destination", + "Destination2": "adcp.types.domains.core.destination", + "DestinationItem": "adcp.types.domains.core.destination_item", + "DestinationMode": "adcp.types.domains.core.reporting_delivery_offering", + "DestinationRef": "adcp.types.domains.core.principal_state", + "DestinationType": "adcp.types.domains.core.destination_item", + "Detail": "adcp.types.domains.core.event_source_health", + "Details": "adcp.types.domains.core.tasks_get_response", + "DevicePlatformForecastDimension": "adcp.types.domains.core.forecast_dimension_device_platform", + "DeviceTypeForecastDimension": "adcp.types.domains.core.forecast_dimension_device_type", + "DiagnosticIssue": "adcp.types.domains.core.diagnostic_issue", + "DigitalSourceType": "adcp.types.domains.core.assets.asset_union", + "Dimensions": "adcp.types.domains.core.format", + "Dimensions1": "adcp.types.domains.core.format", + "DisclosureCapability": "adcp.types.domains.core.format", + "DisclosurePersistence": "adcp.types.domains.core.assets.asset_union", + "DisclosurePosition": "adcp.types.domains.core.assets.asset_union", + "DiscriminatorItem": "adcp.types.domains.core.error", + "DisplayTagAsset1": "adcp.types.domains.core.assets.asset_union", + "DisplayTagAsset2": "adcp.types.domains.core.assets.asset_union", + "DisplayTagAsset3": "adcp.types.domains.core.assets.asset_union", + "DisplayTagAsset4": "adcp.types.domains.core.assets.display_tag_asset", + "DisplayTagAsset5": "adcp.types.domains.core.assets.display_tag_asset", + "DisplayTagAsset6": "adcp.types.domains.core.assets.display_tag_asset", + "DomainBreakdown": "adcp.types.domains.core.tasks_list_response", + "DoohMetrics": "adcp.types.domains.core.delivery_metrics", + "DoohMetrics1": "adcp.types.domains.core.delivery_metrics", + "DownstreamConnectionRequirement": "adcp.types.domains.core.downstream_connection_requirement", + "Duration": "adcp.types.domains.core.duration", + "EducationItem": "adcp.types.domains.core.education_item", + "Effect1": "adcp.types.domains.core.account_identity_change_preview", + "EmbeddedCredential": "adcp.types.domains.core.attestation_reference", + "EmbeddedProvenanceMethod": "adcp.types.domains.core.assets.asset_union", + "EmploymentType": "adcp.types.domains.core.job_item", + "EmptyReport": "adcp.types.domains.core.reporting_canonicalization_contract", + "EstimationBasis": "adcp.types.domains.core.delivery_metrics", + "EvalBudget": "adcp.types.domains.core.evaluator_spec", + "EvaluatorSpec": "adcp.types.domains.core.evaluator_spec", + "EvaluatorSpec1": "adcp.types.domains.core.evaluator_spec", + "EvaluatorSpec2": "adcp.types.domains.core.evaluator_spec", + "EvaluatorSpec3": "adcp.types.domains.core.evaluator_spec", + "Event": "adcp.types.domains.core.event", + "EventCustomData": "adcp.types.domains.core.event_custom_data", + "EventSourceHealth": "adcp.types.domains.core.event_source_health", + "EventSurface": "adcp.types.domains.core.event_surface", + "ExcludedCountry": "adcp.types.domains.core.rights_constraint", + "Exclusion": "adcp.types.domains.core.principal_declarations_state", + "Execution": "adcp.types.domains.core.demographic_targeting_resolution", + "Execution1": "adcp.types.domains.core.demographic_targeting_resolution", + "Execution2": "adcp.types.domains.core.demographic_targeting_resolution", + "ExecutionMode": "adcp.types.domains.core.demographic_targeting_capability", + "Exemplars": "adcp.types.domains.core.evaluator_spec", + "ExperienceLevel": "adcp.types.domains.core.job_item", + "ExperimentalFeatureId": "adcp.types.domains.core.experimental_feature_id", + "Ext": "adcp.types.domains.core.assets.asset_union", + "ExtensionObject": "adcp.types.domains.core.ext", + "FailureCode": "adcp.types.domains.core.reporting_consumer_status", + "FeatureAgent": "adcp.types.domains.core.evaluator_spec", + "FeatureRequirement": "adcp.types.domains.core.feature_requirement", + "FeedFormat": "adcp.types.domains.core.assets.asset_union", + "Field0": "adcp.types.domains.core.delivery_metric_aggregate", + "FieldModel": "adcp.types.domains.core.assets.asset_union", + "FieldTruncation": "adcp.types.domains.core.truncation_sentinel", + "Filters": "adcp.types.domains.core.tasks_list_request", + "FinalityBasis": "adcp.types.domains.core.reporting_revision", + "FinalityPolicies": "adcp.types.domains.core.reporting_report_definition", + "FinalityPolicies1": "adcp.types.domains.core.reporting_report_definition", + "FinalityPolicies2": "adcp.types.domains.core.reporting_report_definition", + "Fit": "adcp.types.domains.core.placement_presentation", + "FlightItem": "adcp.types.domains.core.flight_item", + "ForecastPoint": "adcp.types.domains.core.forecast_point", + "ForecastPointDimensions": "adcp.types.domains.core.forecast_point_dimensions", + "ForecastRange": "adcp.types.domains.core.forecast_range", + "ForecastRateRange": "adcp.types.domains.core.forecast_rate_range", + "ForecastVendorMetricValue": "adcp.types.domains.core.forecast_vendor_metric_value", + "FormatAssetUnion": "adcp.types.domains.core.format", + "FormatCard": "adcp.types.domains.core.format", + "FormatCardDetailed": "adcp.types.domains.core.format", + "FormatKind": "adcp.types.domains.core.canonical_format_option", + "FormatOptionReference": "adcp.types.domains.core.format_option_ref", + "FormatOptionReference1": "adcp.types.domains.core.format_option_ref", + "FormatOptionReference2": "adcp.types.domains.core.format_option_ref", + "FormatOptions": "adcp.types.domains.core.placement_definition", + "FormatReferenceStructuredObject": "adcp.types.domains.core.format_id", + "FrameRate": "adcp.types.domains.core.requirements.video_asset_requirements", + "FrameRateType": "adcp.types.domains.core.assets.asset_union", + "FrequencyCap": "adcp.types.domains.core.frequency_cap", + "FrequencyCapConstraints": "adcp.types.domains.core.frequency_cap_constraints", + "FrequencyCapDurationUnit": "adcp.types.domains.core.frequency_cap_duration_unit", + "FrequencyCapImpressionConstraints": "adcp.types.domains.core.frequency_cap_impression_constraints", + "FrequencyCapIntervalConstraints": "adcp.types.domains.core.frequency_cap_interval_constraints", + "FrequencyCapRequirements": "adcp.types.domains.core.frequency_cap_requirements", + "Freshness": "adcp.types.domains.core.registry_feed_response", + "FuelType": "adcp.types.domains.core.vehicle_item", + "GBEnum": "adcp.types.domains.core.postal_area_support", + "GenerationContext": "adcp.types.domains.core.creative_variant", + "GenerationCredential": "adcp.types.domains.core.generation_credential", + "Geo": "adcp.types.domains.core.planned_delivery", + "GeoDeliveryMetrics": "adcp.types.domains.core.geo_delivery_metrics", + "GeoForecastDimension": "adcp.types.domains.core.forecast_dimension_geo", + "GeoMetro": "adcp.types.domains.core.geo_metro", + "GeoTargets": "adcp.types.domains.core.offering", + "GeographicBreakdownSupport": "adcp.types.domains.core.geo_breakdown_support", + "GeographicPlaceArea": "adcp.types.domains.core.geo_place_area", + "GeographicPlaceCatalogCapability": "adcp.types.domains.core.geo_place_catalog_capability", + "GeographicPlaceCatalogEntry": "adcp.types.domains.core.geo_place_catalog_entry", + "GeographicPlaceIdentifierSystem": "adcp.types.domains.core.geo_place_system", + "GeographicPlaceIdentifierSystem1": "adcp.types.domains.core.geo_place_system", + "GeographicPlaceIdentifierSystem2": "adcp.types.domains.core.geo_place_system", + "GeographicPlaceRequirement": "adcp.types.domains.core.geo_place_requirement", + "GeographicPlaceResolver": "adcp.types.domains.core.geo_place_resolver", + "GeographicPlaceSystemSupport": "adcp.types.domains.core.geo_place_support", + "GeographicPlaceType": "adcp.types.domains.core.geo_place_type", + "GeographicPlaceType1": "adcp.types.domains.core.geo_place_type", + "GeographicPlaceType2": "adcp.types.domains.core.geo_place_type", + "GeographicRegionRequirement": "adcp.types.domains.core.geo_region_requirement", + "GeographicRegionSupport": "adcp.types.domains.core.geo_region_support", + "GetCreativeFeaturesSubmitted": "adcp.types.domains.core.async_response_refs.creative.get_creative_features_async_response_submitted", + "GetGeographicPlaceResolutionRequest": "adcp.types.domains.core.get_geo_place_resolution_request", + "GetGeographicPlaceResolutionResponse": "adcp.types.domains.core.get_geo_place_resolution_response", + "GetProductsInputRequired": "adcp.types.domains.core.async_response_refs.media_buy.get_products_async_response_input_required", + "GetProductsSubmitted": "adcp.types.domains.core.async_response_refs.media_buy.get_products_async_response_submitted", + "GetProductsWorking": "adcp.types.domains.core.async_response_refs.media_buy.get_products_async_response_working", + "GetSignalsSubmitted": "adcp.types.domains.core.async_response_refs.signals.get_signals_async_response_submitted", + "GetSignalsWorking": "adcp.types.domains.core.async_response_refs.signals.get_signals_async_response_working", + "GoldenVectors": "adcp.types.domains.core.reporting_canonicalization_contract", + "GopType": "adcp.types.domains.core.assets.asset_union", + "GovernanceAgent": "adcp.types.domains.core.account", + "GradingProfile": "adcp.types.domains.core.registry_event", + "GrantStatus": "adcp.types.domains.core.rights_constraint", + "GroupFormatAssetUnion": "adcp.types.domains.core.format", + "HistoryItem": "adcp.types.domains.core.tasks_get_response", + "HotelItem": "adcp.types.domains.core.hotel_item", + "HtmlAssetRequirements": "adcp.types.domains.core.requirements.html_asset_requirements", + "HttpMethod": "adcp.types.domains.core.assets.asset_union", + "IanaTimezoneIdentifier": "adcp.types.domains.core.iana_timezone", + "IanaTimezones": "adcp.types.domains.core.targeting_overlay_support", + "IfNotCovered": "adcp.types.domains.core.feature_requirement", + "ImageAssetRequirements": "adcp.types.domains.core.requirements.image_asset_requirements", + "ImageDecoration": "adcp.types.domains.core.placement_presentation", + "ImageRef": "adcp.types.domains.core.placement_presentation", + "Immutability": "adcp.types.domains.core.reporting_resource", + "Impact": "adcp.types.domains.core.account_identity_change_preview", + "Impairment": "adcp.types.domains.core.impairment", + "Indicator": "adcp.types.domains.core.indicator", + "IndicatorBearingResourceState": "adcp.types.domains.core.indicator_bearing", + "IndicatorScope": "adcp.types.domains.core.indicator_scope", + "IndicatorsChangedWebhook": "adcp.types.domains.core.indicators_changed_webhook", + "IndustryIdentifier": "adcp.types.domains.core.industry_identifier", + "Input": "adcp.types.domains.core.creative_asset", + "InputFormat": "adcp.types.domains.core.transformer", + "InputFormat1": "adcp.types.domains.core.transformer", + "InputFormat10": "adcp.types.domains.core.transformer", + "InputFormat11": "adcp.types.domains.core.transformer", + "InputFormat12": "adcp.types.domains.core.transformer", + "InputFormat13": "adcp.types.domains.core.transformer", + "InputFormat14": "adcp.types.domains.core.transformer", + "InputFormat15": "adcp.types.domains.core.transformer", + "InputFormat16": "adcp.types.domains.core.transformer", + "InputFormat17": "adcp.types.domains.core.transformer", + "InputFormat18": "adcp.types.domains.core.transformer", + "InputFormat19": "adcp.types.domains.core.transformer", + "InputFormat2": "adcp.types.domains.core.transformer", + "InputFormat20": "adcp.types.domains.core.transformer", + "InputFormat21": "adcp.types.domains.core.transformer", + "InputFormat22": "adcp.types.domains.core.transformer", + "InputFormat23": "adcp.types.domains.core.transformer", + "InputFormat24": "adcp.types.domains.core.transformer", + "InputFormat25": "adcp.types.domains.core.transformer", + "InputFormat26": "adcp.types.domains.core.transformer", + "InputFormat27": "adcp.types.domains.core.transformer", + "InputFormat28": "adcp.types.domains.core.transformer", + "InputFormat29": "adcp.types.domains.core.transformer", + "InputFormat3": "adcp.types.domains.core.transformer", + "InputFormat30": "adcp.types.domains.core.transformer", + "InputFormat31": "adcp.types.domains.core.transformer", + "InputFormat32": "adcp.types.domains.core.transformer", + "InputFormat33": "adcp.types.domains.core.transformer", + "InputFormat4": "adcp.types.domains.core.transformer", + "InputFormat5": "adcp.types.domains.core.transformer", + "InputFormat6": "adcp.types.domains.core.transformer", + "InputFormat7": "adcp.types.domains.core.transformer", + "InputFormat8": "adcp.types.domains.core.transformer", + "InputFormat9": "adcp.types.domains.core.transformer", + "InsertionOrder": "adcp.types.domains.core.insertion_order", + "Installment": "adcp.types.domains.core.installment", + "InstallmentDeadlines": "adcp.types.domains.core.installment_deadlines", + "InstallmentDeliveryMetrics": "adcp.types.domains.core.installment_delivery_metrics", + "InstallmentPropertyDeliveryMetrics": "adcp.types.domains.core.installment_property_delivery_metrics", + "InstallmentReference": "adcp.types.domains.core.installment_ref", + "Intent": "adcp.types.domains.core.opportunity_context", + "IntervalId": "adcp.types.domains.core.demographic_targeting_resolution", + "InventoryListApplication": "adcp.types.domains.core.inventory_list_application", + "InventoryListApplication1": "adcp.types.domains.core.inventory_list_application", + "InventoryListApplication2": "adcp.types.domains.core.inventory_list_application", + "Issue": "adcp.types.domains.core.error", + "IssueId": "adcp.types.domains.core.reporting_status_changed_webhook", + "IssueState": "adcp.types.domains.core.reporting_status_issue", + "Issuer5": "adcp.types.domains.core.rights_attestation_evaluation", + "Issuer6": "adcp.types.domains.core.rights_attestation_evaluation", + "Issuer8": "adcp.types.domains.core.rights_constraint", + "Issuer9": "adcp.types.domains.core.rights_constraint", + "Items": "adcp.types.domains.core.offering_asset_group", + "JavascriptAssetRequirements": "adcp.types.domains.core.requirements.javascript_asset_requirements", + "JavascriptModuleType": "adcp.types.domains.core.assets.asset_union", + "JobItem": "adcp.types.domains.core.job_item", + "Jurisdiction2": "adcp.types.domains.core.assets.asset_union", + "Keyword": "adcp.types.domains.core.product_filters", + "KeywordDeliveryMetrics": "adcp.types.domains.core.keyword_delivery_metrics", + "KeywordRequirement": "adcp.types.domains.core.targeting_overlay_requirements", + "KeywordRequirement1": "adcp.types.domains.core.targeting_overlay_requirements", + "KeywordSupport": "adcp.types.domains.core.targeting_overlay_support", + "KeywordSupport1": "adcp.types.domains.core.targeting_overlay_support", + "LanguageTag": "adcp.types.domains.core.locale_tag", + "LatencyPercentiles": "adcp.types.domains.core.reporting_reliability_statistics", + "Layer": "adcp.types.domains.core.placement_presentation", + "Level": "adcp.types.domains.core.education_item", + "Limitation": "adcp.types.domains.core.reporting_coverage", + "LimitedSeries": "adcp.types.domains.core.limited_series", + "ListingType": "adcp.types.domains.core.real_estate_item", + "LocalizedCreativeAsset": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset1": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset10": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset11": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset12": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset13": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset14": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset15": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset16": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset17": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset18": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset19": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset2": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset20": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset21": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset22": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset3": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset4": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset5": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset7": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset8": "adcp.types.domains.core.localized_creative_asset", + "LocalizedCreativeAsset9": "adcp.types.domains.core.localized_creative_asset", + "Location1": "adcp.types.domains.core.assets.asset_union", + "Location13": "adcp.types.domains.core.assets.daast_asset", + "Location17": "adcp.types.domains.core.assets.display_tag_asset", + "Location18": "adcp.types.domains.core.assets.display_tag_asset", + "Location2": "adcp.types.domains.core.assets.asset_union", + "Location26": "adcp.types.domains.core.assets.vast_asset", + "Location4": "adcp.types.domains.core.assets.asset_union", + "Location5": "adcp.types.domains.core.assets.asset_union", + "Location6": "adcp.types.domains.core.assets.asset_union", + "Location8": "adcp.types.domains.core.assets.asset_union", + "Location9": "adcp.types.domains.core.assets.asset_union", + "Locator": "adcp.types.domains.core.attestation_reference", + "Locator1": "adcp.types.domains.core.attestation_reference", + "ME": "adcp.types.domains.core.postal_area_support", + "MacroBearingUrl1": "adcp.types.domains.core.assets.asset_union", + "MacroBearingUrl2": "adcp.types.domains.core.assets.asset_union", + "MacroBearingUrl3": "adcp.types.domains.core.macro_bearing_url", + "MacroBearingUrl4": "adcp.types.domains.core.macro_bearing_url", + "MacroDeclaration1": "adcp.types.domains.core.assets.asset_union", + "MacroDeclaration10": "adcp.types.domains.core.assets.asset_union", + "MacroDeclaration12": "adcp.types.domains.core.assets.daast_asset", + "MacroDeclaration15": "adcp.types.domains.core.assets.display_tag_asset", + "MacroDeclaration16": "adcp.types.domains.core.assets.display_tag_asset", + "MacroDeclaration2": "adcp.types.domains.core.assets.asset_union", + "MacroDeclaration20": "adcp.types.domains.core.assets.vast_asset", + "MacroDeclaration3": "adcp.types.domains.core.assets.asset_union", + "MacroDeclaration4": "adcp.types.domains.core.assets.asset_union", + "MacroDeclaration5": "adcp.types.domains.core.assets.asset_union", + "MacroDeclaration6": "adcp.types.domains.core.assets.asset_union", + "MacroDeclaration7": "adcp.types.domains.core.assets.asset_union", + "MacroDeclaration8": "adcp.types.domains.core.assets.asset_union", + "MacroDeclaration9": "adcp.types.domains.core.assets.asset_union", + "MacroDeclarationModel": "adcp.types.domains.core.assets.asset_union", + "MacroDialect": "adcp.types.domains.core.assets.asset_union", + "MacroMappingStatus": "adcp.types.domains.core.assets.asset_union", + "MacroProcessingCapability": "adcp.types.domains.core.macro_resolution_capability", + "MacroProcessingOperation": "adcp.types.domains.core.assets.asset_union", + "MacroResolutionResult": "adcp.types.domains.core.macro_resolution_result", + "MacroResolver": "adcp.types.domains.core.assets.asset_union", + "MacroValueContext": "adcp.types.domains.core.assets.asset_union", + "MappingStatus": "adcp.types.domains.core.macro_resolution_capability", + "MarkdownAssetRequirements": "adcp.types.domains.core.requirements.markdown_asset_requirements", + "MarkdownFlavor": "adcp.types.domains.core.assets.asset_union", + "Market": "adcp.types.domains.core.registry_event", + "MaterialDeadline": "adcp.types.domains.core.material_deadline", + "MaterialStage": "adcp.types.domains.core.deadline_policy", + "MaterialSubmission": "adcp.types.domains.core.product", + "MaxBidWithCostPer": "adcp.types.domains.core.bidding_policy_capability", + "MaxBidWithRoas": "adcp.types.domains.core.bidding_policy_capability", + "McpWebhookPayload": "adcp.types.domains.core.mcp_webhook_payload", + "MeasurementPeriod": "adcp.types.domains.core.performance_feedback", + "MeasurementReadiness": "adcp.types.domains.core.measurement_readiness", + "MeasurementTerms": "adcp.types.domains.core.measurement_terms", + "MeasurementWindow": "adcp.types.domains.core.measurement_window", + "MediaBuy": "adcp.types.domains.core.media_buy", + "MediaBuyAvailableAction": "adcp.types.domains.core.media_buy_available_action", + "MediaBuyAvailableActionId": "adcp.types.domains.core.media_buy_available_action_id", + "MediaBuyChangeTermId": "adcp.types.domains.core.media_buy_change_term_id", + "MediaBuyFeatures": "adcp.types.domains.core.media_buy_features", + "MediaBuyFrequencyCap": "adcp.types.domains.core.media_buy_frequency_cap", + "MediaBuyFrequencyCapCapability": "adcp.types.domains.core.media_buy_frequency_cap_capability", + "MediaBuyFrequencyCapRequirement": "adcp.types.domains.core.media_buy_frequency_cap_requirement", + "MediaBuyFrequencyCapSupport": "adcp.types.domains.core.media_buy_frequency_cap_support", + "MediaBuyTermsReference": "adcp.types.domains.core.media_buy_legacy_terms_ref", + "Metric7": "adcp.types.domains.core.performance_feedback", + "MetricOptimization": "adcp.types.domains.core.product", + "MetroRequirement": "adcp.types.domains.core.targeting_overlay_requirements", + "MetroRequirement1": "adcp.types.domains.core.targeting_overlay_requirements", + "MetroSupport": "adcp.types.domains.core.targeting_overlay_support", + "MetroSupport1": "adcp.types.domains.core.targeting_overlay_support", + "Mileage": "adcp.types.domains.core.vehicle_item", + "MimeType": "adcp.types.domains.core.vast_media_file_requirements", + "MismatchCode": "adcp.types.domains.core.reporting_consumer_status", + "MissingMetric": "adcp.types.domains.core.missing_metric", + "MissingMetric1": "adcp.types.domains.core.missing_metric", + "MissingMetric2": "adcp.types.domains.core.missing_metric", + "Modality": "adcp.types.domains.core.education_item", + "ModuleType": "adcp.types.domains.core.requirements.javascript_asset_requirements", + "MoovAtomPosition": "adcp.types.domains.core.assets.asset_union", + "Multiplicity": "adcp.types.domains.core.transformer", + "NativeCommitEvidence": "adcp.types.domains.core.reporting_verification", + "NegativeKeyword": "adcp.types.domains.core.negative_keyword", + "NonblockingImpact": "adcp.types.domains.core.account_identity_change_preview", + "NonblockingImpacts": "adcp.types.domains.core.account_identity_change_preview", + "NotificationConfig": "adcp.types.domains.core.notification_config", + "NotificationType": "adcp.types.domains.core.wholesale_feed_webhook", + "ObservedThrough": "adcp.types.domains.core.reporting_verification", + "Offering": "adcp.types.domains.core.offering", + "OfferingAssetConstraint": "adcp.types.domains.core.requirements.offering_asset_constraint", + "OfferingAssetGroup": "adcp.types.domains.core.offering_asset_group", + "OohMetrics": "adcp.types.domains.core.delivery_metrics", + "Operation": "adcp.types.domains.core.macro_resolution_capability", + "OperationsContact": "adcp.types.domains.core.reporting_delivery_capabilities", + "Operator": "adcp.types.domains.core.package_signal_targeting_group", + "OperatorIdentity": "adcp.types.domains.core.operator_identity", + "OperatorUnit": "adcp.types.domains.core.operator_unit", + "OpportunityContext": "adcp.types.domains.core.opportunity_context", + "OptimizationGoal1": "adcp.types.domains.core.budget_allocation", + "OptimizationGoal10": "adcp.types.domains.core.optimization_goal", + "OptimizationGoal2": "adcp.types.domains.core.budget_allocation", + "OptimizationGoal3": "adcp.types.domains.core.budget_allocation", + "OptimizationGoal4": "adcp.types.domains.core.budget_allocation", + "OptimizationGoal5": "adcp.types.domains.core.budget_allocation", + "OptimizationGoal6": "adcp.types.domains.core.budget_allocation", + "OptimizationGoal7": "adcp.types.domains.core.budget_allocation", + "OptimizationGoal8": "adcp.types.domains.core.optimization_goal", + "OptimizationGoal9": "adcp.types.domains.core.optimization_goal", + "Option": "adcp.types.domains.core.transformer_param", + "OrderingEncoding": "adcp.types.domains.core.reporting_canonicalization_contract", + "Outcome": "adcp.types.domains.core.attestation_evaluation", + "OutcomeMeasurement": "adcp.types.domains.core.outcome_measurement", + "OutcomeTargetCostPer": "adcp.types.domains.core.outcome_target_cost_per", + "OutputCapabilityId": "adcp.types.domains.core.transformer", + "Overlay": "adcp.types.domains.core.overlay", + "Package": "adcp.types.domains.core.package", + "PackageDeliveryMetricValue": "adcp.types.domains.core.package_delivery_metric_value", + "PackageFormatSnapshot": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot1": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot10": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot11": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot12": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot13": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot14": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot15": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot16": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot17": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot18": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot19": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot2": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot20": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot21": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot22": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot23": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot24": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot25": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot26": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot27": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot28": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot29": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot3": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot30": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot31": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot32": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot33": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot4": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot5": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot6": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot7": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot8": "adcp.types.domains.core.package_format_snapshot", + "PackageFormatSnapshot9": "adcp.types.domains.core.package_format_snapshot", + "PackageSignalTargeting": "adcp.types.domains.core.package_signal_targeting", + "PackageSignalTargeting1": "adcp.types.domains.core.package_signal_targeting", + "PackageSignalTargeting2": "adcp.types.domains.core.package_signal_targeting", + "PackageSignalTargeting3": "adcp.types.domains.core.package_signal_targeting", + "PackageSignalTargeting4": "adcp.types.domains.core.package_signal_targeting", + "PackageSignalTargeting5": "adcp.types.domains.core.package_signal_targeting", + "PackageSignalTargeting6": "adcp.types.domains.core.package_signal_targeting", + "PackageSignalTargeting7": "adcp.types.domains.core.package_signal_targeting", + "PackageSignalTargetingGroup": "adcp.types.domains.core.package_signal_targeting_group", + "PackageSignalTargetingGroups": "adcp.types.domains.core.package_signal_targeting_groups", + "PackageTargetingResolution": "adcp.types.domains.core.package_targeting_resolution", + "PaginationRequest": "adcp.types.domains.core.pagination_request", + "PaginationResponse": "adcp.types.domains.core.pagination_response", + "Panel": "adcp.types.domains.core.delivery_metrics", + "ParentLabel": "adcp.types.domains.core.geo_place_catalog_entry", + "Path": "adcp.types.domains.core.targeting_modification", + "Payload1": "adcp.types.domains.core.registry_event", + "Payload10": "adcp.types.domains.core.registry_event", + "Payload11": "adcp.types.domains.core.registry_event", + "Payload12": "adcp.types.domains.core.registry_event", + "Payload13": "adcp.types.domains.core.registry_event", + "Payload14": "adcp.types.domains.core.registry_event", + "Payload17": "adcp.types.domains.core.wholesale_feed_event", + "Payload18": "adcp.types.domains.core.wholesale_feed_event", + "Payload2": "adcp.types.domains.core.registry_event", + "Payload20": "adcp.types.domains.core.wholesale_feed_event", + "Payload21": "adcp.types.domains.core.wholesale_feed_event", + "Payload22": "adcp.types.domains.core.wholesale_feed_event", + "Payload23": "adcp.types.domains.core.wholesale_feed_event", + "Payload24": "adcp.types.domains.core.wholesale_feed_event", + "Payload25": "adcp.types.domains.core.wholesale_feed_event", + "Payload3": "adcp.types.domains.core.registry_event", + "Payload4": "adcp.types.domains.core.registry_event", + "Payload5": "adcp.types.domains.core.registry_event", + "Payload6": "adcp.types.domains.core.registry_event", + "Payload7": "adcp.types.domains.core.registry_event", + "Payload8": "adcp.types.domains.core.registry_event", + "Payload9": "adcp.types.domains.core.registry_event", + "PaymentTerms": "adcp.types.domains.core.insertion_order", + "PerItemBindings": "adcp.types.domains.core.requirements.catalog_field_binding", + "PerformanceFeedback": "adcp.types.domains.core.performance_feedback", + "PerformanceFeedbackAssertion": "adcp.types.domains.core.performance_feedback_assertion", + "PerformanceFeedbackMetric": "adcp.types.domains.core.performance_feedback_metric", + "PerformanceFeedbackMetric1": "adcp.types.domains.core.performance_feedback_metric", + "PerformanceFeedbackMetric2": "adcp.types.domains.core.performance_feedback_metric", + "PerformanceStandard": "adcp.types.domains.core.performance_standard", + "PeriodAnchorPolicy": "adcp.types.domains.core.reporting_schedule_offering", + "PeriodTimezonePolicy": "adcp.types.domains.core.reporting_schedule_offering", + "Phase": "adcp.types.domains.core.opportunity_context", + "PhysicalChecksums": "adcp.types.domains.core.reporting_verification", + "PhysicalChecksums1": "adcp.types.domains.core.reporting_verification", + "PixelRatio": "adcp.types.domains.core.requirements.image_asset_requirements", + "PixelTrackingEvent": "adcp.types.domains.core.assets.asset_union", + "PlaceCatalogSupport": "adcp.types.domains.core.targeting_overlay_support", + "PlaceSupport": "adcp.types.domains.core.targeting_overlay_support", + "Placement": "adcp.types.domains.core.placement", + "PlacementDefinition": "adcp.types.domains.core.placement_definition", + "PlacementDeliveryMetrics": "adcp.types.domains.core.placement_delivery_metrics", + "PlacementEvidence": "adcp.types.domains.core.placement_evidence", + "PlacementForecastDimension": "adcp.types.domains.core.forecast_dimension_placement", + "PlacementIdentity": "adcp.types.domains.core.placement_identity", + "PlacementIdentity1": "adcp.types.domains.core.placement_identity", + "PlacementIdentity2": "adcp.types.domains.core.placement_identity", + "PlacementPresentationDocument": "adcp.types.domains.core.placement_presentation", + "PlacementPresentationReference": "adcp.types.domains.core.presentation_ref", + "PlacementPropertyDeliveryMetrics": "adcp.types.domains.core.placement_property_delivery_metrics", + "PlacementReference": "adcp.types.domains.core.placement_ref", + "PlacementSelection1": "adcp.types.domains.core.placement_selection", + "PlacementSelection2": "adcp.types.domains.core.placement_selection", + "PlannedDelivery": "adcp.types.domains.core.planned_delivery", + "Platform": "adcp.types.domains.core.app_item", + "PlatformExtensionRef": "adcp.types.domains.core.assets.asset_union", + "PlatformExtensionReference": "adcp.types.domains.core.platform_extension_ref", + "Point": "adcp.types.domains.core.signal_coverage_forecast", + "PolicyProfile": "adcp.types.domains.core.bidding_policy_capability", + "PositivePostalAreaSupport": "adcp.types.domains.core.positive_postal_area_support", + "PostalArea": "adcp.types.domains.core.postal_area", + "PostalArea1": "adcp.types.domains.core.postal_area", + "PostalArea11": "adcp.types.domains.core.postal_area", + "PostalArea110": "adcp.types.domains.core.postal_area", + "PostalArea111": "adcp.types.domains.core.postal_area", + "PostalArea112": "adcp.types.domains.core.postal_area", + "PostalArea113": "adcp.types.domains.core.postal_area", + "PostalArea114": "adcp.types.domains.core.postal_area", + "PostalArea115": "adcp.types.domains.core.postal_area", + "PostalArea116": "adcp.types.domains.core.postal_area", + "PostalArea117": "adcp.types.domains.core.postal_area", + "PostalArea118": "adcp.types.domains.core.postal_area", + "PostalArea119": "adcp.types.domains.core.postal_area", + "PostalArea12": "adcp.types.domains.core.postal_area", + "PostalArea120": "adcp.types.domains.core.postal_area", + "PostalArea121": "adcp.types.domains.core.postal_area", + "PostalArea13": "adcp.types.domains.core.postal_area", + "PostalArea14": "adcp.types.domains.core.postal_area", + "PostalArea15": "adcp.types.domains.core.postal_area", + "PostalArea16": "adcp.types.domains.core.postal_area", + "PostalArea17": "adcp.types.domains.core.postal_area", + "PostalArea18": "adcp.types.domains.core.postal_area", + "PostalArea19": "adcp.types.domains.core.postal_area", + "PostalArea2": "adcp.types.domains.core.postal_area", + "PostalAreaSupport": "adcp.types.domains.core.postal_area_support", + "PostalAreaSupportAdditionalPropertyEnum": "adcp.types.domains.core.postal_area_support", + "PostalCountrySystem": "adcp.types.domains.core.postal_country_system", + "PostalCountrySystem1": "adcp.types.domains.core.postal_country_system", + "PostalCountrySystem10": "adcp.types.domains.core.postal_country_system", + "PostalCountrySystem2": "adcp.types.domains.core.postal_country_system", + "PostalCountrySystem3": "adcp.types.domains.core.postal_country_system", + "PostalCountrySystem4": "adcp.types.domains.core.postal_country_system", + "PostalCountrySystem5": "adcp.types.domains.core.postal_country_system", + "PostalCountrySystem6": "adcp.types.domains.core.postal_country_system", + "PostalCountrySystem7": "adcp.types.domains.core.postal_country_system", + "PostalCountrySystem8": "adcp.types.domains.core.postal_country_system", + "PostalCountrySystem9": "adcp.types.domains.core.postal_country_system", + "Posting": "adcp.types.domains.core.delivery_metrics", + "Presence": "adcp.types.domains.core.forecast_dimension_signal", + "PreviewRendererMetadata": "adcp.types.domains.core.preview_renderer_metadata", + "Price": "adcp.types.domains.core.price", + "PricingModel": "adcp.types.domains.core.canonical_pricing_option", + "PricingOption": "adcp.types.domains.core.pricing_option", + "PrincipalChangedWebhook": "adcp.types.domains.core.principal_changed_webhook", + "PrincipalDeclarationsState": "adcp.types.domains.core.principal_declarations_state", + "PrincipalState": "adcp.types.domains.core.principal_state", + "PriorDestinationRef": "adcp.types.domains.core.agent_reporting_destination_state", + "ProducerIdentity": "adcp.types.domains.core.reporting_delivery_offering", + "Product": "adcp.types.domains.core.product", + "ProductAllocation": "adcp.types.domains.core.product_allocation", + "ProductAllowedAction": "adcp.types.domains.core.product_allowed_action", + "ProductAudienceEvidenceRequirements": "adcp.types.domains.core.product_audience_evidence_requirements", + "ProductCard": "adcp.types.domains.core.product", + "ProductCardDetailed": "adcp.types.domains.core.product", + "ProductCardReferenceAsset": "adcp.types.domains.core.product_card_reference_asset", + "ProductChangeMap": "adcp.types.domains.core.product_change_map", + "ProductChangeMap1": "adcp.types.domains.core.product_change_map", + "ProductDoohPlacementAttributes": "adcp.types.domains.core.placement", + "ProductDoohScreenResolution": "adcp.types.domains.core.placement", + "ProductExecutionRequirement": "adcp.types.domains.core.product_execution_requirement", + "ProductExecutionRequirement1": "adcp.types.domains.core.product_execution_requirement", + "ProductExecutionRequirement2": "adcp.types.domains.core.product_execution_requirement", + "ProductExecutionRequirement3": "adcp.types.domains.core.product_execution_requirement", + "ProductFilters": "adcp.types.domains.core.product_filters", + "ProductFormatDeclaration": "adcp.types.domains.core.product_format_declaration", + "ProductFormatDeclaration1": "adcp.types.domains.core.product_format_declaration", + "ProductFormatDeclaration10": "adcp.types.domains.core.product_format_declaration", + "ProductFormatDeclaration11": "adcp.types.domains.core.product_format_declaration", + "ProductFormatDeclaration12": "adcp.types.domains.core.product_format_declaration", + "ProductFormatDeclaration13": "adcp.types.domains.core.product_format_declaration", + "ProductFormatDeclaration14": "adcp.types.domains.core.product_format_declaration", + "ProductFormatDeclaration15": "adcp.types.domains.core.product_format_declaration", + "ProductFormatDeclaration16": "adcp.types.domains.core.product_format_declaration", + "ProductFormatDeclaration2": "adcp.types.domains.core.product_format_declaration", + "ProductFormatDeclaration3": "adcp.types.domains.core.product_format_declaration", + "ProductFormatDeclaration4": "adcp.types.domains.core.product_format_declaration", + "ProductFormatDeclaration5": "adcp.types.domains.core.product_format_declaration", + "ProductFormatDeclaration6": "adcp.types.domains.core.product_format_declaration", + "ProductFormatDeclaration7": "adcp.types.domains.core.product_format_declaration", + "ProductFormatDeclaration8": "adcp.types.domains.core.product_format_declaration", + "ProductFormatDeclaration9": "adcp.types.domains.core.product_format_declaration", + "ProductIdentity": "adcp.types.domains.core.product_identity", + "ProductMediaBuySupport": "adcp.types.domains.core.media_buy_support", + "ProductMediaBuySupportRequirements": "adcp.types.domains.core.media_buy_support_requirements", + "ProductOfferFilters": "adcp.types.domains.core.product_offer_filters", + "ProductSignalTargetingOption": "adcp.types.domains.core.product_signal_targeting_option", + "ProductTargetingResolution": "adcp.types.domains.core.product_targeting_resolution", + "ProductionStatus": "adcp.types.domains.core.reporting_obligation", + "Progress": "adcp.types.domains.core.tasks_get_response", + "Property": "adcp.types.domains.core.property", + "PropertyDeliveryMetrics": "adcp.types.domains.core.property_delivery_metrics", + "PropertyId": "adcp.types.domains.core.property_id", + "PropertyListReference": "adcp.types.domains.core.property_list_ref", + "PropertyPayload": "adcp.types.domains.core.registry_event", + "PropertyReference": "adcp.types.domains.core.property_ref", + "PropertySource": "adcp.types.domains.core.registry_event", + "PropertyTag": "adcp.types.domains.core.property_tag", + "PropertyType": "adcp.types.domains.core.real_estate_item", + "Proposal": "adcp.types.domains.core.proposal", + "ProposalKind": "adcp.types.domains.core.canonical_proposal", + "Protocol": "adcp.types.domains.core.requirements.url_asset_requirements", + "ProtocolEnvelope": "adcp.types.domains.core.protocol_envelope", + "ProtocolResponse": "adcp.types.domains.core.response", + "ProvenanceRequirements": "adcp.types.domains.core.creative_policy", + "PublishedPostAsset1": "adcp.types.domains.core.assets.asset_union", + "PublishedPostAsset2": "adcp.types.domains.core.assets.asset_union", + "PublisherAdagentsPayload": "adcp.types.domains.core.registry_event", + "PublisherDesignatedPreviewProvider": "adcp.types.domains.core.preview_provider", + "PublisherDoohPlacementAttributes": "adcp.types.domains.core.placement_definition", + "PublisherDoohScreenResolution": "adcp.types.domains.core.placement_definition", + "PublisherProperty1": "adcp.types.domains.core.canonical_product", + "PublisherProperty2": "adcp.types.domains.core.canonical_product", + "PublisherProperty3": "adcp.types.domains.core.canonical_product", + "PublisherProperty4": "adcp.types.domains.core.canonical_product", + "PublisherProperty5": "adcp.types.domains.core.canonical_product", + "PublisherProperty6": "adcp.types.domains.core.canonical_product", + "PublisherProperty7": "adcp.types.domains.core.canonical_product", + "PublisherProperty81": "adcp.types.domains.core.product", + "PublisherProperty82": "adcp.types.domains.core.product", + "PublisherProperty83": "adcp.types.domains.core.product", + "PublisherProperty84": "adcp.types.domains.core.product", + "PublisherProperty85": "adcp.types.domains.core.product", + "PublisherProperty86": "adcp.types.domains.core.product", + "PublisherProperty87": "adcp.types.domains.core.product", + "PublisherPropertySelector": "adcp.types.domains.core.publisher_property_selector", + "PublisherPropertySelector1": "adcp.types.domains.core.publisher_property_selector", + "PublisherPropertySelector2": "adcp.types.domains.core.publisher_property_selector", + "PublisherPropertySelector3": "adcp.types.domains.core.publisher_property_selector", + "PushNotificationConfig": "adcp.types.domains.core.push_notification_config", + "Qualifier1": "adcp.types.domains.core.committed_metric", + "Qualifier3": "adcp.types.domains.core.delivery_metric_aggregate", + "QualifierModel": "adcp.types.domains.core.committed_metric", + "QuartileData": "adcp.types.domains.core.delivery_metrics", + "QuerySummary": "adcp.types.domains.core.tasks_list_response", + "RankByItem": "adcp.types.domains.core.evaluator_spec", + "RankByItem1": "adcp.types.domains.core.evaluator_spec", + "RankByItem2": "adcp.types.domains.core.evaluator_spec", + "ReachWindow": "adcp.types.domains.core.delivery_metrics", + "Readiness": "adcp.types.domains.core.reporting_delivery_ready_webhook", + "RealEstateItem": "adcp.types.domains.core.real_estate_item", + "Recipient": "adcp.types.domains.core.reporting_dataset_share_destination", + "RecommendedAction": "adcp.types.domains.core.reporting_status_issue", + "ReconciliationStatus": "adcp.types.domains.core.reporting_obligation", + "Recovery": "adcp.types.domains.core.error", + "Rectangle": "adcp.types.domains.core.placement_presentation", + "Reference": "adcp.types.domains.core.rights_attestation_evaluation", + "ReferenceAuthorization1": "adcp.types.domains.core.assets.asset_union", + "ReferenceRenderer": "adcp.types.domains.core.reference_renderer", + "RefineProposalsInputRequired": "adcp.types.domains.core.async_response_refs.media_buy.refine_proposals_async_response_input_required", + "RefineProposalsSubmitted": "adcp.types.domains.core.async_response_refs.media_buy.refine_proposals_async_response_submitted", + "RefineProposalsWorking": "adcp.types.domains.core.async_response_refs.media_buy.refine_proposals_async_response_working", + "RegistryEvent": "adcp.types.domains.core.registry_event", + "RegistryEvent1": "adcp.types.domains.core.registry_event", + "RegistryEvent10": "adcp.types.domains.core.registry_event", + "RegistryEvent11": "adcp.types.domains.core.registry_event", + "RegistryEvent12": "adcp.types.domains.core.registry_event", + "RegistryEvent13": "adcp.types.domains.core.registry_event", + "RegistryEvent14": "adcp.types.domains.core.registry_event", + "RegistryEvent15": "adcp.types.domains.core.registry_event", + "RegistryEvent16": "adcp.types.domains.core.registry_event", + "RegistryEvent17": "adcp.types.domains.core.registry_event", + "RegistryEvent18": "adcp.types.domains.core.registry_event", + "RegistryEvent19": "adcp.types.domains.core.registry_event", + "RegistryEvent2": "adcp.types.domains.core.registry_event", + "RegistryEvent20": "adcp.types.domains.core.registry_event", + "RegistryEvent3": "adcp.types.domains.core.registry_event", + "RegistryEvent4": "adcp.types.domains.core.registry_event", + "RegistryEvent5": "adcp.types.domains.core.registry_event", + "RegistryEvent6": "adcp.types.domains.core.registry_event", + "RegistryEvent7": "adcp.types.domains.core.registry_event", + "RegistryEvent8": "adcp.types.domains.core.registry_event", + "RegistryEvent9": "adcp.types.domains.core.registry_event", + "RegistryFeedResponse": "adcp.types.domains.core.registry_feed_response", + "RejectionCode": "adcp.types.domains.core.reporting_receipt", + "RelatedCollection": "adcp.types.domains.core.collection", + "RelationshipKind": "adcp.types.domains.core.indicators_changed_webhook", + "RemovalReason": "adcp.types.domains.core.wholesale_feed_event", + "RenderingOrigin": "adcp.types.domains.core.preview_renderer_metadata", + "Renders": "adcp.types.domains.core.format", + "Renders1": "adcp.types.domains.core.format", + "Repair": "adcp.types.domains.core.account_change", + "ReplacedByValue": "adcp.types.domains.core.geo_place_catalog_entry", + "ReportCalendarTimezoneBasis": "adcp.types.domains.core.reporting_report_definition", + "ReportingAdjustment": "adcp.types.domains.core.reporting_adjustment", + "ReportingAdjustmentReceipt": "adcp.types.domains.core.reporting_adjustment_receipt", + "ReportingAdjustmentRejectionCode": "adcp.types.domains.core.reporting_adjustment_receipt", + "ReportingBucket": "adcp.types.domains.core.account", + "ReportingCanonicalContentDigest": "adcp.types.domains.core.reporting_canonical_content_digest", + "ReportingCanonicalizationContract": "adcp.types.domains.core.reporting_canonicalization_contract", + "ReportingCapabilities": "adcp.types.domains.core.reporting_capabilities", + "ReportingCloud": "adcp.types.domains.core.reporting_dataset_share_destination", + "ReportingConsumerStatus": "adcp.types.domains.core.reporting_consumer_status", + "ReportingControlTotal": "adcp.types.domains.core.reporting_control_total", + "ReportingControlTotal1": "adcp.types.domains.core.reporting_control_total", + "ReportingControlTotal2": "adcp.types.domains.core.reporting_control_total", + "ReportingCoverage": "adcp.types.domains.core.reporting_coverage", + "ReportingDatasetShareDestination": "adcp.types.domains.core.reporting_dataset_share_destination", + "ReportingDatasetShareDestination1": "adcp.types.domains.core.reporting_dataset_share_destination", + "ReportingDatasetShareDestination2": "adcp.types.domains.core.reporting_dataset_share_destination", + "ReportingDeliveryCapabilities": "adcp.types.domains.core.reporting_delivery_capabilities", + "ReportingDeliveryConfigLifecycleState": "adcp.types.domains.core.reporting_delivery_config_state", + "ReportingDeliveryConfiguration": "adcp.types.domains.core.reporting_delivery_config", + "ReportingDeliveryConfigurationState": "adcp.types.domains.core.reporting_delivery_config_state", + "ReportingDeliveryMethod": "adcp.types.domains.core.reporting_delivery_method", + "ReportingDeliveryMethod1": "adcp.types.domains.core.reporting_delivery_method", + "ReportingDeliveryMethod2": "adcp.types.domains.core.reporting_delivery_method", + "ReportingDeliveryMethod3": "adcp.types.domains.core.reporting_delivery_method", + "ReportingDeliveryOffering": "adcp.types.domains.core.reporting_delivery_offering", + "ReportingDeliveryOfferingId": "adcp.types.domains.core.reporting_delivery_offering_id", + "ReportingDeliveryPattern": "adcp.types.domains.core.reporting_delivery_offering", + "ReportingDeliveryReadyWebhook": "adcp.types.domains.core.reporting_delivery_ready_webhook", + "ReportingFeedPurpose": "adcp.types.domains.core.reporting_delivery_offering", + "ReportingFileCompression": "adcp.types.domains.core.reporting_file_compression", + "ReportingFileEntry": "adcp.types.domains.core.reporting_file_entry", + "ReportingFileManifest": "adcp.types.domains.core.reporting_file_manifest", + "ReportingFileObjectReference": "adcp.types.domains.core.reporting_file_object_ref", + "ReportingFrequency": "adcp.types.domains.core.reporting_webhook", + "ReportingLedgerChangedWebhook": "adcp.types.domains.core.reporting_ledger_changed_webhook", + "ReportingMaterialization": "adcp.types.domains.core.reporting_materialization", + "ReportingMediaBuyId": "adcp.types.domains.core.reporting_coverage", + "ReportingMode": "adcp.types.domains.core.demographic_reporting_capability", + "ReportingNativeVersionReference": "adcp.types.domains.core.reporting_native_version_ref", + "ReportingObligation": "adcp.types.domains.core.reporting_obligation", + "ReportingOrchestration": "adcp.types.domains.core.reporting_delivery_method", + "ReportingPackageId": "adcp.types.domains.core.reporting_coverage", + "ReportingPrimaryKey": "adcp.types.domains.core.reporting_canonicalization_contract", + "ReportingProfile": "adcp.types.domains.core.reporting_delivery_offering", + "ReportingReaderCompatibilityItem": "adcp.types.domains.core.reporting_resource", + "ReportingReceipt": "adcp.types.domains.core.reporting_receipt", + "ReportingReconciliationMode": "adcp.types.domains.core.reporting_reconciliation_mode", + "ReportingReliabilityMeasurementPeriod": "adcp.types.domains.core.reporting_reliability_statistics", + "ReportingReliabilityStatistics": "adcp.types.domains.core.reporting_reliability_statistics", + "ReportingReportDefinition": "adcp.types.domains.core.reporting_report_definition", + "ReportingResource": "adcp.types.domains.core.reporting_resource", + "ReportingRevision": "adcp.types.domains.core.reporting_revision", + "ReportingSchedule": "adcp.types.domains.core.reporting_schedule", + "ReportingScheduleAlignment": "adcp.types.domains.core.reporting_schedule", + "ReportingScheduleOffering": "adcp.types.domains.core.reporting_schedule_offering", + "ReportingStatusChangedWebhook": "adcp.types.domains.core.reporting_status_changed_webhook", + "ReportingStatusIssue": "adcp.types.domains.core.reporting_status_issue", + "ReportingStatusSeverity": "adcp.types.domains.core.reporting_status_issue", + "ReportingVerification": "adcp.types.domains.core.reporting_verification", + "ReportingVerificationProfile": "adcp.types.domains.core.reporting_verification_profile", + "ReportingVerificationProfileSet": "adcp.types.domains.core.reporting_verification_profile_set", + "ReportingVerificationProfileSetEnum": "adcp.types.domains.core.reporting_verification_profile_set", + "ReportingWebhook": "adcp.types.domains.core.reporting_webhook", + "ReportingWriteDestination": "adcp.types.domains.core.reporting_write_destination", + "ReportingWriteDestination1": "adcp.types.domains.core.reporting_write_destination", + "ReportingWriteDestination2": "adcp.types.domains.core.reporting_write_destination", + "RepresentationDestination": "adcp.types.domains.core.representation_destination", + "RepresentationRejection": "adcp.types.domains.core.representation_rejection", + "RepresentationSelection": "adcp.types.domains.core.representation_selection", + "RequestProposalsInputRequired": "adcp.types.domains.core.async_response_refs.media_buy.request_proposals_async_response_input_required", + "RequestProposalsSubmitted": "adcp.types.domains.core.async_response_refs.media_buy.request_proposals_async_response_submitted", + "RequestProposalsWorking": "adcp.types.domains.core.async_response_refs.media_buy.request_proposals_async_response_working", + "Required": "adcp.types.domains.core.targeting_overlay_requirements", + "RequiredGeoTargetingItem": "adcp.types.domains.core.product_filters", + "RequiredPerformanceStandard": "adcp.types.domains.core.product_offer_filters", + "ResolutionModel": "adcp.types.domains.core.signal_targeting_rules", + "ResolvedAssets": "adcp.types.domains.core.creative_localization_readback", + "ResolvedAssets1": "adcp.types.domains.core.creative_localization_readback", + "ResolvedBy": "adcp.types.domains.core.representation_selection", + "Resolver": "adcp.types.domains.core.attestation_capabilities", + "ResourceRef": "adcp.types.domains.core.downstream_connection_requirement", + "ResponsePayload": "adcp.types.domains.core.response_payload_jws_envelope", + "ResponsePayloadJwsEnvelope": "adcp.types.domains.core.response_payload_jws_envelope", + "ResponsibleParty": "adcp.types.domains.core.reporting_status_issue", + "Responsive": "adcp.types.domains.core.format", + "RestatementPolicy": "adcp.types.domains.core.reporting_report_definition", + "Restriction": "adcp.types.domains.core.rights_constraint", + "RetiredDestination": "adcp.types.domains.core.principal_state", + "RightsAgent": "adcp.types.domains.core.rights_constraint", + "RightsAttestationEvaluation": "adcp.types.domains.core.rights_attestation_evaluation", + "RightsConstraint": "adcp.types.domains.core.rights_constraint", + "Roas": "adcp.types.domains.core.bidding_policy", + "RoasStrength": "adcp.types.domains.core.bidding_policy_capability", + "Role2": "adcp.types.domains.core.assets.asset_union", + "RotationMode": "adcp.types.domains.core.creative_assignment", + "Route": "adcp.types.domains.core.preview_provider", + "Salary": "adcp.types.domains.core.job_item", + "SampleRate": "adcp.types.domains.core.requirements.audio_asset_requirements", + "Sandbox": "adcp.types.domains.core.requirements.html_asset_requirements", + "ScalarBinding": "adcp.types.domains.core.requirements.catalog_field_binding", + "ScanType": "adcp.types.domains.core.assets.asset_union", + "ScopeCapability": "adcp.types.domains.core.bidding_policy_capability", + "ScopeName": "adcp.types.domains.core.account_authorization", + "ScopedCreativeApproval": "adcp.types.domains.core.creative_approval_scope", + "Selector": "adcp.types.domains.core.targeting_modification", + "SellerAgentReference": "adcp.types.domains.core.seller_agent_ref", + "Severity": "adcp.types.domains.core.diagnostic_issue", + "Signal": "adcp.types.domains.core.wholesale_feed_event", + "SignalCoverageForecast": "adcp.types.domains.core.signal_coverage_forecast", + "SignalDefinition": "adcp.types.domains.core.signal_definition", + "SignalDefinitionEnrichment": "adcp.types.domains.core.signal_definition_enrichment", + "SignalFilters": "adcp.types.domains.core.signal_filters", + "SignalForecastDimension": "adcp.types.domains.core.forecast_dimension_signal", + "SignalId8": "adcp.types.domains.core.signal_id", + "SignalId9": "adcp.types.domains.core.signal_id", + "SignalListing": "adcp.types.domains.core.signal_listing", + "SignalModelingDisclosure": "adcp.types.domains.core.signal_modeling_disclosure", + "SignalPricingOption": "adcp.types.domains.core.signal_pricing_option", + "SignalRef": "adcp.types.domains.core.signal_ref", + "SignalRef1": "adcp.types.domains.core.signal_ref", + "SignalRef2": "adcp.types.domains.core.signal_ref", + "SignalRef3": "adcp.types.domains.core.signal_ref", + "SignalSelectionGroupRule": "adcp.types.domains.core.signal_selection_group_rule", + "SignalTag": "adcp.types.domains.core.data_provider_signal_selector", + "SignalTargeting": "adcp.types.domains.core.signal_targeting", + "SignalTargeting1": "adcp.types.domains.core.signal_targeting", + "SignalTargeting2": "adcp.types.domains.core.signal_targeting", + "SignalTargeting3": "adcp.types.domains.core.signal_targeting", + "SignalTargetingExpression": "adcp.types.domains.core.signal_targeting_expression", + "SignalTargetingExpression1": "adcp.types.domains.core.signal_targeting_expression", + "SignalTargetingExpression2": "adcp.types.domains.core.signal_targeting_expression", + "SignalTargetingExpression3": "adcp.types.domains.core.signal_targeting_expression", + "SignalTargetingItem": "adcp.types.domains.core.product_filters", + "SignalTargetingItem1": "adcp.types.domains.core.product_filters", + "SignalTargetingItem2": "adcp.types.domains.core.product_filters", + "SignalTargetingItem3": "adcp.types.domains.core.product_filters", + "SignalTargetingItem4": "adcp.types.domains.core.product_filters", + "SignalTargetingItem5": "adcp.types.domains.core.product_filters", + "SignalTargetingItem6": "adcp.types.domains.core.product_filters", + "SignalTargetingItem7": "adcp.types.domains.core.product_filters", + "SignalTargetingRules": "adcp.types.domains.core.signal_targeting_rules", + "SlaWindow": "adcp.types.domains.core.sla_window", + "Sort": "adcp.types.domains.core.tasks_list_request", + "SortApplied": "adcp.types.domains.core.tasks_list_response", + "Special": "adcp.types.domains.core.special", + "Specification": "adcp.types.domains.core.product", + "SpotReportingCapability": "adcp.types.domains.core.spot_reporting_capability", + "StartTiming": "adcp.types.domains.core.start_timing", + "StoreItem": "adcp.types.domains.core.store_item", + "Storyboard": "adcp.types.domains.core.registry_event", + "StoryboardStatus": "adcp.types.domains.core.registry_event", + "Strength": "adcp.types.domains.core.bidding_policy", + "Strength1": "adcp.types.domains.core.bidding_policy", + "StringArray": "adcp.types.domains.core.registry_event", + "Subject10": "adcp.types.domains.core.rights_attestation_evaluation", + "Subject11": "adcp.types.domains.core.audience_evidence", + "Subject12": "adcp.types.domains.core.audience_evidence", + "Subject13": "adcp.types.domains.core.audience_evidence", + "Subject14": "adcp.types.domains.core.audience_evidence", + "Subject15": "adcp.types.domains.core.audience_evidence", + "Subject16": "adcp.types.domains.core.audience_evidence", + "Subject17": "adcp.types.domains.core.audience_evidence", + "Subject19": "adcp.types.domains.core.rights_attestation_evaluation", + "Subject2": "adcp.types.domains.core.audience_evidence", + "Subject20": "adcp.types.domains.core.rights_constraint", + "Subject21": "adcp.types.domains.core.audience_evidence", + "Subject22": "adcp.types.domains.core.audience_evidence", + "Subject23": "adcp.types.domains.core.audience_evidence", + "Subject24": "adcp.types.domains.core.audience_evidence", + "Subject25": "adcp.types.domains.core.audience_evidence", + "Subject26": "adcp.types.domains.core.audience_evidence", + "Subject27": "adcp.types.domains.core.audience_evidence", + "Subject29": "adcp.types.domains.core.rights_constraint", + "Subject3": "adcp.types.domains.core.audience_evidence", + "Subject31": "adcp.types.domains.core.audience_evidence", + "Subject32": "adcp.types.domains.core.audience_evidence", + "Subject33": "adcp.types.domains.core.audience_evidence", + "Subject34": "adcp.types.domains.core.audience_evidence", + "Subject35": "adcp.types.domains.core.audience_evidence", + "Subject36": "adcp.types.domains.core.audience_evidence", + "Subject37": "adcp.types.domains.core.audience_evidence", + "Summary": "adcp.types.domains.core.inventory_list_application", + "Summary2": "adcp.types.domains.core.inventory_list_application", + "Supported": "adcp.types.domains.core.targeting_overlay_support", + "SupportedDeliveryMethod": "adcp.types.domains.core.attestation_capabilities", + "SupportedMetric": "adcp.types.domains.core.product", + "SupportedTarget5": "adcp.types.domains.core.product", + "SupportedTimezone": "adcp.types.domains.core.account_timezone_capability", + "SupportedVersion": "adcp.types.domains.core.geo_place_catalog_capability", + "SupportedViewDuration": "adcp.types.domains.core.product", + "SyncCatalogsInputRequired": "adcp.types.domains.core.async_response_refs.media_buy.sync_catalogs_async_response_input_required", + "SyncCatalogsSubmitted": "adcp.types.domains.core.async_response_refs.media_buy.sync_catalogs_async_response_submitted", + "SyncCatalogsWorking": "adcp.types.domains.core.async_response_refs.media_buy.sync_catalogs_async_response_working", + "SyncCreativesInputRequired": "adcp.types.domains.core.async_response_refs.creative.sync_creatives_async_response_input_required", + "SyncCreativesSubmitted": "adcp.types.domains.core.async_response_refs.creative.sync_creatives_async_response_submitted", + "SyncCreativesWorking": "adcp.types.domains.core.async_response_refs.creative.sync_creatives_async_response_working", + "System1": "adcp.types.domains.core.postal_area", + "System11": "adcp.types.domains.core.postal_country_system", + "System12": "adcp.types.domains.core.postal_country_system", + "System13": "adcp.types.domains.core.postal_country_system", + "System19": "adcp.types.domains.core.postal_country_system", + "System2": "adcp.types.domains.core.postal_area", + "System3": "adcp.types.domains.core.postal_area", + "System9": "adcp.types.domains.core.postal_area", + "Tag": "adcp.types.domains.core.signal_definition", + "Talent": "adcp.types.domains.core.talent", + "Target1": "adcp.types.domains.core.assets.asset_union", + "Target10": "adcp.types.domains.core.canonical_optimization_goal", + "Target11": "adcp.types.domains.core.canonical_optimization_goal", + "Target14": "adcp.types.domains.core.optimization_goal", + "Target15": "adcp.types.domains.core.optimization_goal", + "Target16": "adcp.types.domains.core.optimization_goal", + "Target17": "adcp.types.domains.core.optimization_goal", + "Target18": "adcp.types.domains.core.optimization_goal", + "Target19": "adcp.types.domains.core.optimization_goal", + "Target3": "adcp.types.domains.core.budget_allocation", + "Target4": "adcp.types.domains.core.budget_allocation", + "Target5": "adcp.types.domains.core.budget_allocation", + "Target6": "adcp.types.domains.core.budget_allocation", + "Target7": "adcp.types.domains.core.budget_allocation", + "Target8": "adcp.types.domains.core.budget_allocation", + "TargetVariant": "adcp.types.domains.core.creative_localization", + "TargetingModification": "adcp.types.domains.core.targeting_modification", + "TargetingModification1": "adcp.types.domains.core.targeting_modification", + "TargetingModification2": "adcp.types.domains.core.targeting_modification", + "TargetingOverlay": "adcp.types.domains.core.targeting", + "TargetingOverlayInput": "adcp.types.domains.core.targeting_input", + "TargetingOverlayRequirements": "adcp.types.domains.core.targeting_overlay_requirements", + "TargetingOverlaySupport": "adcp.types.domains.core.targeting_overlay_support", + "TargetingUnknownAgeEligibilityConstraint": "adcp.types.domains.core.targeting_unknown_age_eligibility_constraint", + "TargetingVerifiedAgeBasisConstraint": "adcp.types.domains.core.targeting_verified_age_basis_constraint", + "TasksGetRequest": "adcp.types.domains.core.tasks_get_request", + "TasksGetResponse": "adcp.types.domains.core.tasks_get_response", + "TasksListRequest": "adcp.types.domains.core.tasks_list_request", + "TasksListResponse": "adcp.types.domains.core.tasks_list_response", + "Terms": "adcp.types.domains.core.insertion_order", + "TextAssetRequirements": "adcp.types.domains.core.requirements.text_asset_requirements", + "TextDecoration": "adcp.types.domains.core.placement_presentation", + "TimeBasedView": "adcp.types.domains.core.delivery_metrics", + "TimeForecastDimension": "adcp.types.domains.core.forecast_dimension_time", + "TotalBudget": "adcp.types.domains.core.insertion_order", + "TrackerExecutionContract": "adcp.types.domains.core.tracker_execution_contract", + "TrackerExecutionSelector": "adcp.types.domains.core.tracker_execution_selector", + "TrackerExecutionSelector1": "adcp.types.domains.core.tracker_execution_selector", + "TrackerExecutionSelector2": "adcp.types.domains.core.tracker_execution_selector", + "TrackerExecutionSelector3": "adcp.types.domains.core.tracker_execution_selector", + "Tracks": "adcp.types.domains.core.registry_event", + "Transformer": "adcp.types.domains.core.transformer", + "TransformerParam": "adcp.types.domains.core.transformer_param", + "Transition": "adcp.types.domains.core.impairment", + "Transmission": "adcp.types.domains.core.vehicle_item", + "TruncationSentinel": "adcp.types.domains.core.truncation_sentinel", + "Trust": "adcp.types.domains.core.brand_response_authorization_result", + "UnavailableReason": "adcp.types.domains.core.account_change", + "UniversalMacro": "adcp.types.domains.core.assets.asset_union", + "UnknownFormatAsset": "adcp.types.domains.core.format", + "UnknownGroupAsset": "adcp.types.domains.core.format", + "UnknownHandling": "adcp.types.domains.core.demographic_targeting_capability", + "UpdateFrequency": "adcp.types.domains.core.assets.asset_union", + "UpdateMediaBuyInputRequired": "adcp.types.domains.core.async_response_refs.media_buy.update_media_buy_async_response_input_required", + "UpdateMediaBuySubmitted": "adcp.types.domains.core.async_response_refs.media_buy.update_media_buy_async_response_submitted", + "UpdateMediaBuyWorking": "adcp.types.domains.core.async_response_refs.media_buy.update_media_buy_async_response_working", + "UrlAssetRequirements": "adcp.types.domains.core.requirements.url_asset_requirements", + "UrlAssetType": "adcp.types.domains.core.assets.asset_union", + "UserMatch": "adcp.types.domains.core.user_match", + "ValidityHint": "adcp.types.domains.core.attestation_reference", + "ValueSource": "adcp.types.domains.core.transformer_param", + "VariableType": "adcp.types.domains.core.creative_variable", + "VariantDimension": "adcp.types.domains.core.transformer", + "Variants": "adcp.types.domains.core.creative_localization_readback", + "Variants1": "adcp.types.domains.core.creative_localization_readback", + "Variants2": "adcp.types.domains.core.creative_localization_readback", + "VastAsset1": "adcp.types.domains.core.assets.asset_union", + "VastAsset2": "adcp.types.domains.core.assets.asset_union", + "VastAsset3": "adcp.types.domains.core.assets.vast_asset", + "VastAsset4": "adcp.types.domains.core.assets.vast_asset", + "VastAssetRequirements": "adcp.types.domains.core.requirements.vast_asset_requirements", + "VastEvent": "adcp.types.domains.core.vast_tracker_constraints", + "VastMediafileRequirements": "adcp.types.domains.core.vast_media_file_requirements", + "VastOffset": "adcp.types.domains.core.vast_tracker_constraints", + "VastTarget": "adcp.types.domains.core.vast_tracker_constraints", + "VastTrackerConstraints": "adcp.types.domains.core.vast_tracker_constraints", + "VastTrackingEvent": "adcp.types.domains.core.assets.asset_union", + "VastVersion": "adcp.types.domains.core.assets.asset_union", + "VastVersions": "adcp.types.domains.core.vast_tracker_constraints", + "VehicleItem": "adcp.types.domains.core.vehicle_item", + "VendorMetricId": "adcp.types.domains.core.vendor_metric_id", + "VendorMetricOptimization": "adcp.types.domains.core.vendor_metric_optimization", + "VendorMetricOptimizationSupportedMetric": "adcp.types.domains.core.vendor_metric_optimization_supported_metric", + "VendorMetricValue": "adcp.types.domains.core.vendor_metric_value", + "VendorPricing": "adcp.types.domains.core.signal_pricing", + "VendorPricing1": "adcp.types.domains.core.signal_pricing", + "VendorPricing2": "adcp.types.domains.core.signal_pricing", + "VendorPricing3": "adcp.types.domains.core.signal_pricing", + "VendorPricing4": "adcp.types.domains.core.signal_pricing", + "VendorPricing5": "adcp.types.domains.core.signal_pricing", + "VendorPricingOption": "adcp.types.domains.core.vendor_pricing_option", + "VendorPricingOption1": "adcp.types.domains.core.vendor_pricing_option", + "VendorPricingOption10": "adcp.types.domains.core.vendor_pricing_option", + "VendorPricingOption11": "adcp.types.domains.core.vendor_pricing_option", + "VendorPricingOption2": "adcp.types.domains.core.vendor_pricing_option", + "VendorPricingOption3": "adcp.types.domains.core.vendor_pricing_option", + "VendorPricingOption4": "adcp.types.domains.core.vendor_pricing_option", + "VendorPricingOption5": "adcp.types.domains.core.vendor_pricing_option", + "VendorPricingOption6": "adcp.types.domains.core.vendor_pricing_option", + "VendorPricingOption7": "adcp.types.domains.core.vendor_pricing_option", + "VendorPricingOption8": "adcp.types.domains.core.vendor_pricing_option", + "VendorPricingOption9": "adcp.types.domains.core.vendor_pricing_option", + "VenueBreakdownItem": "adcp.types.domains.core.delivery_metrics", + "VerificationPath": "adcp.types.domains.core.reporting_verification", + "VerificationTokenGradingProfile": "adcp.types.domains.core.verification_token_claims", + "VerificationTokenMode": "adcp.types.domains.core.verification_token_claims", + "VerifiedAttestationDigest": "adcp.types.domains.core.canonical_audience_evidence_selection", + "VerifyAgent1": "adcp.types.domains.core.assets.asset_union", + "VerifyAgent10": "adcp.types.domains.core.provenance", + "VideoAssetRequirements": "adcp.types.domains.core.requirements.video_asset_requirements", + "Viewability1": "adcp.types.domains.core.delivery_metrics", + "ViewableRate": "adcp.types.domains.core.forecast_point", + "ViewedSecondsHistogramItem": "adcp.types.domains.core.delivery_metrics", + "ViewedSecondsPercentiles": "adcp.types.domains.core.delivery_metrics", + "Visual": "adcp.types.domains.core.overlay", + "VoiceSynthesisRefItem": "adcp.types.domains.core.transformer", + "Warning": "adcp.types.domains.core.warning", + "WarningAffectedResource": "adcp.types.domains.core.warning_resource", + "WatermarkMediaType": "adcp.types.domains.core.assets.asset_union", + "WebhookActivityRecord": "adcp.types.domains.core.webhook_activity_record", + "WebhookAssetRequirements": "adcp.types.domains.core.requirements.webhook_asset_requirements", + "WebhookChallenge": "adcp.types.domains.core.webhook_challenge", + "WebhookChallengeResponse": "adcp.types.domains.core.webhook_challenge_response", + "WebhookResponseType": "adcp.types.domains.core.assets.asset_union", + "WebhookSecurityMethod": "adcp.types.domains.core.assets.asset_union", + "WebhookSigningAlgorithm": "adcp.types.domains.core.principal_declarations", + "WholesaleFeedEvent": "adcp.types.domains.core.wholesale_feed_event", + "WholesaleFeedEvent1": "adcp.types.domains.core.wholesale_feed_event", + "WholesaleFeedEvent2": "adcp.types.domains.core.wholesale_feed_event", + "WholesaleFeedEvent3": "adcp.types.domains.core.wholesale_feed_event", + "WholesaleFeedEvent4": "adcp.types.domains.core.wholesale_feed_event", + "WholesaleFeedEvent5": "adcp.types.domains.core.wholesale_feed_event", + "WholesaleFeedEvent6": "adcp.types.domains.core.wholesale_feed_event", + "WholesaleFeedEvent7": "adcp.types.domains.core.wholesale_feed_event", + "WholesaleFeedEvent8": "adcp.types.domains.core.wholesale_feed_event", + "WholesaleFeedEvent9": "adcp.types.domains.core.wholesale_feed_event", + "WholesaleFeedWebhook": "adcp.types.domains.core.wholesale_feed_webhook", + "XEntityTypes": "adcp.types.domains.core.x_entity_types", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/creative/__init__.py b/src/adcp/types/domains/creative/__init__.py index e250f48bc6..ac95286b50 100644 --- a/src/adcp/types/domains/creative/__init__.py +++ b/src/adcp/types/domains/creative/__init__.py @@ -9,155 +9,166 @@ it from its own schema's module, ``adcp.types.domains.creative.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.creative.audit_observation import ( - ClaimedValue, - CreativeAuditObservation, - Details, - HumanOversight, -) -from adcp.types.domains.creative.creative_assignment_changed_webhook import ( - ChangeKind, - CreativeAssignmentChangedWebhook, -) -from adcp.types.domains.creative.creative_feature_result import CreativeFeatureResult -from adcp.types.domains.creative.creative_purged_webhook import CreativePurgedWebhook, PurgeKind -from adcp.types.domains.creative.creative_status_changed_webhook import ( - CreativeStatusChangedWebhook, - From, - Transition, -) -from adcp.types.domains.creative.get_creative_delivery_request import GetCreativeDeliveryRequest -from adcp.types.domains.creative.get_creative_delivery_response import ( - GetCreativeDeliveryResponse, - Pagination, - ReportingPeriod, -) -from adcp.types.domains.creative.get_creative_features_async_response_submitted import ( - GetCreativeFeaturesSubmitted, -) -from adcp.types.domains.creative.get_creative_features_request import GetCreativeFeaturesRequest -from adcp.types.domains.creative.get_creative_features_response import ( - GetCreativeFeaturesResponse, - GetCreativeFeaturesResponse1, - GetCreativeFeaturesResponse2, - GetCreativeFeaturesResponse3, -) -from adcp.types.domains.creative.get_creative_features_terminal_success import ( - GetCreativeFeaturesSuccess, -) -from adcp.types.domains.creative.list_creative_formats_request import ( - ListCreativeFormatsRequestCreativeAgent, - Type, -) -from adcp.types.domains.creative.list_creative_formats_response import ( - CreativeAgent, - ListCreativeFormatsResponseCreativeAgent, -) -from adcp.types.domains.creative.list_creatives_request import ( - AssignmentProjection, - Field1, - ListCreativesRequest, - Sort, -) -from adcp.types.domains.creative.list_creatives_response import ( - Assets, - AssignedPackage, - Assignments, - Creatives, - Creatives1, - Indicator, - IndicatorTypesEvaluatedEnum, - ListCreativesResponse, - LocalizationUnavailable, - Purge, - QuerySummary, - Snapshot, - SortApplied, - StatusSummary, -) -from adcp.types.domains.creative.list_transformers_request import ( - ExpandPaginationItem, - ListTransformersRequestCreativeAgent, - OutputCapabilityId, -) -from adcp.types.domains.creative.list_transformers_response import ( - ListTransformersResponseCreativeAgent, -) -from adcp.types.domains.creative.preview_creative_request import ( - Input10, - PreviewCreativeRequest, - Request, - RequestType, -) -from adcp.types.domains.creative.preview_creative_response import ( - Input2, - Preview, - Preview2, - Preview3, - PreviewCreativeResponse, - PreviewCreativeResponse1, - PreviewCreativeResponse2, - PreviewCreativeResponse3, - PreviewCreativeResponse4, - Response, - Result, -) -from adcp.types.domains.creative.preview_render import ( - Dimensions, - Embedding, - PreviewRender, - PreviewRender1, - PreviewRender2, - PreviewRender3, -) -from adcp.types.domains.creative.sync_creatives_async_response_input_required import ( - Reason, - SyncCreativesInputRequired, -) -from adcp.types.domains.creative.sync_creatives_async_response_submitted import ( - SyncCreativesSubmitted, -) -from adcp.types.domains.creative.sync_creatives_async_response_working import SyncCreativesWorking -from adcp.types.domains.creative.sync_creatives_request import ( - Assignment, - AssignmentOperations, - AssignmentOperations1, - AssignmentOperations2, - AssignmentOperations3, - PlacementId, - SyncCreativesRequest, -) -from adcp.types.domains.creative.sync_creatives_response import ( - SyncCreativesResponse, - SyncCreativesResponse1, - SyncCreativesResponse2, - SyncCreativesResponse3, -) -from adcp.types.domains.creative.validate_input_request import ( - Targets, - Targets1, - Targets2, - Targets3, - Targets4, - ValidateInputRequest, -) -from adcp.types.domains.creative.validate_input_response import ValidateInputResponse -from adcp.types.domains.creative.validate_input_result import ( - Kind, - ResultKind, - Target, - ValidateInputResult, - Violation, - Warning, -) -from adcp.types.domains.creative.video_brief import Segment, VideoBrief +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.creative.audit_observation import ( + ClaimedValue, + CreativeAuditObservation, + Details, + HumanOversight, + ) + from adcp.types.domains.creative.creative_assignment_changed_webhook import ( + ChangeKind, + CreativeAssignmentChangedWebhook, + ) + from adcp.types.domains.creative.creative_feature_result import CreativeFeatureResult + from adcp.types.domains.creative.creative_purged_webhook import CreativePurgedWebhook, PurgeKind + from adcp.types.domains.creative.creative_status_changed_webhook import ( + CreativeStatusChangedWebhook, + From, + Transition, + ) + from adcp.types.domains.creative.get_creative_delivery_request import GetCreativeDeliveryRequest + from adcp.types.domains.creative.get_creative_delivery_response import ( + GetCreativeDeliveryResponse, + Pagination, + ReportingPeriod, + ) + from adcp.types.domains.creative.get_creative_features_async_response_submitted import ( + GetCreativeFeaturesSubmitted, + ) + from adcp.types.domains.creative.get_creative_features_request import GetCreativeFeaturesRequest + from adcp.types.domains.creative.get_creative_features_response import ( + GetCreativeFeaturesResponse, + GetCreativeFeaturesResponse1, + GetCreativeFeaturesResponse2, + GetCreativeFeaturesResponse3, + ) + from adcp.types.domains.creative.get_creative_features_terminal_success import ( + GetCreativeFeaturesSuccess, + ) + from adcp.types.domains.creative.list_creative_formats_request import ( + ListCreativeFormatsRequestCreativeAgent, + Type, + ) + from adcp.types.domains.creative.list_creative_formats_response import ( + CreativeAgent, + ListCreativeFormatsResponseCreativeAgent, + ) + from adcp.types.domains.creative.list_creatives_request import ( + AssignmentProjection, + Field1, + ListCreativesRequest, + Sort, + ) + from adcp.types.domains.creative.list_creatives_response import ( + Assets, + AssignedPackage, + Assignments, + Creatives, + Creatives1, + Indicator, + IndicatorTypesEvaluatedEnum, + ListCreativesResponse, + LocalizationUnavailable, + Purge, + QuerySummary, + Snapshot, + SortApplied, + StatusSummary, + ) + from adcp.types.domains.creative.list_transformers_request import ( + ExpandPaginationItem, + ListTransformersRequestCreativeAgent, + OutputCapabilityId, + ) + from adcp.types.domains.creative.list_transformers_response import ( + ListTransformersResponseCreativeAgent, + ) + from adcp.types.domains.creative.preview_creative_request import ( + Input10, + PreviewCreativeRequest, + Request, + RequestType, + ) + from adcp.types.domains.creative.preview_creative_response import ( + Input2, + Preview, + Preview2, + Preview3, + PreviewCreativeResponse, + PreviewCreativeResponse1, + PreviewCreativeResponse2, + PreviewCreativeResponse3, + PreviewCreativeResponse4, + Response, + Result, + ) + from adcp.types.domains.creative.preview_render import ( + Dimensions, + Embedding, + PreviewRender, + PreviewRender1, + PreviewRender2, + PreviewRender3, + ) + from adcp.types.domains.creative.sync_creatives_async_response_input_required import ( + Reason, + SyncCreativesInputRequired, + ) + from adcp.types.domains.creative.sync_creatives_async_response_submitted import ( + SyncCreativesSubmitted, + ) + from adcp.types.domains.creative.sync_creatives_async_response_working import ( + SyncCreativesWorking, + ) + from adcp.types.domains.creative.sync_creatives_request import ( + Assignment, + AssignmentOperations, + AssignmentOperations1, + AssignmentOperations2, + AssignmentOperations3, + PlacementId, + SyncCreativesRequest, + ) + from adcp.types.domains.creative.sync_creatives_response import ( + SyncCreativesResponse, + SyncCreativesResponse1, + SyncCreativesResponse2, + SyncCreativesResponse3, + ) + from adcp.types.domains.creative.validate_input_request import ( + Targets, + Targets1, + Targets2, + Targets3, + Targets4, + ValidateInputRequest, + ) + from adcp.types.domains.creative.validate_input_response import ValidateInputResponse + from adcp.types.domains.creative.validate_input_result import ( + Kind, + ResultKind, + Target, + ValidateInputResult, + Violation, + Warning, + ) + from adcp.types.domains.creative.video_brief import Segment, VideoBrief # Explicit exports __all__ = [ @@ -262,3 +273,123 @@ "Violation", "Warning", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "Assets": "adcp.types.domains.creative.list_creatives_response", + "AssignedPackage": "adcp.types.domains.creative.list_creatives_response", + "Assignment": "adcp.types.domains.creative.sync_creatives_request", + "AssignmentOperations": "adcp.types.domains.creative.sync_creatives_request", + "AssignmentOperations1": "adcp.types.domains.creative.sync_creatives_request", + "AssignmentOperations2": "adcp.types.domains.creative.sync_creatives_request", + "AssignmentOperations3": "adcp.types.domains.creative.sync_creatives_request", + "AssignmentProjection": "adcp.types.domains.creative.list_creatives_request", + "Assignments": "adcp.types.domains.creative.list_creatives_response", + "ChangeKind": "adcp.types.domains.creative.creative_assignment_changed_webhook", + "ClaimedValue": "adcp.types.domains.creative.audit_observation", + "CreativeAgent": "adcp.types.domains.creative.list_creative_formats_response", + "CreativeAssignmentChangedWebhook": "adcp.types.domains.creative.creative_assignment_changed_webhook", + "CreativeAuditObservation": "adcp.types.domains.creative.audit_observation", + "CreativeFeatureResult": "adcp.types.domains.creative.creative_feature_result", + "CreativePurgedWebhook": "adcp.types.domains.creative.creative_purged_webhook", + "CreativeStatusChangedWebhook": "adcp.types.domains.creative.creative_status_changed_webhook", + "Creatives": "adcp.types.domains.creative.list_creatives_response", + "Creatives1": "adcp.types.domains.creative.list_creatives_response", + "Details": "adcp.types.domains.creative.audit_observation", + "Dimensions": "adcp.types.domains.creative.preview_render", + "Embedding": "adcp.types.domains.creative.preview_render", + "ExpandPaginationItem": "adcp.types.domains.creative.list_transformers_request", + "Field1": "adcp.types.domains.creative.list_creatives_request", + "From": "adcp.types.domains.creative.creative_status_changed_webhook", + "GetCreativeDeliveryRequest": "adcp.types.domains.creative.get_creative_delivery_request", + "GetCreativeDeliveryResponse": "adcp.types.domains.creative.get_creative_delivery_response", + "GetCreativeFeaturesRequest": "adcp.types.domains.creative.get_creative_features_request", + "GetCreativeFeaturesResponse": "adcp.types.domains.creative.get_creative_features_response", + "GetCreativeFeaturesResponse1": "adcp.types.domains.creative.get_creative_features_response", + "GetCreativeFeaturesResponse2": "adcp.types.domains.creative.get_creative_features_response", + "GetCreativeFeaturesResponse3": "adcp.types.domains.creative.get_creative_features_response", + "GetCreativeFeaturesSubmitted": "adcp.types.domains.creative.get_creative_features_async_response_submitted", + "GetCreativeFeaturesSuccess": "adcp.types.domains.creative.get_creative_features_terminal_success", + "HumanOversight": "adcp.types.domains.creative.audit_observation", + "Indicator": "adcp.types.domains.creative.list_creatives_response", + "IndicatorTypesEvaluatedEnum": "adcp.types.domains.creative.list_creatives_response", + "Input10": "adcp.types.domains.creative.preview_creative_request", + "Input2": "adcp.types.domains.creative.preview_creative_response", + "Kind": "adcp.types.domains.creative.validate_input_result", + "ListCreativeFormatsRequestCreativeAgent": "adcp.types.domains.creative.list_creative_formats_request", + "ListCreativeFormatsResponseCreativeAgent": "adcp.types.domains.creative.list_creative_formats_response", + "ListCreativesRequest": "adcp.types.domains.creative.list_creatives_request", + "ListCreativesResponse": "adcp.types.domains.creative.list_creatives_response", + "ListTransformersRequestCreativeAgent": "adcp.types.domains.creative.list_transformers_request", + "ListTransformersResponseCreativeAgent": "adcp.types.domains.creative.list_transformers_response", + "LocalizationUnavailable": "adcp.types.domains.creative.list_creatives_response", + "OutputCapabilityId": "adcp.types.domains.creative.list_transformers_request", + "Pagination": "adcp.types.domains.creative.get_creative_delivery_response", + "PlacementId": "adcp.types.domains.creative.sync_creatives_request", + "Preview": "adcp.types.domains.creative.preview_creative_response", + "Preview2": "adcp.types.domains.creative.preview_creative_response", + "Preview3": "adcp.types.domains.creative.preview_creative_response", + "PreviewCreativeRequest": "adcp.types.domains.creative.preview_creative_request", + "PreviewCreativeResponse": "adcp.types.domains.creative.preview_creative_response", + "PreviewCreativeResponse1": "adcp.types.domains.creative.preview_creative_response", + "PreviewCreativeResponse2": "adcp.types.domains.creative.preview_creative_response", + "PreviewCreativeResponse3": "adcp.types.domains.creative.preview_creative_response", + "PreviewCreativeResponse4": "adcp.types.domains.creative.preview_creative_response", + "PreviewRender": "adcp.types.domains.creative.preview_render", + "PreviewRender1": "adcp.types.domains.creative.preview_render", + "PreviewRender2": "adcp.types.domains.creative.preview_render", + "PreviewRender3": "adcp.types.domains.creative.preview_render", + "Purge": "adcp.types.domains.creative.list_creatives_response", + "PurgeKind": "adcp.types.domains.creative.creative_purged_webhook", + "QuerySummary": "adcp.types.domains.creative.list_creatives_response", + "Reason": "adcp.types.domains.creative.sync_creatives_async_response_input_required", + "ReportingPeriod": "adcp.types.domains.creative.get_creative_delivery_response", + "Request": "adcp.types.domains.creative.preview_creative_request", + "RequestType": "adcp.types.domains.creative.preview_creative_request", + "Response": "adcp.types.domains.creative.preview_creative_response", + "Result": "adcp.types.domains.creative.preview_creative_response", + "ResultKind": "adcp.types.domains.creative.validate_input_result", + "Segment": "adcp.types.domains.creative.video_brief", + "Snapshot": "adcp.types.domains.creative.list_creatives_response", + "Sort": "adcp.types.domains.creative.list_creatives_request", + "SortApplied": "adcp.types.domains.creative.list_creatives_response", + "StatusSummary": "adcp.types.domains.creative.list_creatives_response", + "SyncCreativesInputRequired": "adcp.types.domains.creative.sync_creatives_async_response_input_required", + "SyncCreativesRequest": "adcp.types.domains.creative.sync_creatives_request", + "SyncCreativesResponse": "adcp.types.domains.creative.sync_creatives_response", + "SyncCreativesResponse1": "adcp.types.domains.creative.sync_creatives_response", + "SyncCreativesResponse2": "adcp.types.domains.creative.sync_creatives_response", + "SyncCreativesResponse3": "adcp.types.domains.creative.sync_creatives_response", + "SyncCreativesSubmitted": "adcp.types.domains.creative.sync_creatives_async_response_submitted", + "SyncCreativesWorking": "adcp.types.domains.creative.sync_creatives_async_response_working", + "Target": "adcp.types.domains.creative.validate_input_result", + "Targets": "adcp.types.domains.creative.validate_input_request", + "Targets1": "adcp.types.domains.creative.validate_input_request", + "Targets2": "adcp.types.domains.creative.validate_input_request", + "Targets3": "adcp.types.domains.creative.validate_input_request", + "Targets4": "adcp.types.domains.creative.validate_input_request", + "Transition": "adcp.types.domains.creative.creative_status_changed_webhook", + "Type": "adcp.types.domains.creative.list_creative_formats_request", + "ValidateInputRequest": "adcp.types.domains.creative.validate_input_request", + "ValidateInputResponse": "adcp.types.domains.creative.validate_input_response", + "ValidateInputResult": "adcp.types.domains.creative.validate_input_result", + "VideoBrief": "adcp.types.domains.creative.video_brief", + "Violation": "adcp.types.domains.creative.validate_input_result", + "Warning": "adcp.types.domains.creative.validate_input_result", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/enums/__init__.py b/src/adcp/types/domains/enums/__init__.py index b5693f91b9..9b15a709be 100644 --- a/src/adcp/types/domains/enums/__init__.py +++ b/src/adcp/types/domains/enums/__init__.py @@ -9,225 +9,237 @@ it from its own schema's module, ``adcp.types.domains.enums.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.enums.account_currency_mode import AccountCurrencyMode -from adcp.types.domains.enums.account_scope import AccountScope -from adcp.types.domains.enums.account_status import AccountStatus -from adcp.types.domains.enums.action_not_allowed_reason import ActionNotAllowedReason -from adcp.types.domains.enums.action_source import ActionSource -from adcp.types.domains.enums.activation_method import CreativeActivationMethod -from adcp.types.domains.enums.adcp_protocol import AdcpProtocol -from adcp.types.domains.enums.adjustment_kind import PriceAdjustmentKind -from adcp.types.domains.enums.advertiser_industry import AdvertiserIndustry -from adcp.types.domains.enums.age_determination_basis import AgeDeterminationBasis -from adcp.types.domains.enums.age_verification_method import AgeVerificationMethod -from adcp.types.domains.enums.assessment_status import AssessmentStatus -from adcp.types.domains.enums.asset_content_type import AssetContentType -from adcp.types.domains.enums.attestation_claim import AttestationClaim -from adcp.types.domains.enums.attribution_methodology import AttributionMethodology -from adcp.types.domains.enums.attribution_model import AttributionModel -from adcp.types.domains.enums.audience_evidence_methodology import AudienceEvidenceMethodology -from adcp.types.domains.enums.audience_resolution_method import AudienceResolutionMethod -from adcp.types.domains.enums.audience_source import AudienceSource -from adcp.types.domains.enums.audience_status import AudienceStatus -from adcp.types.domains.enums.audience_subject_type import AudienceSubjectType -from adcp.types.domains.enums.audio_channel_layout import AudioChannelLayout -from adcp.types.domains.enums.audio_distribution_type import AudioDistributionType -from adcp.types.domains.enums.auth_scheme import AuthenticationScheme -from adcp.types.domains.enums.availability_status import AvailabilityStatus -from adcp.types.domains.enums.available_metric import AvailableMetric -from adcp.types.domains.enums.billing_party import BillingParty -from adcp.types.domains.enums.binary_verdict import BinaryVerdict -from adcp.types.domains.enums.brand_agent_type import BrandAgentType -from adcp.types.domains.enums.browser_family import BrowserFamily -from adcp.types.domains.enums.c2pa_watermark_action import C2PaWatermarkAction -from adcp.types.domains.enums.canceled_by import CanceledBy -from adcp.types.domains.enums.canonical_media_buy_action import CanonicalMediaBuyActionName -from adcp.types.domains.enums.canonical_media_buy_action_mode import CanonicalMediaBuyActionMode -from adcp.types.domains.enums.catalog_action import CatalogAction -from adcp.types.domains.enums.catalog_item_status import CatalogItemStatus -from adcp.types.domains.enums.catalog_type import CatalogType -from adcp.types.domains.enums.channels import MediaChannel -from adcp.types.domains.enums.cloud_storage_protocol import CloudStorageProtocol -from adcp.types.domains.enums.co_branding_requirement import CoBrandingRequirement -from adcp.types.domains.enums.collection_cadence import CollectionCadence -from adcp.types.domains.enums.collection_kind import CollectionKind -from adcp.types.domains.enums.collection_relationship import CollectionRelationship -from adcp.types.domains.enums.collection_status import CollectionStatus -from adcp.types.domains.enums.completion_source import CompletionSource -from adcp.types.domains.enums.consent_basis import ConsentBasis -from adcp.types.domains.enums.content_id_type import ContentIdType -from adcp.types.domains.enums.content_rating_system import ContentRatingSystem -from adcp.types.domains.enums.creative_action import CreativeAction -from adcp.types.domains.enums.creative_agent_capability import CreativeAgentCapability -from adcp.types.domains.enums.creative_approval_status import CreativeApprovalStatus -from adcp.types.domains.enums.creative_event_reason_code import CreativeEventReasonCode -from adcp.types.domains.enums.creative_identifier_type import CreativeIdentifierType -from adcp.types.domains.enums.creative_quality import CreativeQuality -from adcp.types.domains.enums.creative_selection_strategy import CreativeSelectionStrategy -from adcp.types.domains.enums.creative_sort_field import CreativeSortField -from adcp.types.domains.enums.creative_status import CreativeStatus -from adcp.types.domains.enums.ctv_ad_experience import CtvAdExperience -from adcp.types.domains.enums.daast_tracking_event import DaastTrackingEvent -from adcp.types.domains.enums.daast_version import DaastVersion -from adcp.types.domains.enums.day_of_week import DayOfWeek -from adcp.types.domains.enums.daypart_timezone_mode import DaypartTimezoneMode -from adcp.types.domains.enums.delegation_authority import DelegationAuthority -from adcp.types.domains.enums.delivery_recipient_cloud import DeliveryRecipientCloud -from adcp.types.domains.enums.delivery_status import DeliveryStatus -from adcp.types.domains.enums.delivery_type import DeliveryType -from adcp.types.domains.enums.demographic_system import DemographicSystem -from adcp.types.domains.enums.derivative_type import DerivativeType -from adcp.types.domains.enums.device_platform import DevicePlatform -from adcp.types.domains.enums.device_type import DeviceType -from adcp.types.domains.enums.digital_source_type import DigitalSourceType -from adcp.types.domains.enums.dimension_unit import DimensionUnit -from adcp.types.domains.enums.disclosure_persistence import DisclosurePersistence -from adcp.types.domains.enums.disclosure_position import DisclosurePosition -from adcp.types.domains.enums.distance_unit import DistanceUnit -from adcp.types.domains.enums.distribution_identifier_type import DistributionIdentifierType -from adcp.types.domains.enums.dooh_motion_type import DoohMotionType -from adcp.types.domains.enums.embedded_provenance_method import EmbeddedProvenanceMethod -from adcp.types.domains.enums.error_code import ErrorCode -from adcp.types.domains.enums.error_scope import ErrorScope -from adcp.types.domains.enums.escalation_severity import EscalationSeverity -from adcp.types.domains.enums.event_type import EventType -from adcp.types.domains.enums.exclusivity import Exclusivity -from adcp.types.domains.enums.feature_check_status import FeatureCheckStatus -from adcp.types.domains.enums.feed_format import FeedFormat -from adcp.types.domains.enums.feedback_source import FeedbackSource -from adcp.types.domains.enums.forecast_method import ForecastMethod -from adcp.types.domains.enums.forecast_range_unit import ForecastRangeUnit -from adcp.types.domains.enums.forecastable_metric import ForecastableMetric -from adcp.types.domains.enums.format_id_parameter import FormatIdParameter -from adcp.types.domains.enums.frame_rate_type import FrameRateType -from adcp.types.domains.enums.frequency_cap_control_mode import FrequencyCapControlMode -from adcp.types.domains.enums.frequency_cap_mutable_field import FrequencyCapMutableField -from adcp.types.domains.enums.frequency_cap_scope import FrequencyCapScope -from adcp.types.domains.enums.genre_taxonomy import GenreTaxonomy -from adcp.types.domains.enums.geo_level import GeographicTargetingLevel -from adcp.types.domains.enums.gop_type import GopType -from adcp.types.domains.enums.governance_decision import GovernanceDecision -from adcp.types.domains.enums.governance_domain import GovernanceDomain -from adcp.types.domains.enums.governance_mode import GovernanceMode -from adcp.types.domains.enums.governance_phase import GovernancePhase -from adcp.types.domains.enums.history_entry_type import HistoryEntryType -from adcp.types.domains.enums.http_method import HttpMethod -from adcp.types.domains.enums.identifier_types import PropertyIdentifierTypes -from adcp.types.domains.enums.impairment_offline_state import ImpairmentOfflineState -from adcp.types.domains.enums.impairment_reason_code import ImpairmentReasonCode -from adcp.types.domains.enums.indicator_type import IndicatorType -from adcp.types.domains.enums.installment_status import InstallmentStatus -from adcp.types.domains.enums.javascript_module_type import JavascriptModuleType -from adcp.types.domains.enums.landing_page_requirement import LandingPageRequirement -from adcp.types.domains.enums.legacy_postal_system import CountryFusedPostalCodeSystem -from adcp.types.domains.enums.lift_dimension import LiftDimension -from adcp.types.domains.enums.logo_slot import LogoSlot -from adcp.types.domains.enums.macro_dialect import MacroDialectFamily -from adcp.types.domains.enums.macro_mapping_status import MacroMappingStatus -from adcp.types.domains.enums.macro_processing_operation import MacroProcessingOperation -from adcp.types.domains.enums.macro_resolution_reason import MacroResolutionReason -from adcp.types.domains.enums.macro_resolver import MacroProcessingActor -from adcp.types.domains.enums.macro_value_context import MacroValueContext -from adcp.types.domains.enums.makegood_remedy import MakegoodRemedy -from adcp.types.domains.enums.markdown_flavor import MarkdownFlavor -from adcp.types.domains.enums.match_id_type import MatchIdType -from adcp.types.domains.enums.match_type import MatchType -from adcp.types.domains.enums.media_buy_action_mode import MediaBuyActionMode -from adcp.types.domains.enums.media_buy_frequency_cap_control_mode import ( - MediaBuyFrequencyCapControlMode, -) -from adcp.types.domains.enums.media_buy_health import MediaBuyHealth -from adcp.types.domains.enums.media_buy_status import MediaBuyStatus -from adcp.types.domains.enums.media_buy_valid_action import MediaBuyValidAction -from adcp.types.domains.enums.metric_scope import MetricScope -from adcp.types.domains.enums.metric_type import MetricType -from adcp.types.domains.enums.metro_system import MetroAreaSystem -from adcp.types.domains.enums.moov_atom_position import MoovAtomPosition -from adcp.types.domains.enums.motion_level import CreativeMotionLevel -from adcp.types.domains.enums.notification_type import NotificationType -from adcp.types.domains.enums.offering_availability_status import OfferingAvailabilityStatus -from adcp.types.domains.enums.outcome_target_cost_strength import OutcomeTargetCostStrength -from adcp.types.domains.enums.outcome_type import OutcomeType -from adcp.types.domains.enums.pacing import Pacing -from adcp.types.domains.enums.payment_terms import PaymentTerms -from adcp.types.domains.enums.performance_baseline import PerformanceBaseline -from adcp.types.domains.enums.performance_standard_metric import PerformanceStandardMetric -from adcp.types.domains.enums.pixel_tracking_event import PixelTrackingEvent -from adcp.types.domains.enums.policy_category import PolicyCategory -from adcp.types.domains.enums.policy_enforcement import PolicyEnforcementLevel -from adcp.types.domains.enums.postal_system import PostalCodeSystem -from adcp.types.domains.enums.preview_output_format import PreviewOutputFormat -from adcp.types.domains.enums.pricing_model import PricingModel -from adcp.types.domains.enums.pricing_structure import PricingStructure -from adcp.types.domains.enums.principal_kind import PrincipalKind -from adcp.types.domains.enums.production_quality import ProductionQuality -from adcp.types.domains.enums.property_type import PropertyType -from adcp.types.domains.enums.proposal_decline_reason import ProposalDeclineReason -from adcp.types.domains.enums.proposal_refinement_reason import ProposalRefinementReason -from adcp.types.domains.enums.proposal_status import ProposalStatus -from adcp.types.domains.enums.publisher_identifier_types import PublisherIdentifierTypes -from adcp.types.domains.enums.purchase_type import PurchaseType -from adcp.types.domains.enums.reach_aggregation import ReachAggregation -from adcp.types.domains.enums.reach_unit import ReachUnit -from adcp.types.domains.enums.reporting_destination_setup_state import ( - ReportingDestinationSetupState, -) -from adcp.types.domains.enums.reporting_finality import ReportingFinality -from adcp.types.domains.enums.reporting_frequency import ReportingFrequency -from adcp.types.domains.enums.reporting_health import ReportingHealth -from adcp.types.domains.enums.representation_selection_strategy import ( - RepresentationSelectionStrategy, -) -from adcp.types.domains.enums.request_signing_error_code import RequestSigningErrorCode -from adcp.types.domains.enums.response_type import TmpResponseType -from adcp.types.domains.enums.restricted_attribute import RestrictedAttribute -from adcp.types.domains.enums.right_type import RightType -from adcp.types.domains.enums.right_use import RightUse -from adcp.types.domains.enums.rights_billing_period import RightsBillingPeriod -from adcp.types.domains.enums.scan_type import ScanType -from adcp.types.domains.enums.seller_policy_decline_reason import SellerPolicyDeclineReason -from adcp.types.domains.enums.si_session_status import SiSessionStatus -from adcp.types.domains.enums.signal_catalog_type import SignalAvailabilityType, SignalCatalogType -from adcp.types.domains.enums.signal_source import SignalSource -from adcp.types.domains.enums.signal_value_type import SignalValueType -from adcp.types.domains.enums.snapshot_unavailable_reason import SnapshotUnavailableReason -from adcp.types.domains.enums.social_placement_surface import SocialPlacementSurface -from adcp.types.domains.enums.sort_direction import SortDirection -from adcp.types.domains.enums.sort_metric import SortMetric -from adcp.types.domains.enums.special_category import SpecialCategory -from adcp.types.domains.enums.specialism import AdcpSpecialism -from adcp.types.domains.enums.sponsored_placement_type import SponsoredPlacementType -from adcp.types.domains.enums.talent_role import TalentRole -from adcp.types.domains.enums.task_status import TaskStatus -from adcp.types.domains.enums.task_type import TaskType -from adcp.types.domains.enums.tracker_execution_actor import TrackerExecutionActor -from adcp.types.domains.enums.tracker_firing_path import TrackerFiringPath -from adcp.types.domains.enums.transport_mode import TransportMode -from adcp.types.domains.enums.travel_time_unit import TravelTimeUnit -from adcp.types.domains.enums.uid_type import UidType -from adcp.types.domains.enums.universal_macro import UniversalMacro -from adcp.types.domains.enums.update_frequency import UpdateFrequency -from adcp.types.domains.enums.url_asset_type import UrlAssetType -from adcp.types.domains.enums.validation_mode import ValidationMode -from adcp.types.domains.enums.vast_media_delivery_method import VastMediaDeliveryMethod -from adcp.types.domains.enums.vast_tracking_event import VastTrackingEvent -from adcp.types.domains.enums.vast_version import VastVersion -from adcp.types.domains.enums.vendor_relationship import VendorRelationship -from adcp.types.domains.enums.video_placement_type import VideoPlacementType -from adcp.types.domains.enums.view_threshold_basis import ViewThresholdBasis -from adcp.types.domains.enums.viewability_standard import ViewabilityStandard -from adcp.types.domains.enums.warning_code import WarningCode -from adcp.types.domains.enums.watermark_media_type import WatermarkMediaType -from adcp.types.domains.enums.wcag_level import WcagLevel -from adcp.types.domains.enums.webhook_response_type import WebhookResponseType -from adcp.types.domains.enums.webhook_security_method import WebhookSecurityMethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.enums.account_currency_mode import AccountCurrencyMode + from adcp.types.domains.enums.account_scope import AccountScope + from adcp.types.domains.enums.account_status import AccountStatus + from adcp.types.domains.enums.action_not_allowed_reason import ActionNotAllowedReason + from adcp.types.domains.enums.action_source import ActionSource + from adcp.types.domains.enums.activation_method import CreativeActivationMethod + from adcp.types.domains.enums.adcp_protocol import AdcpProtocol + from adcp.types.domains.enums.adjustment_kind import PriceAdjustmentKind + from adcp.types.domains.enums.advertiser_industry import AdvertiserIndustry + from adcp.types.domains.enums.age_determination_basis import AgeDeterminationBasis + from adcp.types.domains.enums.age_verification_method import AgeVerificationMethod + from adcp.types.domains.enums.assessment_status import AssessmentStatus + from adcp.types.domains.enums.asset_content_type import AssetContentType + from adcp.types.domains.enums.attestation_claim import AttestationClaim + from adcp.types.domains.enums.attribution_methodology import AttributionMethodology + from adcp.types.domains.enums.attribution_model import AttributionModel + from adcp.types.domains.enums.audience_evidence_methodology import AudienceEvidenceMethodology + from adcp.types.domains.enums.audience_resolution_method import AudienceResolutionMethod + from adcp.types.domains.enums.audience_source import AudienceSource + from adcp.types.domains.enums.audience_status import AudienceStatus + from adcp.types.domains.enums.audience_subject_type import AudienceSubjectType + from adcp.types.domains.enums.audio_channel_layout import AudioChannelLayout + from adcp.types.domains.enums.audio_distribution_type import AudioDistributionType + from adcp.types.domains.enums.auth_scheme import AuthenticationScheme + from adcp.types.domains.enums.availability_status import AvailabilityStatus + from adcp.types.domains.enums.available_metric import AvailableMetric + from adcp.types.domains.enums.billing_party import BillingParty + from adcp.types.domains.enums.binary_verdict import BinaryVerdict + from adcp.types.domains.enums.brand_agent_type import BrandAgentType + from adcp.types.domains.enums.browser_family import BrowserFamily + from adcp.types.domains.enums.c2pa_watermark_action import C2PaWatermarkAction + from adcp.types.domains.enums.canceled_by import CanceledBy + from adcp.types.domains.enums.canonical_media_buy_action import CanonicalMediaBuyActionName + from adcp.types.domains.enums.canonical_media_buy_action_mode import CanonicalMediaBuyActionMode + from adcp.types.domains.enums.catalog_action import CatalogAction + from adcp.types.domains.enums.catalog_item_status import CatalogItemStatus + from adcp.types.domains.enums.catalog_type import CatalogType + from adcp.types.domains.enums.channels import MediaChannel + from adcp.types.domains.enums.cloud_storage_protocol import CloudStorageProtocol + from adcp.types.domains.enums.co_branding_requirement import CoBrandingRequirement + from adcp.types.domains.enums.collection_cadence import CollectionCadence + from adcp.types.domains.enums.collection_kind import CollectionKind + from adcp.types.domains.enums.collection_relationship import CollectionRelationship + from adcp.types.domains.enums.collection_status import CollectionStatus + from adcp.types.domains.enums.completion_source import CompletionSource + from adcp.types.domains.enums.consent_basis import ConsentBasis + from adcp.types.domains.enums.content_id_type import ContentIdType + from adcp.types.domains.enums.content_rating_system import ContentRatingSystem + from adcp.types.domains.enums.creative_action import CreativeAction + from adcp.types.domains.enums.creative_agent_capability import CreativeAgentCapability + from adcp.types.domains.enums.creative_approval_status import CreativeApprovalStatus + from adcp.types.domains.enums.creative_event_reason_code import CreativeEventReasonCode + from adcp.types.domains.enums.creative_identifier_type import CreativeIdentifierType + from adcp.types.domains.enums.creative_quality import CreativeQuality + from adcp.types.domains.enums.creative_selection_strategy import CreativeSelectionStrategy + from adcp.types.domains.enums.creative_sort_field import CreativeSortField + from adcp.types.domains.enums.creative_status import CreativeStatus + from adcp.types.domains.enums.ctv_ad_experience import CtvAdExperience + from adcp.types.domains.enums.daast_tracking_event import DaastTrackingEvent + from adcp.types.domains.enums.daast_version import DaastVersion + from adcp.types.domains.enums.day_of_week import DayOfWeek + from adcp.types.domains.enums.daypart_timezone_mode import DaypartTimezoneMode + from adcp.types.domains.enums.delegation_authority import DelegationAuthority + from adcp.types.domains.enums.delivery_recipient_cloud import DeliveryRecipientCloud + from adcp.types.domains.enums.delivery_status import DeliveryStatus + from adcp.types.domains.enums.delivery_type import DeliveryType + from adcp.types.domains.enums.demographic_system import DemographicSystem + from adcp.types.domains.enums.derivative_type import DerivativeType + from adcp.types.domains.enums.device_platform import DevicePlatform + from adcp.types.domains.enums.device_type import DeviceType + from adcp.types.domains.enums.digital_source_type import DigitalSourceType + from adcp.types.domains.enums.dimension_unit import DimensionUnit + from adcp.types.domains.enums.disclosure_persistence import DisclosurePersistence + from adcp.types.domains.enums.disclosure_position import DisclosurePosition + from adcp.types.domains.enums.distance_unit import DistanceUnit + from adcp.types.domains.enums.distribution_identifier_type import DistributionIdentifierType + from adcp.types.domains.enums.dooh_motion_type import DoohMotionType + from adcp.types.domains.enums.embedded_provenance_method import EmbeddedProvenanceMethod + from adcp.types.domains.enums.error_code import ErrorCode + from adcp.types.domains.enums.error_scope import ErrorScope + from adcp.types.domains.enums.escalation_severity import EscalationSeverity + from adcp.types.domains.enums.event_type import EventType + from adcp.types.domains.enums.exclusivity import Exclusivity + from adcp.types.domains.enums.feature_check_status import FeatureCheckStatus + from adcp.types.domains.enums.feed_format import FeedFormat + from adcp.types.domains.enums.feedback_source import FeedbackSource + from adcp.types.domains.enums.forecast_method import ForecastMethod + from adcp.types.domains.enums.forecast_range_unit import ForecastRangeUnit + from adcp.types.domains.enums.forecastable_metric import ForecastableMetric + from adcp.types.domains.enums.format_id_parameter import FormatIdParameter + from adcp.types.domains.enums.frame_rate_type import FrameRateType + from adcp.types.domains.enums.frequency_cap_control_mode import FrequencyCapControlMode + from adcp.types.domains.enums.frequency_cap_mutable_field import FrequencyCapMutableField + from adcp.types.domains.enums.frequency_cap_scope import FrequencyCapScope + from adcp.types.domains.enums.genre_taxonomy import GenreTaxonomy + from adcp.types.domains.enums.geo_level import GeographicTargetingLevel + from adcp.types.domains.enums.gop_type import GopType + from adcp.types.domains.enums.governance_decision import GovernanceDecision + from adcp.types.domains.enums.governance_domain import GovernanceDomain + from adcp.types.domains.enums.governance_mode import GovernanceMode + from adcp.types.domains.enums.governance_phase import GovernancePhase + from adcp.types.domains.enums.history_entry_type import HistoryEntryType + from adcp.types.domains.enums.http_method import HttpMethod + from adcp.types.domains.enums.identifier_types import PropertyIdentifierTypes + from adcp.types.domains.enums.impairment_offline_state import ImpairmentOfflineState + from adcp.types.domains.enums.impairment_reason_code import ImpairmentReasonCode + from adcp.types.domains.enums.indicator_type import IndicatorType + from adcp.types.domains.enums.installment_status import InstallmentStatus + from adcp.types.domains.enums.javascript_module_type import JavascriptModuleType + from adcp.types.domains.enums.landing_page_requirement import LandingPageRequirement + from adcp.types.domains.enums.legacy_postal_system import CountryFusedPostalCodeSystem + from adcp.types.domains.enums.lift_dimension import LiftDimension + from adcp.types.domains.enums.logo_slot import LogoSlot + from adcp.types.domains.enums.macro_dialect import MacroDialectFamily + from adcp.types.domains.enums.macro_mapping_status import MacroMappingStatus + from adcp.types.domains.enums.macro_processing_operation import MacroProcessingOperation + from adcp.types.domains.enums.macro_resolution_reason import MacroResolutionReason + from adcp.types.domains.enums.macro_resolver import MacroProcessingActor + from adcp.types.domains.enums.macro_value_context import MacroValueContext + from adcp.types.domains.enums.makegood_remedy import MakegoodRemedy + from adcp.types.domains.enums.markdown_flavor import MarkdownFlavor + from adcp.types.domains.enums.match_id_type import MatchIdType + from adcp.types.domains.enums.match_type import MatchType + from adcp.types.domains.enums.media_buy_action_mode import MediaBuyActionMode + from adcp.types.domains.enums.media_buy_frequency_cap_control_mode import ( + MediaBuyFrequencyCapControlMode, + ) + from adcp.types.domains.enums.media_buy_health import MediaBuyHealth + from adcp.types.domains.enums.media_buy_status import MediaBuyStatus + from adcp.types.domains.enums.media_buy_valid_action import MediaBuyValidAction + from adcp.types.domains.enums.metric_scope import MetricScope + from adcp.types.domains.enums.metric_type import MetricType + from adcp.types.domains.enums.metro_system import MetroAreaSystem + from adcp.types.domains.enums.moov_atom_position import MoovAtomPosition + from adcp.types.domains.enums.motion_level import CreativeMotionLevel + from adcp.types.domains.enums.notification_type import NotificationType + from adcp.types.domains.enums.offering_availability_status import OfferingAvailabilityStatus + from adcp.types.domains.enums.outcome_target_cost_strength import OutcomeTargetCostStrength + from adcp.types.domains.enums.outcome_type import OutcomeType + from adcp.types.domains.enums.pacing import Pacing + from adcp.types.domains.enums.payment_terms import PaymentTerms + from adcp.types.domains.enums.performance_baseline import PerformanceBaseline + from adcp.types.domains.enums.performance_standard_metric import PerformanceStandardMetric + from adcp.types.domains.enums.pixel_tracking_event import PixelTrackingEvent + from adcp.types.domains.enums.policy_category import PolicyCategory + from adcp.types.domains.enums.policy_enforcement import PolicyEnforcementLevel + from adcp.types.domains.enums.postal_system import PostalCodeSystem + from adcp.types.domains.enums.preview_output_format import PreviewOutputFormat + from adcp.types.domains.enums.pricing_model import PricingModel + from adcp.types.domains.enums.pricing_structure import PricingStructure + from adcp.types.domains.enums.principal_kind import PrincipalKind + from adcp.types.domains.enums.production_quality import ProductionQuality + from adcp.types.domains.enums.property_type import PropertyType + from adcp.types.domains.enums.proposal_decline_reason import ProposalDeclineReason + from adcp.types.domains.enums.proposal_refinement_reason import ProposalRefinementReason + from adcp.types.domains.enums.proposal_status import ProposalStatus + from adcp.types.domains.enums.publisher_identifier_types import PublisherIdentifierTypes + from adcp.types.domains.enums.purchase_type import PurchaseType + from adcp.types.domains.enums.reach_aggregation import ReachAggregation + from adcp.types.domains.enums.reach_unit import ReachUnit + from adcp.types.domains.enums.reporting_destination_setup_state import ( + ReportingDestinationSetupState, + ) + from adcp.types.domains.enums.reporting_finality import ReportingFinality + from adcp.types.domains.enums.reporting_frequency import ReportingFrequency + from adcp.types.domains.enums.reporting_health import ReportingHealth + from adcp.types.domains.enums.representation_selection_strategy import ( + RepresentationSelectionStrategy, + ) + from adcp.types.domains.enums.request_signing_error_code import RequestSigningErrorCode + from adcp.types.domains.enums.response_type import TmpResponseType + from adcp.types.domains.enums.restricted_attribute import RestrictedAttribute + from adcp.types.domains.enums.right_type import RightType + from adcp.types.domains.enums.right_use import RightUse + from adcp.types.domains.enums.rights_billing_period import RightsBillingPeriod + from adcp.types.domains.enums.scan_type import ScanType + from adcp.types.domains.enums.seller_policy_decline_reason import SellerPolicyDeclineReason + from adcp.types.domains.enums.si_session_status import SiSessionStatus + from adcp.types.domains.enums.signal_catalog_type import ( + SignalAvailabilityType, + SignalCatalogType, + ) + from adcp.types.domains.enums.signal_source import SignalSource + from adcp.types.domains.enums.signal_value_type import SignalValueType + from adcp.types.domains.enums.snapshot_unavailable_reason import SnapshotUnavailableReason + from adcp.types.domains.enums.social_placement_surface import SocialPlacementSurface + from adcp.types.domains.enums.sort_direction import SortDirection + from adcp.types.domains.enums.sort_metric import SortMetric + from adcp.types.domains.enums.special_category import SpecialCategory + from adcp.types.domains.enums.specialism import AdcpSpecialism + from adcp.types.domains.enums.sponsored_placement_type import SponsoredPlacementType + from adcp.types.domains.enums.talent_role import TalentRole + from adcp.types.domains.enums.task_status import TaskStatus + from adcp.types.domains.enums.task_type import TaskType + from adcp.types.domains.enums.tracker_execution_actor import TrackerExecutionActor + from adcp.types.domains.enums.tracker_firing_path import TrackerFiringPath + from adcp.types.domains.enums.transport_mode import TransportMode + from adcp.types.domains.enums.travel_time_unit import TravelTimeUnit + from adcp.types.domains.enums.uid_type import UidType + from adcp.types.domains.enums.universal_macro import UniversalMacro + from adcp.types.domains.enums.update_frequency import UpdateFrequency + from adcp.types.domains.enums.url_asset_type import UrlAssetType + from adcp.types.domains.enums.validation_mode import ValidationMode + from adcp.types.domains.enums.vast_media_delivery_method import VastMediaDeliveryMethod + from adcp.types.domains.enums.vast_tracking_event import VastTrackingEvent + from adcp.types.domains.enums.vast_version import VastVersion + from adcp.types.domains.enums.vendor_relationship import VendorRelationship + from adcp.types.domains.enums.video_placement_type import VideoPlacementType + from adcp.types.domains.enums.view_threshold_basis import ViewThresholdBasis + from adcp.types.domains.enums.viewability_standard import ViewabilityStandard + from adcp.types.domains.enums.warning_code import WarningCode + from adcp.types.domains.enums.watermark_media_type import WatermarkMediaType + from adcp.types.domains.enums.wcag_level import WcagLevel + from adcp.types.domains.enums.webhook_response_type import WebhookResponseType + from adcp.types.domains.enums.webhook_security_method import WebhookSecurityMethod # Explicit exports __all__ = [ @@ -439,3 +451,230 @@ "WebhookResponseType", "WebhookSecurityMethod", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "AccountCurrencyMode": "adcp.types.domains.enums.account_currency_mode", + "AccountScope": "adcp.types.domains.enums.account_scope", + "AccountStatus": "adcp.types.domains.enums.account_status", + "ActionNotAllowedReason": "adcp.types.domains.enums.action_not_allowed_reason", + "ActionSource": "adcp.types.domains.enums.action_source", + "AdcpProtocol": "adcp.types.domains.enums.adcp_protocol", + "AdcpSpecialism": "adcp.types.domains.enums.specialism", + "AdvertiserIndustry": "adcp.types.domains.enums.advertiser_industry", + "AgeDeterminationBasis": "adcp.types.domains.enums.age_determination_basis", + "AgeVerificationMethod": "adcp.types.domains.enums.age_verification_method", + "AssessmentStatus": "adcp.types.domains.enums.assessment_status", + "AssetContentType": "adcp.types.domains.enums.asset_content_type", + "AttestationClaim": "adcp.types.domains.enums.attestation_claim", + "AttributionMethodology": "adcp.types.domains.enums.attribution_methodology", + "AttributionModel": "adcp.types.domains.enums.attribution_model", + "AudienceEvidenceMethodology": "adcp.types.domains.enums.audience_evidence_methodology", + "AudienceResolutionMethod": "adcp.types.domains.enums.audience_resolution_method", + "AudienceSource": "adcp.types.domains.enums.audience_source", + "AudienceStatus": "adcp.types.domains.enums.audience_status", + "AudienceSubjectType": "adcp.types.domains.enums.audience_subject_type", + "AudioChannelLayout": "adcp.types.domains.enums.audio_channel_layout", + "AudioDistributionType": "adcp.types.domains.enums.audio_distribution_type", + "AuthenticationScheme": "adcp.types.domains.enums.auth_scheme", + "AvailabilityStatus": "adcp.types.domains.enums.availability_status", + "AvailableMetric": "adcp.types.domains.enums.available_metric", + "BillingParty": "adcp.types.domains.enums.billing_party", + "BinaryVerdict": "adcp.types.domains.enums.binary_verdict", + "BrandAgentType": "adcp.types.domains.enums.brand_agent_type", + "BrowserFamily": "adcp.types.domains.enums.browser_family", + "C2PaWatermarkAction": "adcp.types.domains.enums.c2pa_watermark_action", + "CanceledBy": "adcp.types.domains.enums.canceled_by", + "CanonicalMediaBuyActionMode": "adcp.types.domains.enums.canonical_media_buy_action_mode", + "CanonicalMediaBuyActionName": "adcp.types.domains.enums.canonical_media_buy_action", + "CatalogAction": "adcp.types.domains.enums.catalog_action", + "CatalogItemStatus": "adcp.types.domains.enums.catalog_item_status", + "CatalogType": "adcp.types.domains.enums.catalog_type", + "CloudStorageProtocol": "adcp.types.domains.enums.cloud_storage_protocol", + "CoBrandingRequirement": "adcp.types.domains.enums.co_branding_requirement", + "CollectionCadence": "adcp.types.domains.enums.collection_cadence", + "CollectionKind": "adcp.types.domains.enums.collection_kind", + "CollectionRelationship": "adcp.types.domains.enums.collection_relationship", + "CollectionStatus": "adcp.types.domains.enums.collection_status", + "CompletionSource": "adcp.types.domains.enums.completion_source", + "ConsentBasis": "adcp.types.domains.enums.consent_basis", + "ContentIdType": "adcp.types.domains.enums.content_id_type", + "ContentRatingSystem": "adcp.types.domains.enums.content_rating_system", + "CountryFusedPostalCodeSystem": "adcp.types.domains.enums.legacy_postal_system", + "CreativeAction": "adcp.types.domains.enums.creative_action", + "CreativeActivationMethod": "adcp.types.domains.enums.activation_method", + "CreativeAgentCapability": "adcp.types.domains.enums.creative_agent_capability", + "CreativeApprovalStatus": "adcp.types.domains.enums.creative_approval_status", + "CreativeEventReasonCode": "adcp.types.domains.enums.creative_event_reason_code", + "CreativeIdentifierType": "adcp.types.domains.enums.creative_identifier_type", + "CreativeMotionLevel": "adcp.types.domains.enums.motion_level", + "CreativeQuality": "adcp.types.domains.enums.creative_quality", + "CreativeSelectionStrategy": "adcp.types.domains.enums.creative_selection_strategy", + "CreativeSortField": "adcp.types.domains.enums.creative_sort_field", + "CreativeStatus": "adcp.types.domains.enums.creative_status", + "CtvAdExperience": "adcp.types.domains.enums.ctv_ad_experience", + "DaastTrackingEvent": "adcp.types.domains.enums.daast_tracking_event", + "DaastVersion": "adcp.types.domains.enums.daast_version", + "DayOfWeek": "adcp.types.domains.enums.day_of_week", + "DaypartTimezoneMode": "adcp.types.domains.enums.daypart_timezone_mode", + "DelegationAuthority": "adcp.types.domains.enums.delegation_authority", + "DeliveryRecipientCloud": "adcp.types.domains.enums.delivery_recipient_cloud", + "DeliveryStatus": "adcp.types.domains.enums.delivery_status", + "DeliveryType": "adcp.types.domains.enums.delivery_type", + "DemographicSystem": "adcp.types.domains.enums.demographic_system", + "DerivativeType": "adcp.types.domains.enums.derivative_type", + "DevicePlatform": "adcp.types.domains.enums.device_platform", + "DeviceType": "adcp.types.domains.enums.device_type", + "DigitalSourceType": "adcp.types.domains.enums.digital_source_type", + "DimensionUnit": "adcp.types.domains.enums.dimension_unit", + "DisclosurePersistence": "adcp.types.domains.enums.disclosure_persistence", + "DisclosurePosition": "adcp.types.domains.enums.disclosure_position", + "DistanceUnit": "adcp.types.domains.enums.distance_unit", + "DistributionIdentifierType": "adcp.types.domains.enums.distribution_identifier_type", + "DoohMotionType": "adcp.types.domains.enums.dooh_motion_type", + "EmbeddedProvenanceMethod": "adcp.types.domains.enums.embedded_provenance_method", + "ErrorCode": "adcp.types.domains.enums.error_code", + "ErrorScope": "adcp.types.domains.enums.error_scope", + "EscalationSeverity": "adcp.types.domains.enums.escalation_severity", + "EventType": "adcp.types.domains.enums.event_type", + "Exclusivity": "adcp.types.domains.enums.exclusivity", + "FeatureCheckStatus": "adcp.types.domains.enums.feature_check_status", + "FeedFormat": "adcp.types.domains.enums.feed_format", + "FeedbackSource": "adcp.types.domains.enums.feedback_source", + "ForecastMethod": "adcp.types.domains.enums.forecast_method", + "ForecastRangeUnit": "adcp.types.domains.enums.forecast_range_unit", + "ForecastableMetric": "adcp.types.domains.enums.forecastable_metric", + "FormatIdParameter": "adcp.types.domains.enums.format_id_parameter", + "FrameRateType": "adcp.types.domains.enums.frame_rate_type", + "FrequencyCapControlMode": "adcp.types.domains.enums.frequency_cap_control_mode", + "FrequencyCapMutableField": "adcp.types.domains.enums.frequency_cap_mutable_field", + "FrequencyCapScope": "adcp.types.domains.enums.frequency_cap_scope", + "GenreTaxonomy": "adcp.types.domains.enums.genre_taxonomy", + "GeographicTargetingLevel": "adcp.types.domains.enums.geo_level", + "GopType": "adcp.types.domains.enums.gop_type", + "GovernanceDecision": "adcp.types.domains.enums.governance_decision", + "GovernanceDomain": "adcp.types.domains.enums.governance_domain", + "GovernanceMode": "adcp.types.domains.enums.governance_mode", + "GovernancePhase": "adcp.types.domains.enums.governance_phase", + "HistoryEntryType": "adcp.types.domains.enums.history_entry_type", + "HttpMethod": "adcp.types.domains.enums.http_method", + "ImpairmentOfflineState": "adcp.types.domains.enums.impairment_offline_state", + "ImpairmentReasonCode": "adcp.types.domains.enums.impairment_reason_code", + "IndicatorType": "adcp.types.domains.enums.indicator_type", + "InstallmentStatus": "adcp.types.domains.enums.installment_status", + "JavascriptModuleType": "adcp.types.domains.enums.javascript_module_type", + "LandingPageRequirement": "adcp.types.domains.enums.landing_page_requirement", + "LiftDimension": "adcp.types.domains.enums.lift_dimension", + "LogoSlot": "adcp.types.domains.enums.logo_slot", + "MacroDialectFamily": "adcp.types.domains.enums.macro_dialect", + "MacroMappingStatus": "adcp.types.domains.enums.macro_mapping_status", + "MacroProcessingActor": "adcp.types.domains.enums.macro_resolver", + "MacroProcessingOperation": "adcp.types.domains.enums.macro_processing_operation", + "MacroResolutionReason": "adcp.types.domains.enums.macro_resolution_reason", + "MacroValueContext": "adcp.types.domains.enums.macro_value_context", + "MakegoodRemedy": "adcp.types.domains.enums.makegood_remedy", + "MarkdownFlavor": "adcp.types.domains.enums.markdown_flavor", + "MatchIdType": "adcp.types.domains.enums.match_id_type", + "MatchType": "adcp.types.domains.enums.match_type", + "MediaBuyActionMode": "adcp.types.domains.enums.media_buy_action_mode", + "MediaBuyFrequencyCapControlMode": "adcp.types.domains.enums.media_buy_frequency_cap_control_mode", + "MediaBuyHealth": "adcp.types.domains.enums.media_buy_health", + "MediaBuyStatus": "adcp.types.domains.enums.media_buy_status", + "MediaBuyValidAction": "adcp.types.domains.enums.media_buy_valid_action", + "MediaChannel": "adcp.types.domains.enums.channels", + "MetricScope": "adcp.types.domains.enums.metric_scope", + "MetricType": "adcp.types.domains.enums.metric_type", + "MetroAreaSystem": "adcp.types.domains.enums.metro_system", + "MoovAtomPosition": "adcp.types.domains.enums.moov_atom_position", + "NotificationType": "adcp.types.domains.enums.notification_type", + "OfferingAvailabilityStatus": "adcp.types.domains.enums.offering_availability_status", + "OutcomeTargetCostStrength": "adcp.types.domains.enums.outcome_target_cost_strength", + "OutcomeType": "adcp.types.domains.enums.outcome_type", + "Pacing": "adcp.types.domains.enums.pacing", + "PaymentTerms": "adcp.types.domains.enums.payment_terms", + "PerformanceBaseline": "adcp.types.domains.enums.performance_baseline", + "PerformanceStandardMetric": "adcp.types.domains.enums.performance_standard_metric", + "PixelTrackingEvent": "adcp.types.domains.enums.pixel_tracking_event", + "PolicyCategory": "adcp.types.domains.enums.policy_category", + "PolicyEnforcementLevel": "adcp.types.domains.enums.policy_enforcement", + "PostalCodeSystem": "adcp.types.domains.enums.postal_system", + "PreviewOutputFormat": "adcp.types.domains.enums.preview_output_format", + "PriceAdjustmentKind": "adcp.types.domains.enums.adjustment_kind", + "PricingModel": "adcp.types.domains.enums.pricing_model", + "PricingStructure": "adcp.types.domains.enums.pricing_structure", + "PrincipalKind": "adcp.types.domains.enums.principal_kind", + "ProductionQuality": "adcp.types.domains.enums.production_quality", + "PropertyIdentifierTypes": "adcp.types.domains.enums.identifier_types", + "PropertyType": "adcp.types.domains.enums.property_type", + "ProposalDeclineReason": "adcp.types.domains.enums.proposal_decline_reason", + "ProposalRefinementReason": "adcp.types.domains.enums.proposal_refinement_reason", + "ProposalStatus": "adcp.types.domains.enums.proposal_status", + "PublisherIdentifierTypes": "adcp.types.domains.enums.publisher_identifier_types", + "PurchaseType": "adcp.types.domains.enums.purchase_type", + "ReachAggregation": "adcp.types.domains.enums.reach_aggregation", + "ReachUnit": "adcp.types.domains.enums.reach_unit", + "ReportingDestinationSetupState": "adcp.types.domains.enums.reporting_destination_setup_state", + "ReportingFinality": "adcp.types.domains.enums.reporting_finality", + "ReportingFrequency": "adcp.types.domains.enums.reporting_frequency", + "ReportingHealth": "adcp.types.domains.enums.reporting_health", + "RepresentationSelectionStrategy": "adcp.types.domains.enums.representation_selection_strategy", + "RequestSigningErrorCode": "adcp.types.domains.enums.request_signing_error_code", + "RestrictedAttribute": "adcp.types.domains.enums.restricted_attribute", + "RightType": "adcp.types.domains.enums.right_type", + "RightUse": "adcp.types.domains.enums.right_use", + "RightsBillingPeriod": "adcp.types.domains.enums.rights_billing_period", + "ScanType": "adcp.types.domains.enums.scan_type", + "SellerPolicyDeclineReason": "adcp.types.domains.enums.seller_policy_decline_reason", + "SiSessionStatus": "adcp.types.domains.enums.si_session_status", + "SignalAvailabilityType": "adcp.types.domains.enums.signal_catalog_type", + "SignalCatalogType": "adcp.types.domains.enums.signal_catalog_type", + "SignalSource": "adcp.types.domains.enums.signal_source", + "SignalValueType": "adcp.types.domains.enums.signal_value_type", + "SnapshotUnavailableReason": "adcp.types.domains.enums.snapshot_unavailable_reason", + "SocialPlacementSurface": "adcp.types.domains.enums.social_placement_surface", + "SortDirection": "adcp.types.domains.enums.sort_direction", + "SortMetric": "adcp.types.domains.enums.sort_metric", + "SpecialCategory": "adcp.types.domains.enums.special_category", + "SponsoredPlacementType": "adcp.types.domains.enums.sponsored_placement_type", + "TalentRole": "adcp.types.domains.enums.talent_role", + "TaskStatus": "adcp.types.domains.enums.task_status", + "TaskType": "adcp.types.domains.enums.task_type", + "TmpResponseType": "adcp.types.domains.enums.response_type", + "TrackerExecutionActor": "adcp.types.domains.enums.tracker_execution_actor", + "TrackerFiringPath": "adcp.types.domains.enums.tracker_firing_path", + "TransportMode": "adcp.types.domains.enums.transport_mode", + "TravelTimeUnit": "adcp.types.domains.enums.travel_time_unit", + "UidType": "adcp.types.domains.enums.uid_type", + "UniversalMacro": "adcp.types.domains.enums.universal_macro", + "UpdateFrequency": "adcp.types.domains.enums.update_frequency", + "UrlAssetType": "adcp.types.domains.enums.url_asset_type", + "ValidationMode": "adcp.types.domains.enums.validation_mode", + "VastMediaDeliveryMethod": "adcp.types.domains.enums.vast_media_delivery_method", + "VastTrackingEvent": "adcp.types.domains.enums.vast_tracking_event", + "VastVersion": "adcp.types.domains.enums.vast_version", + "VendorRelationship": "adcp.types.domains.enums.vendor_relationship", + "VideoPlacementType": "adcp.types.domains.enums.video_placement_type", + "ViewThresholdBasis": "adcp.types.domains.enums.view_threshold_basis", + "ViewabilityStandard": "adcp.types.domains.enums.viewability_standard", + "WarningCode": "adcp.types.domains.enums.warning_code", + "WatermarkMediaType": "adcp.types.domains.enums.watermark_media_type", + "WcagLevel": "adcp.types.domains.enums.wcag_level", + "WebhookResponseType": "adcp.types.domains.enums.webhook_response_type", + "WebhookSecurityMethod": "adcp.types.domains.enums.webhook_security_method", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/error_details/__init__.py b/src/adcp/types/domains/error_details/__init__.py index d956e4d80e..e48174371b 100644 --- a/src/adcp/types/domains/error_details/__init__.py +++ b/src/adcp/types/domains/error_details/__init__.py @@ -9,85 +9,98 @@ it from its own schema's module, ``adcp.types.domains.error_details.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.error_details.accessibility_violation import ( - AccessibilityViolationDetails, - FailureKind, - FailureKind1, - Violation, -) -from adcp.types.domains.error_details.account_moved import AccountMovedDetails -from adcp.types.domains.error_details.account_setup_required import AccountSetupRequiredDetails -from adcp.types.domains.error_details.action_not_allowed import ActionNotAllowedDetails -from adcp.types.domains.error_details.agent_permission_denied import AgentPermissionDeniedDetails -from adcp.types.domains.error_details.audience_too_small import AudienceTooSmallDetails -from adcp.types.domains.error_details.authorization_required import AuthorizationRequiredDetails -from adcp.types.domains.error_details.billing_not_permitted_for_agent import ( - BillingNotPermittedForAgentDetails, -) -from adcp.types.domains.error_details.billing_not_supported import BillingNotSupportedDetails -from adcp.types.domains.error_details.budget_too_low import BudgetTooLowDetails -from adcp.types.domains.error_details.conflict import ConflictDetails -from adcp.types.domains.error_details.creative_rejected import CreativeRejectedDetails -from adcp.types.domains.error_details.creative_representation_unresolved import ( - CreativeRepresentationUnresolvedDetails, -) -from adcp.types.domains.error_details.creative_revision_content_mismatch import ( - CreativeRevisionContentMismatchDetails, -) -from adcp.types.domains.error_details.execution_requirement_unmet import ( - ExecutionRequirementUnmetDetails, - Reason, - UnmetRequirement, -) -from adcp.types.domains.error_details.governance_agent_not_accepted import ( - GovernanceAgentNotAcceptedDetails, - GovernanceAgentNotAcceptedDetails1, - GovernanceAgentNotAcceptedDetails2, -) -from adcp.types.domains.error_details.macro_resolution_failed import MacroResolutionFailedDetails -from adcp.types.domains.error_details.policy_violation import Origin, PolicyViolationDetails -from adcp.types.domains.error_details.rate_limited import RateLimitedDetails -from adcp.types.domains.error_details.requote_required import ( - EnvelopeField, - EnvelopeField1, - EnvelopeField1Item, - RequoteRequiredDetails, -) -from adcp.types.domains.error_details.stale_response import ( - OriginalError, - StaleResponseDetails, - Upstream, -) -from adcp.types.domains.error_details.unsupported_refinement_dimension import ( - SupportedDimension, - UnsupportedRefinementDimensionDetails, -) -from adcp.types.domains.error_details.vast_version_mismatch import ( - DocumentRole, - MismatchReason, - VastVersionMismatchDetails, - VastVersionMismatchDetails1, - VastVersionMismatchDetails2, - VastVersionMismatchDetails3, -) -from adcp.types.domains.error_details.vendor_error_codes import ( - Codes, - Recovery, - VendorErrorCodeRegistry, - Vendors, -) -from adcp.types.domains.error_details.version_unsupported import ( - SupportedMajor, - SupportedVersion, - VersionUnsupportedDetails, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.error_details.accessibility_violation import ( + AccessibilityViolationDetails, + FailureKind, + FailureKind1, + Violation, + ) + from adcp.types.domains.error_details.account_moved import AccountMovedDetails + from adcp.types.domains.error_details.account_setup_required import AccountSetupRequiredDetails + from adcp.types.domains.error_details.action_not_allowed import ActionNotAllowedDetails + from adcp.types.domains.error_details.agent_permission_denied import ( + AgentPermissionDeniedDetails, + ) + from adcp.types.domains.error_details.audience_too_small import AudienceTooSmallDetails + from adcp.types.domains.error_details.authorization_required import AuthorizationRequiredDetails + from adcp.types.domains.error_details.billing_not_permitted_for_agent import ( + BillingNotPermittedForAgentDetails, + ) + from adcp.types.domains.error_details.billing_not_supported import BillingNotSupportedDetails + from adcp.types.domains.error_details.budget_too_low import BudgetTooLowDetails + from adcp.types.domains.error_details.conflict import ConflictDetails + from adcp.types.domains.error_details.creative_rejected import CreativeRejectedDetails + from adcp.types.domains.error_details.creative_representation_unresolved import ( + CreativeRepresentationUnresolvedDetails, + ) + from adcp.types.domains.error_details.creative_revision_content_mismatch import ( + CreativeRevisionContentMismatchDetails, + ) + from adcp.types.domains.error_details.execution_requirement_unmet import ( + ExecutionRequirementUnmetDetails, + Reason, + UnmetRequirement, + ) + from adcp.types.domains.error_details.governance_agent_not_accepted import ( + GovernanceAgentNotAcceptedDetails, + GovernanceAgentNotAcceptedDetails1, + GovernanceAgentNotAcceptedDetails2, + ) + from adcp.types.domains.error_details.macro_resolution_failed import ( + MacroResolutionFailedDetails, + ) + from adcp.types.domains.error_details.policy_violation import Origin, PolicyViolationDetails + from adcp.types.domains.error_details.rate_limited import RateLimitedDetails + from adcp.types.domains.error_details.requote_required import ( + EnvelopeField, + EnvelopeField1, + EnvelopeField1Item, + RequoteRequiredDetails, + ) + from adcp.types.domains.error_details.stale_response import ( + OriginalError, + StaleResponseDetails, + Upstream, + ) + from adcp.types.domains.error_details.unsupported_refinement_dimension import ( + SupportedDimension, + UnsupportedRefinementDimensionDetails, + ) + from adcp.types.domains.error_details.vast_version_mismatch import ( + DocumentRole, + MismatchReason, + VastVersionMismatchDetails, + VastVersionMismatchDetails1, + VastVersionMismatchDetails2, + VastVersionMismatchDetails3, + ) + from adcp.types.domains.error_details.vendor_error_codes import ( + Codes, + Recovery, + VendorErrorCodeRegistry, + Vendors, + ) + from adcp.types.domains.error_details.version_unsupported import ( + SupportedMajor, + SupportedVersion, + VersionUnsupportedDetails, + ) # Explicit exports __all__ = [ @@ -141,3 +154,72 @@ "VersionUnsupportedDetails", "Violation", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "AccessibilityViolationDetails": "adcp.types.domains.error_details.accessibility_violation", + "AccountMovedDetails": "adcp.types.domains.error_details.account_moved", + "AccountSetupRequiredDetails": "adcp.types.domains.error_details.account_setup_required", + "ActionNotAllowedDetails": "adcp.types.domains.error_details.action_not_allowed", + "AgentPermissionDeniedDetails": "adcp.types.domains.error_details.agent_permission_denied", + "AudienceTooSmallDetails": "adcp.types.domains.error_details.audience_too_small", + "AuthorizationRequiredDetails": "adcp.types.domains.error_details.authorization_required", + "BillingNotPermittedForAgentDetails": "adcp.types.domains.error_details.billing_not_permitted_for_agent", + "BillingNotSupportedDetails": "adcp.types.domains.error_details.billing_not_supported", + "BudgetTooLowDetails": "adcp.types.domains.error_details.budget_too_low", + "Codes": "adcp.types.domains.error_details.vendor_error_codes", + "ConflictDetails": "adcp.types.domains.error_details.conflict", + "CreativeRejectedDetails": "adcp.types.domains.error_details.creative_rejected", + "CreativeRepresentationUnresolvedDetails": "adcp.types.domains.error_details.creative_representation_unresolved", + "CreativeRevisionContentMismatchDetails": "adcp.types.domains.error_details.creative_revision_content_mismatch", + "DocumentRole": "adcp.types.domains.error_details.vast_version_mismatch", + "EnvelopeField": "adcp.types.domains.error_details.requote_required", + "EnvelopeField1": "adcp.types.domains.error_details.requote_required", + "EnvelopeField1Item": "adcp.types.domains.error_details.requote_required", + "ExecutionRequirementUnmetDetails": "adcp.types.domains.error_details.execution_requirement_unmet", + "FailureKind": "adcp.types.domains.error_details.accessibility_violation", + "FailureKind1": "adcp.types.domains.error_details.accessibility_violation", + "GovernanceAgentNotAcceptedDetails": "adcp.types.domains.error_details.governance_agent_not_accepted", + "GovernanceAgentNotAcceptedDetails1": "adcp.types.domains.error_details.governance_agent_not_accepted", + "GovernanceAgentNotAcceptedDetails2": "adcp.types.domains.error_details.governance_agent_not_accepted", + "MacroResolutionFailedDetails": "adcp.types.domains.error_details.macro_resolution_failed", + "MismatchReason": "adcp.types.domains.error_details.vast_version_mismatch", + "Origin": "adcp.types.domains.error_details.policy_violation", + "OriginalError": "adcp.types.domains.error_details.stale_response", + "PolicyViolationDetails": "adcp.types.domains.error_details.policy_violation", + "RateLimitedDetails": "adcp.types.domains.error_details.rate_limited", + "Reason": "adcp.types.domains.error_details.execution_requirement_unmet", + "Recovery": "adcp.types.domains.error_details.vendor_error_codes", + "RequoteRequiredDetails": "adcp.types.domains.error_details.requote_required", + "StaleResponseDetails": "adcp.types.domains.error_details.stale_response", + "SupportedDimension": "adcp.types.domains.error_details.unsupported_refinement_dimension", + "SupportedMajor": "adcp.types.domains.error_details.version_unsupported", + "SupportedVersion": "adcp.types.domains.error_details.version_unsupported", + "UnmetRequirement": "adcp.types.domains.error_details.execution_requirement_unmet", + "UnsupportedRefinementDimensionDetails": "adcp.types.domains.error_details.unsupported_refinement_dimension", + "Upstream": "adcp.types.domains.error_details.stale_response", + "VastVersionMismatchDetails": "adcp.types.domains.error_details.vast_version_mismatch", + "VastVersionMismatchDetails1": "adcp.types.domains.error_details.vast_version_mismatch", + "VastVersionMismatchDetails2": "adcp.types.domains.error_details.vast_version_mismatch", + "VastVersionMismatchDetails3": "adcp.types.domains.error_details.vast_version_mismatch", + "VendorErrorCodeRegistry": "adcp.types.domains.error_details.vendor_error_codes", + "Vendors": "adcp.types.domains.error_details.vendor_error_codes", + "VersionUnsupportedDetails": "adcp.types.domains.error_details.version_unsupported", + "Violation": "adcp.types.domains.error_details.accessibility_violation", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/extensions/__init__.py b/src/adcp/types/domains/extensions/__init__.py index 9e2e139673..16925e4d15 100644 --- a/src/adcp/types/domains/extensions/__init__.py +++ b/src/adcp/types/domains/extensions/__init__.py @@ -9,14 +9,44 @@ it from its own schema's module, ``adcp.types.domains.extensions.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.extensions.extension_meta import AdcpExtensionFileSchema +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.extensions.extension_meta import AdcpExtensionFileSchema # Explicit exports __all__ = ["AdcpExtensionFileSchema"] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "AdcpExtensionFileSchema": "adcp.types.domains.extensions.extension_meta", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/formats/__init__.py b/src/adcp/types/domains/formats/__init__.py index 596c05ef5e..ca73a5f6c8 100644 --- a/src/adcp/types/domains/formats/__init__.py +++ b/src/adcp/types/domains/formats/__init__.py @@ -9,89 +9,98 @@ it from its own schema's module, ``adcp.types.domains.formats.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.formats.canonical._base import ( - CanonicalFormatBase, - CompositionModel, - ReferenceMutability, - RequiredPixelRatio, - Slot, -) -from adcp.types.domains.formats.canonical.agent_placement import ( - CanonicalFormatAgentPlacementAiSurfaceSponsoredPlacement, -) -from adcp.types.domains.formats.canonical.audio_daast import CanonicalFormatDaastAudio -from adcp.types.domains.formats.canonical.audio_hosted import CanonicalFormatHostedAudio -from adcp.types.domains.formats.canonical.audio_vast import CanonicalFormatVastAudio -from adcp.types.domains.formats.canonical.coordinated_placements import ( - AssetSource5, - AudioCodec2, - CanonicalFormatCoordinatedPlacements, - Components, - Components1, - Components10, - Components11, - Components12, - Components13, - Components2, - Components3, - Components4, - Components5, - Components6, - Components7, - Components8, - Components9, - ImageFormat1, - Params, - Params10, - Params11, - Params12, - Params13, - Params2, - Params3, - Params4, - Params5, - Params6, - Params7, - Params8, - Params9, - ServingPolicy, - SharedSlot, - TransitionMode3, - Transitions1, - Transitions2, - Transitions3, - Transitions4, - Transitions5, -) -from adcp.types.domains.formats.canonical.display_tag import CanonicalFormatDisplayTag -from adcp.types.domains.formats.canonical.html5 import CanonicalFormatHtml5Banner -from adcp.types.domains.formats.canonical.image import CanonicalFormatImage -from adcp.types.domains.formats.canonical.image_carousel import CanonicalFormatImageCarousel -from adcp.types.domains.formats.canonical.native_in_feed import CanonicalFormatNativeInFeed -from adcp.types.domains.formats.canonical.responsive_creative import ( - CanonicalFormatResponsiveCreative, -) -from adcp.types.domains.formats.canonical.seller_rendered_stateful_display import ( - CanonicalFormatSellerRenderedStatefulDisplay, - TransitionMode9, - Transitions10, - Transitions11, - Transitions7, - Transitions8, - Transitions9, -) -from adcp.types.domains.formats.canonical.sponsored_placement import ( - CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven, -) -from adcp.types.domains.formats.canonical.video_hosted import CanonicalFormatHostedVideo -from adcp.types.domains.formats.canonical.video_vast import CanonicalFormatVastVideo +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.formats.canonical._base import ( + CanonicalFormatBase, + CompositionModel, + ReferenceMutability, + RequiredPixelRatio, + Slot, + ) + from adcp.types.domains.formats.canonical.agent_placement import ( + CanonicalFormatAgentPlacementAiSurfaceSponsoredPlacement, + ) + from adcp.types.domains.formats.canonical.audio_daast import CanonicalFormatDaastAudio + from adcp.types.domains.formats.canonical.audio_hosted import CanonicalFormatHostedAudio + from adcp.types.domains.formats.canonical.audio_vast import CanonicalFormatVastAudio + from adcp.types.domains.formats.canonical.coordinated_placements import ( + AssetSource5, + AudioCodec2, + CanonicalFormatCoordinatedPlacements, + Components, + Components1, + Components10, + Components11, + Components12, + Components13, + Components2, + Components3, + Components4, + Components5, + Components6, + Components7, + Components8, + Components9, + ImageFormat1, + Params, + Params10, + Params11, + Params12, + Params13, + Params2, + Params3, + Params4, + Params5, + Params6, + Params7, + Params8, + Params9, + ServingPolicy, + SharedSlot, + TransitionMode3, + Transitions1, + Transitions2, + Transitions3, + Transitions4, + Transitions5, + ) + from adcp.types.domains.formats.canonical.display_tag import CanonicalFormatDisplayTag + from adcp.types.domains.formats.canonical.html5 import CanonicalFormatHtml5Banner + from adcp.types.domains.formats.canonical.image import CanonicalFormatImage + from adcp.types.domains.formats.canonical.image_carousel import CanonicalFormatImageCarousel + from adcp.types.domains.formats.canonical.native_in_feed import CanonicalFormatNativeInFeed + from adcp.types.domains.formats.canonical.responsive_creative import ( + CanonicalFormatResponsiveCreative, + ) + from adcp.types.domains.formats.canonical.seller_rendered_stateful_display import ( + CanonicalFormatSellerRenderedStatefulDisplay, + TransitionMode9, + Transitions10, + Transitions11, + Transitions7, + Transitions8, + Transitions9, + ) + from adcp.types.domains.formats.canonical.sponsored_placement import ( + CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven, + ) + from adcp.types.domains.formats.canonical.video_hosted import CanonicalFormatHostedVideo + from adcp.types.domains.formats.canonical.video_vast import CanonicalFormatVastVideo # Explicit exports __all__ = [ @@ -160,3 +169,87 @@ "Transitions8", "Transitions9", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "AssetSource5": "adcp.types.domains.formats.canonical.coordinated_placements", + "AudioCodec2": "adcp.types.domains.formats.canonical.coordinated_placements", + "CanonicalFormatAgentPlacementAiSurfaceSponsoredPlacement": "adcp.types.domains.formats.canonical.agent_placement", + "CanonicalFormatBase": "adcp.types.domains.formats.canonical._base", + "CanonicalFormatCoordinatedPlacements": "adcp.types.domains.formats.canonical.coordinated_placements", + "CanonicalFormatDaastAudio": "adcp.types.domains.formats.canonical.audio_daast", + "CanonicalFormatDisplayTag": "adcp.types.domains.formats.canonical.display_tag", + "CanonicalFormatHostedAudio": "adcp.types.domains.formats.canonical.audio_hosted", + "CanonicalFormatHostedVideo": "adcp.types.domains.formats.canonical.video_hosted", + "CanonicalFormatHtml5Banner": "adcp.types.domains.formats.canonical.html5", + "CanonicalFormatImage": "adcp.types.domains.formats.canonical.image", + "CanonicalFormatImageCarousel": "adcp.types.domains.formats.canonical.image_carousel", + "CanonicalFormatNativeInFeed": "adcp.types.domains.formats.canonical.native_in_feed", + "CanonicalFormatResponsiveCreative": "adcp.types.domains.formats.canonical.responsive_creative", + "CanonicalFormatSellerRenderedStatefulDisplay": "adcp.types.domains.formats.canonical.seller_rendered_stateful_display", + "CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven": "adcp.types.domains.formats.canonical.sponsored_placement", + "CanonicalFormatVastAudio": "adcp.types.domains.formats.canonical.audio_vast", + "CanonicalFormatVastVideo": "adcp.types.domains.formats.canonical.video_vast", + "Components": "adcp.types.domains.formats.canonical.coordinated_placements", + "Components1": "adcp.types.domains.formats.canonical.coordinated_placements", + "Components10": "adcp.types.domains.formats.canonical.coordinated_placements", + "Components11": "adcp.types.domains.formats.canonical.coordinated_placements", + "Components12": "adcp.types.domains.formats.canonical.coordinated_placements", + "Components13": "adcp.types.domains.formats.canonical.coordinated_placements", + "Components2": "adcp.types.domains.formats.canonical.coordinated_placements", + "Components3": "adcp.types.domains.formats.canonical.coordinated_placements", + "Components4": "adcp.types.domains.formats.canonical.coordinated_placements", + "Components5": "adcp.types.domains.formats.canonical.coordinated_placements", + "Components6": "adcp.types.domains.formats.canonical.coordinated_placements", + "Components7": "adcp.types.domains.formats.canonical.coordinated_placements", + "Components8": "adcp.types.domains.formats.canonical.coordinated_placements", + "Components9": "adcp.types.domains.formats.canonical.coordinated_placements", + "CompositionModel": "adcp.types.domains.formats.canonical._base", + "ImageFormat1": "adcp.types.domains.formats.canonical.coordinated_placements", + "Params": "adcp.types.domains.formats.canonical.coordinated_placements", + "Params10": "adcp.types.domains.formats.canonical.coordinated_placements", + "Params11": "adcp.types.domains.formats.canonical.coordinated_placements", + "Params12": "adcp.types.domains.formats.canonical.coordinated_placements", + "Params13": "adcp.types.domains.formats.canonical.coordinated_placements", + "Params2": "adcp.types.domains.formats.canonical.coordinated_placements", + "Params3": "adcp.types.domains.formats.canonical.coordinated_placements", + "Params4": "adcp.types.domains.formats.canonical.coordinated_placements", + "Params5": "adcp.types.domains.formats.canonical.coordinated_placements", + "Params6": "adcp.types.domains.formats.canonical.coordinated_placements", + "Params7": "adcp.types.domains.formats.canonical.coordinated_placements", + "Params8": "adcp.types.domains.formats.canonical.coordinated_placements", + "Params9": "adcp.types.domains.formats.canonical.coordinated_placements", + "ReferenceMutability": "adcp.types.domains.formats.canonical._base", + "RequiredPixelRatio": "adcp.types.domains.formats.canonical._base", + "ServingPolicy": "adcp.types.domains.formats.canonical.coordinated_placements", + "SharedSlot": "adcp.types.domains.formats.canonical.coordinated_placements", + "Slot": "adcp.types.domains.formats.canonical._base", + "TransitionMode3": "adcp.types.domains.formats.canonical.coordinated_placements", + "TransitionMode9": "adcp.types.domains.formats.canonical.seller_rendered_stateful_display", + "Transitions1": "adcp.types.domains.formats.canonical.coordinated_placements", + "Transitions10": "adcp.types.domains.formats.canonical.seller_rendered_stateful_display", + "Transitions11": "adcp.types.domains.formats.canonical.seller_rendered_stateful_display", + "Transitions2": "adcp.types.domains.formats.canonical.coordinated_placements", + "Transitions3": "adcp.types.domains.formats.canonical.coordinated_placements", + "Transitions4": "adcp.types.domains.formats.canonical.coordinated_placements", + "Transitions5": "adcp.types.domains.formats.canonical.coordinated_placements", + "Transitions7": "adcp.types.domains.formats.canonical.seller_rendered_stateful_display", + "Transitions8": "adcp.types.domains.formats.canonical.seller_rendered_stateful_display", + "Transitions9": "adcp.types.domains.formats.canonical.seller_rendered_stateful_display", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/governance/__init__.py b/src/adcp/types/domains/governance/__init__.py index 1c22fcdcab..eb783951c6 100644 --- a/src/adcp/types/domains/governance/__init__.py +++ b/src/adcp/types/domains/governance/__init__.py @@ -9,119 +9,131 @@ it from its own schema's module, ``adcp.types.domains.governance.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.governance.accepted_governance_agents import ( - AcceptedGovernanceAgents, - AnyOf, - AnyOf1, - AnyOf2, - VerificationMode, -) -from adcp.types.domains.governance.attribute_definition import AttributeDefinition, RegulatoryBasi -from adcp.types.domains.governance.audience_constraints import AudienceConstraints -from adcp.types.domains.governance.check_governance_request import ( - AudienceDistribution, - AudienceDistribution1, - Baseline, - CheckGovernanceRequest, - CheckGovernanceRequest1, - CheckGovernanceRequest2, - CheckGovernanceRequest3, - DeliveryMetrics, - DeliveryMetrics2, - ExecutionCommitment, - Pacing, - ProposedCommitment, - RuntimeAttestation1, - Subject, - Subject4, - Subject6, - Subject8, - Subject9, -) -from adcp.types.domains.governance.check_governance_response import ( - ActionBinding, - CheckGovernanceResponse, - Condition, - RuntimeAttestationEvaluation, -) -from adcp.types.domains.governance.get_plan_audit_logs_request import GetPlanAuditLogsRequest -from adcp.types.domains.governance.get_plan_audit_logs_response import ( - ChannelAllocation, - DeliveryReportingPeriod, - DriftMetrics, - Entry, - Escalation, - EscalationRateTrend, - GetPlanAuditLogsResponse, - GovernedAction, - Statuses, - Summary, - Thresholds, - Type, -) -from adcp.types.domains.governance.policy_category_definition import ( - Facet, - PolicyCategoryDefinition, - RegulatoryFramework, -) -from adcp.types.domains.governance.policy_entry import Exemplar, Exemplars, Issuer, PolicyEntry -from adcp.types.domains.governance.policy_ref import PolicyReference -from adcp.types.domains.governance.report_plan_adjustment_request import ( - Action, - Decision, - ReportPlanAdjustmentRequest, -) -from adcp.types.domains.governance.report_plan_adjustment_response import ( - ReportPlanAdjustmentResponse, -) -from adcp.types.domains.governance.report_plan_outcome_request import ( - Package, - ReportPlanOutcomeRequest, - SellerResponse, -) -from adcp.types.domains.governance.report_plan_outcome_response import ( - OutcomeState, - ReportPlanOutcomeResponse, -) -from adcp.types.domains.governance.reported_outcome_error import ( - BoundedObject, - BoundedScalar, - BoundedScalar1, - BoundedValue, - BoundedValue1, - BoundedValueLevel2, - BoundedValueLevel21, - BoundedValueLevel3, - BoundedValueLevel31, - ClassificationSource, - Recovery, - ReportedOutcomeError, -) -from adcp.types.domains.governance.sync_plans_request import ( - Allocations, - Budget2, - BudgetLimit, - Channels, - Delegation, - Flight, - MixTargets, - Portfolio, - SyncPlansRequest, - TotalBudgetCap, -) -from adcp.types.domains.governance.sync_plans_response import ( - Category, - ResolvedPolicy, - Status51, - SyncPlansResponse, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.governance.accepted_governance_agents import ( + AcceptedGovernanceAgents, + AnyOf, + AnyOf1, + AnyOf2, + VerificationMode, + ) + from adcp.types.domains.governance.attribute_definition import ( + AttributeDefinition, + RegulatoryBasi, + ) + from adcp.types.domains.governance.audience_constraints import AudienceConstraints + from adcp.types.domains.governance.check_governance_request import ( + AudienceDistribution, + AudienceDistribution1, + Baseline, + CheckGovernanceRequest, + CheckGovernanceRequest1, + CheckGovernanceRequest2, + CheckGovernanceRequest3, + DeliveryMetrics, + DeliveryMetrics2, + ExecutionCommitment, + Pacing, + ProposedCommitment, + RuntimeAttestation1, + Subject, + Subject4, + Subject6, + Subject8, + Subject9, + ) + from adcp.types.domains.governance.check_governance_response import ( + ActionBinding, + CheckGovernanceResponse, + Condition, + RuntimeAttestationEvaluation, + ) + from adcp.types.domains.governance.get_plan_audit_logs_request import GetPlanAuditLogsRequest + from adcp.types.domains.governance.get_plan_audit_logs_response import ( + ChannelAllocation, + DeliveryReportingPeriod, + DriftMetrics, + Entry, + Escalation, + EscalationRateTrend, + GetPlanAuditLogsResponse, + GovernedAction, + Statuses, + Summary, + Thresholds, + Type, + ) + from adcp.types.domains.governance.policy_category_definition import ( + Facet, + PolicyCategoryDefinition, + RegulatoryFramework, + ) + from adcp.types.domains.governance.policy_entry import Exemplar, Exemplars, Issuer, PolicyEntry + from adcp.types.domains.governance.policy_ref import PolicyReference + from adcp.types.domains.governance.report_plan_adjustment_request import ( + Action, + Decision, + ReportPlanAdjustmentRequest, + ) + from adcp.types.domains.governance.report_plan_adjustment_response import ( + ReportPlanAdjustmentResponse, + ) + from adcp.types.domains.governance.report_plan_outcome_request import ( + Package, + ReportPlanOutcomeRequest, + SellerResponse, + ) + from adcp.types.domains.governance.report_plan_outcome_response import ( + OutcomeState, + ReportPlanOutcomeResponse, + ) + from adcp.types.domains.governance.reported_outcome_error import ( + BoundedObject, + BoundedScalar, + BoundedScalar1, + BoundedValue, + BoundedValue1, + BoundedValueLevel2, + BoundedValueLevel21, + BoundedValueLevel3, + BoundedValueLevel31, + ClassificationSource, + Recovery, + ReportedOutcomeError, + ) + from adcp.types.domains.governance.sync_plans_request import ( + Allocations, + Budget2, + BudgetLimit, + Channels, + Delegation, + Flight, + MixTargets, + Portfolio, + SyncPlansRequest, + TotalBudgetCap, + ) + from adcp.types.domains.governance.sync_plans_response import ( + Category, + ResolvedPolicy, + Status51, + SyncPlansResponse, + ) # Explicit exports __all__ = [ @@ -212,3 +224,109 @@ "Type", "VerificationMode", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "AcceptedGovernanceAgents": "adcp.types.domains.governance.accepted_governance_agents", + "Action": "adcp.types.domains.governance.report_plan_adjustment_request", + "ActionBinding": "adcp.types.domains.governance.check_governance_response", + "Allocations": "adcp.types.domains.governance.sync_plans_request", + "AnyOf": "adcp.types.domains.governance.accepted_governance_agents", + "AnyOf1": "adcp.types.domains.governance.accepted_governance_agents", + "AnyOf2": "adcp.types.domains.governance.accepted_governance_agents", + "AttributeDefinition": "adcp.types.domains.governance.attribute_definition", + "AudienceConstraints": "adcp.types.domains.governance.audience_constraints", + "AudienceDistribution": "adcp.types.domains.governance.check_governance_request", + "AudienceDistribution1": "adcp.types.domains.governance.check_governance_request", + "Baseline": "adcp.types.domains.governance.check_governance_request", + "BoundedObject": "adcp.types.domains.governance.reported_outcome_error", + "BoundedScalar": "adcp.types.domains.governance.reported_outcome_error", + "BoundedScalar1": "adcp.types.domains.governance.reported_outcome_error", + "BoundedValue": "adcp.types.domains.governance.reported_outcome_error", + "BoundedValue1": "adcp.types.domains.governance.reported_outcome_error", + "BoundedValueLevel2": "adcp.types.domains.governance.reported_outcome_error", + "BoundedValueLevel21": "adcp.types.domains.governance.reported_outcome_error", + "BoundedValueLevel3": "adcp.types.domains.governance.reported_outcome_error", + "BoundedValueLevel31": "adcp.types.domains.governance.reported_outcome_error", + "Budget2": "adcp.types.domains.governance.sync_plans_request", + "BudgetLimit": "adcp.types.domains.governance.sync_plans_request", + "Category": "adcp.types.domains.governance.sync_plans_response", + "ChannelAllocation": "adcp.types.domains.governance.get_plan_audit_logs_response", + "Channels": "adcp.types.domains.governance.sync_plans_request", + "CheckGovernanceRequest": "adcp.types.domains.governance.check_governance_request", + "CheckGovernanceRequest1": "adcp.types.domains.governance.check_governance_request", + "CheckGovernanceRequest2": "adcp.types.domains.governance.check_governance_request", + "CheckGovernanceRequest3": "adcp.types.domains.governance.check_governance_request", + "CheckGovernanceResponse": "adcp.types.domains.governance.check_governance_response", + "ClassificationSource": "adcp.types.domains.governance.reported_outcome_error", + "Condition": "adcp.types.domains.governance.check_governance_response", + "Decision": "adcp.types.domains.governance.report_plan_adjustment_request", + "Delegation": "adcp.types.domains.governance.sync_plans_request", + "DeliveryMetrics": "adcp.types.domains.governance.check_governance_request", + "DeliveryMetrics2": "adcp.types.domains.governance.check_governance_request", + "DeliveryReportingPeriod": "adcp.types.domains.governance.get_plan_audit_logs_response", + "DriftMetrics": "adcp.types.domains.governance.get_plan_audit_logs_response", + "Entry": "adcp.types.domains.governance.get_plan_audit_logs_response", + "Escalation": "adcp.types.domains.governance.get_plan_audit_logs_response", + "EscalationRateTrend": "adcp.types.domains.governance.get_plan_audit_logs_response", + "ExecutionCommitment": "adcp.types.domains.governance.check_governance_request", + "Exemplar": "adcp.types.domains.governance.policy_entry", + "Exemplars": "adcp.types.domains.governance.policy_entry", + "Facet": "adcp.types.domains.governance.policy_category_definition", + "Flight": "adcp.types.domains.governance.sync_plans_request", + "GetPlanAuditLogsRequest": "adcp.types.domains.governance.get_plan_audit_logs_request", + "GetPlanAuditLogsResponse": "adcp.types.domains.governance.get_plan_audit_logs_response", + "GovernedAction": "adcp.types.domains.governance.get_plan_audit_logs_response", + "Issuer": "adcp.types.domains.governance.policy_entry", + "MixTargets": "adcp.types.domains.governance.sync_plans_request", + "OutcomeState": "adcp.types.domains.governance.report_plan_outcome_response", + "Pacing": "adcp.types.domains.governance.check_governance_request", + "Package": "adcp.types.domains.governance.report_plan_outcome_request", + "PolicyCategoryDefinition": "adcp.types.domains.governance.policy_category_definition", + "PolicyEntry": "adcp.types.domains.governance.policy_entry", + "PolicyReference": "adcp.types.domains.governance.policy_ref", + "Portfolio": "adcp.types.domains.governance.sync_plans_request", + "ProposedCommitment": "adcp.types.domains.governance.check_governance_request", + "Recovery": "adcp.types.domains.governance.reported_outcome_error", + "RegulatoryBasi": "adcp.types.domains.governance.attribute_definition", + "RegulatoryFramework": "adcp.types.domains.governance.policy_category_definition", + "ReportPlanAdjustmentRequest": "adcp.types.domains.governance.report_plan_adjustment_request", + "ReportPlanAdjustmentResponse": "adcp.types.domains.governance.report_plan_adjustment_response", + "ReportPlanOutcomeRequest": "adcp.types.domains.governance.report_plan_outcome_request", + "ReportPlanOutcomeResponse": "adcp.types.domains.governance.report_plan_outcome_response", + "ReportedOutcomeError": "adcp.types.domains.governance.reported_outcome_error", + "ResolvedPolicy": "adcp.types.domains.governance.sync_plans_response", + "RuntimeAttestation1": "adcp.types.domains.governance.check_governance_request", + "RuntimeAttestationEvaluation": "adcp.types.domains.governance.check_governance_response", + "SellerResponse": "adcp.types.domains.governance.report_plan_outcome_request", + "Status51": "adcp.types.domains.governance.sync_plans_response", + "Statuses": "adcp.types.domains.governance.get_plan_audit_logs_response", + "Subject": "adcp.types.domains.governance.check_governance_request", + "Subject4": "adcp.types.domains.governance.check_governance_request", + "Subject6": "adcp.types.domains.governance.check_governance_request", + "Subject8": "adcp.types.domains.governance.check_governance_request", + "Subject9": "adcp.types.domains.governance.check_governance_request", + "Summary": "adcp.types.domains.governance.get_plan_audit_logs_response", + "SyncPlansRequest": "adcp.types.domains.governance.sync_plans_request", + "SyncPlansResponse": "adcp.types.domains.governance.sync_plans_response", + "Thresholds": "adcp.types.domains.governance.get_plan_audit_logs_response", + "TotalBudgetCap": "adcp.types.domains.governance.sync_plans_request", + "Type": "adcp.types.domains.governance.get_plan_audit_logs_response", + "VerificationMode": "adcp.types.domains.governance.accepted_governance_agents", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/media_buy/__init__.py b/src/adcp/types/domains/media_buy/__init__.py index 1b71063f60..6c424b7d1d 100644 --- a/src/adcp/types/domains/media_buy/__init__.py +++ b/src/adcp/types/domains/media_buy/__init__.py @@ -9,528 +9,559 @@ it from its own schema's module, ``adcp.types.domains.media_buy.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.media_buy.accept_proposal_async_response_input_required import ( - AcceptProposalInputRequired, -) -from adcp.types.domains.media_buy.accept_proposal_async_response_submitted import ( - AcceptProposalSubmitted, -) -from adcp.types.domains.media_buy.accept_proposal_async_response_working import ( - AcceptProposalWorking, -) -from adcp.types.domains.media_buy.accept_proposal_request import AcceptProposalRequest -from adcp.types.domains.media_buy.accept_proposal_response import ( - AcceptProposalResponse, - AcceptProposalResponse1, - AcceptProposalResponse2, - AcceptProposalResponse3, - AcceptProposalResponse4, - AcceptProposalResponse5, - AcceptProposalResponse6, - AcceptProposalResponse7, -) -from adcp.types.domains.media_buy.acceptance_context import ( - AcceptanceContext, - AdvertiserJurisdiction, - DeliveryJurisdiction, - Subject, -) -from adcp.types.domains.media_buy.acceptance_policy_catalog import AcceptancePolicyCatalog -from adcp.types.domains.media_buy.acceptance_policy_profile import ( - AcceptancePolicyProfile, - Coverage, - PolicyRef, - RegionAliase, - SubjectCategory, -) -from adcp.types.domains.media_buy.acceptance_policy_profile_ref import ( - RegistryAcceptancePolicyProfileReference, -) -from adcp.types.domains.media_buy.acceptance_policy_requirement import ( - AcceptancePolicyRequirement, - AcceptancePolicyRequirement1, - AcceptancePolicyRequirement10, - AcceptancePolicyRequirement11, - AcceptancePolicyRequirement12, - AcceptancePolicyRequirement13, - AcceptancePolicyRequirement14, - AcceptancePolicyRequirement15, - AcceptancePolicyRequirement16, - AcceptancePolicyRequirement17, - AcceptancePolicyRequirement2, - AcceptancePolicyRequirement3, - AcceptancePolicyRequirement4, - AcceptancePolicyRequirement5, - AcceptancePolicyRequirement6, - AcceptancePolicyRequirement7, - AcceptancePolicyRequirement8, - AcceptancePolicyRequirement9, - Criterion, - FormatId, -) -from adcp.types.domains.media_buy.acceptance_policy_rule import AcceptancePolicyRule, Disposition -from adcp.types.domains.media_buy.build_creative_async_response_input_required import ( - BuildCreativeInputRequired, -) -from adcp.types.domains.media_buy.build_creative_async_response_submitted import ( - BuildCreativeSubmitted, -) -from adcp.types.domains.media_buy.build_creative_async_response_working import BuildCreativeWorking -from adcp.types.domains.media_buy.build_creative_request import ( - BuildCreativeRequest, - Dimension, - KeepMode, - MaxSpend, - Mode, - PreviewInput, - SignalCondition, - SignalCondition1, - SignalCondition2, - SignalCondition3, - SignalCondition4, - SignalCondition5, - SignalCondition6, - SignalCondition7, - TargetCapabilityId, - VariantAxis, -) -from adcp.types.domains.media_buy.build_creative_response import ( - BuildCreativeResponse, - BuildCreativeResponse1, - BuildCreativeResponse2, - BuildCreativeResponse3, - BuildCreativeResponse4, - BuildCreativeResponse5, - BuildCreativeResponse6, - CatalogItemRef, - Estimate, - Eval, - Input, - Input2, - PerLeaf, - Preview, - Preview2, - Preview3, - Preview4, - Variant, -) -from adcp.types.domains.media_buy.buy_products_async_response_input_required import ( - BuyProductsInputRequired, -) -from adcp.types.domains.media_buy.buy_products_async_response_submitted import BuyProductsSubmitted -from adcp.types.domains.media_buy.buy_products_async_response_working import BuyProductsWorking -from adcp.types.domains.media_buy.buy_products_request import BuyProductsRequest -from adcp.types.domains.media_buy.buy_products_response import ( - BuyProductsResponse, - BuyProductsResponse1, - BuyProductsResponse2, - BuyProductsResponse3, - BuyProductsResponse4, - BuyProductsResponse5, - BuyProductsResponse6, - BuyProductsResponse7, -) -from adcp.types.domains.media_buy.change_term import AllowedStatus, Condition, MediaBuyChangeTerm -from adcp.types.domains.media_buy.change_term_constraints import ( - MediaBuyChangeTermConstraints, - MediaBuyChangeTermConstraints1, - MediaBuyChangeTermConstraints2, - MediaBuyChangeTermConstraints3, - MediaBuyChangeTermConstraints4, - Money, -) -from adcp.types.domains.media_buy.commercial_terms import ( - CancellationTerms, - CommercialTerms, - Fee, - ReportingCommitment, -) -from adcp.types.domains.media_buy.control_media_buy_async_response_input_required import ( - ControlMediaBuyInputRequired, -) -from adcp.types.domains.media_buy.control_media_buy_async_response_submitted import ( - ControlMediaBuySubmitted, -) -from adcp.types.domains.media_buy.control_media_buy_async_response_working import ( - ControlMediaBuyWorking, -) -from adcp.types.domains.media_buy.control_media_buy_request import ControlMediaBuyRequest -from adcp.types.domains.media_buy.control_media_buy_response import ( - AffectedPackageId, - ControlMediaBuyResponse, - ControlMediaBuyResponse1, - ControlMediaBuyResponse2, - ControlMediaBuyResponse3, -) -from adcp.types.domains.media_buy.create_media_buy_async_response_input_required import ( - CreateMediaBuyInputRequired, -) -from adcp.types.domains.media_buy.create_media_buy_async_response_submitted import ( - CreateMediaBuySubmitted, -) -from adcp.types.domains.media_buy.create_media_buy_async_response_working import ( - CreateMediaBuyWorking, -) -from adcp.types.domains.media_buy.create_media_buy_request import ( - ArtifactWebhook, - Authentication, - BatchFrequency, - CreateMediaBuyRequest, - DeliveryMode, -) -from adcp.types.domains.media_buy.create_media_buy_response import ( - CreateMediaBuyResponse, - CreateMediaBuyResponse1, - CreateMediaBuyResponse2, - CreateMediaBuyResponse3, -) -from adcp.types.domains.media_buy.decline_proposals_async_response_input_required import ( - DeclineProposalsInputRequired, -) -from adcp.types.domains.media_buy.decline_proposals_async_response_submitted import ( - DeclineProposalsSubmitted, -) -from adcp.types.domains.media_buy.decline_proposals_async_response_working import ( - DeclineProposalsWorking, -) -from adcp.types.domains.media_buy.decline_proposals_request import DeclineProposalsRequest -from adcp.types.domains.media_buy.decline_proposals_response import ( - DeclineProposalsResponse, - DeclineProposalsResponse1, - DeclineProposalsResponse2, - Results1, - Results2, - Results3, - Results4, - Results5, -) -from adcp.types.domains.media_buy.get_media_buy_delivery_request import ( - AttributionWindow, - CatalogItem, - Demographic, - DevicePlatform, - DeviceType, - Format, - Geo, - GetMediaBuyDeliveryRequest, - Keyword, - Placement, - ReportingDimensions, - Spot, -) -from adcp.types.domains.media_buy.get_media_buy_delivery_response import ( - AggregatedTotals, - ByAudienceItem, - ByDemographicItem, - ByDevicePlatformItem, - ByDeviceTypeItem, - ByFormatItem, - ByPackageItem1, - BySpotItem, - DailyBreakdownItem, - DailyBreakdownItem1, - GetMediaBuyDeliveryResponse, - ReportingRevisionBinding, - Window, -) -from adcp.types.domains.media_buy.get_media_buys_request import GetMediaBuysRequest -from adcp.types.domains.media_buy.get_media_buys_response import ( - Cancellation, - Cancellation1, - CreativeApproval, - GetMediaBuysResponse, - HistoryItem, - Indicator, - Indicator1, - Indicator2, - IndicatorTypesEvaluatedEnum, - IndicatorTypesEvaluatedEnum1, - IndicatorTypesEvaluatedEnum2, - MediaBuy, - Package, - Snapshot, -) -from adcp.types.domains.media_buy.get_products_async_response_input_required import ( - GetProductsInputRequired, -) -from adcp.types.domains.media_buy.get_products_async_response_submitted import GetProductsSubmitted -from adcp.types.domains.media_buy.get_products_async_response_working import GetProductsWorking -from adcp.types.domains.media_buy.get_products_rejected import GetProductsRejected -from adcp.types.domains.media_buy.get_products_request import ( - Action9, - BuyingMode, - Field1, - Fields, - GetProductsRequest, - Refine, - Refine1, - Refine2, - Refine3, -) -from adcp.types.domains.media_buy.get_products_response import ( - ExcludedBy, - Extensions, - FilterDiagnostics, - GetProductsResponse, - RefinementApplied, - RefinementApplied1, - RefinementApplied2, - RefinementApplied3, - Semantics, -) -from adcp.types.domains.media_buy.get_products_targeting_resolution import ( - ProductDiscoveryTargetingResolution, -) -from adcp.types.domains.media_buy.get_reporting_status_request import ( - DeliveryConfigId, - GetReportingStatusRequest, - Period, - ReportingStatusView, -) -from adcp.types.domains.media_buy.get_reporting_status_response import ( - DeliveryConfigGeneration, - GetReportingStatusResponse, - ObligationCounts, -) -from adcp.types.domains.media_buy.legacy_purchase_continuation_input import ( - AcceptedLoss, - CompatibilityPurchaseCoordinatorInput, - SelectedProductId, -) -from adcp.types.domains.media_buy.list_creative_formats_request import ListCreativeFormatsRequest -from adcp.types.domains.media_buy.list_creative_formats_response import ( - CreativeAgent, - ListCreativeFormatsResponse, -) -from adcp.types.domains.media_buy.list_products_request import ListProductsRequest -from adcp.types.domains.media_buy.list_products_response import ( - IncompleteItem3, - ListProductsResponse, - ListProductsResponse1, - ListProductsResponse2, -) -from adcp.types.domains.media_buy.log_event_request import LogEventRequest -from adcp.types.domains.media_buy.log_event_response import ( - LogEventResponse, - LogEventResponse1, - LogEventResponse2, - PartialFailure, -) -from adcp.types.domains.media_buy.media_buy_commitment_response import ( - MediaBuyCommitmentResponse, - MediaBuyCommitmentResponse1, - MediaBuyCommitmentResponse2, - MediaBuyCommitmentResponse3, -) -from adcp.types.domains.media_buy.media_buy_delivery_webhook_result import ( - MediaBuyDeliveryWebhookResult, -) -from adcp.types.domains.media_buy.outcome_target import Goal, Goal1, OutcomeTarget -from adcp.types.domains.media_buy.package_control import PackageControl -from adcp.types.domains.media_buy.package_request import ( - CommittedMetrics, - CommittedMetrics1, - CommittedMetrics2, - PackageRequest, - Qualifier, -) -from adcp.types.domains.media_buy.package_update import ( - KeywordTargetsAddItem, - KeywordTargetsRemoveItem, - NegativeKeywordsAddItem, - NegativeKeywordsRemoveItem, - PackageUpdate, -) -from adcp.types.domains.media_buy.product_discovery_criteria import ProductDiscoveryCriteria -from adcp.types.domains.media_buy.product_fields import ProductResponseField, ProductResponseFields -from adcp.types.domains.media_buy.product_purchase import ProductPurchase -from adcp.types.domains.media_buy.product_purchase_input import ProductPurchaseInput -from adcp.types.domains.media_buy.product_refinement import ( - Action11, - ProductRefinementRequests, - ProductRefinementRequests1, - ProductRefinementRequests2, - ProductRefinementRequests3, - ProductRefinementRequests4, -) -from adcp.types.domains.media_buy.proposal_budget_constraint import ProposalBudgetConstraint -from adcp.types.domains.media_buy.proposal_decline import ProposalDecline -from adcp.types.domains.media_buy.proposal_refinement import ( - Alternatives, - ChangeKind, - Constraints, - Constraints1, - Constraints2, - Constraints3, - Constraints4, - Constraints5, - Constraints6, - Constraints7, - Cpm, - Flight, - Impressions, - ProposalRefinement, - ProposalRefinement2, - ProposalRefinement3, - ProposalRefinement4, - ProposalRefinement5, - ProposalRefinement6, - ProposalRefinement7, - ProposalRefinement8, - ProposalRefinement9, -) -from adcp.types.domains.media_buy.provide_performance_feedback_request import ( - ProvidePerformanceFeedbackRequest, -) -from adcp.types.domains.media_buy.provide_performance_feedback_response import ( - ProvidePerformanceFeedbackResponse, - ProvidePerformanceFeedbackResponse1, - ProvidePerformanceFeedbackResponse2, -) -from adcp.types.domains.media_buy.refine_proposals_async_response_input_required import ( - RefineProposalsInputRequired, -) -from adcp.types.domains.media_buy.refine_proposals_async_response_submitted import ( - RefineProposalsSubmitted, -) -from adcp.types.domains.media_buy.refine_proposals_async_response_working import ( - RefineProposalsWorking, -) -from adcp.types.domains.media_buy.refine_proposals_request import ( - RefineProposalsRequest, - Refinements, -) -from adcp.types.domains.media_buy.refine_proposals_response import ( - Proposal2, - Proposal3, - Proposal4, - Proposal5, - Proposal6, - ProposalKind, - RefineProposalsResponse, - RefineProposalsResponse1, - RefineProposalsResponse2, - Results10, - Results11, - Results12, - Results14, - Results15, - Results16, - Results17, - Results18, - Results8, - Results9, - TotalBudgetGuidance, - UnsatisfiedConstraint, -) -from adcp.types.domains.media_buy.request_proposals_async_response_input_required import ( - RequestProposalsInputRequired, -) -from adcp.types.domains.media_buy.request_proposals_async_response_submitted import ( - RequestProposalsSubmitted, -) -from adcp.types.domains.media_buy.request_proposals_async_response_working import ( - RequestProposalsWorking, -) -from adcp.types.domains.media_buy.request_proposals_request import RequestProposalsRequest -from adcp.types.domains.media_buy.request_proposals_response import ( - IncompleteItem5, - IncompleteItem6, - IncompleteItem7, - Loss, - Losses, - PurchaseContinuation, - PurchaseContinuation1, - PurchaseContinuation2, - PurchaseContinuation3, - PurchaseContinuation4, - PurchaseContinuation5, - PurchaseContinuation6, - PurchaseContinuation7, - RequestProposalsResponse, - RequestProposalsResponse1, - RequestProposalsResponse2, - RequestProposalsResponse3, - RequestProposalsResponse4, - SourceAdcpVersion, -) -from adcp.types.domains.media_buy.sync_audiences_request import ( - AudienceType, - SyncAudiencesRequest, - Tag, -) -from adcp.types.domains.media_buy.sync_audiences_response import ( - MatchBreakdown, - SyncAudiencesResponse, - SyncAudiencesResponse1, - SyncAudiencesResponse2, - SyncAudiencesResponse3, -) -from adcp.types.domains.media_buy.sync_catalogs_async_response_input_required import ( - SyncCatalogsInputRequired, -) -from adcp.types.domains.media_buy.sync_catalogs_async_response_submitted import ( - SyncCatalogsSubmitted, -) -from adcp.types.domains.media_buy.sync_catalogs_async_response_working import SyncCatalogsWorking -from adcp.types.domains.media_buy.sync_catalogs_request import SyncCatalogsRequest -from adcp.types.domains.media_buy.sync_catalogs_response import ( - Catalog, - ItemIssue, - SyncCatalogsResponse, - SyncCatalogsResponse1, - SyncCatalogsResponse2, - SyncCatalogsResponse3, -) -from adcp.types.domains.media_buy.sync_event_sources_request import ( - SyncEventSourcesRequest, - ValueCurrency, -) -from adcp.types.domains.media_buy.sync_event_sources_response import ( - Setup, - SyncEventSourcesResponse, - SyncEventSourcesResponse1, - SyncEventSourcesResponse2, -) -from adcp.types.domains.media_buy.sync_reporting_receipts_request import ( - SyncReportingReceiptsRequest, -) -from adcp.types.domains.media_buy.sync_reporting_receipts_response import ( - Results20, - Results21, - Results22, - Results23, - SyncReportingReceiptsResponse, -) -from adcp.types.domains.media_buy.sync_reporting_status_request import SyncReportingStatusRequest -from adcp.types.domains.media_buy.sync_reporting_status_response import ( - Results26, - Results27, - SyncReportingStatusResponse, -) -from adcp.types.domains.media_buy.update_media_buy_async_response_input_required import ( - UpdateMediaBuyInputRequired, -) -from adcp.types.domains.media_buy.update_media_buy_async_response_submitted import ( - UpdateMediaBuySubmitted, -) -from adcp.types.domains.media_buy.update_media_buy_async_response_working import ( - UpdateMediaBuyWorking, -) -from adcp.types.domains.media_buy.update_media_buy_request import UpdateMediaBuyRequest -from adcp.types.domains.media_buy.update_media_buy_response import ( - UpdateMediaBuyResponse, - UpdateMediaBuyResponse1, - UpdateMediaBuyResponse2, - UpdateMediaBuyResponse3, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.media_buy.accept_proposal_async_response_input_required import ( + AcceptProposalInputRequired, + ) + from adcp.types.domains.media_buy.accept_proposal_async_response_submitted import ( + AcceptProposalSubmitted, + ) + from adcp.types.domains.media_buy.accept_proposal_async_response_working import ( + AcceptProposalWorking, + ) + from adcp.types.domains.media_buy.accept_proposal_request import AcceptProposalRequest + from adcp.types.domains.media_buy.accept_proposal_response import ( + AcceptProposalResponse, + AcceptProposalResponse1, + AcceptProposalResponse2, + AcceptProposalResponse3, + AcceptProposalResponse4, + AcceptProposalResponse5, + AcceptProposalResponse6, + AcceptProposalResponse7, + ) + from adcp.types.domains.media_buy.acceptance_context import ( + AcceptanceContext, + AdvertiserJurisdiction, + DeliveryJurisdiction, + Subject, + ) + from adcp.types.domains.media_buy.acceptance_policy_catalog import AcceptancePolicyCatalog + from adcp.types.domains.media_buy.acceptance_policy_profile import ( + AcceptancePolicyProfile, + Coverage, + PolicyRef, + RegionAliase, + SubjectCategory, + ) + from adcp.types.domains.media_buy.acceptance_policy_profile_ref import ( + RegistryAcceptancePolicyProfileReference, + ) + from adcp.types.domains.media_buy.acceptance_policy_requirement import ( + AcceptancePolicyRequirement, + AcceptancePolicyRequirement1, + AcceptancePolicyRequirement10, + AcceptancePolicyRequirement11, + AcceptancePolicyRequirement12, + AcceptancePolicyRequirement13, + AcceptancePolicyRequirement14, + AcceptancePolicyRequirement15, + AcceptancePolicyRequirement16, + AcceptancePolicyRequirement17, + AcceptancePolicyRequirement2, + AcceptancePolicyRequirement3, + AcceptancePolicyRequirement4, + AcceptancePolicyRequirement5, + AcceptancePolicyRequirement6, + AcceptancePolicyRequirement7, + AcceptancePolicyRequirement8, + AcceptancePolicyRequirement9, + Criterion, + FormatId, + ) + from adcp.types.domains.media_buy.acceptance_policy_rule import ( + AcceptancePolicyRule, + Disposition, + ) + from adcp.types.domains.media_buy.build_creative_async_response_input_required import ( + BuildCreativeInputRequired, + ) + from adcp.types.domains.media_buy.build_creative_async_response_submitted import ( + BuildCreativeSubmitted, + ) + from adcp.types.domains.media_buy.build_creative_async_response_working import ( + BuildCreativeWorking, + ) + from adcp.types.domains.media_buy.build_creative_request import ( + BuildCreativeRequest, + Dimension, + KeepMode, + MaxSpend, + Mode, + PreviewInput, + SignalCondition, + SignalCondition1, + SignalCondition2, + SignalCondition3, + SignalCondition4, + SignalCondition5, + SignalCondition6, + SignalCondition7, + TargetCapabilityId, + VariantAxis, + ) + from adcp.types.domains.media_buy.build_creative_response import ( + BuildCreativeResponse, + BuildCreativeResponse1, + BuildCreativeResponse2, + BuildCreativeResponse3, + BuildCreativeResponse4, + BuildCreativeResponse5, + BuildCreativeResponse6, + CatalogItemRef, + Estimate, + Eval, + Input, + Input2, + PerLeaf, + Preview, + Preview2, + Preview3, + Preview4, + Variant, + ) + from adcp.types.domains.media_buy.buy_products_async_response_input_required import ( + BuyProductsInputRequired, + ) + from adcp.types.domains.media_buy.buy_products_async_response_submitted import ( + BuyProductsSubmitted, + ) + from adcp.types.domains.media_buy.buy_products_async_response_working import BuyProductsWorking + from adcp.types.domains.media_buy.buy_products_request import BuyProductsRequest + from adcp.types.domains.media_buy.buy_products_response import ( + BuyProductsResponse, + BuyProductsResponse1, + BuyProductsResponse2, + BuyProductsResponse3, + BuyProductsResponse4, + BuyProductsResponse5, + BuyProductsResponse6, + BuyProductsResponse7, + ) + from adcp.types.domains.media_buy.change_term import ( + AllowedStatus, + Condition, + MediaBuyChangeTerm, + ) + from adcp.types.domains.media_buy.change_term_constraints import ( + MediaBuyChangeTermConstraints, + MediaBuyChangeTermConstraints1, + MediaBuyChangeTermConstraints2, + MediaBuyChangeTermConstraints3, + MediaBuyChangeTermConstraints4, + Money, + ) + from adcp.types.domains.media_buy.commercial_terms import ( + CancellationTerms, + CommercialTerms, + Fee, + ReportingCommitment, + ) + from adcp.types.domains.media_buy.control_media_buy_async_response_input_required import ( + ControlMediaBuyInputRequired, + ) + from adcp.types.domains.media_buy.control_media_buy_async_response_submitted import ( + ControlMediaBuySubmitted, + ) + from adcp.types.domains.media_buy.control_media_buy_async_response_working import ( + ControlMediaBuyWorking, + ) + from adcp.types.domains.media_buy.control_media_buy_request import ControlMediaBuyRequest + from adcp.types.domains.media_buy.control_media_buy_response import ( + AffectedPackageId, + ControlMediaBuyResponse, + ControlMediaBuyResponse1, + ControlMediaBuyResponse2, + ControlMediaBuyResponse3, + ) + from adcp.types.domains.media_buy.create_media_buy_async_response_input_required import ( + CreateMediaBuyInputRequired, + ) + from adcp.types.domains.media_buy.create_media_buy_async_response_submitted import ( + CreateMediaBuySubmitted, + ) + from adcp.types.domains.media_buy.create_media_buy_async_response_working import ( + CreateMediaBuyWorking, + ) + from adcp.types.domains.media_buy.create_media_buy_request import ( + ArtifactWebhook, + Authentication, + BatchFrequency, + CreateMediaBuyRequest, + DeliveryMode, + ) + from adcp.types.domains.media_buy.create_media_buy_response import ( + CreateMediaBuyResponse, + CreateMediaBuyResponse1, + CreateMediaBuyResponse2, + CreateMediaBuyResponse3, + ) + from adcp.types.domains.media_buy.decline_proposals_async_response_input_required import ( + DeclineProposalsInputRequired, + ) + from adcp.types.domains.media_buy.decline_proposals_async_response_submitted import ( + DeclineProposalsSubmitted, + ) + from adcp.types.domains.media_buy.decline_proposals_async_response_working import ( + DeclineProposalsWorking, + ) + from adcp.types.domains.media_buy.decline_proposals_request import DeclineProposalsRequest + from adcp.types.domains.media_buy.decline_proposals_response import ( + DeclineProposalsResponse, + DeclineProposalsResponse1, + DeclineProposalsResponse2, + Results1, + Results2, + Results3, + Results4, + Results5, + ) + from adcp.types.domains.media_buy.get_media_buy_delivery_request import ( + AttributionWindow, + CatalogItem, + Demographic, + DevicePlatform, + DeviceType, + Format, + Geo, + GetMediaBuyDeliveryRequest, + Keyword, + Placement, + ReportingDimensions, + Spot, + ) + from adcp.types.domains.media_buy.get_media_buy_delivery_response import ( + AggregatedTotals, + ByAudienceItem, + ByDemographicItem, + ByDevicePlatformItem, + ByDeviceTypeItem, + ByFormatItem, + ByPackageItem1, + BySpotItem, + DailyBreakdownItem, + DailyBreakdownItem1, + GetMediaBuyDeliveryResponse, + ReportingRevisionBinding, + Window, + ) + from adcp.types.domains.media_buy.get_media_buys_request import GetMediaBuysRequest + from adcp.types.domains.media_buy.get_media_buys_response import ( + Cancellation, + Cancellation1, + CreativeApproval, + GetMediaBuysResponse, + HistoryItem, + Indicator, + Indicator1, + Indicator2, + IndicatorTypesEvaluatedEnum, + IndicatorTypesEvaluatedEnum1, + IndicatorTypesEvaluatedEnum2, + MediaBuy, + Package, + Snapshot, + ) + from adcp.types.domains.media_buy.get_products_async_response_input_required import ( + GetProductsInputRequired, + ) + from adcp.types.domains.media_buy.get_products_async_response_submitted import ( + GetProductsSubmitted, + ) + from adcp.types.domains.media_buy.get_products_async_response_working import GetProductsWorking + from adcp.types.domains.media_buy.get_products_rejected import GetProductsRejected + from adcp.types.domains.media_buy.get_products_request import ( + Action9, + BuyingMode, + Field1, + Fields, + GetProductsRequest, + Refine, + Refine1, + Refine2, + Refine3, + ) + from adcp.types.domains.media_buy.get_products_response import ( + ExcludedBy, + Extensions, + FilterDiagnostics, + GetProductsResponse, + RefinementApplied, + RefinementApplied1, + RefinementApplied2, + RefinementApplied3, + Semantics, + ) + from adcp.types.domains.media_buy.get_products_targeting_resolution import ( + ProductDiscoveryTargetingResolution, + ) + from adcp.types.domains.media_buy.get_reporting_status_request import ( + DeliveryConfigId, + GetReportingStatusRequest, + Period, + ReportingStatusView, + ) + from adcp.types.domains.media_buy.get_reporting_status_response import ( + DeliveryConfigGeneration, + GetReportingStatusResponse, + ObligationCounts, + ) + from adcp.types.domains.media_buy.legacy_purchase_continuation_input import ( + AcceptedLoss, + CompatibilityPurchaseCoordinatorInput, + SelectedProductId, + ) + from adcp.types.domains.media_buy.list_creative_formats_request import ( + ListCreativeFormatsRequest, + ) + from adcp.types.domains.media_buy.list_creative_formats_response import ( + CreativeAgent, + ListCreativeFormatsResponse, + ) + from adcp.types.domains.media_buy.list_products_request import ListProductsRequest + from adcp.types.domains.media_buy.list_products_response import ( + IncompleteItem3, + ListProductsResponse, + ListProductsResponse1, + ListProductsResponse2, + ) + from adcp.types.domains.media_buy.log_event_request import LogEventRequest + from adcp.types.domains.media_buy.log_event_response import ( + LogEventResponse, + LogEventResponse1, + LogEventResponse2, + PartialFailure, + ) + from adcp.types.domains.media_buy.media_buy_commitment_response import ( + MediaBuyCommitmentResponse, + MediaBuyCommitmentResponse1, + MediaBuyCommitmentResponse2, + MediaBuyCommitmentResponse3, + ) + from adcp.types.domains.media_buy.media_buy_delivery_webhook_result import ( + MediaBuyDeliveryWebhookResult, + ) + from adcp.types.domains.media_buy.outcome_target import Goal, Goal1, OutcomeTarget + from adcp.types.domains.media_buy.package_control import PackageControl + from adcp.types.domains.media_buy.package_request import ( + CommittedMetrics, + CommittedMetrics1, + CommittedMetrics2, + PackageRequest, + Qualifier, + ) + from adcp.types.domains.media_buy.package_update import ( + KeywordTargetsAddItem, + KeywordTargetsRemoveItem, + NegativeKeywordsAddItem, + NegativeKeywordsRemoveItem, + PackageUpdate, + ) + from adcp.types.domains.media_buy.product_discovery_criteria import ProductDiscoveryCriteria + from adcp.types.domains.media_buy.product_fields import ( + ProductResponseField, + ProductResponseFields, + ) + from adcp.types.domains.media_buy.product_purchase import ProductPurchase + from adcp.types.domains.media_buy.product_purchase_input import ProductPurchaseInput + from adcp.types.domains.media_buy.product_refinement import ( + Action11, + ProductRefinementRequests, + ProductRefinementRequests1, + ProductRefinementRequests2, + ProductRefinementRequests3, + ProductRefinementRequests4, + ) + from adcp.types.domains.media_buy.proposal_budget_constraint import ProposalBudgetConstraint + from adcp.types.domains.media_buy.proposal_decline import ProposalDecline + from adcp.types.domains.media_buy.proposal_refinement import ( + Alternatives, + ChangeKind, + Constraints, + Constraints1, + Constraints2, + Constraints3, + Constraints4, + Constraints5, + Constraints6, + Constraints7, + Cpm, + Flight, + Impressions, + ProposalRefinement, + ProposalRefinement2, + ProposalRefinement3, + ProposalRefinement4, + ProposalRefinement5, + ProposalRefinement6, + ProposalRefinement7, + ProposalRefinement8, + ProposalRefinement9, + ) + from adcp.types.domains.media_buy.provide_performance_feedback_request import ( + ProvidePerformanceFeedbackRequest, + ) + from adcp.types.domains.media_buy.provide_performance_feedback_response import ( + ProvidePerformanceFeedbackResponse, + ProvidePerformanceFeedbackResponse1, + ProvidePerformanceFeedbackResponse2, + ) + from adcp.types.domains.media_buy.refine_proposals_async_response_input_required import ( + RefineProposalsInputRequired, + ) + from adcp.types.domains.media_buy.refine_proposals_async_response_submitted import ( + RefineProposalsSubmitted, + ) + from adcp.types.domains.media_buy.refine_proposals_async_response_working import ( + RefineProposalsWorking, + ) + from adcp.types.domains.media_buy.refine_proposals_request import ( + RefineProposalsRequest, + Refinements, + ) + from adcp.types.domains.media_buy.refine_proposals_response import ( + Proposal2, + Proposal3, + Proposal4, + Proposal5, + Proposal6, + ProposalKind, + RefineProposalsResponse, + RefineProposalsResponse1, + RefineProposalsResponse2, + Results10, + Results11, + Results12, + Results14, + Results15, + Results16, + Results17, + Results18, + Results8, + Results9, + TotalBudgetGuidance, + UnsatisfiedConstraint, + ) + from adcp.types.domains.media_buy.request_proposals_async_response_input_required import ( + RequestProposalsInputRequired, + ) + from adcp.types.domains.media_buy.request_proposals_async_response_submitted import ( + RequestProposalsSubmitted, + ) + from adcp.types.domains.media_buy.request_proposals_async_response_working import ( + RequestProposalsWorking, + ) + from adcp.types.domains.media_buy.request_proposals_request import RequestProposalsRequest + from adcp.types.domains.media_buy.request_proposals_response import ( + IncompleteItem5, + IncompleteItem6, + IncompleteItem7, + Loss, + Losses, + PurchaseContinuation, + PurchaseContinuation1, + PurchaseContinuation2, + PurchaseContinuation3, + PurchaseContinuation4, + PurchaseContinuation5, + PurchaseContinuation6, + PurchaseContinuation7, + RequestProposalsResponse, + RequestProposalsResponse1, + RequestProposalsResponse2, + RequestProposalsResponse3, + RequestProposalsResponse4, + SourceAdcpVersion, + ) + from adcp.types.domains.media_buy.sync_audiences_request import ( + AudienceType, + SyncAudiencesRequest, + Tag, + ) + from adcp.types.domains.media_buy.sync_audiences_response import ( + MatchBreakdown, + SyncAudiencesResponse, + SyncAudiencesResponse1, + SyncAudiencesResponse2, + SyncAudiencesResponse3, + ) + from adcp.types.domains.media_buy.sync_catalogs_async_response_input_required import ( + SyncCatalogsInputRequired, + ) + from adcp.types.domains.media_buy.sync_catalogs_async_response_submitted import ( + SyncCatalogsSubmitted, + ) + from adcp.types.domains.media_buy.sync_catalogs_async_response_working import ( + SyncCatalogsWorking, + ) + from adcp.types.domains.media_buy.sync_catalogs_request import SyncCatalogsRequest + from adcp.types.domains.media_buy.sync_catalogs_response import ( + Catalog, + ItemIssue, + SyncCatalogsResponse, + SyncCatalogsResponse1, + SyncCatalogsResponse2, + SyncCatalogsResponse3, + ) + from adcp.types.domains.media_buy.sync_event_sources_request import ( + SyncEventSourcesRequest, + ValueCurrency, + ) + from adcp.types.domains.media_buy.sync_event_sources_response import ( + Setup, + SyncEventSourcesResponse, + SyncEventSourcesResponse1, + SyncEventSourcesResponse2, + ) + from adcp.types.domains.media_buy.sync_reporting_receipts_request import ( + SyncReportingReceiptsRequest, + ) + from adcp.types.domains.media_buy.sync_reporting_receipts_response import ( + Results20, + Results21, + Results22, + Results23, + SyncReportingReceiptsResponse, + ) + from adcp.types.domains.media_buy.sync_reporting_status_request import ( + SyncReportingStatusRequest, + ) + from adcp.types.domains.media_buy.sync_reporting_status_response import ( + Results26, + Results27, + SyncReportingStatusResponse, + ) + from adcp.types.domains.media_buy.update_media_buy_async_response_input_required import ( + UpdateMediaBuyInputRequired, + ) + from adcp.types.domains.media_buy.update_media_buy_async_response_submitted import ( + UpdateMediaBuySubmitted, + ) + from adcp.types.domains.media_buy.update_media_buy_async_response_working import ( + UpdateMediaBuyWorking, + ) + from adcp.types.domains.media_buy.update_media_buy_request import UpdateMediaBuyRequest + from adcp.types.domains.media_buy.update_media_buy_response import ( + UpdateMediaBuyResponse, + UpdateMediaBuyResponse1, + UpdateMediaBuyResponse2, + UpdateMediaBuyResponse3, + ) # Explicit exports __all__ = [ @@ -910,3 +941,398 @@ "VariantAxis", "Window", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "AcceptProposalInputRequired": "adcp.types.domains.media_buy.accept_proposal_async_response_input_required", + "AcceptProposalRequest": "adcp.types.domains.media_buy.accept_proposal_request", + "AcceptProposalResponse": "adcp.types.domains.media_buy.accept_proposal_response", + "AcceptProposalResponse1": "adcp.types.domains.media_buy.accept_proposal_response", + "AcceptProposalResponse2": "adcp.types.domains.media_buy.accept_proposal_response", + "AcceptProposalResponse3": "adcp.types.domains.media_buy.accept_proposal_response", + "AcceptProposalResponse4": "adcp.types.domains.media_buy.accept_proposal_response", + "AcceptProposalResponse5": "adcp.types.domains.media_buy.accept_proposal_response", + "AcceptProposalResponse6": "adcp.types.domains.media_buy.accept_proposal_response", + "AcceptProposalResponse7": "adcp.types.domains.media_buy.accept_proposal_response", + "AcceptProposalSubmitted": "adcp.types.domains.media_buy.accept_proposal_async_response_submitted", + "AcceptProposalWorking": "adcp.types.domains.media_buy.accept_proposal_async_response_working", + "AcceptanceContext": "adcp.types.domains.media_buy.acceptance_context", + "AcceptancePolicyCatalog": "adcp.types.domains.media_buy.acceptance_policy_catalog", + "AcceptancePolicyProfile": "adcp.types.domains.media_buy.acceptance_policy_profile", + "AcceptancePolicyRequirement": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "AcceptancePolicyRequirement1": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "AcceptancePolicyRequirement10": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "AcceptancePolicyRequirement11": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "AcceptancePolicyRequirement12": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "AcceptancePolicyRequirement13": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "AcceptancePolicyRequirement14": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "AcceptancePolicyRequirement15": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "AcceptancePolicyRequirement16": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "AcceptancePolicyRequirement17": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "AcceptancePolicyRequirement2": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "AcceptancePolicyRequirement3": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "AcceptancePolicyRequirement4": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "AcceptancePolicyRequirement5": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "AcceptancePolicyRequirement6": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "AcceptancePolicyRequirement7": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "AcceptancePolicyRequirement8": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "AcceptancePolicyRequirement9": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "AcceptancePolicyRule": "adcp.types.domains.media_buy.acceptance_policy_rule", + "AcceptedLoss": "adcp.types.domains.media_buy.legacy_purchase_continuation_input", + "Action11": "adcp.types.domains.media_buy.product_refinement", + "Action9": "adcp.types.domains.media_buy.get_products_request", + "AdvertiserJurisdiction": "adcp.types.domains.media_buy.acceptance_context", + "AffectedPackageId": "adcp.types.domains.media_buy.control_media_buy_response", + "AggregatedTotals": "adcp.types.domains.media_buy.get_media_buy_delivery_response", + "AllowedStatus": "adcp.types.domains.media_buy.change_term", + "Alternatives": "adcp.types.domains.media_buy.proposal_refinement", + "ArtifactWebhook": "adcp.types.domains.media_buy.create_media_buy_request", + "AttributionWindow": "adcp.types.domains.media_buy.get_media_buy_delivery_request", + "AudienceType": "adcp.types.domains.media_buy.sync_audiences_request", + "Authentication": "adcp.types.domains.media_buy.create_media_buy_request", + "BatchFrequency": "adcp.types.domains.media_buy.create_media_buy_request", + "BuildCreativeInputRequired": "adcp.types.domains.media_buy.build_creative_async_response_input_required", + "BuildCreativeRequest": "adcp.types.domains.media_buy.build_creative_request", + "BuildCreativeResponse": "adcp.types.domains.media_buy.build_creative_response", + "BuildCreativeResponse1": "adcp.types.domains.media_buy.build_creative_response", + "BuildCreativeResponse2": "adcp.types.domains.media_buy.build_creative_response", + "BuildCreativeResponse3": "adcp.types.domains.media_buy.build_creative_response", + "BuildCreativeResponse4": "adcp.types.domains.media_buy.build_creative_response", + "BuildCreativeResponse5": "adcp.types.domains.media_buy.build_creative_response", + "BuildCreativeResponse6": "adcp.types.domains.media_buy.build_creative_response", + "BuildCreativeSubmitted": "adcp.types.domains.media_buy.build_creative_async_response_submitted", + "BuildCreativeWorking": "adcp.types.domains.media_buy.build_creative_async_response_working", + "BuyProductsInputRequired": "adcp.types.domains.media_buy.buy_products_async_response_input_required", + "BuyProductsRequest": "adcp.types.domains.media_buy.buy_products_request", + "BuyProductsResponse": "adcp.types.domains.media_buy.buy_products_response", + "BuyProductsResponse1": "adcp.types.domains.media_buy.buy_products_response", + "BuyProductsResponse2": "adcp.types.domains.media_buy.buy_products_response", + "BuyProductsResponse3": "adcp.types.domains.media_buy.buy_products_response", + "BuyProductsResponse4": "adcp.types.domains.media_buy.buy_products_response", + "BuyProductsResponse5": "adcp.types.domains.media_buy.buy_products_response", + "BuyProductsResponse6": "adcp.types.domains.media_buy.buy_products_response", + "BuyProductsResponse7": "adcp.types.domains.media_buy.buy_products_response", + "BuyProductsSubmitted": "adcp.types.domains.media_buy.buy_products_async_response_submitted", + "BuyProductsWorking": "adcp.types.domains.media_buy.buy_products_async_response_working", + "BuyingMode": "adcp.types.domains.media_buy.get_products_request", + "ByAudienceItem": "adcp.types.domains.media_buy.get_media_buy_delivery_response", + "ByDemographicItem": "adcp.types.domains.media_buy.get_media_buy_delivery_response", + "ByDevicePlatformItem": "adcp.types.domains.media_buy.get_media_buy_delivery_response", + "ByDeviceTypeItem": "adcp.types.domains.media_buy.get_media_buy_delivery_response", + "ByFormatItem": "adcp.types.domains.media_buy.get_media_buy_delivery_response", + "ByPackageItem1": "adcp.types.domains.media_buy.get_media_buy_delivery_response", + "BySpotItem": "adcp.types.domains.media_buy.get_media_buy_delivery_response", + "Cancellation": "adcp.types.domains.media_buy.get_media_buys_response", + "Cancellation1": "adcp.types.domains.media_buy.get_media_buys_response", + "CancellationTerms": "adcp.types.domains.media_buy.commercial_terms", + "Catalog": "adcp.types.domains.media_buy.sync_catalogs_response", + "CatalogItem": "adcp.types.domains.media_buy.get_media_buy_delivery_request", + "CatalogItemRef": "adcp.types.domains.media_buy.build_creative_response", + "ChangeKind": "adcp.types.domains.media_buy.proposal_refinement", + "CommercialTerms": "adcp.types.domains.media_buy.commercial_terms", + "CommittedMetrics": "adcp.types.domains.media_buy.package_request", + "CommittedMetrics1": "adcp.types.domains.media_buy.package_request", + "CommittedMetrics2": "adcp.types.domains.media_buy.package_request", + "CompatibilityPurchaseCoordinatorInput": "adcp.types.domains.media_buy.legacy_purchase_continuation_input", + "Condition": "adcp.types.domains.media_buy.change_term", + "Constraints": "adcp.types.domains.media_buy.proposal_refinement", + "Constraints1": "adcp.types.domains.media_buy.proposal_refinement", + "Constraints2": "adcp.types.domains.media_buy.proposal_refinement", + "Constraints3": "adcp.types.domains.media_buy.proposal_refinement", + "Constraints4": "adcp.types.domains.media_buy.proposal_refinement", + "Constraints5": "adcp.types.domains.media_buy.proposal_refinement", + "Constraints6": "adcp.types.domains.media_buy.proposal_refinement", + "Constraints7": "adcp.types.domains.media_buy.proposal_refinement", + "ControlMediaBuyInputRequired": "adcp.types.domains.media_buy.control_media_buy_async_response_input_required", + "ControlMediaBuyRequest": "adcp.types.domains.media_buy.control_media_buy_request", + "ControlMediaBuyResponse": "adcp.types.domains.media_buy.control_media_buy_response", + "ControlMediaBuyResponse1": "adcp.types.domains.media_buy.control_media_buy_response", + "ControlMediaBuyResponse2": "adcp.types.domains.media_buy.control_media_buy_response", + "ControlMediaBuyResponse3": "adcp.types.domains.media_buy.control_media_buy_response", + "ControlMediaBuySubmitted": "adcp.types.domains.media_buy.control_media_buy_async_response_submitted", + "ControlMediaBuyWorking": "adcp.types.domains.media_buy.control_media_buy_async_response_working", + "Coverage": "adcp.types.domains.media_buy.acceptance_policy_profile", + "Cpm": "adcp.types.domains.media_buy.proposal_refinement", + "CreateMediaBuyInputRequired": "adcp.types.domains.media_buy.create_media_buy_async_response_input_required", + "CreateMediaBuyRequest": "adcp.types.domains.media_buy.create_media_buy_request", + "CreateMediaBuyResponse": "adcp.types.domains.media_buy.create_media_buy_response", + "CreateMediaBuyResponse1": "adcp.types.domains.media_buy.create_media_buy_response", + "CreateMediaBuyResponse2": "adcp.types.domains.media_buy.create_media_buy_response", + "CreateMediaBuyResponse3": "adcp.types.domains.media_buy.create_media_buy_response", + "CreateMediaBuySubmitted": "adcp.types.domains.media_buy.create_media_buy_async_response_submitted", + "CreateMediaBuyWorking": "adcp.types.domains.media_buy.create_media_buy_async_response_working", + "CreativeAgent": "adcp.types.domains.media_buy.list_creative_formats_response", + "CreativeApproval": "adcp.types.domains.media_buy.get_media_buys_response", + "Criterion": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "DailyBreakdownItem": "adcp.types.domains.media_buy.get_media_buy_delivery_response", + "DailyBreakdownItem1": "adcp.types.domains.media_buy.get_media_buy_delivery_response", + "DeclineProposalsInputRequired": "adcp.types.domains.media_buy.decline_proposals_async_response_input_required", + "DeclineProposalsRequest": "adcp.types.domains.media_buy.decline_proposals_request", + "DeclineProposalsResponse": "adcp.types.domains.media_buy.decline_proposals_response", + "DeclineProposalsResponse1": "adcp.types.domains.media_buy.decline_proposals_response", + "DeclineProposalsResponse2": "adcp.types.domains.media_buy.decline_proposals_response", + "DeclineProposalsSubmitted": "adcp.types.domains.media_buy.decline_proposals_async_response_submitted", + "DeclineProposalsWorking": "adcp.types.domains.media_buy.decline_proposals_async_response_working", + "DeliveryConfigGeneration": "adcp.types.domains.media_buy.get_reporting_status_response", + "DeliveryConfigId": "adcp.types.domains.media_buy.get_reporting_status_request", + "DeliveryJurisdiction": "adcp.types.domains.media_buy.acceptance_context", + "DeliveryMode": "adcp.types.domains.media_buy.create_media_buy_request", + "Demographic": "adcp.types.domains.media_buy.get_media_buy_delivery_request", + "DevicePlatform": "adcp.types.domains.media_buy.get_media_buy_delivery_request", + "DeviceType": "adcp.types.domains.media_buy.get_media_buy_delivery_request", + "Dimension": "adcp.types.domains.media_buy.build_creative_request", + "Disposition": "adcp.types.domains.media_buy.acceptance_policy_rule", + "Estimate": "adcp.types.domains.media_buy.build_creative_response", + "Eval": "adcp.types.domains.media_buy.build_creative_response", + "ExcludedBy": "adcp.types.domains.media_buy.get_products_response", + "Extensions": "adcp.types.domains.media_buy.get_products_response", + "Fee": "adcp.types.domains.media_buy.commercial_terms", + "Field1": "adcp.types.domains.media_buy.get_products_request", + "Fields": "adcp.types.domains.media_buy.get_products_request", + "FilterDiagnostics": "adcp.types.domains.media_buy.get_products_response", + "Flight": "adcp.types.domains.media_buy.proposal_refinement", + "Format": "adcp.types.domains.media_buy.get_media_buy_delivery_request", + "FormatId": "adcp.types.domains.media_buy.acceptance_policy_requirement", + "Geo": "adcp.types.domains.media_buy.get_media_buy_delivery_request", + "GetMediaBuyDeliveryRequest": "adcp.types.domains.media_buy.get_media_buy_delivery_request", + "GetMediaBuyDeliveryResponse": "adcp.types.domains.media_buy.get_media_buy_delivery_response", + "GetMediaBuysRequest": "adcp.types.domains.media_buy.get_media_buys_request", + "GetMediaBuysResponse": "adcp.types.domains.media_buy.get_media_buys_response", + "GetProductsInputRequired": "adcp.types.domains.media_buy.get_products_async_response_input_required", + "GetProductsRejected": "adcp.types.domains.media_buy.get_products_rejected", + "GetProductsRequest": "adcp.types.domains.media_buy.get_products_request", + "GetProductsResponse": "adcp.types.domains.media_buy.get_products_response", + "GetProductsSubmitted": "adcp.types.domains.media_buy.get_products_async_response_submitted", + "GetProductsWorking": "adcp.types.domains.media_buy.get_products_async_response_working", + "GetReportingStatusRequest": "adcp.types.domains.media_buy.get_reporting_status_request", + "GetReportingStatusResponse": "adcp.types.domains.media_buy.get_reporting_status_response", + "Goal": "adcp.types.domains.media_buy.outcome_target", + "Goal1": "adcp.types.domains.media_buy.outcome_target", + "HistoryItem": "adcp.types.domains.media_buy.get_media_buys_response", + "Impressions": "adcp.types.domains.media_buy.proposal_refinement", + "IncompleteItem3": "adcp.types.domains.media_buy.list_products_response", + "IncompleteItem5": "adcp.types.domains.media_buy.request_proposals_response", + "IncompleteItem6": "adcp.types.domains.media_buy.request_proposals_response", + "IncompleteItem7": "adcp.types.domains.media_buy.request_proposals_response", + "Indicator": "adcp.types.domains.media_buy.get_media_buys_response", + "Indicator1": "adcp.types.domains.media_buy.get_media_buys_response", + "Indicator2": "adcp.types.domains.media_buy.get_media_buys_response", + "IndicatorTypesEvaluatedEnum": "adcp.types.domains.media_buy.get_media_buys_response", + "IndicatorTypesEvaluatedEnum1": "adcp.types.domains.media_buy.get_media_buys_response", + "IndicatorTypesEvaluatedEnum2": "adcp.types.domains.media_buy.get_media_buys_response", + "Input": "adcp.types.domains.media_buy.build_creative_response", + "Input2": "adcp.types.domains.media_buy.build_creative_response", + "ItemIssue": "adcp.types.domains.media_buy.sync_catalogs_response", + "KeepMode": "adcp.types.domains.media_buy.build_creative_request", + "Keyword": "adcp.types.domains.media_buy.get_media_buy_delivery_request", + "KeywordTargetsAddItem": "adcp.types.domains.media_buy.package_update", + "KeywordTargetsRemoveItem": "adcp.types.domains.media_buy.package_update", + "ListCreativeFormatsRequest": "adcp.types.domains.media_buy.list_creative_formats_request", + "ListCreativeFormatsResponse": "adcp.types.domains.media_buy.list_creative_formats_response", + "ListProductsRequest": "adcp.types.domains.media_buy.list_products_request", + "ListProductsResponse": "adcp.types.domains.media_buy.list_products_response", + "ListProductsResponse1": "adcp.types.domains.media_buy.list_products_response", + "ListProductsResponse2": "adcp.types.domains.media_buy.list_products_response", + "LogEventRequest": "adcp.types.domains.media_buy.log_event_request", + "LogEventResponse": "adcp.types.domains.media_buy.log_event_response", + "LogEventResponse1": "adcp.types.domains.media_buy.log_event_response", + "LogEventResponse2": "adcp.types.domains.media_buy.log_event_response", + "Loss": "adcp.types.domains.media_buy.request_proposals_response", + "Losses": "adcp.types.domains.media_buy.request_proposals_response", + "MatchBreakdown": "adcp.types.domains.media_buy.sync_audiences_response", + "MaxSpend": "adcp.types.domains.media_buy.build_creative_request", + "MediaBuy": "adcp.types.domains.media_buy.get_media_buys_response", + "MediaBuyChangeTerm": "adcp.types.domains.media_buy.change_term", + "MediaBuyChangeTermConstraints": "adcp.types.domains.media_buy.change_term_constraints", + "MediaBuyChangeTermConstraints1": "adcp.types.domains.media_buy.change_term_constraints", + "MediaBuyChangeTermConstraints2": "adcp.types.domains.media_buy.change_term_constraints", + "MediaBuyChangeTermConstraints3": "adcp.types.domains.media_buy.change_term_constraints", + "MediaBuyChangeTermConstraints4": "adcp.types.domains.media_buy.change_term_constraints", + "MediaBuyCommitmentResponse": "adcp.types.domains.media_buy.media_buy_commitment_response", + "MediaBuyCommitmentResponse1": "adcp.types.domains.media_buy.media_buy_commitment_response", + "MediaBuyCommitmentResponse2": "adcp.types.domains.media_buy.media_buy_commitment_response", + "MediaBuyCommitmentResponse3": "adcp.types.domains.media_buy.media_buy_commitment_response", + "MediaBuyDeliveryWebhookResult": "adcp.types.domains.media_buy.media_buy_delivery_webhook_result", + "Mode": "adcp.types.domains.media_buy.build_creative_request", + "Money": "adcp.types.domains.media_buy.change_term_constraints", + "NegativeKeywordsAddItem": "adcp.types.domains.media_buy.package_update", + "NegativeKeywordsRemoveItem": "adcp.types.domains.media_buy.package_update", + "ObligationCounts": "adcp.types.domains.media_buy.get_reporting_status_response", + "OutcomeTarget": "adcp.types.domains.media_buy.outcome_target", + "Package": "adcp.types.domains.media_buy.get_media_buys_response", + "PackageControl": "adcp.types.domains.media_buy.package_control", + "PackageRequest": "adcp.types.domains.media_buy.package_request", + "PackageUpdate": "adcp.types.domains.media_buy.package_update", + "PartialFailure": "adcp.types.domains.media_buy.log_event_response", + "PerLeaf": "adcp.types.domains.media_buy.build_creative_response", + "Period": "adcp.types.domains.media_buy.get_reporting_status_request", + "Placement": "adcp.types.domains.media_buy.get_media_buy_delivery_request", + "PolicyRef": "adcp.types.domains.media_buy.acceptance_policy_profile", + "Preview": "adcp.types.domains.media_buy.build_creative_response", + "Preview2": "adcp.types.domains.media_buy.build_creative_response", + "Preview3": "adcp.types.domains.media_buy.build_creative_response", + "Preview4": "adcp.types.domains.media_buy.build_creative_response", + "PreviewInput": "adcp.types.domains.media_buy.build_creative_request", + "ProductDiscoveryCriteria": "adcp.types.domains.media_buy.product_discovery_criteria", + "ProductDiscoveryTargetingResolution": "adcp.types.domains.media_buy.get_products_targeting_resolution", + "ProductPurchase": "adcp.types.domains.media_buy.product_purchase", + "ProductPurchaseInput": "adcp.types.domains.media_buy.product_purchase_input", + "ProductRefinementRequests": "adcp.types.domains.media_buy.product_refinement", + "ProductRefinementRequests1": "adcp.types.domains.media_buy.product_refinement", + "ProductRefinementRequests2": "adcp.types.domains.media_buy.product_refinement", + "ProductRefinementRequests3": "adcp.types.domains.media_buy.product_refinement", + "ProductRefinementRequests4": "adcp.types.domains.media_buy.product_refinement", + "ProductResponseField": "adcp.types.domains.media_buy.product_fields", + "ProductResponseFields": "adcp.types.domains.media_buy.product_fields", + "Proposal2": "adcp.types.domains.media_buy.refine_proposals_response", + "Proposal3": "adcp.types.domains.media_buy.refine_proposals_response", + "Proposal4": "adcp.types.domains.media_buy.refine_proposals_response", + "Proposal5": "adcp.types.domains.media_buy.refine_proposals_response", + "Proposal6": "adcp.types.domains.media_buy.refine_proposals_response", + "ProposalBudgetConstraint": "adcp.types.domains.media_buy.proposal_budget_constraint", + "ProposalDecline": "adcp.types.domains.media_buy.proposal_decline", + "ProposalKind": "adcp.types.domains.media_buy.refine_proposals_response", + "ProposalRefinement": "adcp.types.domains.media_buy.proposal_refinement", + "ProposalRefinement2": "adcp.types.domains.media_buy.proposal_refinement", + "ProposalRefinement3": "adcp.types.domains.media_buy.proposal_refinement", + "ProposalRefinement4": "adcp.types.domains.media_buy.proposal_refinement", + "ProposalRefinement5": "adcp.types.domains.media_buy.proposal_refinement", + "ProposalRefinement6": "adcp.types.domains.media_buy.proposal_refinement", + "ProposalRefinement7": "adcp.types.domains.media_buy.proposal_refinement", + "ProposalRefinement8": "adcp.types.domains.media_buy.proposal_refinement", + "ProposalRefinement9": "adcp.types.domains.media_buy.proposal_refinement", + "ProvidePerformanceFeedbackRequest": "adcp.types.domains.media_buy.provide_performance_feedback_request", + "ProvidePerformanceFeedbackResponse": "adcp.types.domains.media_buy.provide_performance_feedback_response", + "ProvidePerformanceFeedbackResponse1": "adcp.types.domains.media_buy.provide_performance_feedback_response", + "ProvidePerformanceFeedbackResponse2": "adcp.types.domains.media_buy.provide_performance_feedback_response", + "PurchaseContinuation": "adcp.types.domains.media_buy.request_proposals_response", + "PurchaseContinuation1": "adcp.types.domains.media_buy.request_proposals_response", + "PurchaseContinuation2": "adcp.types.domains.media_buy.request_proposals_response", + "PurchaseContinuation3": "adcp.types.domains.media_buy.request_proposals_response", + "PurchaseContinuation4": "adcp.types.domains.media_buy.request_proposals_response", + "PurchaseContinuation5": "adcp.types.domains.media_buy.request_proposals_response", + "PurchaseContinuation6": "adcp.types.domains.media_buy.request_proposals_response", + "PurchaseContinuation7": "adcp.types.domains.media_buy.request_proposals_response", + "Qualifier": "adcp.types.domains.media_buy.package_request", + "Refine": "adcp.types.domains.media_buy.get_products_request", + "Refine1": "adcp.types.domains.media_buy.get_products_request", + "Refine2": "adcp.types.domains.media_buy.get_products_request", + "Refine3": "adcp.types.domains.media_buy.get_products_request", + "RefineProposalsInputRequired": "adcp.types.domains.media_buy.refine_proposals_async_response_input_required", + "RefineProposalsRequest": "adcp.types.domains.media_buy.refine_proposals_request", + "RefineProposalsResponse": "adcp.types.domains.media_buy.refine_proposals_response", + "RefineProposalsResponse1": "adcp.types.domains.media_buy.refine_proposals_response", + "RefineProposalsResponse2": "adcp.types.domains.media_buy.refine_proposals_response", + "RefineProposalsSubmitted": "adcp.types.domains.media_buy.refine_proposals_async_response_submitted", + "RefineProposalsWorking": "adcp.types.domains.media_buy.refine_proposals_async_response_working", + "RefinementApplied": "adcp.types.domains.media_buy.get_products_response", + "RefinementApplied1": "adcp.types.domains.media_buy.get_products_response", + "RefinementApplied2": "adcp.types.domains.media_buy.get_products_response", + "RefinementApplied3": "adcp.types.domains.media_buy.get_products_response", + "Refinements": "adcp.types.domains.media_buy.refine_proposals_request", + "RegionAliase": "adcp.types.domains.media_buy.acceptance_policy_profile", + "RegistryAcceptancePolicyProfileReference": "adcp.types.domains.media_buy.acceptance_policy_profile_ref", + "ReportingCommitment": "adcp.types.domains.media_buy.commercial_terms", + "ReportingDimensions": "adcp.types.domains.media_buy.get_media_buy_delivery_request", + "ReportingRevisionBinding": "adcp.types.domains.media_buy.get_media_buy_delivery_response", + "ReportingStatusView": "adcp.types.domains.media_buy.get_reporting_status_request", + "RequestProposalsInputRequired": "adcp.types.domains.media_buy.request_proposals_async_response_input_required", + "RequestProposalsRequest": "adcp.types.domains.media_buy.request_proposals_request", + "RequestProposalsResponse": "adcp.types.domains.media_buy.request_proposals_response", + "RequestProposalsResponse1": "adcp.types.domains.media_buy.request_proposals_response", + "RequestProposalsResponse2": "adcp.types.domains.media_buy.request_proposals_response", + "RequestProposalsResponse3": "adcp.types.domains.media_buy.request_proposals_response", + "RequestProposalsResponse4": "adcp.types.domains.media_buy.request_proposals_response", + "RequestProposalsSubmitted": "adcp.types.domains.media_buy.request_proposals_async_response_submitted", + "RequestProposalsWorking": "adcp.types.domains.media_buy.request_proposals_async_response_working", + "Results1": "adcp.types.domains.media_buy.decline_proposals_response", + "Results10": "adcp.types.domains.media_buy.refine_proposals_response", + "Results11": "adcp.types.domains.media_buy.refine_proposals_response", + "Results12": "adcp.types.domains.media_buy.refine_proposals_response", + "Results14": "adcp.types.domains.media_buy.refine_proposals_response", + "Results15": "adcp.types.domains.media_buy.refine_proposals_response", + "Results16": "adcp.types.domains.media_buy.refine_proposals_response", + "Results17": "adcp.types.domains.media_buy.refine_proposals_response", + "Results18": "adcp.types.domains.media_buy.refine_proposals_response", + "Results2": "adcp.types.domains.media_buy.decline_proposals_response", + "Results20": "adcp.types.domains.media_buy.sync_reporting_receipts_response", + "Results21": "adcp.types.domains.media_buy.sync_reporting_receipts_response", + "Results22": "adcp.types.domains.media_buy.sync_reporting_receipts_response", + "Results23": "adcp.types.domains.media_buy.sync_reporting_receipts_response", + "Results26": "adcp.types.domains.media_buy.sync_reporting_status_response", + "Results27": "adcp.types.domains.media_buy.sync_reporting_status_response", + "Results3": "adcp.types.domains.media_buy.decline_proposals_response", + "Results4": "adcp.types.domains.media_buy.decline_proposals_response", + "Results5": "adcp.types.domains.media_buy.decline_proposals_response", + "Results8": "adcp.types.domains.media_buy.refine_proposals_response", + "Results9": "adcp.types.domains.media_buy.refine_proposals_response", + "SelectedProductId": "adcp.types.domains.media_buy.legacy_purchase_continuation_input", + "Semantics": "adcp.types.domains.media_buy.get_products_response", + "Setup": "adcp.types.domains.media_buy.sync_event_sources_response", + "SignalCondition": "adcp.types.domains.media_buy.build_creative_request", + "SignalCondition1": "adcp.types.domains.media_buy.build_creative_request", + "SignalCondition2": "adcp.types.domains.media_buy.build_creative_request", + "SignalCondition3": "adcp.types.domains.media_buy.build_creative_request", + "SignalCondition4": "adcp.types.domains.media_buy.build_creative_request", + "SignalCondition5": "adcp.types.domains.media_buy.build_creative_request", + "SignalCondition6": "adcp.types.domains.media_buy.build_creative_request", + "SignalCondition7": "adcp.types.domains.media_buy.build_creative_request", + "Snapshot": "adcp.types.domains.media_buy.get_media_buys_response", + "SourceAdcpVersion": "adcp.types.domains.media_buy.request_proposals_response", + "Spot": "adcp.types.domains.media_buy.get_media_buy_delivery_request", + "Subject": "adcp.types.domains.media_buy.acceptance_context", + "SubjectCategory": "adcp.types.domains.media_buy.acceptance_policy_profile", + "SyncAudiencesRequest": "adcp.types.domains.media_buy.sync_audiences_request", + "SyncAudiencesResponse": "adcp.types.domains.media_buy.sync_audiences_response", + "SyncAudiencesResponse1": "adcp.types.domains.media_buy.sync_audiences_response", + "SyncAudiencesResponse2": "adcp.types.domains.media_buy.sync_audiences_response", + "SyncAudiencesResponse3": "adcp.types.domains.media_buy.sync_audiences_response", + "SyncCatalogsInputRequired": "adcp.types.domains.media_buy.sync_catalogs_async_response_input_required", + "SyncCatalogsRequest": "adcp.types.domains.media_buy.sync_catalogs_request", + "SyncCatalogsResponse": "adcp.types.domains.media_buy.sync_catalogs_response", + "SyncCatalogsResponse1": "adcp.types.domains.media_buy.sync_catalogs_response", + "SyncCatalogsResponse2": "adcp.types.domains.media_buy.sync_catalogs_response", + "SyncCatalogsResponse3": "adcp.types.domains.media_buy.sync_catalogs_response", + "SyncCatalogsSubmitted": "adcp.types.domains.media_buy.sync_catalogs_async_response_submitted", + "SyncCatalogsWorking": "adcp.types.domains.media_buy.sync_catalogs_async_response_working", + "SyncEventSourcesRequest": "adcp.types.domains.media_buy.sync_event_sources_request", + "SyncEventSourcesResponse": "adcp.types.domains.media_buy.sync_event_sources_response", + "SyncEventSourcesResponse1": "adcp.types.domains.media_buy.sync_event_sources_response", + "SyncEventSourcesResponse2": "adcp.types.domains.media_buy.sync_event_sources_response", + "SyncReportingReceiptsRequest": "adcp.types.domains.media_buy.sync_reporting_receipts_request", + "SyncReportingReceiptsResponse": "adcp.types.domains.media_buy.sync_reporting_receipts_response", + "SyncReportingStatusRequest": "adcp.types.domains.media_buy.sync_reporting_status_request", + "SyncReportingStatusResponse": "adcp.types.domains.media_buy.sync_reporting_status_response", + "Tag": "adcp.types.domains.media_buy.sync_audiences_request", + "TargetCapabilityId": "adcp.types.domains.media_buy.build_creative_request", + "TotalBudgetGuidance": "adcp.types.domains.media_buy.refine_proposals_response", + "UnsatisfiedConstraint": "adcp.types.domains.media_buy.refine_proposals_response", + "UpdateMediaBuyInputRequired": "adcp.types.domains.media_buy.update_media_buy_async_response_input_required", + "UpdateMediaBuyRequest": "adcp.types.domains.media_buy.update_media_buy_request", + "UpdateMediaBuyResponse": "adcp.types.domains.media_buy.update_media_buy_response", + "UpdateMediaBuyResponse1": "adcp.types.domains.media_buy.update_media_buy_response", + "UpdateMediaBuyResponse2": "adcp.types.domains.media_buy.update_media_buy_response", + "UpdateMediaBuyResponse3": "adcp.types.domains.media_buy.update_media_buy_response", + "UpdateMediaBuySubmitted": "adcp.types.domains.media_buy.update_media_buy_async_response_submitted", + "UpdateMediaBuyWorking": "adcp.types.domains.media_buy.update_media_buy_async_response_working", + "ValueCurrency": "adcp.types.domains.media_buy.sync_event_sources_request", + "Variant": "adcp.types.domains.media_buy.build_creative_response", + "VariantAxis": "adcp.types.domains.media_buy.build_creative_request", + "Window": "adcp.types.domains.media_buy.get_media_buy_delivery_response", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/pricing_options/__init__.py b/src/adcp/types/domains/pricing_options/__init__.py index 69b875cae1..dac43b4e02 100644 --- a/src/adcp/types/domains/pricing_options/__init__.py +++ b/src/adcp/types/domains/pricing_options/__init__.py @@ -9,29 +9,38 @@ it from its own schema's module, ``adcp.types.domains.pricing_options.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.pricing_options.cpa_option import CpaPricingOption -from adcp.types.domains.pricing_options.cpc_option import CpcPricingOption -from adcp.types.domains.pricing_options.cpcv_option import CpcvPricingOption -from adcp.types.domains.pricing_options.cpm_option import CpmPricingOption -from adcp.types.domains.pricing_options.cpp_option import CppPricingOption -from adcp.types.domains.pricing_options.cpv_option import ( - CpvPricingOption, - ViewThreshold, - ViewThreshold1, -) -from adcp.types.domains.pricing_options.flat_rate_option import FlatRatePricingOption -from adcp.types.domains.pricing_options.price_breakdown import Adjustment, PriceBreakdown -from adcp.types.domains.pricing_options.price_guidance import PriceGuidance -from adcp.types.domains.pricing_options.revenue_share_option import RevenueSharePricingOption -from adcp.types.domains.pricing_options.time_option import TimeBasedPricingOption, TimeUnit -from adcp.types.domains.pricing_options.vcpm_option import VcpmPricingOption +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.pricing_options.cpa_option import CpaPricingOption + from adcp.types.domains.pricing_options.cpc_option import CpcPricingOption + from adcp.types.domains.pricing_options.cpcv_option import CpcvPricingOption + from adcp.types.domains.pricing_options.cpm_option import CpmPricingOption + from adcp.types.domains.pricing_options.cpp_option import CppPricingOption + from adcp.types.domains.pricing_options.cpv_option import ( + CpvPricingOption, + ViewThreshold, + ViewThreshold1, + ) + from adcp.types.domains.pricing_options.flat_rate_option import FlatRatePricingOption + from adcp.types.domains.pricing_options.price_breakdown import Adjustment, PriceBreakdown + from adcp.types.domains.pricing_options.price_guidance import PriceGuidance + from adcp.types.domains.pricing_options.revenue_share_option import RevenueSharePricingOption + from adcp.types.domains.pricing_options.time_option import TimeBasedPricingOption, TimeUnit + from adcp.types.domains.pricing_options.vcpm_option import VcpmPricingOption # Explicit exports __all__ = [ @@ -52,3 +61,39 @@ "ViewThreshold", "ViewThreshold1", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "Adjustment": "adcp.types.domains.pricing_options.price_breakdown", + "CpaPricingOption": "adcp.types.domains.pricing_options.cpa_option", + "CpcPricingOption": "adcp.types.domains.pricing_options.cpc_option", + "CpcvPricingOption": "adcp.types.domains.pricing_options.cpcv_option", + "CpmPricingOption": "adcp.types.domains.pricing_options.cpm_option", + "CppPricingOption": "adcp.types.domains.pricing_options.cpp_option", + "CpvPricingOption": "adcp.types.domains.pricing_options.cpv_option", + "FlatRatePricingOption": "adcp.types.domains.pricing_options.flat_rate_option", + "PriceBreakdown": "adcp.types.domains.pricing_options.price_breakdown", + "PriceGuidance": "adcp.types.domains.pricing_options.price_guidance", + "RevenueSharePricingOption": "adcp.types.domains.pricing_options.revenue_share_option", + "TimeBasedPricingOption": "adcp.types.domains.pricing_options.time_option", + "TimeUnit": "adcp.types.domains.pricing_options.time_option", + "VcpmPricingOption": "adcp.types.domains.pricing_options.vcpm_option", + "ViewThreshold": "adcp.types.domains.pricing_options.cpv_option", + "ViewThreshold1": "adcp.types.domains.pricing_options.cpv_option", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/property/__init__.py b/src/adcp/types/domains/property/__init__.py index e463444240..8661e550e9 100644 --- a/src/adcp/types/domains/property/__init__.py +++ b/src/adcp/types/domains/property/__init__.py @@ -9,60 +9,75 @@ it from its own schema's module, ``adcp.types.domains.property.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.property.authorization_result import AuthorizationResult, Violation -from adcp.types.domains.property.base_property_source import ( - BasePropertySource, - BasePropertySource1, - BasePropertySource2, - BasePropertySource3, -) -from adcp.types.domains.property.create_property_list_request import CreatePropertyListRequest -from adcp.types.domains.property.create_property_list_response import CreatePropertyListResponse -from adcp.types.domains.property.delete_property_list_request import DeletePropertyListRequest -from adcp.types.domains.property.delete_property_list_response import DeletePropertyListResponse -from adcp.types.domains.property.delivery_record import DeliveryRecord -from adcp.types.domains.property.get_property_list_request import GetPropertyListRequest, Pagination -from adcp.types.domains.property.get_property_list_response import GetPropertyListResponse -from adcp.types.domains.property.list_property_lists_request import ListPropertyListsRequest -from adcp.types.domains.property.list_property_lists_response import ListPropertyListsResponse -from adcp.types.domains.property.property_error import Code, PropertyError -from adcp.types.domains.property.property_feature import PropertyFeature -from adcp.types.domains.property.property_feature_definition import ( - Coverage, - PropertyFeatureDefinition, - Range, - Type, -) -from adcp.types.domains.property.property_feature_result import ( - CoverageStatus, - PropertyFeatureResult, -) -from adcp.types.domains.property.property_feature_value import PropertyFeatureValue -from adcp.types.domains.property.property_list import PropertyList -from adcp.types.domains.property.property_list_changed_webhook import ( - ChangeSummary, - PropertyListChangedWebhook, -) -from adcp.types.domains.property.property_list_filters import CountriesAllItem, PropertyListFilters -from adcp.types.domains.property.update_property_list_request import UpdatePropertyListRequest -from adcp.types.domains.property.update_property_list_response import UpdatePropertyListResponse -from adcp.types.domains.property.validate_property_delivery_request import ( - ValidatePropertyDeliveryRequest, -) -from adcp.types.domains.property.validate_property_delivery_response import ( - Aggregate, - AuthorizationSummary, - Summary, - ValidatePropertyDeliveryResponse, -) -from adcp.types.domains.property.validation_result import Feature, Requirement, ValidationResult +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.property.authorization_result import AuthorizationResult, Violation + from adcp.types.domains.property.base_property_source import ( + BasePropertySource, + BasePropertySource1, + BasePropertySource2, + BasePropertySource3, + ) + from adcp.types.domains.property.create_property_list_request import CreatePropertyListRequest + from adcp.types.domains.property.create_property_list_response import CreatePropertyListResponse + from adcp.types.domains.property.delete_property_list_request import DeletePropertyListRequest + from adcp.types.domains.property.delete_property_list_response import DeletePropertyListResponse + from adcp.types.domains.property.delivery_record import DeliveryRecord + from adcp.types.domains.property.get_property_list_request import ( + GetPropertyListRequest, + Pagination, + ) + from adcp.types.domains.property.get_property_list_response import GetPropertyListResponse + from adcp.types.domains.property.list_property_lists_request import ListPropertyListsRequest + from adcp.types.domains.property.list_property_lists_response import ListPropertyListsResponse + from adcp.types.domains.property.property_error import Code, PropertyError + from adcp.types.domains.property.property_feature import PropertyFeature + from adcp.types.domains.property.property_feature_definition import ( + Coverage, + PropertyFeatureDefinition, + Range, + Type, + ) + from adcp.types.domains.property.property_feature_result import ( + CoverageStatus, + PropertyFeatureResult, + ) + from adcp.types.domains.property.property_feature_value import PropertyFeatureValue + from adcp.types.domains.property.property_list import PropertyList + from adcp.types.domains.property.property_list_changed_webhook import ( + ChangeSummary, + PropertyListChangedWebhook, + ) + from adcp.types.domains.property.property_list_filters import ( + CountriesAllItem, + PropertyListFilters, + ) + from adcp.types.domains.property.update_property_list_request import UpdatePropertyListRequest + from adcp.types.domains.property.update_property_list_response import UpdatePropertyListResponse + from adcp.types.domains.property.validate_property_delivery_request import ( + ValidatePropertyDeliveryRequest, + ) + from adcp.types.domains.property.validate_property_delivery_response import ( + Aggregate, + AuthorizationSummary, + Summary, + ValidatePropertyDeliveryResponse, + ) + from adcp.types.domains.property.validation_result import Feature, Requirement, ValidationResult # Explicit exports __all__ = [ @@ -108,3 +123,64 @@ "ValidationResult", "Violation", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "Aggregate": "adcp.types.domains.property.validate_property_delivery_response", + "AuthorizationResult": "adcp.types.domains.property.authorization_result", + "AuthorizationSummary": "adcp.types.domains.property.validate_property_delivery_response", + "BasePropertySource": "adcp.types.domains.property.base_property_source", + "BasePropertySource1": "adcp.types.domains.property.base_property_source", + "BasePropertySource2": "adcp.types.domains.property.base_property_source", + "BasePropertySource3": "adcp.types.domains.property.base_property_source", + "ChangeSummary": "adcp.types.domains.property.property_list_changed_webhook", + "Code": "adcp.types.domains.property.property_error", + "CountriesAllItem": "adcp.types.domains.property.property_list_filters", + "Coverage": "adcp.types.domains.property.property_feature_definition", + "CoverageStatus": "adcp.types.domains.property.property_feature_result", + "CreatePropertyListRequest": "adcp.types.domains.property.create_property_list_request", + "CreatePropertyListResponse": "adcp.types.domains.property.create_property_list_response", + "DeletePropertyListRequest": "adcp.types.domains.property.delete_property_list_request", + "DeletePropertyListResponse": "adcp.types.domains.property.delete_property_list_response", + "DeliveryRecord": "adcp.types.domains.property.delivery_record", + "Feature": "adcp.types.domains.property.validation_result", + "GetPropertyListRequest": "adcp.types.domains.property.get_property_list_request", + "GetPropertyListResponse": "adcp.types.domains.property.get_property_list_response", + "ListPropertyListsRequest": "adcp.types.domains.property.list_property_lists_request", + "ListPropertyListsResponse": "adcp.types.domains.property.list_property_lists_response", + "Pagination": "adcp.types.domains.property.get_property_list_request", + "PropertyError": "adcp.types.domains.property.property_error", + "PropertyFeature": "adcp.types.domains.property.property_feature", + "PropertyFeatureDefinition": "adcp.types.domains.property.property_feature_definition", + "PropertyFeatureResult": "adcp.types.domains.property.property_feature_result", + "PropertyFeatureValue": "adcp.types.domains.property.property_feature_value", + "PropertyList": "adcp.types.domains.property.property_list", + "PropertyListChangedWebhook": "adcp.types.domains.property.property_list_changed_webhook", + "PropertyListFilters": "adcp.types.domains.property.property_list_filters", + "Range": "adcp.types.domains.property.property_feature_definition", + "Requirement": "adcp.types.domains.property.validation_result", + "Summary": "adcp.types.domains.property.validate_property_delivery_response", + "Type": "adcp.types.domains.property.property_feature_definition", + "UpdatePropertyListRequest": "adcp.types.domains.property.update_property_list_request", + "UpdatePropertyListResponse": "adcp.types.domains.property.update_property_list_response", + "ValidatePropertyDeliveryRequest": "adcp.types.domains.property.validate_property_delivery_request", + "ValidatePropertyDeliveryResponse": "adcp.types.domains.property.validate_property_delivery_response", + "ValidationResult": "adcp.types.domains.property.validation_result", + "Violation": "adcp.types.domains.property.authorization_result", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/protocol/__init__.py b/src/adcp/types/domains/protocol/__init__.py index 759b2996ef..f91793760e 100644 --- a/src/adcp/types/domains/protocol/__init__.py +++ b/src/adcp/types/domains/protocol/__init__.py @@ -9,199 +9,216 @@ it from its own schema's module, ``adcp.types.domains.protocol.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-11 15:56:10 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.protocol.get_adcp_capabilities_request import ( - GetAdcpCapabilitiesRequest, - Protocol, -) -from adcp.types.domains.protocol.get_adcp_capabilities_response import ( - AcceptancePolicyDiscovery, - AccessMode, - Account, - Accreditation, - Adcp, - AgeRestriction, - Algorithm, - AttributionWindow, - AudienceEvidence, - AudienceTargeting, - Brand, - BudgetCapping, - BudgetCappingTimezoneBasis, - BuyingMode, - CapabilityChanges, - CapabilityReportingDeliveryMethod, - ChangeFeed, - ChangeFeed1, - ComplianceTesting, - CompromiseNotification, - ContentStandards, - ConversionTracking, - CoversContentDigest, - Creative, - CreativeApprovalMode, - CreativeFeature, - CreativeSpecs, - DataProviderDomain, - DefaultProfileId, - Demographics, - DiscoveryMode, - Endpoint, - EventType, - EventType3, - Execution, - ExtensionsSupportedItem, - Features, - Format, - FrequencyCapping, - GeoMetros, - GeoProximity, - GetAdcpCapabilitiesResponse, - Governance, - GovernanceEnforcement, - Idempotency, - Idempotency1, - Identity, - IdentityUpdates, - IdentityUpdates1, - KeyOrigins, - KeywordTargets, - Language, - LifecycleTool, - Localization, - MajorVersion, - MatchingLatencyHours, - Measurement, - MeasurementGateway, - MediaBuy, - Metric, - Mode, - MraidVersion, - Multiplicity, - NegativeKeywords, - Notifications, - Notifications1, - Notifications2, - Notifications3, - Oauth, - Operation, - Pattern, - PerformanceFeedback, - Portfolio, - Preview, - PrimaryCountry, - Principal, - PropagationSurface, - PropertyFeature, - ProposalRefinement, - ProtocolMethodsRequiredForItem, - ProtocolMethodsSupportedForItem, - ProtocolMethodsWarnForItem, - PublisherDomain, - Range, - RegistrationTask, - RelationshipNotifications, - RenderingOrigin, - ReportingDestinationOffering, - RepresentationResolution, - RequestSigning, - RequiredForItem, - Requirement, - ResourceType, - RightsAttestations, - Route, - RuntimeAttestations, - SignalActivation, - Signals, - SponsoredIntelligence, - SupportedChange, - SupportedDimension, - SupportedForItem, - SupportedFormat, - SupportedIdentifierType, - SupportedOptimizationMetric, - SupportedPresenceMode, - SupportedProtocol, - SupportedRequirementMode, - SupportedScope, - SupportedSection, - SupportedTarget, - SupportedTarget1, - SupportedVersion, - Targeting, - Tasks, - Tasks1, - Tasks2, - Tasks3, - Tasks4, - Tasks5, - Tasks6, - Tasks7, - Tasks8, - Tasks9, - Transport, - Transport1, - TrustedMatch, - Type6, - VariantDimension, - VastValidation, - VendorMetricOptimization, - WarnForItem, - WebhookSigning, - WholesaleFeedVersioning, - WholesaleFeedWebhooks, -) -from adcp.types.domains.protocol.get_principal_request import GetPrincipalRequest -from adcp.types.domains.protocol.get_principal_response import ( - GetPrincipalResponse, - PrincipalCurrentResult, - PrincipalReadFailedResult, - PrincipalRecognizedResult, - PrincipalUnconfiguredResult, - Result2, - Result3, - Result5, -) -from adcp.types.domains.protocol.get_task_status_request import GetTaskStatusRequest -from adcp.types.domains.protocol.get_task_status_response import ( - Details, - Error, - GetTaskStatusResponse, - HistoryItem, - Progress, -) -from adcp.types.domains.protocol.list_tasks_request import Field1, Filters, ListTasksRequest, Sort -from adcp.types.domains.protocol.list_tasks_response import ( - Direction, - Domain, - DomainBreakdown, - ListTasksResponse, - QuerySummary, - SortApplied, - Task, -) -from adcp.types.domains.protocol.sync_agent_notification_configs_request import ( - SyncAgentNotificationConfigsRequest, -) -from adcp.types.domains.protocol.sync_agent_notification_configs_response import ( - SyncAgentNotificationConfigsResponse, -) -from adcp.types.domains.protocol.sync_principal_request import Configuration, SyncPrincipalRequest -from adcp.types.domains.protocol.sync_principal_response import ( - Action33, - PrincipalAppliedResult, - PrincipalSyncFailedResult, - PrincipalValidatedResult, - Result13, - Result15, - SyncPrincipalResponse, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.protocol.get_adcp_capabilities_request import ( + GetAdcpCapabilitiesRequest, + Protocol, + ) + from adcp.types.domains.protocol.get_adcp_capabilities_response import ( + AcceptancePolicyDiscovery, + AccessMode, + Account, + Accreditation, + Adcp, + AgeRestriction, + Algorithm, + AttributionWindow, + AudienceEvidence, + AudienceTargeting, + Brand, + BudgetCapping, + BudgetCappingTimezoneBasis, + BuyingMode, + CapabilityChanges, + CapabilityReportingDeliveryMethod, + ChangeFeed, + ChangeFeed1, + ComplianceTesting, + CompromiseNotification, + ContentStandards, + ConversionTracking, + CoversContentDigest, + Creative, + CreativeApprovalMode, + CreativeFeature, + CreativeSpecs, + DataProviderDomain, + DefaultProfileId, + Demographics, + DiscoveryMode, + Endpoint, + EventType, + EventType3, + Execution, + ExtensionsSupportedItem, + Features, + Format, + FrequencyCapping, + GeoMetros, + GeoProximity, + GetAdcpCapabilitiesResponse, + Governance, + GovernanceEnforcement, + Idempotency, + Idempotency1, + Identity, + IdentityUpdates, + IdentityUpdates1, + KeyOrigins, + KeywordTargets, + Language, + LifecycleTool, + Localization, + MajorVersion, + MatchingLatencyHours, + Measurement, + MeasurementGateway, + MediaBuy, + Metric, + Mode, + MraidVersion, + Multiplicity, + NegativeKeywords, + Notifications, + Notifications1, + Notifications2, + Notifications3, + Oauth, + Operation, + Pattern, + PerformanceFeedback, + Portfolio, + Preview, + PrimaryCountry, + Principal, + PropagationSurface, + PropertyFeature, + ProposalRefinement, + ProtocolMethodsRequiredForItem, + ProtocolMethodsSupportedForItem, + ProtocolMethodsWarnForItem, + PublisherDomain, + Range, + RegistrationTask, + RelationshipNotifications, + RenderingOrigin, + ReportingDestinationOffering, + RepresentationResolution, + RequestSigning, + RequiredForItem, + Requirement, + ResourceType, + RightsAttestations, + Route, + RuntimeAttestations, + SignalActivation, + Signals, + SponsoredIntelligence, + SupportedChange, + SupportedDimension, + SupportedForItem, + SupportedFormat, + SupportedIdentifierType, + SupportedOptimizationMetric, + SupportedPresenceMode, + SupportedProtocol, + SupportedRequirementMode, + SupportedScope, + SupportedSection, + SupportedTarget, + SupportedTarget1, + SupportedVersion, + Targeting, + Tasks, + Tasks1, + Tasks2, + Tasks3, + Tasks4, + Tasks5, + Tasks6, + Tasks7, + Tasks8, + Tasks9, + Transport, + Transport1, + TrustedMatch, + Type6, + VariantDimension, + VastValidation, + VendorMetricOptimization, + WarnForItem, + WebhookSigning, + WholesaleFeedVersioning, + WholesaleFeedWebhooks, + ) + from adcp.types.domains.protocol.get_principal_request import GetPrincipalRequest + from adcp.types.domains.protocol.get_principal_response import ( + GetPrincipalResponse, + PrincipalCurrentResult, + PrincipalReadFailedResult, + PrincipalRecognizedResult, + PrincipalUnconfiguredResult, + Result2, + Result3, + Result5, + ) + from adcp.types.domains.protocol.get_task_status_request import GetTaskStatusRequest + from adcp.types.domains.protocol.get_task_status_response import ( + Details, + Error, + GetTaskStatusResponse, + HistoryItem, + Progress, + ) + from adcp.types.domains.protocol.list_tasks_request import ( + Field1, + Filters, + ListTasksRequest, + Sort, + ) + from adcp.types.domains.protocol.list_tasks_response import ( + Direction, + Domain, + DomainBreakdown, + ListTasksResponse, + QuerySummary, + SortApplied, + Task, + ) + from adcp.types.domains.protocol.sync_agent_notification_configs_request import ( + SyncAgentNotificationConfigsRequest, + ) + from adcp.types.domains.protocol.sync_agent_notification_configs_response import ( + SyncAgentNotificationConfigsResponse, + ) + from adcp.types.domains.protocol.sync_principal_request import ( + Configuration, + SyncPrincipalRequest, + ) + from adcp.types.domains.protocol.sync_principal_response import ( + Action33, + PrincipalAppliedResult, + PrincipalSyncFailedResult, + PrincipalValidatedResult, + Result13, + Result15, + SyncPrincipalResponse, + ) # Explicit exports __all__ = [ @@ -380,3 +397,197 @@ "WholesaleFeedVersioning", "WholesaleFeedWebhooks", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "AcceptancePolicyDiscovery": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "AccessMode": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Account": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Accreditation": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Action33": "adcp.types.domains.protocol.sync_principal_response", + "Adcp": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "AgeRestriction": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Algorithm": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "AttributionWindow": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "AudienceEvidence": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "AudienceTargeting": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Brand": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "BudgetCapping": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "BudgetCappingTimezoneBasis": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "BuyingMode": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "CapabilityChanges": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "CapabilityReportingDeliveryMethod": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "ChangeFeed": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "ChangeFeed1": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "ComplianceTesting": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "CompromiseNotification": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Configuration": "adcp.types.domains.protocol.sync_principal_request", + "ContentStandards": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "ConversionTracking": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "CoversContentDigest": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Creative": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "CreativeApprovalMode": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "CreativeFeature": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "CreativeSpecs": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "DataProviderDomain": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "DefaultProfileId": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Demographics": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Details": "adcp.types.domains.protocol.get_task_status_response", + "Direction": "adcp.types.domains.protocol.list_tasks_response", + "DiscoveryMode": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Domain": "adcp.types.domains.protocol.list_tasks_response", + "DomainBreakdown": "adcp.types.domains.protocol.list_tasks_response", + "Endpoint": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Error": "adcp.types.domains.protocol.get_task_status_response", + "EventType": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "EventType3": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Execution": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "ExtensionsSupportedItem": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Features": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Field1": "adcp.types.domains.protocol.list_tasks_request", + "Filters": "adcp.types.domains.protocol.list_tasks_request", + "Format": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "FrequencyCapping": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "GeoMetros": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "GeoProximity": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "GetAdcpCapabilitiesRequest": "adcp.types.domains.protocol.get_adcp_capabilities_request", + "GetAdcpCapabilitiesResponse": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "GetPrincipalRequest": "adcp.types.domains.protocol.get_principal_request", + "GetPrincipalResponse": "adcp.types.domains.protocol.get_principal_response", + "GetTaskStatusRequest": "adcp.types.domains.protocol.get_task_status_request", + "GetTaskStatusResponse": "adcp.types.domains.protocol.get_task_status_response", + "Governance": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "GovernanceEnforcement": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "HistoryItem": "adcp.types.domains.protocol.get_task_status_response", + "Idempotency": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Idempotency1": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Identity": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "IdentityUpdates": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "IdentityUpdates1": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "KeyOrigins": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "KeywordTargets": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Language": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "LifecycleTool": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "ListTasksRequest": "adcp.types.domains.protocol.list_tasks_request", + "ListTasksResponse": "adcp.types.domains.protocol.list_tasks_response", + "Localization": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "MajorVersion": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "MatchingLatencyHours": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Measurement": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "MeasurementGateway": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "MediaBuy": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Metric": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Mode": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "MraidVersion": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Multiplicity": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "NegativeKeywords": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Notifications": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Notifications1": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Notifications2": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Notifications3": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Oauth": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Operation": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Pattern": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "PerformanceFeedback": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Portfolio": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Preview": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "PrimaryCountry": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Principal": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "PrincipalAppliedResult": "adcp.types.domains.protocol.sync_principal_response", + "PrincipalCurrentResult": "adcp.types.domains.protocol.get_principal_response", + "PrincipalReadFailedResult": "adcp.types.domains.protocol.get_principal_response", + "PrincipalRecognizedResult": "adcp.types.domains.protocol.get_principal_response", + "PrincipalSyncFailedResult": "adcp.types.domains.protocol.sync_principal_response", + "PrincipalUnconfiguredResult": "adcp.types.domains.protocol.get_principal_response", + "PrincipalValidatedResult": "adcp.types.domains.protocol.sync_principal_response", + "Progress": "adcp.types.domains.protocol.get_task_status_response", + "PropagationSurface": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "PropertyFeature": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "ProposalRefinement": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Protocol": "adcp.types.domains.protocol.get_adcp_capabilities_request", + "ProtocolMethodsRequiredForItem": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "ProtocolMethodsSupportedForItem": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "ProtocolMethodsWarnForItem": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "PublisherDomain": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "QuerySummary": "adcp.types.domains.protocol.list_tasks_response", + "Range": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "RegistrationTask": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "RelationshipNotifications": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "RenderingOrigin": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "ReportingDestinationOffering": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "RepresentationResolution": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "RequestSigning": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "RequiredForItem": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Requirement": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "ResourceType": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Result13": "adcp.types.domains.protocol.sync_principal_response", + "Result15": "adcp.types.domains.protocol.sync_principal_response", + "Result2": "adcp.types.domains.protocol.get_principal_response", + "Result3": "adcp.types.domains.protocol.get_principal_response", + "Result5": "adcp.types.domains.protocol.get_principal_response", + "RightsAttestations": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Route": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "RuntimeAttestations": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "SignalActivation": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Signals": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Sort": "adcp.types.domains.protocol.list_tasks_request", + "SortApplied": "adcp.types.domains.protocol.list_tasks_response", + "SponsoredIntelligence": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "SupportedChange": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "SupportedDimension": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "SupportedForItem": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "SupportedFormat": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "SupportedIdentifierType": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "SupportedOptimizationMetric": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "SupportedPresenceMode": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "SupportedProtocol": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "SupportedRequirementMode": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "SupportedScope": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "SupportedSection": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "SupportedTarget": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "SupportedTarget1": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "SupportedVersion": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "SyncAgentNotificationConfigsRequest": "adcp.types.domains.protocol.sync_agent_notification_configs_request", + "SyncAgentNotificationConfigsResponse": "adcp.types.domains.protocol.sync_agent_notification_configs_response", + "SyncPrincipalRequest": "adcp.types.domains.protocol.sync_principal_request", + "SyncPrincipalResponse": "adcp.types.domains.protocol.sync_principal_response", + "Targeting": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Task": "adcp.types.domains.protocol.list_tasks_response", + "Tasks": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Tasks1": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Tasks2": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Tasks3": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Tasks4": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Tasks5": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Tasks6": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Tasks7": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Tasks8": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Tasks9": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Transport": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Transport1": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "TrustedMatch": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "Type6": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "VariantDimension": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "VastValidation": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "VendorMetricOptimization": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "WarnForItem": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "WebhookSigning": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "WholesaleFeedVersioning": "adcp.types.domains.protocol.get_adcp_capabilities_response", + "WholesaleFeedWebhooks": "adcp.types.domains.protocol.get_adcp_capabilities_response", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/registries/__init__.py b/src/adcp/types/domains/registries/__init__.py index 133424d847..3fadd5c01c 100644 --- a/src/adcp/types/domains/registries/__init__.py +++ b/src/adcp/types/domains/registries/__init__.py @@ -9,24 +9,33 @@ it from its own schema's module, ``adcp.types.domains.registries.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.registries.v1_canonical_mapping import ( - Dimensions, - Mapping, - ParameterMapping, - Structural, - Transform, - V1Pattern, - V1Pattern1, - V1V2CanonicalFormatMappingRegistry, - V2, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.registries.v1_canonical_mapping import ( + Dimensions, + Mapping, + ParameterMapping, + Structural, + Transform, + V1Pattern, + V1Pattern1, + V1V2CanonicalFormatMappingRegistry, + V2, + ) # Explicit exports __all__ = [ @@ -40,3 +49,32 @@ "V1V2CanonicalFormatMappingRegistry", "V2", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "Dimensions": "adcp.types.domains.registries.v1_canonical_mapping", + "Mapping": "adcp.types.domains.registries.v1_canonical_mapping", + "ParameterMapping": "adcp.types.domains.registries.v1_canonical_mapping", + "Structural": "adcp.types.domains.registries.v1_canonical_mapping", + "Transform": "adcp.types.domains.registries.v1_canonical_mapping", + "V1Pattern": "adcp.types.domains.registries.v1_canonical_mapping", + "V1Pattern1": "adcp.types.domains.registries.v1_canonical_mapping", + "V1V2CanonicalFormatMappingRegistry": "adcp.types.domains.registries.v1_canonical_mapping", + "V2": "adcp.types.domains.registries.v1_canonical_mapping", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/signals/__init__.py b/src/adcp/types/domains/signals/__init__.py index 6d256e0763..1b0295b621 100644 --- a/src/adcp/types/domains/signals/__init__.py +++ b/src/adcp/types/domains/signals/__init__.py @@ -9,50 +9,63 @@ it from its own schema's module, ``adcp.types.domains.signals.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.signals.activate_signal_request import Action, ActivateSignalRequest -from adcp.types.domains.signals.activate_signal_response import ( - ActivateSignalResponse, - ActivateSignalResponse1, - ActivateSignalResponse2, -) -from adcp.types.domains.signals.get_signals_async_response_submitted import GetSignalsSubmitted -from adcp.types.domains.signals.get_signals_async_response_working import GetSignalsWorking -from adcp.types.domains.signals.get_signals_request import DiscoveryMode, Field1, GetSignalsRequest -from adcp.types.domains.signals.get_signals_response import ( - AiActRiskClass, - Art9Basis, - CacheScope, - Channel, - DataSource, - DataSubjectRights, - GetSignalsResponse, - IncompleteItem, - MatchKey, - Method, - Methodology, - Modeling, - Onboarder, - ParentMatchBehavior, - PreOnboardingPrecisionLevel, - Range, - RefreshCadence, - Right, - Scope, - SeedSource, - Signal, - Taxonomy, - TrainingDataJurisdiction, - Type, - Value, - ValueMapping, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.signals.activate_signal_request import Action, ActivateSignalRequest + from adcp.types.domains.signals.activate_signal_response import ( + ActivateSignalResponse, + ActivateSignalResponse1, + ActivateSignalResponse2, + ) + from adcp.types.domains.signals.get_signals_async_response_submitted import GetSignalsSubmitted + from adcp.types.domains.signals.get_signals_async_response_working import GetSignalsWorking + from adcp.types.domains.signals.get_signals_request import ( + DiscoveryMode, + Field1, + GetSignalsRequest, + ) + from adcp.types.domains.signals.get_signals_response import ( + AiActRiskClass, + Art9Basis, + CacheScope, + Channel, + DataSource, + DataSubjectRights, + GetSignalsResponse, + IncompleteItem, + MatchKey, + Method, + Methodology, + Modeling, + Onboarder, + ParentMatchBehavior, + PreOnboardingPrecisionLevel, + Range, + RefreshCadence, + Right, + Scope, + SeedSource, + Signal, + Taxonomy, + TrainingDataJurisdiction, + Type, + Value, + ValueMapping, + ) # Explicit exports __all__ = [ @@ -93,3 +106,59 @@ "Value", "ValueMapping", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "Action": "adcp.types.domains.signals.activate_signal_request", + "ActivateSignalRequest": "adcp.types.domains.signals.activate_signal_request", + "ActivateSignalResponse": "adcp.types.domains.signals.activate_signal_response", + "ActivateSignalResponse1": "adcp.types.domains.signals.activate_signal_response", + "ActivateSignalResponse2": "adcp.types.domains.signals.activate_signal_response", + "AiActRiskClass": "adcp.types.domains.signals.get_signals_response", + "Art9Basis": "adcp.types.domains.signals.get_signals_response", + "CacheScope": "adcp.types.domains.signals.get_signals_response", + "Channel": "adcp.types.domains.signals.get_signals_response", + "DataSource": "adcp.types.domains.signals.get_signals_response", + "DataSubjectRights": "adcp.types.domains.signals.get_signals_response", + "DiscoveryMode": "adcp.types.domains.signals.get_signals_request", + "Field1": "adcp.types.domains.signals.get_signals_request", + "GetSignalsRequest": "adcp.types.domains.signals.get_signals_request", + "GetSignalsResponse": "adcp.types.domains.signals.get_signals_response", + "GetSignalsSubmitted": "adcp.types.domains.signals.get_signals_async_response_submitted", + "GetSignalsWorking": "adcp.types.domains.signals.get_signals_async_response_working", + "IncompleteItem": "adcp.types.domains.signals.get_signals_response", + "MatchKey": "adcp.types.domains.signals.get_signals_response", + "Method": "adcp.types.domains.signals.get_signals_response", + "Methodology": "adcp.types.domains.signals.get_signals_response", + "Modeling": "adcp.types.domains.signals.get_signals_response", + "Onboarder": "adcp.types.domains.signals.get_signals_response", + "ParentMatchBehavior": "adcp.types.domains.signals.get_signals_response", + "PreOnboardingPrecisionLevel": "adcp.types.domains.signals.get_signals_response", + "Range": "adcp.types.domains.signals.get_signals_response", + "RefreshCadence": "adcp.types.domains.signals.get_signals_response", + "Right": "adcp.types.domains.signals.get_signals_response", + "Scope": "adcp.types.domains.signals.get_signals_response", + "SeedSource": "adcp.types.domains.signals.get_signals_response", + "Signal": "adcp.types.domains.signals.get_signals_response", + "Taxonomy": "adcp.types.domains.signals.get_signals_response", + "TrainingDataJurisdiction": "adcp.types.domains.signals.get_signals_response", + "Type": "adcp.types.domains.signals.get_signals_response", + "Value": "adcp.types.domains.signals.get_signals_response", + "ValueMapping": "adcp.types.domains.signals.get_signals_response", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/sponsored_intelligence/__init__.py b/src/adcp/types/domains/sponsored_intelligence/__init__.py index 4e8d572928..718593f2df 100644 --- a/src/adcp/types/domains/sponsored_intelligence/__init__.py +++ b/src/adcp/types/domains/sponsored_intelligence/__init__.py @@ -9,85 +9,96 @@ it from its own schema's module, ``adcp.types.domains.sponsored_intelligence.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.sponsored_intelligence.si_capabilities import ( - A2ui, - Avatar, - Commerce, - Components, - Modalities, - SiCapabilities, - StandardEnum, - Video, - Voice, -) -from adcp.types.domains.sponsored_intelligence.si_context_use import SiContextUse -from adcp.types.domains.sponsored_intelligence.si_get_offering_request import SiGetOfferingRequest -from adcp.types.domains.sponsored_intelligence.si_get_offering_response import ( - MatchingProduct, - Offering, - SiGetOfferingResponse, -) -from adcp.types.domains.sponsored_intelligence.si_identity import ( - ConsentScopeEnum, - PrivacyPolicyAcknowledged, - ShippingAddress, - SiIdentity, - User, -) -from adcp.types.domains.sponsored_intelligence.si_initiate_session_request import ( - SiInitiateSessionRequest, -) -from adcp.types.domains.sponsored_intelligence.si_initiate_session_response import ( - SiInitiateSessionResponse, -) -from adcp.types.domains.sponsored_intelligence.si_send_message_request import ( - ActionResponse, - SiSendMessageRequest, -) -from adcp.types.domains.sponsored_intelligence.si_send_message_response import ( - ContextForCheckout, - Handoff, - Intent, - Price, - SiSendMessageResponse, -) -from adcp.types.domains.sponsored_intelligence.si_sponsored_context import ( - Account, - DeclaredBy, - DisclosureObligation, - Jurisdiction, - PayingPrincipal, - Proximity, - Role, - SiSponsoredContext, - Timing, -) -from adcp.types.domains.sponsored_intelligence.si_sponsored_context_receipt import ( - DisclosureCommitment, - HostReceipt, - SiSponsoredContextReceipt, - Status, - Status46, -) -from adcp.types.domains.sponsored_intelligence.si_terminate_session_request import ( - Reason, - SiTerminateSessionRequest, - TerminationContext, - TransactionIntent, -) -from adcp.types.domains.sponsored_intelligence.si_terminate_session_response import ( - AcpHandoff, - FollowUp, - SiTerminateSessionResponse, -) -from adcp.types.domains.sponsored_intelligence.si_ui_element import SiUiElement +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.sponsored_intelligence.si_capabilities import ( + A2ui, + Avatar, + Commerce, + Components, + Modalities, + SiCapabilities, + StandardEnum, + Video, + Voice, + ) + from adcp.types.domains.sponsored_intelligence.si_context_use import SiContextUse + from adcp.types.domains.sponsored_intelligence.si_get_offering_request import ( + SiGetOfferingRequest, + ) + from adcp.types.domains.sponsored_intelligence.si_get_offering_response import ( + MatchingProduct, + Offering, + SiGetOfferingResponse, + ) + from adcp.types.domains.sponsored_intelligence.si_identity import ( + ConsentScopeEnum, + PrivacyPolicyAcknowledged, + ShippingAddress, + SiIdentity, + User, + ) + from adcp.types.domains.sponsored_intelligence.si_initiate_session_request import ( + SiInitiateSessionRequest, + ) + from adcp.types.domains.sponsored_intelligence.si_initiate_session_response import ( + SiInitiateSessionResponse, + ) + from adcp.types.domains.sponsored_intelligence.si_send_message_request import ( + ActionResponse, + SiSendMessageRequest, + ) + from adcp.types.domains.sponsored_intelligence.si_send_message_response import ( + ContextForCheckout, + Handoff, + Intent, + Price, + SiSendMessageResponse, + ) + from adcp.types.domains.sponsored_intelligence.si_sponsored_context import ( + Account, + DeclaredBy, + DisclosureObligation, + Jurisdiction, + PayingPrincipal, + Proximity, + Role, + SiSponsoredContext, + Timing, + ) + from adcp.types.domains.sponsored_intelligence.si_sponsored_context_receipt import ( + DisclosureCommitment, + HostReceipt, + SiSponsoredContextReceipt, + Status, + Status46, + ) + from adcp.types.domains.sponsored_intelligence.si_terminate_session_request import ( + Reason, + SiTerminateSessionRequest, + TerminationContext, + TransactionIntent, + ) + from adcp.types.domains.sponsored_intelligence.si_terminate_session_response import ( + AcpHandoff, + FollowUp, + SiTerminateSessionResponse, + ) + from adcp.types.domains.sponsored_intelligence.si_ui_element import SiUiElement # Explicit exports __all__ = [ @@ -142,3 +153,73 @@ "Video", "Voice", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "A2ui": "adcp.types.domains.sponsored_intelligence.si_capabilities", + "Account": "adcp.types.domains.sponsored_intelligence.si_sponsored_context", + "AcpHandoff": "adcp.types.domains.sponsored_intelligence.si_terminate_session_response", + "ActionResponse": "adcp.types.domains.sponsored_intelligence.si_send_message_request", + "Avatar": "adcp.types.domains.sponsored_intelligence.si_capabilities", + "Commerce": "adcp.types.domains.sponsored_intelligence.si_capabilities", + "Components": "adcp.types.domains.sponsored_intelligence.si_capabilities", + "ConsentScopeEnum": "adcp.types.domains.sponsored_intelligence.si_identity", + "ContextForCheckout": "adcp.types.domains.sponsored_intelligence.si_send_message_response", + "DeclaredBy": "adcp.types.domains.sponsored_intelligence.si_sponsored_context", + "DisclosureCommitment": "adcp.types.domains.sponsored_intelligence.si_sponsored_context_receipt", + "DisclosureObligation": "adcp.types.domains.sponsored_intelligence.si_sponsored_context", + "FollowUp": "adcp.types.domains.sponsored_intelligence.si_terminate_session_response", + "Handoff": "adcp.types.domains.sponsored_intelligence.si_send_message_response", + "HostReceipt": "adcp.types.domains.sponsored_intelligence.si_sponsored_context_receipt", + "Intent": "adcp.types.domains.sponsored_intelligence.si_send_message_response", + "Jurisdiction": "adcp.types.domains.sponsored_intelligence.si_sponsored_context", + "MatchingProduct": "adcp.types.domains.sponsored_intelligence.si_get_offering_response", + "Modalities": "adcp.types.domains.sponsored_intelligence.si_capabilities", + "Offering": "adcp.types.domains.sponsored_intelligence.si_get_offering_response", + "PayingPrincipal": "adcp.types.domains.sponsored_intelligence.si_sponsored_context", + "Price": "adcp.types.domains.sponsored_intelligence.si_send_message_response", + "PrivacyPolicyAcknowledged": "adcp.types.domains.sponsored_intelligence.si_identity", + "Proximity": "adcp.types.domains.sponsored_intelligence.si_sponsored_context", + "Reason": "adcp.types.domains.sponsored_intelligence.si_terminate_session_request", + "Role": "adcp.types.domains.sponsored_intelligence.si_sponsored_context", + "ShippingAddress": "adcp.types.domains.sponsored_intelligence.si_identity", + "SiCapabilities": "adcp.types.domains.sponsored_intelligence.si_capabilities", + "SiContextUse": "adcp.types.domains.sponsored_intelligence.si_context_use", + "SiGetOfferingRequest": "adcp.types.domains.sponsored_intelligence.si_get_offering_request", + "SiGetOfferingResponse": "adcp.types.domains.sponsored_intelligence.si_get_offering_response", + "SiIdentity": "adcp.types.domains.sponsored_intelligence.si_identity", + "SiInitiateSessionRequest": "adcp.types.domains.sponsored_intelligence.si_initiate_session_request", + "SiInitiateSessionResponse": "adcp.types.domains.sponsored_intelligence.si_initiate_session_response", + "SiSendMessageRequest": "adcp.types.domains.sponsored_intelligence.si_send_message_request", + "SiSendMessageResponse": "adcp.types.domains.sponsored_intelligence.si_send_message_response", + "SiSponsoredContext": "adcp.types.domains.sponsored_intelligence.si_sponsored_context", + "SiSponsoredContextReceipt": "adcp.types.domains.sponsored_intelligence.si_sponsored_context_receipt", + "SiTerminateSessionRequest": "adcp.types.domains.sponsored_intelligence.si_terminate_session_request", + "SiTerminateSessionResponse": "adcp.types.domains.sponsored_intelligence.si_terminate_session_response", + "SiUiElement": "adcp.types.domains.sponsored_intelligence.si_ui_element", + "StandardEnum": "adcp.types.domains.sponsored_intelligence.si_capabilities", + "Status": "adcp.types.domains.sponsored_intelligence.si_sponsored_context_receipt", + "Status46": "adcp.types.domains.sponsored_intelligence.si_sponsored_context_receipt", + "TerminationContext": "adcp.types.domains.sponsored_intelligence.si_terminate_session_request", + "Timing": "adcp.types.domains.sponsored_intelligence.si_sponsored_context", + "TransactionIntent": "adcp.types.domains.sponsored_intelligence.si_terminate_session_request", + "User": "adcp.types.domains.sponsored_intelligence.si_identity", + "Video": "adcp.types.domains.sponsored_intelligence.si_capabilities", + "Voice": "adcp.types.domains.sponsored_intelligence.si_capabilities", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/src/adcp/types/domains/trusted_match/__init__.py b/src/adcp/types/domains/trusted_match/__init__.py index 86d06402b8..84831fa38e 100644 --- a/src/adcp/types/domains/trusted_match/__init__.py +++ b/src/adcp/types/domains/trusted_match/__init__.py @@ -9,63 +9,72 @@ it from its own schema's module, ``adcp.types.domains.trusted_match.``. Nothing here is renamed. +This root is lazy (PEP 562): importing it, or a schema module under it, +does not import every schema in the domain. A name is bound from its +declaring module on first access and cached here. That is what keeps a +schema module's ``from ... import `` from re-entering a +half-initialized module through this package (#1784). + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-11-01 23:49:37 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.trusted_match.available_package import AvailablePackage -from adcp.types.domains.trusted_match.context_match_request import ( - ArtifactRef, - ContextMatchRequest, - ContextSignals, - Geo, - Keyword, - Metro, - Sentiment, - Type, -) -from adcp.types.domains.trusted_match.context_match_response import ( - ContextMatchResponseRouterPublisher, - SignalsByProvider, -) -from adcp.types.domains.trusted_match.error import Code, TmpError -from adcp.types.domains.trusted_match.identity_match_request import ( - Attestation, - Consent, - Identity, - IdentityMatchRequest, - SealedCredential, - VerificationLevel, -) -from adcp.types.domains.trusted_match.identity_match_response import ( - IdentityMatchResponse, - IdentityMatchResponseRouterPublisher, - TmpxProviders, -) -from adcp.types.domains.trusted_match.offer import Offer -from adcp.types.domains.trusted_match.offer_price import Model, OfferPrice -from adcp.types.domains.trusted_match.provider_context_match_response import ( - ContextMatchResponseProviderRouter, -) -from adcp.types.domains.trusted_match.provider_identity_match_response import ( - IdentityMatchResponseProviderRouter, -) -from adcp.types.domains.trusted_match.provider_registration import ( - Country, - Status, - TmpProviderRegistration, - TmpProviderRegistration1, - TmpProviderRegistration2, - TmpxSlot, -) -from adcp.types.domains.trusted_match.publisher_targeting_kv_config import ( - PublisherTargetingKvMapping, -) -from adcp.types.domains.trusted_match.publisher_tmpx_config import PublisherTmpxMacroMapping -from adcp.types.domains.trusted_match.tmpx_chunk import TmpxChunk +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adcp.types.domains.trusted_match.available_package import AvailablePackage + from adcp.types.domains.trusted_match.context_match_request import ( + ArtifactRef, + ContextMatchRequest, + ContextSignals, + Geo, + Keyword, + Metro, + Sentiment, + Type, + ) + from adcp.types.domains.trusted_match.context_match_response import ( + ContextMatchResponseRouterPublisher, + SignalsByProvider, + ) + from adcp.types.domains.trusted_match.error import Code, TmpError + from adcp.types.domains.trusted_match.identity_match_request import ( + Attestation, + Consent, + Identity, + IdentityMatchRequest, + SealedCredential, + VerificationLevel, + ) + from adcp.types.domains.trusted_match.identity_match_response import ( + IdentityMatchResponse, + IdentityMatchResponseRouterPublisher, + TmpxProviders, + ) + from adcp.types.domains.trusted_match.offer import Offer + from adcp.types.domains.trusted_match.offer_price import Model, OfferPrice + from adcp.types.domains.trusted_match.provider_context_match_response import ( + ContextMatchResponseProviderRouter, + ) + from adcp.types.domains.trusted_match.provider_identity_match_response import ( + IdentityMatchResponseProviderRouter, + ) + from adcp.types.domains.trusted_match.provider_registration import ( + Country, + Status, + TmpProviderRegistration, + TmpProviderRegistration1, + TmpProviderRegistration2, + TmpxSlot, + ) + from adcp.types.domains.trusted_match.publisher_targeting_kv_config import ( + PublisherTargetingKvMapping, + ) + from adcp.types.domains.trusted_match.publisher_tmpx_config import PublisherTmpxMacroMapping + from adcp.types.domains.trusted_match.tmpx_chunk import TmpxChunk # Explicit exports __all__ = [ @@ -106,3 +115,59 @@ "Type", "VerificationLevel", ] + + +#: The schema module that declares each name, for the lazy binding below. +_DECLARED_IN = { + "ArtifactRef": "adcp.types.domains.trusted_match.context_match_request", + "Attestation": "adcp.types.domains.trusted_match.identity_match_request", + "AvailablePackage": "adcp.types.domains.trusted_match.available_package", + "Code": "adcp.types.domains.trusted_match.error", + "Consent": "adcp.types.domains.trusted_match.identity_match_request", + "ContextMatchRequest": "adcp.types.domains.trusted_match.context_match_request", + "ContextMatchResponseProviderRouter": "adcp.types.domains.trusted_match.provider_context_match_response", + "ContextMatchResponseRouterPublisher": "adcp.types.domains.trusted_match.context_match_response", + "ContextSignals": "adcp.types.domains.trusted_match.context_match_request", + "Country": "adcp.types.domains.trusted_match.provider_registration", + "Geo": "adcp.types.domains.trusted_match.context_match_request", + "Identity": "adcp.types.domains.trusted_match.identity_match_request", + "IdentityMatchRequest": "adcp.types.domains.trusted_match.identity_match_request", + "IdentityMatchResponse": "adcp.types.domains.trusted_match.identity_match_response", + "IdentityMatchResponseProviderRouter": "adcp.types.domains.trusted_match.provider_identity_match_response", + "IdentityMatchResponseRouterPublisher": "adcp.types.domains.trusted_match.identity_match_response", + "Keyword": "adcp.types.domains.trusted_match.context_match_request", + "Metro": "adcp.types.domains.trusted_match.context_match_request", + "Model": "adcp.types.domains.trusted_match.offer_price", + "Offer": "adcp.types.domains.trusted_match.offer", + "OfferPrice": "adcp.types.domains.trusted_match.offer_price", + "PublisherTargetingKvMapping": "adcp.types.domains.trusted_match.publisher_targeting_kv_config", + "PublisherTmpxMacroMapping": "adcp.types.domains.trusted_match.publisher_tmpx_config", + "SealedCredential": "adcp.types.domains.trusted_match.identity_match_request", + "Sentiment": "adcp.types.domains.trusted_match.context_match_request", + "SignalsByProvider": "adcp.types.domains.trusted_match.context_match_response", + "Status": "adcp.types.domains.trusted_match.provider_registration", + "TmpError": "adcp.types.domains.trusted_match.error", + "TmpProviderRegistration": "adcp.types.domains.trusted_match.provider_registration", + "TmpProviderRegistration1": "adcp.types.domains.trusted_match.provider_registration", + "TmpProviderRegistration2": "adcp.types.domains.trusted_match.provider_registration", + "TmpxChunk": "adcp.types.domains.trusted_match.tmpx_chunk", + "TmpxProviders": "adcp.types.domains.trusted_match.identity_match_response", + "TmpxSlot": "adcp.types.domains.trusted_match.provider_registration", + "Type": "adcp.types.domains.trusted_match.context_match_request", + "VerificationLevel": "adcp.types.domains.trusted_match.identity_match_request", +} + +if not TYPE_CHECKING: + import importlib as _importlib + + def __getattr__(name: str) -> object: + module_name = _DECLARED_IN.get(name) + if module_name is None: + # Not a declared name: let the import system try a submodule, or fail. + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(module_name), name) + globals()[name] = value + return value + + def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/tests/test_domain_roots_import_in_any_order.py b/tests/test_domain_roots_import_in_any_order.py new file mode 100644 index 0000000000..1bbd519f91 --- /dev/null +++ b/tests/test_domain_roots_import_in_any_order.py @@ -0,0 +1,91 @@ +"""Every public types package imports first, in a fresh interpreter (#1784). + +``from adcp.types.domains.formats import Slot`` used to raise ``ImportError`` +when it was the first adcp import in a process: the domain roots imported every +schema module eagerly, so ``formats/canonical/_base.py``'s ``from ...core import +`` ran ``core/__init__``, which pulled in every response union, one of +which reached back into ``formats/canonical`` while ``_base`` was half built. +Whether a script failed depended on which unrelated import came first. + +The domain roots are lazy now (PEP 562): importing one, or a schema module +under it, binds nothing until a name is read. These tests import each public +types package first in a fresh interpreter, which is the only way to grade an +import-order defect, and pin the behaviours a lazy root has to keep. +""" + +from __future__ import annotations + +import subprocess +import sys + +import pytest + +import adcp.types.domains as domains + +_FRESH = [sys.executable, "-I", "-c"] + + +def _fresh_import(statement: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [*_FRESH, statement + "\nprint('imported')"], + capture_output=True, + text=True, + check=False, + timeout=600, + ) + + +@pytest.mark.parametrize( + "statement", + [ + "from adcp.types.domains.formats import Slot", + "import adcp.types.domains.formats.canonical", + "import adcp.types; from adcp.types.domains.formats import Slot", + "from adcp.types.domains.core import error; error.Error", + "from adcp.types.domains.creative.list_creative_formats_response import " + "ListCreativeFormatsResponseCreativeAgent", + ], +) +def test_the_reported_entry_points_import_first(statement: str) -> None: + result = _fresh_import(statement) + assert result.returncode == 0, result.stderr[-2000:] + assert result.stdout.strip() == "imported" + + +@pytest.mark.parametrize("domain", sorted(domains.DOMAINS)) +def test_each_domain_root_imports_first_and_binds_its_names(domain: str) -> None: + statement = ( + f"import adcp.types.domains.{domain} as root\n" + "for name in root.__all__:\n" + " getattr(root, name)\n" + "assert not hasattr(root, 'NoSuchName')\n" + "assert set(root.__all__) <= set(dir(root))" + ) + result = _fresh_import(statement) + assert result.returncode == 0, result.stderr[-2000:] + + +def test_a_domain_root_imports_nothing_until_a_name_is_read() -> None: + statement = ( + "import sys\n" + "import adcp.types.domains.core as core\n" + "before = {m for m in sys.modules if m.startswith('adcp.types.domains.core.')}\n" + "assert before == set(), sorted(before)[:5]\n" + "core.AccountReference\n" + "after = {m for m in sys.modules if m.startswith('adcp.types.domains.core.')}\n" + "assert 'adcp.types.domains.core.account_ref' in after\n" + "assert 'adcp.types.domains.core.format' not in after, " + "'reading one name imported the domain'" + ) + result = _fresh_import(statement) + assert result.returncode == 0, result.stderr[-2000:] + + +def test_a_resolved_name_is_the_declaring_modules_object_and_is_cached() -> None: + from adcp.types.domains import core + from adcp.types.domains.core.account_ref import AccountReference + + assert core.AccountReference is AccountReference + assert "AccountReference" in vars(core) + with pytest.raises(AttributeError): + core.NoSuchName # noqa: B018 From 272fced52d57632dedf32f2c175cf294100bff63 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 2 Nov 2026 00:29:51 -0500 Subject: [PATCH 12/28] feat(decisioning): let a raised AdcpError reject a request with every error (#1780) (#1810) A seller that validates a whole request could only raise one AdcpError, so the payload errors[] carried one entry even when the caller listed every problem in details["errors"]. AdcpError takes errors=[...] (AdcpError, generated Error or wire mapping entries) and AdcpError.from_errors([...]) builds the raise from the list, the first entry filling adcp_error. build_mcp_error_result, the A2A failed-task builder and translate_error write the whole list to errors[]; adcp_error is unchanged. A details["errors"] list whose entries carry a string code and message is lifted into errors[] as well. An entry without recovery takes the raised error's; entry details are sanitized like to_wire(). A single-error raise still mirrors adcp_error into errors[] (#1558). Co-authored-by: Claude Opus 5.5 --- docs/handler-authoring.md | 27 +++ src/adcp/decisioning/types.py | 107 +++++++++++- src/adcp/server/a2a_server.py | 7 +- src/adcp/server/translate.py | 21 ++- tests/test_adcp_error_multi.py | 303 +++++++++++++++++++++++++++++++++ 5 files changed, 457 insertions(+), 8 deletions(-) create mode 100644 tests/test_adcp_error_multi.py diff --git a/docs/handler-authoring.md b/docs/handler-authoring.md index 28adb7f5a4..2ebeedc897 100644 --- a/docs/handler-authoring.md +++ b/docs/handler-authoring.md @@ -801,6 +801,33 @@ except ADCPTaskError as exc: Custom error codes (outside `STANDARD_ERROR_CODES`) default to `recovery="terminal"`. Override with `adcp_error("MY_CODE", recovery="correctable")`. +### Rejecting a request with every problem + +A `DecisioningPlatform` method that validates a whole request raises one +`AdcpError` carrying every problem, so the buyer fixes the request in one +pass. `AdcpError.from_errors` puts the first entry in the envelope +`adcp_error` and all of them in the payload `errors[]`, on MCP and A2A: + +```python +from adcp.decisioning import AdcpError + +problems = [] +if package_budget < floor: + problems.append(AdcpError("BUDGET_TOO_LOW", message="budget below minimum", + recovery="correctable", field="packages[0].budget")) +if end_time <= start_time: + problems.append(AdcpError("INVALID_REQUEST", message="end_time before start_time", + recovery="correctable", field="end_time")) +if problems: + raise AdcpError.from_errors(problems) +``` + +Entries may also be generated `Error` models or wire mappings; one without +`recovery` takes the raised error's. To keep a summary envelope instead, pass +the list as `AdcpError("INVALID_REQUEST", message="2 problems", errors=problems)`. +A list in `details["errors"]` whose entries carry a string `code` and +`message` is lifted into `errors[]` too. + ## Response builders Manual `model_dump()` on response Pydantic objects is error-prone — diff --git a/src/adcp/decisioning/types.py b/src/adcp/decisioning/types.py index 3eee00efd0..5be0d1ed75 100644 --- a/src/adcp/decisioning/types.py +++ b/src/adcp/decisioning/types.py @@ -21,7 +21,7 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Generic, Literal @@ -50,6 +50,7 @@ AccountScope, BusinessEntity, CreditLimit, + Error, GovernanceAgent, PaymentTerms, ReportingBucket, @@ -105,8 +106,28 @@ class AdcpError(Exception): error. :param retry_after: Seconds to wait before retrying. Only meaningful with ``recovery='transient'``. - :param details: Free-form extras for codes that need them - (e.g. ``{'errors': [...]}`` for multi-error preflight). + :param details: Free-form extras for codes that need them. + :param errors: Every problem found, for a request rejected with + several at once (a whole-request preflight). Each entry is an + :class:`AdcpError`, a generated ``Error`` model or a wire mapping + with string ``code`` and ``message``; an entry without + ``recovery`` takes this error's. The framework writes the list to + the payload ``errors[]`` and this error to the envelope + ``adcp_error``. :meth:`from_errors` builds the error from the + list, so ``adcp_error`` is the first entry. Without ``errors``, + ``errors[]`` carries this error alone, unless ``details['errors']`` + holds entries of that shape (the slot this class documented for + multi-error preflight before ``errors`` existed), which are + lifted into ``errors[]``. + + Example — reject a whole request with every problem:: + + raise AdcpError.from_errors([ + AdcpError("BUDGET_TOO_LOW", message="budget below minimum", + recovery="correctable", field="packages[0].budget"), + AdcpError("INVALID_REQUEST", message="end_time before start_time", + recovery="correctable", field="end_time"), + ]) """ def __init__( @@ -121,6 +142,7 @@ def __init__( suggestion: str | None = None, retry_after: int | None = None, details: dict[str, Any] | None = None, + errors: Sequence[AdcpError | Error | Mapping[str, Any]] | None = None, ) -> None: super().__init__(message or code) self.code = code @@ -129,6 +151,38 @@ def __init__( self.suggestion = suggestion self.retry_after = retry_after self.details = details or {} + if errors is not None: + self.errors: list[dict[str, Any]] = [ + _error_entry_to_wire(entry, recovery) for entry in errors + ] + else: + self.errors = _lift_detail_errors(self.details, recovery) + + @classmethod + def from_errors(cls, errors: Sequence[AdcpError | Error | Mapping[str, Any]], /) -> AdcpError: + """Build one raisable error that rejects a request for every entry. + + The first entry fills the envelope ``adcp_error``; all of them, + in order, fill the payload ``errors[]``. Entries are as for the + ``errors`` parameter; one without ``recovery`` takes the first + entry's (``terminal`` when the first has none). + + :raises ValueError: if ``errors`` is empty. + """ + if not errors: + raise ValueError("AdcpError.from_errors needs at least one error") + first = _error_entry_to_wire(errors[0], "terminal") + details = first.get("details") + return AdcpError( + first["code"], + message=first["message"], + recovery=first["recovery"], + field=first.get("field"), + suggestion=first.get("suggestion"), + retry_after=first.get("retry_after"), + details=dict(details) if isinstance(details, Mapping) else None, + errors=errors, + ) def __str__(self) -> str: return f"AdcpError[{self.code} / {self.recovery}]: {self.args[0]}" @@ -158,6 +212,53 @@ def to_wire(self) -> dict[str, Any]: out["details"] = details return out + def errors_to_wire(self) -> list[dict[str, Any]]: + """Project to the payload ``errors[]``: every entry, else this error.""" + if self.errors: + return [dict(entry) for entry in self.errors] + return [self.to_wire()] + + +def _error_entry_to_wire(entry: Any, recovery: str) -> dict[str, Any]: + """Normalize one ``errors`` entry to its wire dict.""" + if isinstance(entry, AdcpError): + return entry.to_wire() + if hasattr(entry, "model_dump"): + entry = entry.model_dump(mode="json", exclude_none=True) + if not isinstance(entry, Mapping): + raise TypeError( + "AdcpError errors entries must be AdcpError, Error or a mapping, " + f"got {type(entry).__name__}" + ) + code, message = entry.get("code"), entry.get("message") + if not isinstance(code, str) or not code or not isinstance(message, str): + raise ValueError("AdcpError errors entries need a non-empty string code and a message") + out = {key: value for key, value in entry.items() if value is not None} + out.setdefault("recovery", recovery) + if out.get("details"): + details = sanitize_error_details(code, out["details"]) + if details: + out["details"] = details + else: + del out["details"] + return out + + +def _lift_detail_errors(details: Mapping[str, Any], recovery: str) -> list[dict[str, Any]]: + """Read the documented ``details['errors']`` multi-error slot, if well formed.""" + entries = details.get("errors") + if not isinstance(entries, Sequence) or isinstance(entries, (str, bytes)) or not entries: + return [] + if not all( + isinstance(entry, Mapping) + and isinstance(entry.get("code"), str) + and entry["code"] + and isinstance(entry.get("message"), str) + for entry in entries + ): + return [] + return [_error_entry_to_wire(entry, recovery) for entry in entries] + #: Per-platform metadata generic. Defaults to ``dict[str, Any]`` for #: adopters who don't define a typed metadata shape; multi-tenant adopters diff --git a/src/adcp/server/a2a_server.py b/src/adcp/server/a2a_server.py index 7ef9769e34..926d8243f5 100644 --- a/src/adcp/server/a2a_server.py +++ b/src/adcp/server/a2a_server.py @@ -839,7 +839,7 @@ async def _send_adcp_error( # Lazy import — ``translate.py`` pulls in heavier server deps # (mcp.types) which the A2A module doesn't otherwise need. from adcp.server.helpers import inject_context - from adcp.server.translate import _extract_structured_fields + from adcp.server.translate import _extract_structured_fields, _payload_errors code, message, recovery, field, suggestion, details, _errors = _extract_structured_fields( exc @@ -863,7 +863,10 @@ async def _send_adcp_error( # A fatal failure populates both layers (L3 error handling): the payload # carries the protocol-neutral ``errors[]`` and the envelope ``adcp_error``. - data: dict[str, Any] = {"adcp_error": adcp_error, "errors": [dict(adcp_error)]} + data: dict[str, Any] = { + "adcp_error": adcp_error, + "errors": _payload_errors(exc, adcp_error), + } if params is not None: inject_context(params, data) diff --git a/src/adcp/server/translate.py b/src/adcp/server/translate.py index e18b30cc7e..cdc486bbcb 100644 --- a/src/adcp/server/translate.py +++ b/src/adcp/server/translate.py @@ -167,7 +167,7 @@ def _extract_structured_fields( suggestion = decisioning_exc.suggestion recovery = decisioning_exc.recovery details = decisioning_exc.details or None - errors = None + errors = decisioning_exc.errors_to_wire() if decisioning_exc.errors else None field = decisioning_exc.field elif isinstance(exc, ADCPError): code = _error_code_for_exception(exc) @@ -192,6 +192,17 @@ def _extract_structured_fields( return code, message, recovery, field, suggestion, details, errors +def _payload_errors(exc: Any, adcp_error: dict[str, Any]) -> list[dict[str, Any]]: + """The payload ``errors[]`` for a fatal failure. + + Every error a raised decisioning ``AdcpError`` carries (see its + ``errors`` parameter), else a copy of the envelope ``adcp_error``. + """ + if isinstance(exc, _get_decisioning_adcp_error_types()) and cast(Any, exc).errors: + return cast("list[dict[str, Any]]", cast(Any, exc).errors_to_wire()) + return [dict(adcp_error)] + + def build_mcp_error_result( exc: ADCPError | Error | Any, *, @@ -208,7 +219,8 @@ def build_mcp_error_result( same envelope — the transport-errors.mdx §MCP Binding ``adcp_error`` plus the payload ``errors[]`` that L3 error handling says a fatal failure SHOULD also carry (storyboards read both ``/adcp_error/code`` - and ``errors[0].field``). + and ``errors[0].field``). A decisioning ``AdcpError`` raised with + several ``errors`` puts all of them in ``errors[]``. The text fallback in ``content[]`` preserves human-readable display for clients that do not consume ``structuredContent`` (LLM tool-use @@ -263,7 +275,10 @@ def build_mcp_error_result( # A fatal failure populates both layers (L3 error handling): the payload # carries the protocol-neutral ``errors[]`` and the envelope ``adcp_error``. - structured: dict[str, Any] = {"adcp_error": adcp_error, "errors": [dict(adcp_error)]} + structured: dict[str, Any] = { + "adcp_error": adcp_error, + "errors": _payload_errors(exc, adcp_error), + } if params is not None: inject_context(params, structured) diff --git a/tests/test_adcp_error_multi.py b/tests/test_adcp_error_multi.py new file mode 100644 index 0000000000..65e167564c --- /dev/null +++ b/tests/test_adcp_error_multi.py @@ -0,0 +1,303 @@ +"""A raised ``AdcpError`` can reject a request with every problem (#1780). + +AdCP's payload ``errors[]`` exists so a seller that validates a whole request +returns all of its problems in one response. ``AdcpError(errors=[...])`` and +:meth:`AdcpError.from_errors` carry the list; both transports write it to +``errors[]`` and keep the envelope ``adcp_error``. The ``details['errors']`` +slot the class documented for multi-error preflight is lifted too. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from a2a import types as pb +from a2a.auth.user import UnauthenticatedUser +from a2a.server.agent_execution.context import RequestContext as A2ARequestContext +from a2a.server.context import ServerCallContext +from a2a.server.events.event_queue import EventQueueLegacy as EventQueue +from google.protobuf.json_format import MessageToDict, ParseDict +from google.protobuf.struct_pb2 import Value +from mcp.types import CallToolResult + +from adcp.decisioning import AdcpError, ValidationError +from adcp.server import ADCPHandler +from adcp.server.a2a_server import ADCPAgentExecutor +from adcp.server.translate import build_mcp_error_result, translate_error +from adcp.types import Error + + +def _budget() -> AdcpError: + return AdcpError( + "BUDGET_TOO_LOW", + message="budget below minimum", + recovery="correctable", + field="packages[0].budget", + ) + + +def _dates() -> AdcpError: + return AdcpError( + "INVALID_REQUEST", + message="end_time before start_time", + recovery="correctable", + field="end_time", + ) + + +class TestAdcpErrorErrors: + def test_single_error_has_no_list(self) -> None: + error = _budget() + assert error.errors == [] + assert error.errors_to_wire() == [error.to_wire()] + + def test_from_errors_first_entry_fills_the_envelope(self) -> None: + error = AdcpError.from_errors([_budget(), _dates()]) + assert error.code == "BUDGET_TOO_LOW" + assert error.field == "packages[0].budget" + assert error.recovery == "correctable" + assert error.errors_to_wire() == [_budget().to_wire(), _dates().to_wire()] + assert error.errors_to_wire()[0] == error.to_wire() + + def test_from_errors_accepts_mappings_and_models(self) -> None: + error = AdcpError.from_errors( + [ + {"code": "INVALID_REQUEST", "message": "unknown product", "field": "packages[1]"}, + Error(code="BUDGET_TOO_LOW", message="too low", recovery="correctable"), + ] + ) + wire = error.errors_to_wire() + # A mapping without ``recovery`` takes the first entry's, ``terminal`` here. + assert wire[0] == { + "code": "INVALID_REQUEST", + "message": "unknown product", + "field": "packages[1]", + "recovery": "terminal", + } + assert wire[1] == { + "code": "BUDGET_TOO_LOW", + "message": "too low", + "recovery": "correctable", + } + assert error.code == "INVALID_REQUEST" + + def test_from_errors_returns_the_base_class_for_a_subclass_entry(self) -> None: + error = AdcpError.from_errors([ValidationError(field="name"), _dates()]) + assert type(error) is AdcpError + assert error.code == "VALIDATION_ERROR" + assert [entry["code"] for entry in error.errors] == ["VALIDATION_ERROR", "INVALID_REQUEST"] + + def test_from_errors_rejects_an_empty_list(self) -> None: + with pytest.raises(ValueError): + AdcpError.from_errors([]) + + def test_errors_keyword_keeps_a_summary_envelope(self) -> None: + error = AdcpError( + "INVALID_REQUEST", + message="2 problems", + recovery="correctable", + errors=[_budget(), {"code": "INVALID_REQUEST", "message": "bad end_time"}], + ) + assert error.to_wire()["message"] == "2 problems" + assert [entry["message"] for entry in error.errors_to_wire()] == [ + "budget below minimum", + "bad end_time", + ] + # An entry without recovery takes the raised error's. + assert error.errors_to_wire()[1]["recovery"] == "correctable" + + @pytest.mark.parametrize( + "entry", + [ + {"message": "no code"}, + {"code": "", "message": "empty code"}, + {"code": "INVALID_REQUEST"}, + {"code": 7, "message": "numeric code"}, + ], + ) + def test_malformed_mapping_entry_is_refused(self, entry: dict[str, Any]) -> None: + with pytest.raises(ValueError): + AdcpError("INVALID_REQUEST", errors=[entry]) + + def test_non_mapping_entry_is_refused(self) -> None: + with pytest.raises(TypeError): + AdcpError("INVALID_REQUEST", errors=["budget below minimum"]) + + def test_entry_details_are_sanitized_like_to_wire(self) -> None: + details = {"connection": {"type": "oauth", "secret": "hunter2"}} + entry = AdcpError("AUTHORIZATION_REQUIRED", message="connect", details=details) + error = AdcpError.from_errors( + [_budget(), {"code": "AUTHORIZATION_REQUIRED", "message": "x", "details": details}] + ) + assert error.errors_to_wire()[1].get("details") == entry.to_wire().get("details") + + def test_errors_to_wire_returns_copies(self) -> None: + error = AdcpError.from_errors([_budget(), _dates()]) + error.errors_to_wire()[0]["code"] = "MUTATED" + assert error.errors_to_wire()[0]["code"] == "BUDGET_TOO_LOW" + + +class TestDetailsErrorsSlot: + def test_well_formed_details_errors_are_lifted(self) -> None: + error = AdcpError( + "INVALID_REQUEST", + message="2 problems", + recovery="correctable", + details={ + "errors": [ + {"code": "INVALID_REQUEST", "message": "budget", "field": "packages[0].budget"}, + {"code": "INVALID_REQUEST", "message": "dates", "field": "end_time"}, + ] + }, + ) + assert [entry["field"] for entry in error.errors_to_wire()] == [ + "packages[0].budget", + "end_time", + ] + assert {entry["recovery"] for entry in error.errors_to_wire()} == {"correctable"} + # The envelope keeps its details unchanged. + assert len(error.to_wire()["details"]["errors"]) == 2 + + @pytest.mark.parametrize( + "value", + [ + ["budget below minimum"], + [{"code": "INVALID_REQUEST"}], + [{"path": "x", "msg": "bad"}], + [], + "not a list", + ], + ) + def test_other_details_errors_are_left_alone(self, value: Any) -> None: + error = AdcpError("INVALID_REQUEST", message="bad", details={"errors": value}) + assert error.errors == [] + assert error.errors_to_wire() == [error.to_wire()] + + def test_explicit_errors_win_over_details(self) -> None: + error = AdcpError( + "INVALID_REQUEST", + details={"errors": [{"code": "A", "message": "a"}]}, + errors=[_dates()], + ) + assert [entry["code"] for entry in error.errors_to_wire()] == ["INVALID_REQUEST"] + + +class TestMcpProjection: + def test_issue_repro_returns_both_problems(self) -> None: + error = AdcpError( + "INVALID_REQUEST", + message="2 problems", + recovery="correctable", + details={ + "errors": [ + { + "code": "INVALID_REQUEST", + "message": "budget below minimum", + "field": "packages[0].budget", + }, + { + "code": "INVALID_REQUEST", + "message": "end_time before start_time", + "field": "end_time", + }, + ] + }, + ) + structured = build_mcp_error_result(error).structured_content + assert [entry.get("field") for entry in structured["errors"]] == [ + "packages[0].budget", + "end_time", + ] + assert structured["adcp_error"]["code"] == "INVALID_REQUEST" + + def test_from_errors_envelope_is_errors_zero(self) -> None: + structured = build_mcp_error_result( + AdcpError.from_errors([_budget(), _dates()]) + ).structured_content + assert structured["adcp_error"] == structured["errors"][0] + assert structured["adcp_error"] is not structured["errors"][0] + assert [entry["field"] for entry in structured["errors"]] == [ + "packages[0].budget", + "end_time", + ] + + def test_single_error_still_mirrors(self) -> None: + structured = build_mcp_error_result(_budget()).structured_content + assert structured["errors"] == [structured["adcp_error"]] + + def test_errors_survive_context_echo_and_enhancer(self) -> None: + seen: list[int] = [] + + def enhancer(response: dict[str, Any]) -> None: + seen.append(len(response["errors"])) + + structured = build_mcp_error_result( + AdcpError.from_errors([_budget(), _dates()]), + params={"context": {"correlation_id": "c-1"}}, + response_enhancer=enhancer, + ).structured_content + assert seen == [2] + assert structured["context"] == {"correlation_id": "c-1"} + + @pytest.mark.asyncio + async def test_through_tool_registration(self) -> None: + from mcp.server import MCPServer + + from adcp.server.serve import _register_tool + + async def caller(_kwargs: dict[str, Any], *, context: Any = None) -> Any: + raise AdcpError.from_errors([_budget(), _dates()]) + + mcp = MCPServer("test-1780") + _register_tool(mcp, "create_media_buy", "test", {"type": "object"}, caller) + result = await mcp.call_tool("create_media_buy", {}) + + assert isinstance(result, CallToolResult) + assert result.is_error is True + assert [entry["code"] for entry in result.structured_content["errors"]] == [ + "BUDGET_TOO_LOW", + "INVALID_REQUEST", + ] + + def test_translate_error_a2a_data_carries_every_error(self) -> None: + a2a_error = translate_error(AdcpError.from_errors([_budget(), _dates()]), "a2a") + data: Any = a2a_error.data # type: ignore[attr-defined] + assert [entry["field"] for entry in data["errors"]] == ["packages[0].budget", "end_time"] + + def test_translate_error_a2a_single_error_has_no_errors_key(self) -> None: + a2a_error = translate_error(_budget(), "a2a") + data: Any = a2a_error.data # type: ignore[attr-defined] + assert "errors" not in data + + +class _Raiser(ADCPHandler): + async def get_adcp_capabilities(self, params: Any, context: Any = None) -> dict[str, Any]: + return {"adcp": {"major_versions": [3]}, "supported_protocols": ["media_buy"]} + + async def get_products(self, params: Any, context: Any = None) -> Any: + raise AdcpError.from_errors([_budget(), _dates()]) + + +@pytest.mark.asyncio +async def test_a2a_failed_task_carries_every_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ADCP_SANDBOX", "1") + value = Value() + ParseDict({"skill": "get_products", "parameters": {}}, value) + message = pb.Message(message_id="m-1", role=pb.Role.ROLE_USER, parts=[pb.Part(data=value)]) + request = A2ARequestContext( + call_context=ServerCallContext(user=UnauthenticatedUser()), + request=pb.SendMessageRequest(message=message), + ) + queue = EventQueue() + await ADCPAgentExecutor(_Raiser(), validation=None).execute(request, queue) + task = await queue.dequeue_event() + + payloads = [ + MessageToDict(part.data) + for part in task.artifacts[0].parts + if part.WhichOneof("content") == "data" + ] + payload = next(p for p in payloads if "adcp_error" in p) + assert payload["adcp_error"]["code"] == "BUDGET_TOO_LOW" + assert [entry["field"] for entry in payload["errors"]] == ["packages[0].budget", "end_time"] From 67142036f40e6a68fd9ca3d5c51acaa7bfe402fb Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 2 Nov 2026 04:56:25 -0500 Subject: [PATCH 13/28] fix(canonical-formats): read versions from typed legacy formats (#1757) * fix(canonical-formats): read version constraints from typed legacy formats * test(a2a): isolate context ID handler registration --- src/adcp/canonical_formats/v1_to_v2.py | 7 ++- tests/integration/test_a2a_context_id.py | 10 ++-- tests/test_canonical_formats_v1_to_v2.py | 59 ++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/adcp/canonical_formats/v1_to_v2.py b/src/adcp/canonical_formats/v1_to_v2.py index c8e3205155..45f975f5c8 100644 --- a/src/adcp/canonical_formats/v1_to_v2.py +++ b/src/adcp/canonical_formats/v1_to_v2.py @@ -49,7 +49,7 @@ from dataclasses import dataclass, field from typing import Any -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError from adcp.canonical_formats.advisory import _echo_identifier, make_sdk_advisory from adcp.canonical_formats.registry import ( @@ -173,10 +173,13 @@ def _v1_version_constraints( """Collect VAST/DAAST version values from a v1 format. The version may live at the top level (a flat catalog entry) or - inside per-asset requirements. Walks both. Returns an empty list + inside per-asset requirements. Walks both dictionary and typed-model + inputs, including root-model version lists. Returns an empty list when none are declared. """ out: list[str] = [] + if isinstance(v1_format, BaseModel): + v1_format = v1_format.model_dump(exclude_none=True) src = v1_format if isinstance(v1_format, dict) else None if src is None: return out diff --git a/tests/integration/test_a2a_context_id.py b/tests/integration/test_a2a_context_id.py index 83551b9262..28c7fbca6b 100644 --- a/tests/integration/test_a2a_context_id.py +++ b/tests/integration/test_a2a_context_id.py @@ -60,7 +60,7 @@ ) -class _EchoHandler(ADCPHandler): +class _ContextIdEchoHandler(ADCPHandler): """Minimal handler for the happy-path tests — the assertions are at the protocol layer, not the handler. Returns spec-compliant empty payloads so the client's strict response validator passes.""" @@ -195,7 +195,7 @@ async def test_two_calls_share_server_assigned_context_id(): assertion that calls[0] == calls[1] catches that regression.""" observer = _Observer() wire_messages: list[dict[str, Any]] = [] - async with _running_server(_EchoHandler(), observer) as base_url: + async with _running_server(_ContextIdEchoHandler(), observer) as base_url: config = AgentConfig( id="ctx-test-agent", agent_uri=base_url, @@ -237,7 +237,7 @@ async def test_reset_context_produces_new_server_side_session(): different server-side context than the one before. If reset were a no-op the two server-observed ids would match and this would fire.""" observer = _Observer() - async with _running_server(_EchoHandler(), observer) as base_url: + async with _running_server(_ContextIdEchoHandler(), observer) as base_url: config = AgentConfig( id="ctx-test-agent", agent_uri=base_url, @@ -262,7 +262,7 @@ async def test_seeded_context_id_reaches_server_on_first_call(): state lands on the same server-side session.""" seed = f"buyer-seeded-{uuid4()}" observer = _Observer() - async with _running_server(_EchoHandler(), observer) as base_url: + async with _running_server(_ContextIdEchoHandler(), observer) as base_url: config = AgentConfig( id="ctx-test-agent", agent_uri=base_url, @@ -505,7 +505,7 @@ async def test_resume_across_simulated_restart_lands_on_same_session(): with the persisted id, and its first call must carry that id so the server can reattach it to the original session.""" observer = _Observer() - async with _running_server(_EchoHandler(), observer) as base_url: + async with _running_server(_ContextIdEchoHandler(), observer) as base_url: config = AgentConfig( id="ctx-test-agent", agent_uri=base_url, diff --git a/tests/test_canonical_formats_v1_to_v2.py b/tests/test_canonical_formats_v1_to_v2.py index 7c2fd2a3f0..bc57a930ae 100644 --- a/tests/test_canonical_formats_v1_to_v2.py +++ b/tests/test_canonical_formats_v1_to_v2.py @@ -18,6 +18,7 @@ ) from adcp.canonical_formats.advisory import SDK_ID from adcp.types import CanonicalFormatKind +from adcp.types.legacy import LegacyFormat _FIXTURES = Path(__file__).parent / "fixtures" / "canonical" @@ -245,6 +246,64 @@ def test_retina_rendition_set_registry_mapping_preserves_slot_contract( # --------------------------------------------------------------------------- +@pytest.mark.parametrize( + ("asset_type", "version_key", "version", "expected_kind"), + [ + ("vast", "vast_version", "4.2", CanonicalFormatKind.video_vast), + ("vast", "vast_version", "3.0", CanonicalFormatKind.video_vast), + ("vast", "vast_version", "2.0", CanonicalFormatKind.video_vast), + ("daast", "daast_version", "1.0", CanonicalFormatKind.audio_daast), + ("daast", "daast_versions", ["1.0", "1.1"], CanonicalFormatKind.audio_daast), + ], +) +def test_typed_versioned_format_matches_dict_projection( + asset_type: str, + version_key: str, + version: str | list[str], + expected_kind: CanonicalFormatKind, +) -> None: + raw = { + "format_id": {"agent_url": "https://creative.example", "id": "acme_preroll"}, + "name": "Acme preroll", + "assets": [ + { + "item_type": "individual", + "asset_id": "media", + "asset_type": asset_type, + "required": True, + "requirements": {version_key: version}, + } + ], + } + typed = LegacyFormat.model_validate(raw) + from_dict = project_v1_format_to_declaration(raw) + from_model = project_v1_format_to_declaration(typed) + assert from_dict.declaration is not None + assert from_dict.declaration.format_kind == expected_kind + assert from_model == from_dict + + +@pytest.mark.parametrize("asset_type", ["vast", "daast"]) +def test_typed_versionless_format_still_fails_closed(asset_type: str) -> None: + raw = { + "format_id": {"agent_url": "https://creative.example", "id": "acme_preroll"}, + "name": "Acme preroll", + "assets": [ + { + "item_type": "individual", + "asset_id": "media", + "asset_type": asset_type, + "required": True, + "requirements": {}, + } + ], + } + from_dict = project_v1_format_to_declaration(raw) + from_model = project_v1_format_to_declaration(LegacyFormat.model_validate(raw)) + assert from_dict.declaration is None + assert from_model == from_dict + + def test_structural_match_emits_ambiguous_advisory() -> None: v1 = { "format_id": {"agent_url": "https://x.example", "id": "weird_vast_format"}, From 617762e94371b5817f400481bbbc866c760a6a1a Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 2 Nov 2026 04:56:30 -0500 Subject: [PATCH 14/28] fix(canonical-formats): check narrowing on seller-asserted references (#1758) * fix(canonical-formats): check narrowing on seller-asserted references * test(a2a): isolate context ID handler registration --- src/adcp/canonical_formats/narrowing.py | 76 ++++- src/adcp/canonical_formats/v2_to_v1.py | 30 ++ .../canonical-narrowing-vectors-3.2.7.json | 316 ++++++++++++++++++ tests/test_canonical_formats_narrowing.py | 84 +++++ tests/test_canonical_formats_v2_to_v1.py | 61 ++++ 5 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/canonical-narrowing-vectors-3.2.7.json diff --git a/src/adcp/canonical_formats/narrowing.py b/src/adcp/canonical_formats/narrowing.py index edbf8d6574..fc9077f32e 100644 --- a/src/adcp/canonical_formats/narrowing.py +++ b/src/adcp/canonical_formats/narrowing.py @@ -39,7 +39,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Literal +from typing import Any, Literal, TypeGuard from adcp.canonical_formats.advisory import _echo_identifier, make_sdk_advisory from adcp.types import Error, Format @@ -148,6 +148,7 @@ def to_dict(self) -> dict[str, Any]: "allowed_card_media_asset_types", "vast_versions", "daast_versions", + "pixel_ratios", ) _EXACT_FIELDS: tuple[str, ...] = ( "aspect_ratio", @@ -155,6 +156,15 @@ def to_dict(self) -> dict[str, Any]: "ssl_required", "vast_version", # singular form on video_vast.json "daast_version", # singular form on audio_daast.json + "width", + "height", + "duration_ms_exact", +) +_NUMERIC_EXACT_FIELDS = {"width", "height", "duration_ms_exact"} +_RANGE_FIELDS = ( + ("width", None, "width"), + ("height", None, "height"), + ("duration_ms", "duration_ms_range", "duration_ms_exact"), ) # Per-element cap when echoing seller-controlled allowed-set / declared- @@ -212,6 +222,35 @@ def _is_subset(v2: Any, v1: Any) -> bool: return all(any(item == allowed for allowed in v1) for item in v2) +def _numeric_pair(value: Any) -> TypeGuard[list[Any] | tuple[Any, Any]]: + return ( + isinstance(value, (list, tuple)) + and len(value) == 2 + and all(bound is None or _is_numeric(bound) for bound in value) + and any(_is_numeric(bound) for bound in value) + ) + + +def _interval( + source: dict[str, Any], base: str, range_field: str | None, exact_field: str +) -> tuple[Any, Any, bool, bool]: + # Exact duration wins over the range per video_hosted.json. A null range + # endpoint is explicitly unbounded; a missing bound inherits from v1. + exact = source.get(exact_field) + if _is_numeric(exact): + return exact, exact, True, True + value = source.get(range_field) if range_field is not None else source.get(base) + if _numeric_pair(value): + return value[0], value[1], True, True + minimum, maximum = source.get(f"min_{base}"), source.get(f"max_{base}") + return ( + minimum if _is_numeric(minimum) else None, + maximum if _is_numeric(maximum) else None, + _is_numeric(minimum), + _is_numeric(maximum), + ) + + def _echo_set(value: Any) -> list[Any]: """Cap a list/set value to ``_ECHO_SET_CAP`` items before echoing into details. @@ -253,6 +292,8 @@ def check_narrows( v2_value = v2.get(field_name) if v2_value is None: v2_value = v2.get(field_name.removeprefix("max_")) + if v2_value is None and field_name == "max_duration_ms": + v2_value = v2.get("duration_ms_exact") if v2_value is None: continue # The value-being-capped form: v2 ``width`` against v1 ``max_width`` @@ -269,6 +310,8 @@ def check_narrows( v2_value = v2.get(field_name) if v2_value is None: v2_value = v2.get(field_name.removeprefix("min_")) + if v2_value is None and field_name == "min_duration_ms": + v2_value = v2.get("duration_ms_exact") if v2_value is None: continue if _is_numeric(v2_value) and v2_value < v1_min: @@ -296,11 +339,42 @@ def check_narrows( v2_value = v2.get(field_name) if v1_value is None or v2_value is None: continue + if field_name in _NUMERIC_EXACT_FIELDS and not ( + _is_numeric(v1_value) and _is_numeric(v2_value) + ): + continue if v1_value != v2_value: divergences.append( Divergence(field=field_name, kind="not_equal", cap=v1_value, value=v2_value) ) + for base, range_field, exact_field in _RANGE_FIELDS: + v1_lower, v1_upper, _, _ = _interval(v1, base, range_field, exact_field) + v2_lower, v2_upper, has_lower, has_upper = _interval(v2, base, range_field, exact_field) + if _is_numeric(v2.get(exact_field)): + lower_field = upper_field = exact_field + elif range_field is not None and _numeric_pair(v2.get(range_field)): + lower_field = upper_field = range_field + else: + lower_field, upper_field = f"min_{base}", f"max_{base}" + related_fields = {base, range_field, exact_field, f"min_{base}", f"max_{base}"} + + def add_interval_divergence(field: str, kind: DivergenceKind, cap: Any, value: Any) -> None: + # Keep existing scalar/named-cap diagnostics without duplicates. + if not any( + d.field in related_fields + and ( + d.kind == "not_equal" or (d.kind == kind and d.cap == cap and d.value == value) + ) + for d in divergences + ): + divergences.append(Divergence(field=field, kind=kind, cap=cap, value=value)) + + if v1_lower is not None and has_lower and (v2_lower is None or v2_lower < v1_lower): + add_interval_divergence(lower_field, "below_min", v1_lower, v2_lower) + if v1_upper is not None and has_upper and (v2_upper is None or v2_upper > v1_upper): + add_interval_divergence(upper_field, "exceeds_max", v1_upper, v2_upper) + return divergences diff --git a/src/adcp/canonical_formats/v2_to_v1.py b/src/adcp/canonical_formats/v2_to_v1.py index 5cdd2f6e45..d84454a543 100644 --- a/src/adcp/canonical_formats/v2_to_v1.py +++ b/src/adcp/canonical_formats/v2_to_v1.py @@ -36,10 +36,12 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass, field from typing import Any from adcp.canonical_formats.advisory import _echo_identifier, make_sdk_advisory +from adcp.canonical_formats.narrowing import narrowing_advisory from adcp.types import ( CanonicalFormatKind, Error, @@ -126,6 +128,7 @@ def project_declaration_to_v1( *, field_path: str = "format_options[]", product_id: str | None = None, + v1_requirements_lookup: Callable[[FormatId], dict[str, Any] | None] | None = None, ) -> V2ToV1Projection: """Project a single declaration to v1, emitting advisories per the resolution order documented at module level. @@ -138,6 +141,13 @@ def project_declaration_to_v1( callers wrapping a ``Product`` should pass the indexed form. product_id: Optional product identifier — surfaced in advisory ``details.product_id`` for buyer-side correlation. + v1_requirements_lookup: Optional synchronous lookup of the exact full + format reference in an operator-provisioned catalog snapshot. + Returned requirements are checked for narrowing; missing entries + (``None``) and an omitted lookup leave references unchecked, as + before. The SDK does not fetch a catalog or certify the lookup's + contents. Lookup exceptions propagate rather than hiding a failed + check. References are preserved even when an advisory is emitted. Returns: :class:`V2ToV1Projection` with the projected refs and any @@ -155,6 +165,21 @@ def project_declaration_to_v1( # Step 2: seller-asserted v1 link — emit refs, check multi-size fan-out. if refs: advisories: list[Error] = [] + if v1_requirements_lookup is not None: + for index, ref in enumerate(refs): + requirements = v1_requirements_lookup(ref) + if requirements is None: + continue + advisory = narrowing_advisory( + declaration, + v1_requirements=requirements, + v1_format_id=ref.id, + field_path=f"{field_path}.v1_format_ref[{index}]", + ) + if advisory is not None: + if product_id is not None and advisory.details is not None: + advisory.details["product_id"] = _echo_identifier(product_id) + advisories.append(advisory) sizes_n = _params_sizes_count(declaration) if sizes_n > len(refs): details: dict[str, Any] = { @@ -220,6 +245,7 @@ def project_product_to_v1( product: Any, *, product_index: int | None = None, + v1_requirements_lookup: Callable[[FormatId], dict[str, Any] | None] | None = None, ) -> V2ToV1Projection: """Project every ``format_options[]`` entry on a ``Product`` to v1. @@ -237,6 +263,9 @@ def project_product_to_v1( enclosing ``Products[]`` array. When provided, advisories carry the indexed field path (``products[N].format_options[K]``) so multi-product responses don't collapse to ambiguous pointers. + v1_requirements_lookup: Optional provisioned lookup forwarded to every + declaration. An omitted lookup or a missing catalog entry leaves + its references unchecked; see :func:`project_declaration_to_v1`. Returns: :class:`V2ToV1Projection` with the union of per-declaration results. @@ -255,6 +284,7 @@ def project_product_to_v1( decl, field_path=prefix, product_id=product_id, + v1_requirements_lookup=v1_requirements_lookup, ) out.format_ids.extend(result.format_ids) out.advisories.extend(result.advisories) diff --git a/tests/fixtures/canonical-narrowing-vectors-3.2.7.json b/tests/fixtures/canonical-narrowing-vectors-3.2.7.json new file mode 100644 index 0000000000..a2df84949a --- /dev/null +++ b/tests/fixtures/canonical-narrowing-vectors-3.2.7.json @@ -0,0 +1,316 @@ +{ + "version": 3, + "registry": "/schemas/registries/v1-canonical-mapping.json", + "registry_version": "1.5.2", + "description": "Reference vectors for literal v1 format-id projection, seller-authored projection precedence, and the normative v2-narrows-v1 comparison. Resolution uses the first exact format_id_glob match only when no higher-priority seller canonical annotation exists; these vectors do not provide a format definition for structural fallback. Narrowing vectors express the registry-expanded v1 baseline as exact, range, or set constraints so every SDK can apply the same parameter-by-parameter subsumption rules.", + "vectors": [ + { + "id": "parameterized-display-image-template", + "format_id": "display_image", + "expected_v2": { + "canonical": "image", + "parameter_mappings": [ + { "source_field": "width", "target_parameter": "width" }, + { "source_field": "height", "target_parameter": "height" }, + { "source_field": "pixel_ratio", "target_parameter": "pixel_ratios", "transform": "singleton_array" } + ] + }, + "notes": "SDKs consume these machine-readable rules to copy width, height, and pixel_ratio from the full structured format_id." + }, + { + "id": "static-display-with-no-size-constraint", + "format_id": "display_static", + "expected_v2": { "canonical": "image" }, + "notes": "The missing width and height are intentional; the id declares no size constraint." + }, + { + "id": "display-300-by-250", + "format_id": "display_300x250", + "expected_v2": { "canonical": "image", "parameters": { "width": 300, "height": 250 } } + }, + { + "id": "display-728-by-90", + "format_id": "display_728x90", + "expected_v2": { "canonical": "image", "parameters": { "width": 728, "height": 90 } } + }, + { + "id": "display-320-by-50", + "format_id": "display_320x50", + "expected_v2": { "canonical": "image", "parameters": { "width": 320, "height": 50 } } + }, + { + "id": "display-300-by-600", + "format_id": "display_300x600", + "expected_v2": { "canonical": "image", "parameters": { "width": 300, "height": 600 } } + }, + { + "id": "display-160-by-600", + "format_id": "display_160x600", + "expected_v2": { "canonical": "image", "parameters": { "width": 160, "height": 600 } } + }, + { + "id": "display-970-by-250", + "format_id": "display_970x250", + "expected_v2": { "canonical": "image", "parameters": { "width": 970, "height": 250 } } + }, + { + "id": "display-250-by-250", + "format_id": "display_250x250", + "expected_v2": { "canonical": "image", "parameters": { "width": 250, "height": 250 } } + }, + { + "id": "display-200-by-200", + "format_id": "display_200x200", + "expected_v2": { "canonical": "image", "parameters": { "width": 200, "height": 200 } } + }, + { + "id": "display-300-by-50", + "format_id": "display_300x50", + "expected_v2": { "canonical": "image", "parameters": { "width": 300, "height": 50 } } + }, + { + "id": "display-320-by-480", + "format_id": "display_320x480", + "expected_v2": { "canonical": "image", "parameters": { "width": 320, "height": 480 } } + }, + { + "id": "display-320-by-400", + "format_id": "display_320x400", + "expected_v2": { "canonical": "image", "parameters": { "width": 320, "height": 400 } } + }, + { + "id": "display-320-by-320", + "format_id": "display_320x320", + "expected_v2": { "canonical": "image", "parameters": { "width": 320, "height": 320 } } + }, + { + "id": "display-320-by-250", + "format_id": "display_320x250", + "expected_v2": { "canonical": "image", "parameters": { "width": 320, "height": 250 } } + }, + { + "id": "display-300-by-250-2x", + "format_id": "display_300x250_image_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 300, "height": 250, "pixel_ratios": [2] } } + }, + { + "id": "display-728-by-90-2x", + "format_id": "display_728x90_image_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 728, "height": 90, "pixel_ratios": [2] } } + }, + { + "id": "display-320-by-50-2x", + "format_id": "display_320x50_image_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 320, "height": 50, "pixel_ratios": [2] } } + }, + { + "id": "display-160-by-600-2x", + "format_id": "display_160x600_image_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 160, "height": 600, "pixel_ratios": [2] } } + }, + { + "id": "display-336-by-280-2x", + "format_id": "display_336x280_image_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 336, "height": 280, "pixel_ratios": [2] } } + }, + { + "id": "display-300-by-600-2x", + "format_id": "display_300x600_image_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 300, "height": 600, "pixel_ratios": [2] } } + }, + { + "id": "display-970-by-250-2x", + "format_id": "display_970x250_image_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 970, "height": 250, "pixel_ratios": [2] } } + }, + { + "id": "display-300-by-250-1x-2x", + "format_id": "display_300x250_image_1x_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 300, "height": 250, "pixel_ratios": [1, 2], "slots": [{ "asset_group_id": "image_main", "asset_type": "image", "required": true, "min": 2, "max": 2, "pixel_ratios": [1, 2], "required_pixel_ratios": [1, 2] }] } } + }, + { + "id": "display-728-by-90-1x-2x", + "format_id": "display_728x90_image_1x_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 728, "height": 90, "pixel_ratios": [1, 2], "slots": [{ "asset_group_id": "image_main", "asset_type": "image", "required": true, "min": 2, "max": 2, "pixel_ratios": [1, 2], "required_pixel_ratios": [1, 2] }] } } + }, + { + "id": "display-320-by-50-1x-2x", + "format_id": "display_320x50_image_1x_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 320, "height": 50, "pixel_ratios": [1, 2], "slots": [{ "asset_group_id": "image_main", "asset_type": "image", "required": true, "min": 2, "max": 2, "pixel_ratios": [1, 2], "required_pixel_ratios": [1, 2] }] } } + }, + { + "id": "display-160-by-600-1x-2x", + "format_id": "display_160x600_image_1x_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 160, "height": 600, "pixel_ratios": [1, 2], "slots": [{ "asset_group_id": "image_main", "asset_type": "image", "required": true, "min": 2, "max": 2, "pixel_ratios": [1, 2], "required_pixel_ratios": [1, 2] }] } } + }, + { + "id": "display-336-by-280-1x-2x", + "format_id": "display_336x280_image_1x_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 336, "height": 280, "pixel_ratios": [1, 2], "slots": [{ "asset_group_id": "image_main", "asset_type": "image", "required": true, "min": 2, "max": 2, "pixel_ratios": [1, 2], "required_pixel_ratios": [1, 2] }] } } + }, + { + "id": "display-300-by-600-1x-2x", + "format_id": "display_300x600_image_1x_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 300, "height": 600, "pixel_ratios": [1, 2], "slots": [{ "asset_group_id": "image_main", "asset_type": "image", "required": true, "min": 2, "max": 2, "pixel_ratios": [1, 2], "required_pixel_ratios": [1, 2] }] } } + }, + { + "id": "display-970-by-250-1x-2x", + "format_id": "display_970x250_image_1x_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 970, "height": 250, "pixel_ratios": [1, 2], "slots": [{ "asset_group_id": "image_main", "asset_type": "image", "required": true, "min": 2, "max": 2, "pixel_ratios": [1, 2], "required_pixel_ratios": [1, 2] }] } } + }, + { + "id": "hosted-video-30-seconds", + "format_id": "video_30s", + "expected_v2": { "canonical": "video_hosted", "parameters": { "duration_ms_exact": 30000 } } + }, + { + "id": "hosted-video-15-seconds", + "format_id": "video_15s", + "expected_v2": { "canonical": "video_hosted", "parameters": { "duration_ms_exact": 15000 } } + }, + { + "id": "pre-roll-video-30-seconds", + "format_id": "video_pre_roll_30s", + "expected_v2": { "canonical": "video_hosted", "parameters": { "duration_ms_exact": 30000 } } + }, + { + "id": "pre-roll-video-15-seconds", + "format_id": "video_pre_roll_15s", + "expected_v2": { "canonical": "video_hosted", "parameters": { "duration_ms_exact": 15000 } } + }, + { + "id": "hosted-video-640-by-480", + "format_id": "video_640x480", + "expected_v2": { "canonical": "video_hosted", "parameters": { "width": 640, "height": 480 } } + }, + { + "id": "hosted-video-300-by-250", + "format_id": "video_300x250", + "expected_v2": { "canonical": "video_hosted", "parameters": { "width": 300, "height": 250 } } + }, + { + "id": "hosted-video-16-to-9-30-seconds", + "format_id": "video_16x9_30s", + "expected_v2": { "canonical": "video_hosted", "parameters": { "aspect_ratio": "16:9", "duration_ms_exact": 30000 } } + }, + { + "id": "vast-token-suffix-640-by-360", + "format_id": "video_640x360_vast", + "expected_v2": { "canonical": "video_vast", "parameters": { "width": 640, "height": 360 } } + }, + { + "id": "hosted-audio-15-seconds", + "format_id": "audio_15s", + "expected_v2": { "canonical": "audio_hosted", "parameters": { "duration_ms_exact": 15000 } } + }, + { + "id": "durationless-pre-roll-fails-closed", + "format_id": "video_pre_roll", + "expected_v2": null, + "expected_outcome": "no_literal_match", + "notes": "Deliberately omitted because video_hosted without duration_ms_exact is untraffickable." + }, + { + "id": "durationless-mid-roll-fails-closed", + "format_id": "video_mid_roll", + "expected_v2": null, + "expected_outcome": "no_literal_match", + "notes": "Deliberately omitted because video_hosted without duration_ms_exact is untraffickable." + } + ], + "resolution_precedence_vectors": [ + { + "id": "seller-slot-override-replaces-registry-retina-defaults", + "format_id": "display_300x250_image_1x_2x", + "requirements": { "width": 300, "height": 250 }, + "seller_canonical": { + "kind": "image", + "asset_source": "agent_synthesized", + "slots_override": [ + { "asset_group_id": "generation_prompt", "asset_type": "text", "required": true } + ] + }, + "expected_v2": { + "canonical": "image", + "parameters": { + "width": 300, + "height": 250, + "asset_source": "agent_synthesized", + "slots": [ + { "asset_group_id": "generation_prompt", "asset_type": "text", "required": true } + ] + } + }, + "ignored_registry_parameters": ["pixel_ratios", "slots"], + "notes": "The seller canonical annotation is selected before the matching registry literal. Requirements still project, but registry pixel-density and rendition-slot defaults MUST NOT merge into the seller-authored declaration." + } + ], + "narrowing_vectors": [ + { + "id": "scalar-inside-v1-range-narrows", + "v1_baseline": { "width": { "kind": "range", "min": 200, "max": 400 } }, + "v2_params": { "width": 300 }, + "expected": { "narrows": true } + }, + { + "id": "scalar-outside-v1-range-conflicts", + "v1_baseline": { "width": { "kind": "range", "min": 200, "max": 400 } }, + "v2_params": { "width": 500 }, + "expected": { "narrows": false, "conflict": "width" } + }, + { + "id": "enum-subset-narrows", + "v1_baseline": { "image_formats": { "kind": "set", "values": ["jpg", "png", "gif", "webp"] } }, + "v2_params": { "image_formats": ["jpg", "png"] }, + "expected": { "narrows": true } + }, + { + "id": "enum-value-outside-v1-set-conflicts", + "v1_baseline": { "image_formats": { "kind": "set", "values": ["jpg", "png"] } }, + "v2_params": { "image_formats": ["jpg", "webp"] }, + "expected": { "narrows": false, "conflict": "image_formats" } + }, + { + "id": "pixel-density-subset-narrows", + "v1_baseline": { "pixel_ratios": { "kind": "set", "values": [1, 1.5, 2] } }, + "v2_params": { "pixel_ratios": [1, 2] }, + "expected": { "narrows": true } + }, + { + "id": "pixel-density-outside-v1-set-conflicts", + "v1_baseline": { "pixel_ratios": { "kind": "set", "values": [1, 2] } }, + "v2_params": { "pixel_ratios": [1, 3] }, + "expected": { "narrows": false, "conflict": "pixel_ratios" } + }, + { + "id": "contained-range-narrows", + "v1_baseline": { "duration_ms_range": { "kind": "range", "min": 3000, "max": 90000 } }, + "v2_params": { "duration_ms_range": [5000, 30000] }, + "expected": { "narrows": true } + }, + { + "id": "range-escaping-v1-upper-bound-conflicts", + "v1_baseline": { "duration_ms_range": { "kind": "range", "min": 3000, "max": 90000 } }, + "v2_params": { "duration_ms_range": [5000, 120000] }, + "expected": { "narrows": false, "conflict": "duration_ms_range" } + }, + { + "id": "absent-v2-parameter-inherits-v1", + "v1_baseline": { "width": { "kind": "exact", "value": 300 } }, + "v2_params": {}, + "expected": { "narrows": true } + }, + { + "id": "v2-constraint-against-open-v1-baseline-narrows", + "v1_baseline": {}, + "v2_params": { "image_formats": ["jpg"] }, + "expected": { "narrows": true } + }, + { + "id": "exact-v1-value-mismatch-conflicts", + "v1_baseline": { "width": { "kind": "exact", "value": 300 } }, + "v2_params": { "width": 320 }, + "expected": { "narrows": false, "conflict": "width" } + } + ] +} diff --git a/tests/test_canonical_formats_narrowing.py b/tests/test_canonical_formats_narrowing.py index cfde2e17e8..12a497df1a 100644 --- a/tests/test_canonical_formats_narrowing.py +++ b/tests/test_canonical_formats_narrowing.py @@ -8,12 +8,96 @@ from __future__ import annotations +import json +from pathlib import Path + import pytest from adcp.canonical_formats import check_narrows, narrowing_advisory from adcp.canonical_formats.advisory import SDK_ID from adcp.types import CanonicalFormatKind, Format +# Unmodified protocol v3.2.7 static/test-vectors/v1-canonical-mapping.json. +_NARROWING_VECTORS = json.loads( + (Path(__file__).parent / "fixtures/canonical-narrowing-vectors-3.2.7.json").read_text() +)["narrowing_vectors"] + + +@pytest.mark.parametrize("vector", _NARROWING_VECTORS, ids=lambda vector: vector["id"]) +def test_published_narrowing_vector(vector: dict) -> None: + requirements = {} + for field, constraint in vector["v1_baseline"].items(): + if constraint["kind"] == "exact": + requirements[field] = constraint["value"] + elif constraint["kind"] == "set": + requirements[field] = constraint["values"] + elif field.endswith("_range"): + requirements[field] = [constraint["min"], constraint["max"]] + else: + requirements[f"min_{field}"] = constraint["min"] + requirements[f"max_{field}"] = constraint["max"] + divergences = check_narrows(vector["v2_params"], requirements) + assert (not divergences) is vector["expected"]["narrows"] + if divergences: + fields = {d.field.removeprefix("min_").removeprefix("max_") for d in divergences} + assert vector["expected"]["conflict"] in fields + + +@pytest.mark.parametrize( + ("params", "requirements", "kind"), + [ + ( + {"duration_ms_range": [5000, 120000]}, + {"min_duration_ms": 3000, "max_duration_ms": 90000}, + "exceeds_max", + ), + ({"duration_ms_range": [1000, 30000]}, {"duration_ms_range": [3000, 90000]}, "below_min"), + ({"duration_ms_exact": 120000}, {"duration_ms_range": [3000, 90000]}, "exceeds_max"), + ( + {"duration_ms_exact": 120000}, + {"min_duration_ms": 3000, "max_duration_ms": 90000}, + "exceeds_max", + ), + ({"min_width": 200, "max_width": 400}, {"width": 300}, "exceeds_max"), + ({"min_width": 200, "max_width": 400}, {"width": 300}, "below_min"), + ({"height": 240}, {"height": 250}, "not_equal"), + ( + {"duration_ms_range": [None, 120000]}, + {"duration_ms_range": [3000, 90000]}, + "exceeds_max", + ), + ({"duration_ms_range": [None, 30000]}, {"duration_ms_range": [3000, 90000]}, "below_min"), + ({"duration_ms_range": [5000, None]}, {"duration_ms_range": [3000, 90000]}, "exceeds_max"), + ( + {"duration_ms_exact": 120000, "duration_ms_range": [5000, 60000]}, + {"duration_ms_range": [3000, 90000]}, + "exceeds_max", + ), + ], +) +def test_range_and_exact_constraint_conflicts(params: dict, requirements: dict, kind: str) -> None: + assert any(d.kind == kind for d in check_narrows(params, requirements)) + + +@pytest.mark.parametrize( + ("params", "requirements"), + [ + ({"duration_ms_range": [3000, 90000]}, {"min_duration_ms": 3000, "max_duration_ms": 90000}), + ({"duration_ms_exact": 5000}, {"duration_ms_range": [3000, 90000]}), + ({"width": 300}, {"width": 300}), + ({"min_width": 300, "max_width": 300}, {"width": 300}), + ({"duration_ms_range": [None, 30000]}, {"duration_ms_range": [None, 90000]}), + ({"duration_ms_range": [5000, None]}, {"duration_ms_range": [3000, None]}), + ( + {"duration_ms_exact": 5000, "duration_ms_range": [1000, 120000]}, + {"duration_ms_range": [3000, 90000]}, + ), + ], +) +def test_equal_or_contained_constraints_narrow(params: dict, requirements: dict) -> None: + assert check_narrows(params, requirements) == [] + + # --------------------------------------------------------------------------- # check_narrows — no divergence cases # --------------------------------------------------------------------------- diff --git a/tests/test_canonical_formats_v2_to_v1.py b/tests/test_canonical_formats_v2_to_v1.py index 62b66eddef..44ca513fc4 100644 --- a/tests/test_canonical_formats_v2_to_v1.py +++ b/tests/test_canonical_formats_v2_to_v1.py @@ -8,6 +8,8 @@ from __future__ import annotations +from types import SimpleNamespace + import pytest from adcp.canonical_formats import ( @@ -34,6 +36,65 @@ def _format_schema() -> dict[str, str]: } +def test_seller_refs_check_each_exact_reference_with_operator_lookup() -> None: + refs = [ + _ref("acme_banner"), + FormatId(agent_url="https://other-creative.example", id="acme_banner"), + ] + declaration = Format( + format_kind=CanonicalFormatKind.image, params={"width": 320}, v1_format_ref=refs + ) + looked_up = [] + + def lookup(ref: FormatId) -> dict: + looked_up.append(ref) + return {"width": 300 if ref.agent_url == refs[0].agent_url else 320} + + result = project_declaration_to_v1(declaration, v1_requirements_lookup=lookup) + assert looked_up == refs + assert ( + result.format_ids == refs + ), "advisory emission does not discard seller-authored references" + assert len(result.advisories) == 1 + assert result.advisories[0].code == "FORMAT_DECLARATION_DIVERGENT" + assert result.advisories[0].field == "format_options[].v1_format_ref[0]" + + +def test_product_projection_threads_lookup_and_advisory_location() -> None: + declaration = Format( + format_kind=CanonicalFormatKind.image, params={"width": 320}, v1_format_ref=[_ref()] + ) + product = SimpleNamespace(product_id="acme_display", format_options=[declaration]) + result = project_product_to_v1( + product, product_index=2, v1_requirements_lookup=lambda ref: {"width": 300} + ) + assert len(result.advisories) == 1 + assert result.advisories[0].field == "products[2].format_options[0].v1_format_ref[0]" + assert result.advisories[0].details["product_id"] == "acme_display" + + +def test_missing_requirements_preserve_unchecked_reference_behavior() -> None: + declaration = Format( + format_kind=CanonicalFormatKind.image, params={"width": 320}, v1_format_ref=[_ref()] + ) + assert project_declaration_to_v1(declaration).advisories == [] + result = project_declaration_to_v1(declaration, v1_requirements_lookup=lambda ref: None) + assert result.format_ids == list(declaration.legacy_format_refs) + assert result.advisories == [] + + +def test_requirement_lookup_failure_is_not_silenced() -> None: + declaration = Format( + format_kind=CanonicalFormatKind.image, params={"width": 320}, v1_format_ref=[_ref()] + ) + + def unavailable(ref: FormatId) -> dict: + raise ValueError("catalog snapshot unavailable") + + with pytest.raises(ValueError, match="catalog snapshot unavailable"): + project_declaration_to_v1(declaration, v1_requirements_lookup=unavailable) + + # --------------------------------------------------------------------------- # Step 1 — explicit v1-unreachability is silent (no refs, no advisories) # --------------------------------------------------------------------------- From f49ffc5d04989ba7f74e238b016af5f607757316 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 2 Nov 2026 05:50:35 -0500 Subject: [PATCH 15/28] ci(release): gate publishes on a companion the built distribution works with, on main (#1790) * ci(release): gate publishes on a companion the built distribution works with scripts/check_companion_releases.py refused an adcp release only when no published adcp-reporting-storage *range* accepted it. adcp 10.0.0b4 changed the ledger column lists while the published storage (adcp>=9.0.0,<11) still accepted it, so the pair installed cleanly and failed every reporting turn (#1763). Range compatibility is not contract compatibility. The gate now proves the pair works. With --dist, it installs the wheel just built together with the published companion into a private environment and runs scripts/companion_smoke.py there, which compares the storage contract each side declares (adcp.reporting.ledger.storage_contract versus adcp_reporting_storage.contract; releases predating the declaration are classified by their column lists) and, with ADCP_PG_TEST_URL, creates the ledger schema and round-trips a configuration, obligation and revision through the installed stores on PostgreSQL, the calls that failed on the incompatible pair. Storage publishes are gated too, in the other direction: the built storage wheel is smoked with the oldest and the newest published adcp its own requirement accepts, so a floor that admits an adcp of another contract is refused. release-publish.yml builds first, runs the gate for both packages against a PostgreSQL service, then uploads. Verified locally against PyPI and PostgreSQL 16: the adcp wheel built from next is refused against the published 0.2.0b0 (contract 2 versus 1), the storage wheel from #1787 (floor 10.0.0b4) passes with 10.0.0b4, and the published 0.1.0b4 passes with adcp 9.0.0 and 9.3.1. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 7b40f3429dbbe274cd820b915eb9ba57207f6541) * test(release): assert the gate smokes both packages and classify stubbed contracts The release configuration test expected the companion gate to run only for adcp publishes; it now runs for both packages after the build, with a PostgreSQL service. The smoke classification test stubs both declaration modules instead of assuming the workspace pair agrees, which it does not until the storage declaration (#1787) lands. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 876faa5e92ba2d571a52fa098d24b21b2f77e697) * test(release): classify the smoke's undeclared adcp from patched column lists The test asserted this checkout's column lists carry offering_id, which is true on next and false on main; both classifications are now driven by patched lists so the test is branch-agnostic. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- .github/workflows/release-publish.yml | 42 ++++- RELEASING.md | 14 ++ docs/releasing.md | 10 +- scripts/check_companion_releases.py | 190 ++++++++++++++++++-- scripts/companion_smoke.py | 214 +++++++++++++++++++++++ tests/test_check_companion_releases.py | 233 ++++++++++++++++++++++++- tests/test_release_configuration.py | 32 ++++ 7 files changed, 706 insertions(+), 29 deletions(-) create mode 100644 scripts/companion_smoke.py diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 8f60b6a11b..ada88615f3 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -35,6 +35,19 @@ jobs: permissions: contents: read actions: read + services: + postgres: + image: postgres:16 + env: + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_DB: adcp_release_gate + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 10 steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: @@ -71,12 +84,6 @@ jobs: - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.12' - - name: Require installable companion distributions for an adcp release - if: inputs.package == 'adcp' - run: | - python -m pip install packaging - version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" - python scripts/check_companion_releases.py "$version" - name: Build and check distributions env: PACKAGE: ${{ inputs.package }} @@ -88,6 +95,29 @@ jobs: python -m build --outdir dist/ packages/adcp-reporting-storage fi python -m twine check dist/* + # Every release, on main and on next, needs a published companion it + # actually works with, not one whose version range merely admits it + # (#1763: adcp 10.0.0b4 installed beside adcp-reporting-storage 0.2.0b0 + # and failed every reporting turn). The gate installs the wheel just + # built with the published companion into a private environment and + # runs scripts/companion_smoke.py there: both sides must declare the same + # storage contract, and the ledger must create its schema and round-trip + # records on the PostgreSQL service above. For adcp the companion is the + # newest published adcp-reporting-storage its range admits; for + # adcp-reporting-storage it is the oldest and the newest published adcp + # its own requirement accepts. + - name: Require a companion release the built distribution works with + env: + PACKAGE: ${{ inputs.package }} + ADCP_PG_TEST_URL: postgresql://postgres@localhost:5432/adcp_release_gate + run: | + python -m pip install packaging + if [ "$PACKAGE" = adcp ]; then + version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" + python scripts/check_companion_releases.py "$version" --dist dist/adcp-*.whl + else + python scripts/check_companion_releases.py --package "$PACKAGE" --dist dist/adcp_reporting_storage-*.whl + fi - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: release-dist diff --git a/RELEASING.md b/RELEASING.md index 1ead3f0078..f08658d454 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -79,6 +79,20 @@ so that merges in either direction never change the other line's release flow: to `main`'s releases, and adopters opt in with `pip install --pre adcp` or `adcp==10.0.0a0`. +- The companion gate (`scripts/check_companion_releases.py`, #1605, #1763) + runs for every publish of either package, from `main` and from `next`. An + `adcp` publish needs a published `adcp-reporting-storage` whose range + accepts it **and that works with it**: the workflow installs the wheel it + just built with that storage release into a private environment and runs + `scripts/companion_smoke.py`, which refuses a pair whose storage contracts + differ (`adcp.reporting.ledger.storage_contract` versus + `adcp_reporting_storage.contract`) and round-trips ledger records on a + PostgreSQL service. A storage publish is gated the same way against the + oldest and the newest published `adcp` its own requirement accepts. So when + the contract changes, order the releases: publish the storage release that + implements the new contract (its floor is the first `adcp` of that + contract) before the `adcp` release that declares it. + One-time setup: add `next` to the `release-publish` environment's deployment branches (GitHub Settings → Environments). The PyPI Trusted Publisher needs no change, because it is bound to the `release-publish.yml` file name and the diff --git a/docs/releasing.md b/docs/releasing.md index 68deea89ad..2f78d8bf11 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -75,8 +75,14 @@ commits with no such PR. Conventional-commit scopes are free text, but On a merge, `release-please.yml` queues `release-publish.yml` with `package=adcp-reporting-storage`; the workflow builds that package's sdist and wheel from `packages/adcp-reporting-storage` and publishes them through the same -`release-publish` environment approval. The storage wheel pins -`adcp>=9.0.0bN,<10`; publish the `adcp` release it depends on first. +`release-publish` environment approval. The storage wheel's `adcp` floor is +the first `adcp` release of the storage contract it implements +(`adcp_reporting_storage.contract`); publish that `adcp` release first. +Both publishes run the companion gate (`scripts/check_companion_releases.py`): +the wheel just built is installed beside the published companion in a private +environment and `scripts/companion_smoke.py` must pass there, so a pair whose +storage contracts differ, or whose ledger does not round-trip on PostgreSQL, +never reaches PyPI (#1763). One-time setup, before the first storage release: diff --git a/scripts/check_companion_releases.py b/scripts/check_companion_releases.py index 7df876bcef..81d531dc68 100644 --- a/scripts/check_companion_releases.py +++ b/scripts/check_companion_releases.py @@ -1,22 +1,42 @@ #!/usr/bin/env python3 -"""Refuse to publish an ``adcp`` release its companion distributions can't install with. +"""Refuse to publish a distribution its companion can't actually work with. 9.0.0 moved the PostgreSQL reporting stores into ``adcp-reporting-storage`` before that package was on PyPI, so the documented ``pip install "adcp-reporting-storage[pg]"`` failed and Postgres adopters were -stuck on the last beta. Before an ``adcp`` release publishes, every companion -distribution must have a published, non-yanked release whose ``adcp`` -requirement accepts the version being released. +stuck on the last beta. Then adcp 10.0.0b4 changed the ledger column lists the +storage package reads against its DDL, and the published storage release +(``adcp>=9.0.0,<11``) still *installed* beside it and failed every reporting +turn (adcp-client-python#1763). Range compatibility is not enough. + +Before an ``adcp`` release publishes, every companion distribution must have a +published, non-yanked release whose ``adcp`` requirement accepts the version +being released (``compatible_release``). With ``--dist``, the gate goes +further and proves the pair works: it installs the built distribution and the +published companion into a private environment and runs +``scripts/companion_smoke.py`` there, which compares the storage contract both +sides declare and, when ``ADCP_PG_TEST_URL`` is set, creates the ledger schema +and round-trips records on PostgreSQL. ``--package adcp-reporting-storage`` +gates the companion in the other direction: the built storage distribution is +smoked with both the oldest and the newest published ``adcp`` its own +requirement accepts. """ from __future__ import annotations import argparse import json +import os +import shutil +import subprocess import sys +import tempfile import urllib.error import urllib.request +import zipfile from collections.abc import Callable +from email.parser import HeaderParser +from pathlib import Path from typing import Any from packaging.requirements import Requirement @@ -25,8 +45,12 @@ #: Distributions maintained in this repository that adopters install with adcp. COMPANIONS = ("adcp-reporting-storage",) +#: The extra that installs the PostgreSQL driver the smoke needs. +STORAGE_EXTRA = "pg" +SMOKE = Path(__file__).with_name("companion_smoke.py") Fetch = Callable[[str], Any] +Run = Callable[[list[str]], subprocess.CompletedProcess[str]] def _pypi(url: str) -> Any: @@ -40,6 +64,13 @@ def _pypi(url: str) -> Any: raise +def _run(command: list[str]) -> subprocess.CompletedProcess[str]: + environment = {key: value for key, value in os.environ.items() if key != "PYTHONPATH"} + return subprocess.run( # noqa: S603 + command, capture_output=True, text=True, timeout=900, env=environment, check=False + ) + + def _adcp_requirement(requires_dist: list[str] | None) -> Requirement | None: for line in requires_dist or (): requirement = Requirement(line) @@ -48,20 +79,25 @@ def _adcp_requirement(requires_dist: list[str] | None) -> Requirement | None: return None -def compatible_release(companion: str, adcp_version: Version, fetch: Fetch = _pypi) -> str | None: - """Return the newest published ``companion`` release that installs with ``adcp_version``.""" - project = fetch(f"https://pypi.org/pypi/{companion}/json") - if not project: - return None +def _published_versions(project: Any) -> list[Version]: + """Non-yanked releases of a PyPI project, newest first.""" candidates = [] - for raw, files in project.get("releases", {}).items(): + for raw, files in (project or {}).get("releases", {}).items(): try: version = Version(raw) except InvalidVersion: continue if files and not all(file.get("yanked") for file in files): candidates.append(version) - for version in sorted(candidates, reverse=True): + return sorted(candidates, reverse=True) + + +def compatible_release(companion: str, adcp_version: Version, fetch: Fetch = _pypi) -> str | None: + """Return the newest published ``companion`` release that installs with ``adcp_version``.""" + project = fetch(f"https://pypi.org/pypi/{companion}/json") + if not project: + return None + for version in _published_versions(project): release = fetch(f"https://pypi.org/pypi/{companion}/{version}/json") requirement = _adcp_requirement((release or {}).get("info", {}).get("requires_dist")) if requirement is not None and requirement.specifier.contains( @@ -71,18 +107,74 @@ def compatible_release(companion: str, adcp_version: Version, fetch: Fetch = _py return None -def main(argv: list[str] | None = None, fetch: Fetch = _pypi) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("adcp_version", help="the adcp version about to be published") - args = parser.parse_args(argv) - adcp_version = Version(args.adcp_version) +def accepted_adcp_releases(requirement: Requirement, fetch: Fetch = _pypi) -> list[str]: + """Published, non-yanked ``adcp`` releases the requirement accepts, oldest first.""" + project = fetch("https://pypi.org/pypi/adcp/json") + accepted = [ + version + for version in _published_versions(project) + if requirement.specifier.contains(version, prereleases=True) + ] + return [str(version) for version in sorted(accepted)] + + +def wheel_adcp_requirement(distribution: Path) -> Requirement: + """The ``adcp`` requirement a built companion wheel declares.""" + with zipfile.ZipFile(distribution) as wheel: + metadata = next(name for name in wheel.namelist() if name.endswith(".dist-info/METADATA")) + headers = HeaderParser().parsestr(wheel.read(metadata).decode("utf-8")) + requirement = _adcp_requirement(headers.get_all("Requires-Dist")) + if requirement is None: + raise SystemExit(f"{distribution.name} declares no adcp requirement") + return requirement + + +def smoke(requirements: list[str], *, label: str, run: Run = _run) -> bool: + """Install ``requirements`` into a private environment and run the companion smoke.""" + with tempfile.TemporaryDirectory(prefix="adcp-companion-") as root: + environment = Path(root) / "env" + if shutil.which("uv"): + create = ["uv", "venv", "--quiet", "--python", sys.executable, str(environment)] + python = environment / "bin" / "python" + install = ["uv", "pip", "install", "--quiet", "--python", str(python)] + else: + create = [sys.executable, "-m", "venv", str(environment)] + python = environment / "bin" / "python" + install = [str(python), "-m", "pip", "install", "--quiet"] + for step, command in (("create", create), ("install", [*install, *requirements])): + completed = run(command) + if completed.returncode != 0: + print(f"{label}: could not {step} the smoke environment", file=sys.stderr) + print(completed.stderr[-4000:], file=sys.stderr) + return False + script = Path(root) / SMOKE.name + shutil.copy2(SMOKE, script) + completed = run([str(python), "-I", str(script)]) + sys.stdout.write(completed.stdout) + if completed.returncode != 0: + print(f"{label}: {completed.stderr.strip()}", file=sys.stderr) + return False + return True + + +def gate_adcp( + adcp_version: Version, distribution: Path | None, fetch: Fetch = _pypi, run: Run = _run +) -> int: + """An adcp release needs a published companion that installs with it and works with it.""" missing = [] + failed = [] for companion in COMPANIONS: found = compatible_release(companion, adcp_version, fetch) if found is None: missing.append(companion) - else: - print(f"{companion} {found} installs with adcp {adcp_version}") + continue + print(f"{companion} {found} installs with adcp {adcp_version}") + if distribution is not None and not smoke( + [str(distribution), f"{companion}[{STORAGE_EXTRA}]=={found}"], + label=f"adcp {adcp_version} + {companion} {found}", + run=run, + ): + failed.append(f"{companion} {found}") if missing: print( f"No published release of {', '.join(missing)} accepts adcp {adcp_version}. " @@ -90,8 +182,70 @@ def main(argv: list[str] | None = None, fetch: Fetch = _pypi) -> int: file=sys.stderr, ) return 1 + if failed: + print( + f"adcp {adcp_version} does not work with the published {', '.join(failed)}. " + "Publish a companion release that matches this adcp's storage contract first.", + file=sys.stderr, + ) + return 1 + return 0 + + +def gate_companion(distribution: Path, fetch: Fetch = _pypi, run: Run = _run) -> int: + """A companion release must work with the oldest and newest adcp its range accepts.""" + requirement = wheel_adcp_requirement(distribution) + accepted = accepted_adcp_releases(requirement, fetch) + if not accepted: + print( + f"No published adcp release satisfies {requirement}; publish adcp first.", + file=sys.stderr, + ) + return 1 + failed = [] + for adcp_version in sorted({accepted[0], accepted[-1]}, key=Version): + print(f"{distribution.name} ({requirement}) accepts the published adcp {adcp_version}") + if not smoke( + [f"adcp=={adcp_version}", f"{distribution}[{STORAGE_EXTRA}]"], + label=f"{distribution.name} + adcp {adcp_version}", + run=run, + ): + failed.append(adcp_version) + if failed: + print( + f"{distribution.name} does not work with the published adcp {', '.join(failed)} its " + "requirement accepts. Raise the floor to the first adcp of its storage contract.", + file=sys.stderr, + ) + return 1 return 0 +def main(argv: list[str] | None = None, fetch: Fetch = _pypi, run: Run = _run) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "adcp_version", nargs="?", help="the adcp version about to be published (package adcp)" + ) + parser.add_argument( + "--package", + choices=("adcp", *COMPANIONS), + default="adcp", + help="the distribution about to be published", + ) + parser.add_argument( + "--dist", + type=Path, + help="the built wheel of that distribution; when given, the pair is installed and smoked", + ) + args = parser.parse_args(argv) + if args.package == "adcp": + if not args.adcp_version: + parser.error("the adcp version is required for --package adcp") + return gate_adcp(Version(args.adcp_version), args.dist, fetch, run) + if args.dist is None: + parser.error(f"--dist is required for --package {args.package}") + return gate_companion(args.dist, fetch, run) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/scripts/companion_smoke.py b/scripts/companion_smoke.py new file mode 100644 index 0000000000..439e85421c --- /dev/null +++ b/scripts/companion_smoke.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Prove an installed ``adcp`` + ``adcp-reporting-storage`` pair works, before publishing. + +Run inside an environment where both distributions are installed (the release +gate builds one from the distribution under release and the published +companion). Checks, in order: + +1. **Storage contract.** Both sides name the reporting ledger layout they + implement with one integer (``adcp.reporting.ledger.storage_contract``, + ``adcp_reporting_storage.contract``). Releases that predate the + declaration are classified: an adcp whose obligation column list carries + ``offering_id`` is contract 2, else 1; a storage without the module is + contract 1. A mismatch is the failure of adcp-client-python#1763, refused + here with both versions named. +2. **The ledger, on PostgreSQL** (when ``ADCP_PG_TEST_URL`` is set): in a + private schema, ``create_schema()``, then write and read back a + configuration generation, an obligation and a revision through the + installed stores, the calls that failed on the incompatible pair. + +Exit status 0 when the pair works; 1 with a one-line reason otherwise. Uses +only APIs shared by adcp 9.x and 10.x, so the gate runs on both release lines. +""" + +from __future__ import annotations + +import asyncio +import os +import secrets +import sys +from datetime import datetime, timedelta, timezone +from importlib import import_module +from importlib.metadata import PackageNotFoundError, version + + +def installed(distribution: str) -> str: + try: + return version(distribution) + except PackageNotFoundError: + return "not installed" + + +def adcp_contract() -> int: + try: + return int( + import_module("adcp.reporting.ledger.storage_contract").REPORTING_STORAGE_CONTRACT + ) + except ImportError: + columns = import_module("adcp.reporting.ledger._record_rows")._OBLIGATION_COLUMNS + return 2 if "offering_id" in [item.strip() for item in columns.split(",")] else 1 + + +def storage_contract() -> int: + try: + return int(import_module("adcp_reporting_storage.contract").STORAGE_CONTRACT) + except ImportError: + return 1 # 0.1.0b4 and 0.2.0b0 predate the declaration + + +def check_contract() -> None: + expected, implemented = adcp_contract(), storage_contract() + pair = ( + f"adcp {installed('adcp')} + adcp-reporting-storage {installed('adcp-reporting-storage')}" + ) + if expected != implemented: + raise SystemExit( + f"{pair}: adcp reads the ledger with storage contract {expected} but the storage " + f"implements contract {implemented}; the pair would fail every reporting turn" + ) + print(f"{pair}: storage contract {expected} on both sides") + + +async def check_ledger(url: str) -> None: + from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore + from psycopg import AsyncConnection, sql + from psycopg_pool import AsyncConnectionPool + + from adcp.reporting.ledger import ( + ReportingConfiguration, + ReportingObligationRecord, + ReportingRevisionRecord, + ReportingScheduleSpec, + ReportingStatusCaller, + derive_period, + revision_content_sha256, + ) + from adcp.reporting.ledger.models import first_ordinal_after + + schedule = ReportingScheduleSpec(period_duration="PT1H", delivery_sla="PT1H", alignment="utc") + activated = datetime(2026, 9, 1, 0, 20, tzinfo=timezone.utc) + configuration = ReportingConfiguration( + delivery_config_id="smoke", + delivery_config_version=1, + account_id="smoke-account", + consumer_id="smoke-buyer", + report_definition_id="smoke_v1", + reporting_profile="paid_media_delivery", + feed_purpose="analytics", + schedule=schedule, + required_finality="official", + activated_at=activated, + media_buy_ids=("mb_1",), + ) + ordinal = first_ordinal_after(schedule, account_timezone="UTC", activated_at=activated) + period = derive_period(schedule, account_timezone="UTC", ordinal=ordinal) + obligation = ReportingObligationRecord( + reporting_obligation_id="rpo_smoke", + account_id=configuration.account_id, + consumer_id=configuration.consumer_id, + delivery_config_id=configuration.delivery_config_id, + delivery_config_version=configuration.delivery_config_version, + report_definition_id=configuration.report_definition_id, + reporting_profile=configuration.reporting_profile, + feed_purpose=configuration.feed_purpose, + period=period, + scope_resolved_at=period.end, + media_buy_ids=configuration.media_buy_ids, + required_finality=configuration.required_finality, + automated_recovery_deadline_at=period.expected_at + timedelta(hours=6), + schedule=schedule, + created_at=period.end, + currency="USD", + ) + rows = [{"media_buy_id": "mb_1", "impressions": 5}] + totals = (("impressions", "5"),) + revision = ReportingRevisionRecord( + reporting_revision_id="rpr_smoke", + account_id=obligation.account_id, + reporting_obligation_id=obligation.reporting_obligation_id, + finality="official", + revision_content_sha256=revision_content_sha256( + reporting_revision_id="rpr_smoke", + row_count=1, + control_totals=totals, + reporting_rows=rows, + ), + row_count=1, + control_totals=totals, + observed_at=period.end, + data_through=period.end, + created_at=period.expected_at, + finality_basis="source_final", + finality_policy_id="policy_1", + finalized_at=period.end, + ) + + schema = f"adcp_companion_smoke_{secrets.token_hex(6)}" + async with await AsyncConnection.connect(url, autocommit=True) as admin: + await admin.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema))) + try: + async with AsyncConnectionPool( + url, + kwargs={"options": f"-csearch_path={schema}", "autocommit": True}, + min_size=1, + max_size=4, + open=False, + ) as pool: + await pool.open(wait=True) + store = PgReportingLedgerStore(pool=pool) + await store.create_schema() + await store.put_configuration(configuration) + committed = await store.commit_obligation(obligation) + await store.commit_revision(revision, rows) + found = await store.find_obligation( + account_id=configuration.account_id, + consumer_id=configuration.consumer_id, + delivery_config_id=configuration.delivery_config_id, + delivery_config_version=configuration.delivery_config_version, + period_start=period.start, + period_end=period.end, + ) + listed = await store.list_configurations( + caller=ReportingStatusCaller( + account_id=configuration.account_id, + consumer_id=configuration.consumer_id, + ) + ) + revisions = await store.list_revisions( + account_id=configuration.account_id, + reporting_obligation_id=committed.reporting_obligation_id, + ) + recovered = await store.list_all_configurations() + await store.create_schema() # a restart's bootstrap is a no-op + if found is None or found.reporting_obligation_id != "rpo_smoke": + raise SystemExit("the installed pair cannot find the obligation it committed") + if len(listed) != 1 or len(recovered) != 1: + raise SystemExit("the installed pair cannot read the configuration it stored") + if [item.reporting_revision_id for item in revisions] != ["rpr_smoke"]: + raise SystemExit("the installed pair cannot read the revision it committed") + finally: + await admin.execute(sql.SQL("DROP SCHEMA {} CASCADE").format(sql.Identifier(schema))) + print("the installed pair creates the ledger schema and round-trips its records") + + +def main() -> int: + try: + check_contract() + url = os.environ.get("ADCP_PG_TEST_URL") + if url: + asyncio.run(check_ledger(url)) + else: + print("ADCP_PG_TEST_URL not set: the PostgreSQL round trip was skipped") + except SystemExit as error: + if error.code not in (None, 0): + print(f"companion smoke failed: {error.code}", file=sys.stderr) + return 1 + raise + except Exception as error: # a driver or DDL failure is a failing gate too + print(f"companion smoke failed: {type(error).__name__}: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_check_companion_releases.py b/tests/test_check_companion_releases.py index dd1441b888..82cbe46143 100644 --- a/tests/test_check_companion_releases.py +++ b/tests/test_check_companion_releases.py @@ -1,15 +1,22 @@ -"""The adcp release gate requires an installable companion on PyPI.""" +"""The release gate requires a companion on PyPI that installs *and works* with the release.""" from __future__ import annotations +import subprocess +import sys +import types +import zipfile +from pathlib import Path from typing import Any import pytest +from packaging.requirements import Requirement from packaging.version import Version from scripts import check_companion_releases as gate _PROJECT = "https://pypi.org/pypi/adcp-reporting-storage/json" +_ADCP = "https://pypi.org/pypi/adcp/json" def _release(requirement: str | None) -> dict[str, Any]: @@ -17,11 +24,16 @@ def _release(requirement: str | None) -> dict[str, Any]: return {"info": {"requires_dist": requires}} -def _fetch(releases: dict[str, tuple[str | None, bool]]) -> gate.Fetch: +def _fetch( + releases: dict[str, tuple[str | None, bool]], adcp: dict[str, bool] | None = None +) -> gate.Fetch: data: dict[str, Any] = { _PROJECT: { "releases": {version: [{"yanked": yanked}] for version, (_, yanked) in releases.items()} - } + }, + _ADCP: { + "releases": {version: [{"yanked": yanked}] for version, yanked in (adcp or {}).items()} + }, } for version, (requirement, _) in releases.items(): data[f"https://pypi.org/pypi/adcp-reporting-storage/{version}/json"] = _release(requirement) @@ -72,3 +84,218 @@ def test_newest_compatible_release_is_reported() -> None: } ) assert gate.compatible_release("adcp-reporting-storage", Version("9.1.0"), fetch) == "0.1.0b4" + + +# -- the built distribution must work with the published companion (#1763) ---- + + +class _Runner: + """A fake subprocess: records the commands, scripts the smoke's verdict.""" + + def __init__(self, smoke_returncode: int = 0, install_returncode: int = 0) -> None: + self.commands: list[list[str]] = [] + self.smoke_returncode = smoke_returncode + self.install_returncode = install_returncode + + def __call__(self, command: list[str]) -> subprocess.CompletedProcess[str]: + self.commands.append(command) + if command[-1].endswith("companion_smoke.py"): + return subprocess.CompletedProcess( + command, + self.smoke_returncode, + stdout="smoke stdout\n", + stderr="" if self.smoke_returncode == 0 else "storage contract 2 vs 1", + ) + if "install" in command: + return subprocess.CompletedProcess(command, self.install_returncode, "", "pip failed") + return subprocess.CompletedProcess(command, 0, "", "") + + def installed(self) -> list[str]: + """The requirements of the install step, without the installer's own options.""" + for command in self.commands: + if "install" not in command: + continue + arguments = iter(command[command.index("install") + 1 :]) + requirements = [] + for item in arguments: + if item == "--python": + next(arguments) # the interpreter path + elif not item.startswith("-"): + requirements.append(item) + return requirements + return [] + + def smoked(self) -> int: + return sum(1 for command in self.commands if command[-1].endswith("companion_smoke.py")) + + +def test_the_range_compatible_companion_is_smoked_with_the_built_adcp(tmp_path: Path) -> None: + dist = tmp_path / "adcp-10.0.0b6-py3-none-any.whl" + dist.touch() + fetch = _fetch({"0.2.0b1": ("adcp>=10.0.0b4,<11", False)}) + runner = _Runner() + assert gate.main(["10.0.0b6", "--dist", str(dist)], fetch=fetch, run=runner) == 0 + assert runner.installed() == [str(dist), "adcp-reporting-storage[pg]==0.2.0b1"] + assert runner.smoked() == 1 + assert all("-I" in command for command in runner.commands if command[-1].endswith(".py")) + + +def test_a_companion_that_installs_but_does_not_work_blocks_the_release( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """The #1763 pair: range-compatible, contract-incompatible.""" + dist = tmp_path / "adcp-10.0.0b4-py3-none-any.whl" + dist.touch() + fetch = _fetch({"0.2.0b0": ("adcp>=9.0.0,<11", False)}) + assert gate.main(["10.0.0b4", "--dist", str(dist)], fetch=fetch, run=_Runner(1)) == 1 + err = capsys.readouterr().err + assert "storage contract 2 vs 1" in err + assert "does not work with the published adcp-reporting-storage 0.2.0b0" in err + + +def test_a_smoke_environment_that_cannot_install_is_a_failing_gate(tmp_path: Path) -> None: + dist = tmp_path / "adcp-10.0.0b6-py3-none-any.whl" + dist.touch() + fetch = _fetch({"0.2.0b1": ("adcp>=10.0.0b4,<11", False)}) + runner = _Runner(install_returncode=1) + assert gate.main(["10.0.0b6", "--dist", str(dist)], fetch=fetch, run=runner) == 1 + assert runner.smoked() == 0 + + +def test_without_a_built_distribution_only_the_range_is_checked() -> None: + runner = _Runner(1) + fetch = _fetch({"0.2.0b0": ("adcp>=9.0.0,<11", False)}) + assert gate.main(["10.0.0b5"], fetch=fetch, run=runner) == 0 + assert runner.commands == [] + + +def _storage_wheel(path: Path, requirement: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(path, "w") as wheel: + wheel.writestr( + "adcp_reporting_storage-0.2.0b1.dist-info/METADATA", + "Metadata-Version: 2.1\nName: adcp-reporting-storage\nVersion: 0.2.0b1\n" + f"Requires-Dist: {requirement}\nRequires-Dist: psycopg>=3; extra == 'pg'\n", + ) + return path + + +def test_the_built_companion_is_smoked_with_the_oldest_and_newest_accepted_adcp( + tmp_path: Path, +) -> None: + dist = _storage_wheel( + tmp_path / "adcp_reporting_storage-0.2.0b1-py3-none-any.whl", "adcp<11,>=10.0.0b4" + ) + assert gate.wheel_adcp_requirement(dist) == Requirement("adcp<11,>=10.0.0b4") + fetch = _fetch( + {}, adcp={"10.0.0b3": False, "10.0.0b4": False, "10.0.0b5": False, "9.4.0": False} + ) + assert gate.accepted_adcp_releases(Requirement("adcp>=10.0.0b4,<11"), fetch) == [ + "10.0.0b4", + "10.0.0b5", + ] + runner = _Runner() + assert ( + gate.main( + ["--package", "adcp-reporting-storage", "--dist", str(dist)], fetch=fetch, run=runner + ) + == 0 + ) + assert runner.smoked() == 2 + installs = [c for c in runner.commands if "install" in c] + assert [c[-2] for c in installs] == ["adcp==10.0.0b4", "adcp==10.0.0b5"] + assert all(c[-1] == f"{dist}[pg]" for c in installs) + + +def test_a_companion_whose_floor_admits_an_adcp_it_fails_with_is_refused( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + dist = _storage_wheel( + tmp_path / "adcp_reporting_storage-0.2.0b1-py3-none-any.whl", "adcp>=9.0.0,<11" + ) + fetch = _fetch({}, adcp={"9.3.1": False, "10.0.0b4": False}) + assert ( + gate.main( + ["--package", "adcp-reporting-storage", "--dist", str(dist)], + fetch=fetch, + run=_Runner(1), + ) + == 1 + ) + assert "Raise the floor" in capsys.readouterr().err + + +def test_a_companion_with_no_published_adcp_in_range_is_refused(tmp_path: Path) -> None: + dist = _storage_wheel( + tmp_path / "adcp_reporting_storage-0.3.0-py3-none-any.whl", "adcp>=11,<12" + ) + fetch = _fetch({}, adcp={"10.0.0b5": False}) + assert ( + gate.main( + ["--package", "adcp-reporting-storage", "--dist", str(dist)], fetch=fetch, run=_Runner() + ) + == 1 + ) + + +def test_the_companion_package_requires_a_built_distribution() -> None: + with pytest.raises(SystemExit): + gate.main(["--package", "adcp-reporting-storage"]) + with pytest.raises(SystemExit): + gate.main(["--package", "adcp"]) + + +@pytest.fixture +def smoke() -> Any: + import importlib.util + + spec = importlib.util.spec_from_file_location("companion_smoke", gate.SMOKE) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _declaring(name: str, value: int) -> Any: + return types.SimpleNamespace(**{name: value}) + + +def test_the_smoke_script_reads_declared_contracts( + smoke: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setitem( + sys.modules, + "adcp.reporting.ledger.storage_contract", + _declaring("REPORTING_STORAGE_CONTRACT", 2), + ) + monkeypatch.setitem( + sys.modules, "adcp_reporting_storage.contract", _declaring("STORAGE_CONTRACT", 2) + ) + assert smoke.adcp_contract() == smoke.storage_contract() == 2 + smoke.check_contract() + monkeypatch.setitem( + sys.modules, "adcp_reporting_storage.contract", _declaring("STORAGE_CONTRACT", 3) + ) + with pytest.raises(SystemExit, match="contract 2 but the storage implements contract 3"): + smoke.check_contract() + + +def test_the_smoke_script_classifies_releases_without_a_declaration( + smoke: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """adcp 10.0.0b4/b5 by their column lists; storage 0.1.0b4/0.2.0b0 as contract 1.""" + from adcp.reporting.ledger import _record_rows + + monkeypatch.setitem(sys.modules, "adcp.reporting.ledger.storage_contract", None) + monkeypatch.setitem(sys.modules, "adcp_reporting_storage.contract", None) + assert smoke.storage_contract() == 1 + without = _record_rows._OBLIGATION_COLUMNS.replace(", offering_id", "") + monkeypatch.setattr(_record_rows, "_OBLIGATION_COLUMNS", without) + assert smoke.adcp_contract() == 1 # 9.x and 10.0.0b0-b3 + smoke.check_contract() + monkeypatch.setattr(_record_rows, "_OBLIGATION_COLUMNS", without + ", offering_id") + assert smoke.adcp_contract() == 2 # 10.0.0b4 and b5 + with pytest.raises( + SystemExit, match="storage contract 2 but the storage implements contract 1" + ): + smoke.check_contract() # the #1763 pair diff --git a/tests/test_release_configuration.py b/tests/test_release_configuration.py index a559150451..a2ad0a75ea 100644 --- a/tests/test_release_configuration.py +++ b/tests/test_release_configuration.py @@ -6,6 +6,8 @@ import re from pathlib import Path +import yaml + from scripts.normalize_pyproject_prerelease import pep440_prerelease ROOT = Path(__file__).parent.parent @@ -69,6 +71,36 @@ def test_storage_distribution_version_matches_its_manifest_entry() -> None: assert config["packages"]["."]["exclude-paths"] == ["packages/adcp-reporting-storage"] +def test_release_publish_gates_both_packages_on_a_working_companion() -> None: + """The companion gate (#1605, #1763) runs for every publish of either package. + + It runs after the build, so the wheel just built is installed beside the + published companion and smoked, with a PostgreSQL service available: a + pair whose version ranges admit each other but whose storage contracts + differ never reaches PyPI. + """ + publisher = yaml.load( + (ROOT / ".github/workflows/release-publish.yml").read_text(), Loader=yaml.BaseLoader + ) + steps = publisher["jobs"]["build"]["steps"] + companion_steps = [ + index + for index, step in enumerate(steps) + if "check_companion_releases.py" in step.get("run", "") + ] + assert len(companion_steps) == 1 + companion = steps[companion_steps[0]] + assert "if" not in companion, "both packages are gated" + assert "--dist dist/adcp-*.whl" in companion["run"] + assert '--package "$PACKAGE" --dist dist/adcp_reporting_storage-*.whl' in companion["run"] + assert companion["env"]["ADCP_PG_TEST_URL"].startswith("postgresql://") + build_steps = [ + index for index, step in enumerate(steps) if "python -m build" in step.get("run", "") + ] + assert build_steps and build_steps[0] < companion_steps[0], "gate the built wheel" + assert publisher["jobs"]["build"]["services"]["postgres"]["image"].startswith("postgres:") + + def test_storage_distribution_pins_one_adcp_major() -> None: pyproject = (STORAGE / "pyproject.toml").read_text() assert re.search(r'"adcp>=9\.0\.0(?:b\d+)?,<10"', pyproject), "adcp range must be one major" From e45e1b84e64867944063254738679918589ce947 Mon Sep 17 00:00:00 2001 From: "aao-ipr-bot[bot]" <279263549+aao-ipr-bot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 05:52:20 -0500 Subject: [PATCH 16/28] chore(main): release 9.5.0 (#1815) Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a6992a9dda..fd63edd02b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { - ".": "9.4.0", + ".": "9.5.0", "packages/adcp-reporting-storage": "0.1.0-beta.4" } diff --git a/CHANGELOG.md b/CHANGELOG.md index ab17f677f2..177cc2eec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -192,6 +192,21 @@ change public consumer defaults. See [format-kind migration](docs/canonical-format-kinds-migration.md). +## [9.5.0](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.4.0...v9.5.0) (2026-11-02) + + +### Features + +* **decisioning:** let a raised AdcpError reject a request with every error ([#1780](https://github.com/adcontextprotocol/adcp-client-python/issues/1780)) ([#1810](https://github.com/adcontextprotocol/adcp-client-python/issues/1810)) ([272fced](https://github.com/adcontextprotocol/adcp-client-python/commit/272fced52d57632dedf32f2c175cf294100bff63)) + + +### Bug Fixes + +* **canonical-formats:** check narrowing on seller-asserted references ([#1758](https://github.com/adcontextprotocol/adcp-client-python/issues/1758)) ([617762e](https://github.com/adcontextprotocol/adcp-client-python/commit/617762e94371b5817f400481bbbc866c760a6a1a)) +* **canonical-formats:** read versions from typed legacy formats ([#1757](https://github.com/adcontextprotocol/adcp-client-python/issues/1757)) ([6714203](https://github.com/adcontextprotocol/adcp-client-python/commit/67142036f40e6a68fd9ca3d5c51acaa7bfe402fb)) +* **server:** return task and workflow handoffs uncached from IdempotencyStore.wrap ([#1795](https://github.com/adcontextprotocol/adcp-client-python/issues/1795)) ([126f905](https://github.com/adcontextprotocol/adcp-client-python/commit/126f9052ab79399f1e7bab8a53f34e24999ef95e)), closes [#1781](https://github.com/adcontextprotocol/adcp-client-python/issues/1781) +* **types:** make the domain package roots lazy so a types package imports first in any order ([#1798](https://github.com/adcontextprotocol/adcp-client-python/issues/1798)) ([7447d00](https://github.com/adcontextprotocol/adcp-client-python/commit/7447d00ceba97490109c46f0cf2f0ca9d6f8e5f3)) + ## [9.4.0](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.3.1...v9.4.0) (2026-10-31) diff --git a/pyproject.toml b/pyproject.toml index d438e88a03..8789536cee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "adcp" -version = "9.4.0" +version = "9.5.0" description = "Official Python client for the Ad Context Protocol (AdCP)" authors = [ {name = "AdCP Community", email = "maintainers@adcontextprotocol.org"} From 495593ec319bca75b22174c582fa5417f9730860 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 2 Nov 2026 20:24:16 -0500 Subject: [PATCH 17/28] fix(ci): check PR titles without stripping a trailing issue reference (#1819) The conventional-commit policy stripped a trailing "(#N)" from every message, including PR titles. #1810's title ended in "(#1780)", passed as if that were the PR number, and integrated as "... (#1780) (#1810)", which the main check rejects. Main CI failed at the 9.5.0 release commit, so Release publish refused to publish 9.5.0. PR titles are now checked as written, since the squash appends the PR number only at integration. 272fced5 keeps its subject through an exact-commit-and-subject exception, like the historical breaking-footer ones. Every main commit since the policy floor validates. Co-authored-by: Claude Opus 5.5 --- scripts/check_main_policies.py | 37 +++++++++++++--- tests/test_main_release_policies.py | 67 ++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 8 deletions(-) diff --git a/scripts/check_main_policies.py b/scripts/check_main_policies.py index 2e78f4d4f0..6cfbc131b4 100644 --- a/scripts/check_main_policies.py +++ b/scripts/check_main_policies.py @@ -106,6 +106,18 @@ def pages(self, suffix: str, key: str | None = None) -> list[Any]: ), } +# #1810's PR title ended in an issue reference, "(#1780)". The PR check +# stripped it as if it were the PR number, and the squash subject then carried +# a second reference, so the integrated description keeps "(#1780)". PR titles +# are now checked without that stripping; only this exact commit and subject +# keep the reference. +HISTORICAL_DESCRIPTION_SUBJECTS = { + "272fced52d57632dedf32f2c175cf294100bff63": ( + "feat(decisioning): let a raised AdcpError reject a request with every error" + " (#1780) (#1810)" + ), +} + # Rebase-merged PRs record only their last commit as merge_commit_sha, so the # earlier commits they put on main match no PR by merge commit. These two were # rebase-merged so a storage-only commit would not release adcp; each maps to @@ -131,15 +143,28 @@ def main_commits(target: str) -> list[str]: return commits -def validate_message(message: str, *, commit_sha: str | None = None) -> None: +def validate_message( + message: str, *, commit_sha: str | None = None, integrated: bool = True +) -> None: subject, _, body = message.partition("\n") match = CONVENTIONAL.fullmatch(subject) require(match is not None, "commit subject is not a conventional commit") assert match is not None - # GitHub integration subjects may include the PR number. Parentheses/quotes in - # the actual description remain forbidden by the repository's parser rule. - description = re.sub(r" \(#[1-9][0-9]*\)$", "", match["description"]) - require(description and not any(c in description for c in '()"'), "unsafe release description") + # A squash integration appends the PR number to the subject. A PR title has + # none yet, so a trailing "(#N)" there is part of the description. + # Parentheses/quotes in the actual description remain forbidden by the + # repository's parser rule. + description = match["description"] + if integrated: + description = re.sub(r" \(#[1-9][0-9]*\)$", "", description) + historical_description_exception = ( + commit_sha is not None and HISTORICAL_DESCRIPTION_SUBJECTS.get(commit_sha) == subject + ) + require( + description + and (historical_description_exception or not any(c in description for c in '()"')), + "unsafe release description", + ) historical_footer_exception = ( commit_sha is not None and HISTORICAL_BREAKING_SUBJECTS.get(commit_sha) == subject ) @@ -199,7 +224,7 @@ def conventional( api = GitHub(os.environ["GH_TOKEN"]) current = api.repo(f"pulls/{pr['number']}") require(current["head"]["sha"] == pr["head"]["sha"], "PR head changed; run current CI") - validate_message(current["title"] + "\n\n" + (current["body"] or "")) + validate_message(current["title"] + "\n\n" + (current["body"] or ""), integrated=False) head, base = pr["head"]["sha"], pr["base"]["sha"] require(SHA.fullmatch(head) and SHA.fullmatch(base), "invalid PR source identities") fork = git("merge-base", base, head) diff --git a/tests/test_main_release_policies.py b/tests/test_main_release_policies.py index 4fa64cc8f0..65c950ab0b 100644 --- a/tests/test_main_release_policies.py +++ b/tests/test_main_release_policies.py @@ -19,6 +19,8 @@ LEDGER_SHA = "d" * 40 BREAKING_SUBJECT = "fix(reporting)!: scope configuration generations by account (#1174)" +# A PR title carries no PR number; the squash subject appends it. +BREAKING_PR_TITLE = BREAKING_SUBJECT.removesuffix(" (#1174)") BREAKING_FOOTER = ( "BREAKING CHANGE: generation_key returns ReportingConfigurationGenerationKey " "instead of a two-tuple. Use its named fields." @@ -107,7 +109,7 @@ def test_conventional_rerun_reads_the_corrected_pr_body(history: list[str]) -> N "number": 1174, "head": {"sha": history[-1]}, "base": {"sha": history[0]}, - "title": BREAKING_SUBJECT, + "title": BREAKING_PR_TITLE, "body": "Missing the breaking footer in the original webhook.", } api.data["pulls/1174"] = {**pr, "body": BREAKING_FOOTER} @@ -124,7 +126,7 @@ def test_conventional_rerun_rejects_a_newly_invalid_pr_message(history: list[str "number": 1174, "head": {"sha": history[-1]}, "base": {"sha": history[0]}, - "title": BREAKING_SUBJECT, + "title": BREAKING_PR_TITLE, "body": BREAKING_FOOTER, } api.data["pulls/1174"] = {**pr, "body": None} @@ -133,6 +135,67 @@ def test_conventional_rerun_rejects_a_newly_invalid_pr_message(history: list[str policies.conventional("pull_request", history[-1], {"pull_request": pr}, api=api) +def test_conventional_pr_title_keeps_a_trailing_issue_reference(history: list[str]) -> None: + # A PR title has no PR number yet; the squash appends one. A title ending + # in "(#1780)" would integrate as "... (#1780) (#1810)", which main rejects. + api = PolicyAPI(history) + pr = { + "number": 1810, + "head": {"sha": history[-1]}, + "base": {"sha": history[0]}, + "title": "feat(decisioning): reject a request with every error (#1780)", + "body": "", + } + api.data["pulls/1810"] = pr + + with pytest.raises(ReleaseRejectedError, match="unsafe release description"): + policies.conventional("pull_request", history[-1], {"pull_request": pr}, api=api) + + +@pytest.mark.parametrize( + ("message", "integrated", "accepted"), + [ + ("fix: handle the empty case (#1810)", True, True), + ("fix: handle the empty case (#1810)", False, False), + ("fix: handle the empty case (#1780) (#1810)", True, False), + ("fix: handle the empty case", False, True), + ], +) +def test_pr_number_is_stripped_only_from_integrated_subjects( + message: str, integrated: bool, accepted: bool +) -> None: + if accepted: + policies.validate_message(message, integrated=integrated) + else: + with pytest.raises(ReleaseRejectedError, match="unsafe release description"): + policies.validate_message(message, integrated=integrated) + + +HISTORICAL_DESCRIPTION_SHA = "272fced52d57632dedf32f2c175cf294100bff63" +HISTORICAL_DESCRIPTION_SUBJECT = ( + "feat(decisioning): let a raised AdcpError reject a request with every error (#1780) (#1810)" +) + + +@pytest.mark.parametrize( + ("commit_sha", "subject", "accepted"), + [ + (HISTORICAL_DESCRIPTION_SHA, HISTORICAL_DESCRIPTION_SUBJECT, True), + ("a" * 40, HISTORICAL_DESCRIPTION_SUBJECT, False), + (None, HISTORICAL_DESCRIPTION_SUBJECT, False), + (HISTORICAL_DESCRIPTION_SHA, "fix: bad (description) (#1810)", False), + ], +) +def test_historical_description_exception_requires_exact_commit_and_subject( + commit_sha: str | None, subject: str, accepted: bool +) -> None: + if accepted: + policies.validate_message(subject, commit_sha=commit_sha) + else: + with pytest.raises(ReleaseRejectedError, match="unsafe release description"): + policies.validate_message(subject, commit_sha=commit_sha) + + def test_conventional_rerun_cannot_validate_a_different_pr_head(history: list[str]) -> None: api = PolicyAPI(history) pr = { From ce209053cb40de1e60083c85e562a26ee0211c1f Mon Sep 17 00:00:00 2001 From: "aao-ipr-bot[bot]" <279263549+aao-ipr-bot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:15:52 -0500 Subject: [PATCH 18/28] chore(main): release 9.5.1 (#1824) Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index fd63edd02b..96332ac50b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { - ".": "9.5.0", + ".": "9.5.1", "packages/adcp-reporting-storage": "0.1.0-beta.4" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 177cc2eec8..12c1675cdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -192,6 +192,13 @@ change public consumer defaults. See [format-kind migration](docs/canonical-format-kinds-migration.md). +## [9.5.1](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.5.0...v9.5.1) (2026-11-03) + + +### Bug Fixes + +* **ci:** check PR titles without stripping a trailing issue reference ([#1819](https://github.com/adcontextprotocol/adcp-client-python/issues/1819)) ([495593e](https://github.com/adcontextprotocol/adcp-client-python/commit/495593ec319bca75b22174c582fa5417f9730860)) + ## [9.5.0](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.4.0...v9.5.0) (2026-11-02) diff --git a/pyproject.toml b/pyproject.toml index 8789536cee..a4d5e7b021 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "adcp" -version = "9.5.0" +version = "9.5.1" description = "Official Python client for the Ad Context Protocol (AdCP)" authors = [ {name = "AdCP Community", email = "maintainers@adcontextprotocol.org"} From 6bb34df00821e11f03aed2a018a0c5a6ccab3cdf Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 2 Nov 2026 22:26:52 -0500 Subject: [PATCH 19/28] feat(reporting-storage): accept 255 character row binding IDs and credential refs (#1828) * feat(reporting-storage): accept 255 character row binding IDs and credential refs row_binding_id and a binding's credential_ref were capped at 128 characters, tighter than every comparable AdCP identifier. Both now take 1 to 255 characters of A-Za-z0-9_.:-, in the configuration check, the provider client maps and the row bindings CHECK. The CHECK is replaced in place and idempotently by the guarded DDL, under the name the JavaScript SDK uses, and the catalog manifest is regenerated. The configuration check uses a full match, so a trailing newline that the old pattern let through is refused, and a long rejected ID is truncated in the message. Mirrors adcp-client#3595. Closes #1498. Co-Authored-By: Claude Sonnet 5.5 * fix(reporting-storage): refuse a non-string binding ID without a type error The invalid binding ID message passed the key to json.dumps, which raised a TypeError for a key such as bytes instead of the intended INVALID_INPUT. Stringify the key before truncating and quoting it. Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 --- docs/reporting-release-notes.md | 15 + docs/reporting-row-storage.md | 17 + .../ledger/reporting_row_storage.sql | 39 +- .../ledger/required_row_storage_schema.json | 4 +- .../ledger/row_storage.py | 31 +- .../ledger/row_storage_object.py | 9 + .../ledger/row_storage_provider_io.py | 21 +- .../test_reporting_row_binding_bounds_pg.py | 232 ++++++++++++ ...porting-row-storage-pre-binding-bounds.sql | 334 ++++++++++++++++++ tests/test_reporting_row_binding_bounds.py | 164 +++++++++ 10 files changed, 849 insertions(+), 17 deletions(-) create mode 100644 tests/conformance/reporting/test_reporting_row_binding_bounds_pg.py create mode 100644 tests/fixtures/reporting-row-storage-pre-binding-bounds.sql create mode 100644 tests/test_reporting_row_binding_bounds.py diff --git a/docs/reporting-release-notes.md b/docs/reporting-release-notes.md index 575ad019e3..40577a7117 100644 --- a/docs/reporting-release-notes.md +++ b/docs/reporting-release-notes.md @@ -36,6 +36,21 @@ Reads handle both forms. `migrate_inline_rows` moves legacy rows with verificati rows do not reproduce its binding, and `retire_legacy_inline_rows` then renames the legacy table so older binaries fail closed. See [row storage](reporting-row-storage.md). +## Row binding IDs and credential references accept 255 characters + +`row_binding_id` and a binding's `credential_ref` were capped at 128 characters. Every comparable AdCP identifier +(`reporting_revision_id`, `resource_ref`, `destination_ref`) allows 255, so an ID derived from an AdCP destination +reference could be refused for no protocol reason. Both now accept 1 to 255 characters of `A-Za-z0-9_.:-`, matching the +JavaScript SDK ([adcp-client#3595](https://github.com/adcontextprotocol/adcp-client/pull/3595)). The configuration +check, the `adcp_reporting_row_bindings` `CHECK` and the provider `clients` map share the bound, which is exported as +`REPORTING_ROW_BINDING_ID_MAX_LENGTH` and `REPORTING_ROW_CREDENTIAL_REF_MAX_LENGTH`. A malformed value that the old +check let through (a trailing newline) is now refused too. `create_schema` replaces the original unnamed 128-character +`CHECK` with `adcp_reporting_row_bindings_row_binding_id_bounds` in place, idempotently, with no data migration; stop +older writers first, because an older binary requires the original constraint and fails closed against an upgraded +database. Nothing that was valid before is refused. See +[stored names follow AdCP](reporting-row-storage.md#stored-names-follow-adcp) +([#1498](https://github.com/adcontextprotocol/adcp-client-python/issues/1498)). + ## Row storage field alignment (breaking for row storage adopters) Row storage stores every value in the format AdCP defines for it, matching the JavaScript SDK byte for byte: diff --git a/docs/reporting-row-storage.md b/docs/reporting-row-storage.md index 7c8d3ec48e..8c9e3317ca 100644 --- a/docs/reporting-row-storage.md +++ b/docs/reporting-row-storage.md @@ -164,6 +164,13 @@ await store.create_schema() fails with `UNSAFE_BINDING`, never `INVALID_INPUT`, which would read as bad rows), is not part of the binding identity, and is still used for reads and retirement. Rows already written stay readable at their recorded `object_ref`; reads never re-render a key. Keep a retired binding configured while revisions still reference it. +- **Identifiers you choose are AdCP-bounded.** A binding ID and a binding's `credential_ref` (and the keys of a + provider's `clients` map) are 1 to 255 characters of `A-Za-z0-9_.:-`, the bound `reporting_revision_id`, + `resource_ref` and `destination_ref` use (`REPORTING_ROW_BINDING_ID_MAX_LENGTH` and + `REPORTING_ROW_CREDENTIAL_REF_MAX_LENGTH` in `adcp_reporting_storage.ledger.row_storage_object`). They were capped at + 128, so an ID derived from an AdCP destination reference could be refused for no protocol reason. A longer or + malformed value fails `INVALID_INPUT` at configuration with the bound named (a long ID is truncated in the message, + never echoed whole). The `adcp_reporting_row_bindings` `CHECK` is the same bound. - **`{period_date}` is AdCP's `YYYY-MM-DD`**: the calendar date of the period start in the period's `source_timezone`, as one path segment (a period starting `2026-10-01T00:00:00Z` in `America/Los_Angeles` is `2026-09-30`). It fails closed (`INVALID_INPUT`) on a missing, unknown or non-IANA timezone and never assumes UTC; @@ -334,6 +341,7 @@ by an earlier release in place, idempotently, under both SDKs' migration advisor | `adcp_reporting_row_chunks.byte_count` | `canonical_size_bytes` | uncompressed canonical bytes; the digest-pinned manifest value | | `adcp_reporting_row_sets.canonical_byte_count` | `content_size_bytes` | size of the content `content_sha256` covers (a revision's includes the envelope, so it is not the sum of its chunks) | | `adcp_reporting_row_sets.encoding` | `format` = `jsonl`, `algorithm` = `adcp_jcs_rows_v1` | `adcp_jcs_rows_v1` names the per-row RFC 8785 canonicalization | +| `adcp_reporting_row_bindings.row_binding_id` `CHECK` `{1,128}` (unnamed) | `{1,255}`, named `adcp_reporting_row_bindings_row_binding_id_bounds` | AdCP's identifier bound; `credential_ref` is bounded the same in code (it lives in `operational_config`) | In Python: `ReportingRowChunkLocatorV1` has `object_ref`, `native_version_ref` and `size_bytes`; provider inputs take `object_ref` and `native_version_ref`; `ReportingRowBindingSelectionInputV1.content_size_bytes` replaces @@ -343,6 +351,15 @@ change, and no digest changed: the format and algorithm tokens feed none. Open w `{"object_ref", "native_version_ref"}`; the sweep and the reference check also read the earlier `objectKey` / `nativeVersion` spelling, for a writer still running during a rolling upgrade. +Widening `row_binding_id` drops the original unnamed `{1,128}` `CHECK` (found through `pg_get_constraintdef`) and adds +the named `{1,255}` one, only when the database still has the old one, so a current schema changes nothing and no data +migrates; the two tables that reference the ID hold it by foreign key only. A host-customised constraint of another +shape is left alone. The startup catalog check requires the new constraint, so an application upgraded before +`create_schema` runs fails closed with `STATE_UNAVAILABLE` naming `adcp_reporting_row_bindings_row_binding_id_bounds`, +and an older binary fails closed against an upgraded database (it requires the original constraint name): stop older +writers first. The JavaScript SDK makes the same change with the same constraint name +([adcp-client#3595](https://github.com/adcontextprotocol/adcp-client/pull/3595)). + The migration does not rewrite recorded object keys. Objects written before the upgrade keep their recorded `object_ref` (rendered under the old UTC `yyyy/MM/dd` layout), so reads, retention and deletes keep working; new writes render the new key. It refuses, naming the count and rolling back, a database that already holds a key or version diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_row_storage.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_row_storage.sql index 0862039adb..1181ce05f3 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_row_storage.sql +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_row_storage.sql @@ -107,7 +107,9 @@ CREATE TABLE IF NOT EXISTS adcp_persistence_installation ( INSERT INTO adcp_persistence_installation (singleton) VALUES (TRUE) ON CONFLICT DO NOTHING; CREATE TABLE IF NOT EXISTS adcp_reporting_row_bindings ( - row_binding_id TEXT COLLATE "C" PRIMARY KEY CHECK (row_binding_id ~ '^[A-Za-z0-9_.:-]{1,128}$'), + row_binding_id TEXT COLLATE "C" PRIMARY KEY + CONSTRAINT adcp_reporting_row_bindings_row_binding_id_bounds + CHECK (row_binding_id ~ '^[A-Za-z0-9_.:-]{1,255}$'), kind TEXT NOT NULL CHECK (kind IN ('postgres', 'object')), provider TEXT NOT NULL CHECK (provider ~ '^[a-z][a-z0-9_]{0,63}$'), identity_config JSONB NOT NULL CHECK (jsonb_typeof(identity_config) = 'object'), @@ -316,6 +318,41 @@ BEGIN END $bounds$; +-- row_binding_id is bounded like AdCP's comparable identifiers (reporting_revision_id, +-- resource_ref and destination_ref use ^[A-Za-z0-9_.:-]{1,255}$): 255 characters, where the +-- original CHECK allowed 128 (adcp-client-python#1498, adcp-client#3595). A database installed +-- with the original unnamed CHECK drops it and gets the named constraint a fresh install carries +-- inline. Guarded on the catalog, so a current schema changes nothing. Widening a CHECK needs no +-- data migration, and the tables that reference row_binding_id hold it by foreign key only. The +-- constraint name and the bound are the JavaScript SDK's, so either SDK re-applying is a no-op. +DO $binding_id_bound$ +DECLARE + bindings regclass := to_regclass(quote_ident(current_schema()) || '.adcp_reporting_row_bindings'); + legacy TEXT; +BEGIN + IF bindings IS NULL THEN + RETURN; + END IF; + FOR legacy IN + SELECT conname FROM pg_constraint + WHERE conrelid = bindings AND contype = 'c' + AND conname <> 'adcp_reporting_row_bindings_row_binding_id_bounds' + AND strpos(pg_get_constraintdef(oid), 'row_binding_id') > 0 + AND strpos(pg_get_constraintdef(oid), '{1,128}') > 0 + LOOP + EXECUTE format('ALTER TABLE adcp_reporting_row_bindings DROP CONSTRAINT %I', legacy); + END LOOP; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = bindings AND conname = 'adcp_reporting_row_bindings_row_binding_id_bounds' + ) THEN + ALTER TABLE adcp_reporting_row_bindings + ADD CONSTRAINT adcp_reporting_row_bindings_row_binding_id_bounds + CHECK (row_binding_id ~ '^[A-Za-z0-9_.:-]{1,255}$'); + END IF; +END +$binding_id_bound$; + -- Open write intents written before the rename name objects objectKey / nativeVersion. UPDATE adcp_reporting_row_write_intents SET created_objects = ( diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/required_row_storage_schema.json b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/required_row_storage_schema.json index 421c0dd889..bbd7cce0f7 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/required_row_storage_schema.json +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/required_row_storage_schema.json @@ -291,9 +291,9 @@ "enabled": true, "fingerprint": "b0825d60a99952fe5be6f33e0123ae5166e1d17eda49af821d22a5ca8856e13b" }, - "constraint:adcp_reporting_row_bindings.adcp_reporting_row_bindings_row_binding_id_check": { + "constraint:adcp_reporting_row_bindings.adcp_reporting_row_bindings_row_binding_id_bounds": { "enabled": true, - "fingerprint": "a804a095e5b6b0b8434f809b087674c480399eef72e852c297ad00ecb3c5694c" + "fingerprint": "700166f3efc148bd5cab036b1fcdf3ae35acba6f2a127c24fa9f8888187d8c1d" }, "constraint:adcp_reporting_row_bindings.adcp_reporting_row_bindings_state_check": { "enabled": true, diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage.py index 39d7ef542f..a7415ddb2d 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage.py @@ -25,7 +25,6 @@ import hashlib import inspect import json -import re import zlib from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass @@ -55,6 +54,8 @@ ReportingRowStoreErrorCode, ) from adcp_reporting_storage.ledger.row_storage_object import ( + REPORTING_ROW_BINDING_ID_MAX_LENGTH, + REPORTING_ROW_CREDENTIAL_REF_MAX_LENGTH, REPORTING_ROW_DEFAULT_KEY_TEMPLATE, ReportingRowObjectContextV1, ReportingRowObjectDeleteInputV1, @@ -66,6 +67,7 @@ assert_reporting_row_prefix_v1, render_reporting_row_object_key_v1, ) +from adcp_reporting_storage.ledger.row_storage_provider_io import is_row_identifier if TYPE_CHECKING: from adcp.reporting.ledger.row_encoding import ( @@ -105,8 +107,6 @@ Compression = Literal["none", "gzip"] _DEFAULT_BINDING_ID = "postgres" -_BINDING_ID = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$") -_CREDENTIAL_REF = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$") _NATIVE_VERSION_REF_MAX_LENGTH = 1024 #: Upper bound for adopting an existing object whose encoding may differ byte for byte. _MAX_ADOPTED_OBJECT_BYTES = 2 * REPORTING_ROW_CHUNK_MAX_BYTES + 1024 * 1024 @@ -141,7 +141,8 @@ class ReportingObjectRowBindingV1: key_template: str | None = None #: Stored encoding. Digests always cover uncompressed bytes. compression: Compression = "gzip" - #: Host-mapped client reference passed to the provider; never a secret. + #: Host-mapped client reference passed to the provider; never a secret. 1 to + #: ``REPORTING_ROW_CREDENTIAL_REF_MAX_LENGTH`` (255) characters of ``A-Za-z0-9_.:-``. credential_ref: str | None = None #: Keep this binding configured only to read (and retire) row sets already written through #: it, for example under an older template without ``{revision_key}``. A read-only binding is @@ -171,8 +172,9 @@ class ReportingRowStorageOptionsV1: ``bindings`` maps a logical binding ID to its immutable definition. Changing a definition requires a new ID; keep retired definitions configured while - revisions still reference them. ``deployment_namespace`` is combined with the - schema's installation identity into each binding's ``namespace_key``, so two + revisions still reference them. An ID is 1 to ``REPORTING_ROW_BINDING_ID_MAX_LENGTH`` (255) + characters of ``A-Za-z0-9_.:-``, the AdCP identifier bound. ``deployment_namespace`` is + combined with the schema's installation identity into each binding's ``namespace_key``, so two SDKs on one database must use the same value. ``select_row_binding`` picks the binding for each new row set; it defaults to the only binding, or ``postgres``. """ @@ -386,9 +388,14 @@ def __init__(self, options: ReportingRowStorageOptionsV1 | None = None) -> None: raise ReportingRowStoreError("INVALID_INPUT", "at least one row binding is required") providers = dict(options.providers or {}) for binding_id, binding in bindings.items(): - if not _BINDING_ID.match(binding_id): + if not is_row_identifier(binding_id): + # Quoted and truncated, so a hostile ID is not echoed whole into logs. + text = str(binding_id) + shown = text if len(text) <= 64 else f"{text[:64]}..." raise ReportingRowStoreError( - "INVALID_INPUT", f"invalid row binding ID {binding_id}" + "INVALID_INPUT", + f"invalid row binding ID {json.dumps(shown)}: use 1 to " + f"{REPORTING_ROW_BINDING_ID_MAX_LENGTH} characters of A-Za-z0-9_.:-", ) if isinstance(binding, ReportingPostgresRowBindingV1): continue @@ -408,11 +415,11 @@ def __init__(self, options: ReportingRowStorageOptionsV1 | None = None) -> None: raise ReportingRowStoreError( "INVALID_INPUT", f"row binding {binding_id} has an unsupported compression" ) - if binding.credential_ref is not None and not _CREDENTIAL_REF.match( - binding.credential_ref - ): + if binding.credential_ref is not None and not is_row_identifier(binding.credential_ref): raise ReportingRowStoreError( - "INVALID_INPUT", f"row binding {binding_id} has an invalid credential_ref" + "INVALID_INPUT", + f"row binding {binding_id} has an invalid credential_ref: use 1 to " + f"{REPORTING_ROW_CREDENTIAL_REF_MAX_LENGTH} characters of A-Za-z0-9_.:-", ) namespace = options.deployment_namespace if not namespace or len(namespace) > 256: diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_object.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_object.py index ecb4f18cdf..cfa1876cf2 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_object.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_object.py @@ -30,6 +30,8 @@ from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError __all__ = [ + "REPORTING_ROW_BINDING_ID_MAX_LENGTH", + "REPORTING_ROW_CREDENTIAL_REF_MAX_LENGTH", "REPORTING_ROW_DEFAULT_KEY_TEMPLATE", "REPORTING_ROW_KEY_PLACEHOLDERS", "REPORTING_ROW_NATIVE_VERSION_REF_MAX_LENGTH", @@ -65,6 +67,13 @@ class ReportingRowObjectContextV1: #: characters. The row-storage ``CHECK`` constraints enforce the same. REPORTING_ROW_OBJECT_REF_MAX_LENGTH = 1024 REPORTING_ROW_NATIVE_VERSION_REF_MAX_LENGTH = 1024 +#: Bound on a row binding ID, 1 to 255 characters of ``A-Za-z0-9_.:-``: the AdCP identifier +#: bound that ``reporting_revision_id``, ``resource_ref`` and ``destination_ref`` use +#: (``^[A-Za-z0-9_.:-]{1,255}$``). The ``adcp_reporting_row_bindings`` ``CHECK`` enforces the same. +REPORTING_ROW_BINDING_ID_MAX_LENGTH = 255 +#: Bound on a binding's ``credential_ref`` (and on the keys of a provider's ``clients`` map): the +#: same 1 to 255 characters of ``A-Za-z0-9_.:-`` as a row binding ID. +REPORTING_ROW_CREDENTIAL_REF_MAX_LENGTH = 255 @dataclass(frozen=True, slots=True) diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_provider_io.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_provider_io.py index d59b6cc464..bd65b115f5 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_provider_io.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_provider_io.py @@ -13,16 +13,33 @@ from adcp_reporting_storage.ledger.row_storage_object import ReportingRowObjectGetInputV1 __all__ = [ + "ROW_IDENTIFIER_MAX_LENGTH", "assert_key", "assert_location_keys", "assert_read_bounds", "client_map", + "is_row_identifier", "prefix_filter_overlaps", "select_client", ] _T = TypeVar("_T") -_CREDENTIAL_REF = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$") + +#: Bound for the row-storage identifiers a host chooses, ``row_binding_id`` and the binding's +#: credential reference: 1 to 255 characters of ``A-Za-z0-9_.:-``, the AdCP identifier bound +#: (``reporting_revision_id``, ``resource_ref`` and ``destination_ref`` use +#: ``^[A-Za-z0-9_.:-]{1,255}$``). The ``adcp_reporting_row_bindings`` ``CHECK`` enforces the same. +ROW_IDENTIFIER_MAX_LENGTH = 255 +_ROW_IDENTIFIER = re.compile(rf"[A-Za-z0-9_.:-]{{1,{ROW_IDENTIFIER_MAX_LENGTH}}}") + + +def is_row_identifier(value: object) -> bool: + """Whether ``value`` is a well-formed host-chosen row-storage identifier. + + ``fullmatch``, not ``match`` with ``$``: ``$`` also matches before a trailing newline, + which the database ``CHECK`` refuses. + """ + return isinstance(value, str) and _ROW_IDENTIFIER.fullmatch(value) is not None def assert_location_keys( @@ -66,7 +83,7 @@ def client_map(clients: Mapping[str, _T] | None) -> dict[str, _T]: return {} result: dict[str, _T] = {} for reference, client in clients.items(): - if not _CREDENTIAL_REF.match(reference) or client is None: + if not is_row_identifier(reference) or client is None: raise ReportingRowStoreError( "INVALID_INPUT", "clients must map credential references to clients" ) diff --git a/tests/conformance/reporting/test_reporting_row_binding_bounds_pg.py b/tests/conformance/reporting/test_reporting_row_binding_bounds_pg.py new file mode 100644 index 0000000000..e4738f2913 --- /dev/null +++ b/tests/conformance/reporting/test_reporting_row_binding_bounds_pg.py @@ -0,0 +1,232 @@ +"""``row_binding_id`` is bounded at 255 characters in the database too (adcp-client-python #1498). + +``tests/fixtures/reporting-row-storage-pre-binding-bounds.sql`` is the row-storage DDL as released +before the bound moved from 128 to 255 (an unnamed ``CHECK`` PostgreSQL named after the column). +Re-applying the current DDL drops it and adds ``adcp_reporting_row_bindings_row_binding_id_bounds``, +the constraint a fresh install carries inline and the JavaScript SDK names the same. Ported from +adcp-client #3595. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +from adcp_reporting_storage import PgReportingLedgerStore, ReportingRowStorageOptionsV1 +from adcp_reporting_storage.ledger.row_storage import ( + REPORTING_ROW_STORAGE_DDL_PATH, + REPORTING_ROW_STORAGE_TABLES, + ReportingObjectRowBindingV1, + ReportingPostgresRowBindingV1, +) +from adcp_reporting_storage.ledger.row_storage_object import ( + create_filesystem_reporting_row_object_provider_v1, +) +from adcp_reporting_storage.outbox._schema import _catalog_objects + +from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError + +from ._generation_support import isolated_reporting_pool +from .test_reporting_row_storage_pg import fetch_all, many_rows, seeded, typed_revision, walk + +OLD_DDL = ( + Path(__file__).resolve().parents[2] + / "fixtures" + / "reporting-row-storage-pre-binding-bounds.sql" +) +CONSTRAINT = "adcp_reporting_row_bindings_row_binding_id_bounds" +LEGACY_CONSTRAINT = "adcp_reporting_row_bindings_row_binding_id_check" +SHA = "a" * 64 + + +async def insert_binding(pool: Any, binding_id: str) -> None: + async with pool.connection() as connection, connection.transaction(): + await connection.execute( + "INSERT INTO adcp_reporting_row_bindings (row_binding_id, kind, provider," + " identity_config, identity_sha256, namespace_key)" + " VALUES (%s, 'postgres', 'postgres', '{}', %s, %s)", + (binding_id, SHA, "b" * 64), + ) + + +async def binding_constraints(pool: Any) -> list[tuple[Any, ...]]: + return await fetch_all( + pool, + "SELECT conname, oid::text, pg_get_constraintdef(oid) FROM pg_constraint" + " WHERE conrelid = 'adcp_reporting_row_bindings'::regclass AND contype = 'c'" + " AND pg_get_constraintdef(oid) LIKE '%%row_binding_id%%' ORDER BY conname", + ) + + +async def catalog(pool: Any) -> dict[str, Any]: + async with pool.connection() as connection: + return await _catalog_objects( + connection, + table_names=REPORTING_ROW_STORAGE_TABLES, + function_identity=("adcp_reporting_row_storage_immutable", ""), + name_prefix="adcp_", + ) + + +async def apply(pool: Any, ddl: str) -> None: + async with pool.connection() as connection, connection.transaction(): + await connection.execute(ddl) + + +async def test_a_fresh_install_accepts_255_and_refuses_the_rest_under_one_named_constraint() -> ( + None +): + psycopg = pytest.importorskip("psycopg") + async with isolated_reporting_pool() as pool: + await apply(pool, REPORTING_ROW_STORAGE_DDL_PATH.read_text()) + assert [row[0] for row in await binding_constraints(pool)] == [CONSTRAINT] + assert "{1,255}" in (await binding_constraints(pool))[0][2] + await insert_binding(pool, "a" * 255) + await insert_binding(pool, "a" * 128) + for bad in ("a" * 256, "", "has space", "trailing\n", "аcyrillic", "x" * 5000): + with pytest.raises(psycopg.errors.CheckViolation): + await insert_binding(pool, bad) + + +async def test_a_database_with_the_original_128_limit_is_widened_in_place_and_idempotently() -> ( + None +): + psycopg = pytest.importorskip("psycopg") + current = REPORTING_ROW_STORAGE_DDL_PATH.read_text() + async with isolated_reporting_pool() as old, isolated_reporting_pool() as fresh: + await apply(old, OLD_DDL.read_text()) + assert [row[0] for row in await binding_constraints(old)] == [LEGACY_CONSTRAINT] + await insert_binding(old, "k" * 128) + with pytest.raises(psycopg.errors.CheckViolation): + await insert_binding(old, "k" * 129) + + await apply(old, current) + upgraded = await binding_constraints(old) + assert [row[0] for row in upgraded] == [CONSTRAINT], "the original CHECK is gone" + await insert_binding(old, "k" * 255) + with pytest.raises(psycopg.errors.CheckViolation): + await insert_binding(old, "k" * 256) + assert await fetch_all(old, "SELECT count(*) FROM adcp_reporting_row_bindings") == [ + (2,) + ], "no recorded binding was touched" + + # Re-applying changes nothing: the same constraint object survives. + await apply(old, current) + await apply(old, current) + assert await binding_constraints(old) == upgraded + + # The upgraded database is indistinguishable from a fresh install. + await apply(fresh, current) + assert await catalog(old) == await catalog(fresh) + + +async def test_a_current_schema_is_not_rewritten_by_the_guard() -> None: + current = REPORTING_ROW_STORAGE_DDL_PATH.read_text() + async with isolated_reporting_pool() as pool: + await apply(pool, current) + before = await binding_constraints(pool) + await apply(pool, current) + assert await binding_constraints(pool) == before + + +async def test_the_guard_leaves_a_host_customised_constraint_alone() -> None: + """Only the ``{1,128}`` shape this SDK shipped is replaced; anything else is the host's.""" + psycopg = pytest.importorskip("psycopg") + current = REPORTING_ROW_STORAGE_DDL_PATH.read_text() + async with isolated_reporting_pool() as pool: + await apply(pool, OLD_DDL.read_text()) + async with pool.connection() as connection, connection.transaction(): + await connection.execute( + f"ALTER TABLE adcp_reporting_row_bindings DROP CONSTRAINT {LEGACY_CONSTRAINT}" + ) + await connection.execute( + "ALTER TABLE adcp_reporting_row_bindings ADD CONSTRAINT host_row_binding_id_64" + " CHECK (row_binding_id ~ '^[A-Za-z0-9_.:-]{1,64}$')" + ) + await apply(pool, current) + assert [row[0] for row in await binding_constraints(pool)] == [ + CONSTRAINT, + "host_row_binding_id_64", + ] + with pytest.raises(psycopg.errors.CheckViolation): + await insert_binding(pool, "h" * 65) + + +async def test_the_guard_does_nothing_before_the_bindings_table_exists() -> None: + async with isolated_reporting_pool() as pool: + ddl = REPORTING_ROW_STORAGE_DDL_PATH.read_text() + start = ddl.index("DO $binding_id_bound$") + block = ddl[start : ddl.index("$binding_id_bound$;", start) + len("$binding_id_bound$;")] + await apply(pool, block) + + +async def test_a_binding_registered_against_the_original_limit_fails_closed_until_the_ddl_runs( + tmp_path: Path, +) -> None: + """The startup catalog check names the missing bound; it never reaches a bare CHECK failure.""" + async with isolated_reporting_pool() as pool: + store = PgReportingLedgerStore( + pool=pool, + row_storage=ReportingRowStorageOptionsV1( + deployment_namespace="test", + bindings={"x" * 200: ReportingPostgresRowBindingV1()}, + ), + ) + await apply(pool, _ledger_ddl()) + await apply(pool, OLD_DDL.read_text()) + with pytest.raises(ReportingRowStoreError) as failure: + await store.ready_row_storage() + assert failure.value.code == "STATE_UNAVAILABLE" + assert CONSTRAINT in str(failure.value) + + await store.create_schema() # applies the current row-storage DDL in place + await store.ready_row_storage() + assert await fetch_all( + pool, "SELECT length(row_binding_id) FROM adcp_reporting_row_bindings" + ) == [(200,)] + + +def _ledger_ddl() -> str: + from adcp_reporting_storage.ledger.pg import _DDL_PATH + + return _DDL_PATH.read_text() + + +async def test_a_255_character_binding_id_and_credential_ref_write_and_read_back_a_revision( + tmp_path: Path, +) -> None: + binding_id = "r" * 255 + root = tmp_path / "rows" + provider = create_filesystem_reporting_row_object_provider_v1(roots={"rows": root}) + options = ReportingRowStorageOptionsV1( + deployment_namespace="test", + bindings={ + binding_id: ReportingObjectRowBindingV1( + provider="filesystem", + location={"root": "rows"}, + prefix="adcp-rows", + credential_ref="c" * 255, + ), + }, + providers={"filesystem": provider}, + ) + async with isolated_reporting_pool() as pool: + store = PgReportingLedgerStore(pool=pool, row_storage=options) + await store.create_schema() + obligation = await seeded(store) + async with pool.connection() as connection: + await connection.execute( + "UPDATE reporting_configurations SET lease_worker_id = 'worker-1'," + " lease_expires_at = clock_timestamp() + INTERVAL '10 minutes'" + ) + rows = many_rows(50) + revision = typed_revision(obligation, rows, suffix="long") + assert await store.stage_revision_rows(revision, rows) is True + await store.commit_revision(revision, rows) + + assert await fetch_all( + pool, "SELECT row_binding_id, operational_config FROM adcp_reporting_row_bindings" + ) == [(binding_id, {"compression": "gzip", "credentialRef": "c" * 255})] + walked, _ = await walk(store, obligation.account_id, revision.reporting_revision_id, 20) + assert walked == rows diff --git a/tests/fixtures/reporting-row-storage-pre-binding-bounds.sql b/tests/fixtures/reporting-row-storage-pre-binding-bounds.sql new file mode 100644 index 0000000000..0862039adb --- /dev/null +++ b/tests/fixtures/reporting-row-storage-pre-binding-bounds.sql @@ -0,0 +1,334 @@ +-- AdCP Reliable Reporting row storage: bindings, row sets, chunk manifests, +-- PostgreSQL chunk bodies and write intents. +-- +-- These are the shared SDK persistence tables (adcontextprotocol/adcp#7996, +-- section 3.2, reporting-row-storage v1). The JavaScript SDK ships the same +-- tables and columns, so one database can hold chunks written by either SDK: +-- every reader verifies stored bytes against digests committed here. +-- +-- Apply after reporting_ledger.sql (PgReportingLedgerStore.create_schema does +-- this when row storage is enabled). PostgreSQL 13 or later (gen_random_uuid, +-- sha256). Idempotent; reapplying preserves the installation identity and every +-- recorded location. +-- +-- One deliberate difference from the JavaScript DDL: adcp_reporting_row_sets +-- .obligation_id has no foreign key. Until the shared Core layout (persistence +-- Stage 3), Python obligations live in reporting_obligations, not in the +-- JavaScript adcp_reporting_obligations table, so a foreign key to either would +-- reject the other SDK's row sets. The (row_set_id, account_id, obligation_id) +-- composite key still binds every chunk and every shared row set to its own +-- account and obligation. +-- +-- Stored values use AdCP's names and bounds (reporting-file-manifest.json, +-- reporting-file-entry.json): object_ref and native_version_ref are 1 to 1024 +-- characters, size_bytes is the stored object's size, and the row-set format and +-- algorithm are jsonl and adcp_jcs_rows_v1. A database created before this +-- alignment (object_key, native_version, byte_count, physical_byte_count, +-- canonical_byte_count, encoding) is renamed in place by the first block below, +-- which also rewrites open write intents. Recorded object keys are not rewritten. +-- +-- The adcp_ prefix keeps these objects outside the reporting_* catalog contract +-- older binaries verify, so adding them cannot change any retained manifest. + +SELECT pg_advisory_xact_lock(hashtext('adcp.reporting.schema'), hashtext(current_schema())); +-- The JavaScript SDK serializes its appliers on this key; taking it too serializes the two SDKs' +-- migrations of these shared tables against each other. +SELECT pg_advisory_xact_lock(hashtextextended('adcp-reporting-row-storage-migration', 0)); + +-- In-place upgrade from the pre-AdCP-alignment names. Every step is guarded, so a fresh install +-- (no tables yet) and a reapplied migration are both no-ops here. +DO $align$ +DECLARE + renames TEXT[][] := ARRAY[ + ARRAY['adcp_reporting_row_chunks', 'object_key', 'object_ref'], + ARRAY['adcp_reporting_row_chunks', 'native_version', 'native_version_ref'], + ARRAY['adcp_reporting_row_chunks', 'byte_count', 'canonical_size_bytes'], + ARRAY['adcp_reporting_row_chunks', 'physical_byte_count', 'size_bytes'], + ARRAY['adcp_reporting_row_sets', 'canonical_byte_count', 'content_size_bytes'] + ]; + -- Column CHECK constraints are named after their column when created; keep an upgraded database + -- identical to a fresh one, which the startup catalog check compares by name. + constraint_renames TEXT[][] := ARRAY[ + ARRAY['adcp_reporting_row_chunks', 'adcp_reporting_row_chunks_byte_count_check', + 'adcp_reporting_row_chunks_canonical_size_bytes_check'], + ARRAY['adcp_reporting_row_chunks', 'adcp_reporting_row_chunks_physical_byte_count_check', + 'adcp_reporting_row_chunks_size_bytes_check'], + ARRAY['adcp_reporting_row_sets', 'adcp_reporting_row_sets_canonical_byte_count_check', + 'adcp_reporting_row_sets_content_size_bytes_check'] + ]; + item TEXT[]; +BEGIN + FOREACH item SLICE 1 IN ARRAY renames LOOP + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = item[1] AND column_name = item[2] + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = item[1] AND column_name = item[3] + ) THEN + EXECUTE format('ALTER TABLE %I RENAME COLUMN %I TO %I', item[1], item[2], item[3]); + END IF; + END LOOP; + FOREACH item SLICE 1 IN ARRAY constraint_renames LOOP + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = item[2] AND conrelid = to_regclass(quote_ident(current_schema()) || '.' || quote_ident(item[1])) + ) AND NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = item[3] AND conrelid = to_regclass(quote_ident(current_schema()) || '.' || quote_ident(item[1])) + ) THEN + EXECUTE format('ALTER TABLE %I RENAME CONSTRAINT %I TO %I', item[1], item[2], item[3]); + END IF; + END LOOP; + -- encoding = 'adcp_canonical_jsonl_v1' becomes AdCP's format = 'jsonl' plus + -- algorithm = 'adcp_jcs_rows_v1'. The old CHECK admitted one value, so the defaults backfill + -- every row without an UPDATE (the immutability trigger forbids one). + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = 'adcp_reporting_row_sets' AND column_name = 'encoding' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = 'adcp_reporting_row_sets' AND column_name = 'format' + ) THEN + ALTER TABLE adcp_reporting_row_sets + ADD COLUMN format TEXT NOT NULL DEFAULT 'jsonl' CHECK (format = 'jsonl'), + ADD COLUMN algorithm TEXT NOT NULL DEFAULT 'adcp_jcs_rows_v1' CHECK (algorithm = 'adcp_jcs_rows_v1'); + ALTER TABLE adcp_reporting_row_sets ALTER COLUMN format DROP DEFAULT, ALTER COLUMN algorithm DROP DEFAULT; + ALTER TABLE adcp_reporting_row_sets DROP COLUMN encoding; + END IF; +END +$align$; + +CREATE TABLE IF NOT EXISTS adcp_persistence_installation ( + singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), + installation_id UUID NOT NULL DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() +); +INSERT INTO adcp_persistence_installation (singleton) VALUES (TRUE) ON CONFLICT DO NOTHING; + +CREATE TABLE IF NOT EXISTS adcp_reporting_row_bindings ( + row_binding_id TEXT COLLATE "C" PRIMARY KEY CHECK (row_binding_id ~ '^[A-Za-z0-9_.:-]{1,128}$'), + kind TEXT NOT NULL CHECK (kind IN ('postgres', 'object')), + provider TEXT NOT NULL CHECK (provider ~ '^[a-z][a-z0-9_]{0,63}$'), + identity_config JSONB NOT NULL CHECK (jsonb_typeof(identity_config) = 'object'), + identity_sha256 TEXT COLLATE "C" NOT NULL CHECK (identity_sha256 ~ '^[0-9a-f]{64}$'), + operational_config JSONB NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(operational_config) = 'object'), + namespace_key TEXT COLLATE "C" NOT NULL CHECK (namespace_key ~ '^[0-9a-f]{64}$'), + state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active', 'read_only', 'retired')), + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + retired_at TIMESTAMPTZ, + CHECK ((state = 'retired') = (retired_at IS NOT NULL)) +); + +CREATE TABLE IF NOT EXISTS adcp_reporting_row_sets ( + row_set_id TEXT COLLATE "C" PRIMARY KEY, + row_set_kind TEXT NOT NULL CHECK (row_set_kind IN ('revision', 'adjustment')), + account_id TEXT COLLATE "C" NOT NULL, + obligation_id TEXT COLLATE "C" NOT NULL, + format TEXT NOT NULL CHECK (format = 'jsonl'), + algorithm TEXT NOT NULL CHECK (algorithm = 'adcp_jcs_rows_v1'), + digest_profile TEXT NOT NULL CHECK (digest_profile IN ('revision_envelope_v1', 'rows_v1')), + content_sha256 TEXT COLLATE "C" NOT NULL CHECK (content_sha256 ~ '^[0-9a-f]{64}$'), + content_size_bytes BIGINT NOT NULL CHECK (content_size_bytes > 0), + row_count BIGINT NOT NULL CHECK (row_count >= 0), + row_manifest_sha256 TEXT COLLATE "C" NOT NULL CHECK (row_manifest_sha256 ~ '^[0-9a-f]{64}$'), + chunk_count INTEGER NOT NULL CHECK (chunk_count >= 0), + rows_shared_from_row_set_id TEXT COLLATE "C", + row_binding_id TEXT COLLATE "C" NOT NULL REFERENCES adcp_reporting_row_bindings(row_binding_id), + row_location_version INTEGER NOT NULL DEFAULT 1 CHECK (row_location_version > 0), + rows_state TEXT NOT NULL DEFAULT 'live' CHECK (rows_state IN ('live', 'pruning', 'pruned', 'unavailable')), + rows_state_changed_at TIMESTAMPTZ, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + CHECK ((row_count = 0) = (chunk_count = 0)), + CHECK (rows_shared_from_row_set_id IS NULL OR rows_shared_from_row_set_id <> row_set_id), + -- Tenant columns are the composite key children reference, so a chunk or a + -- shared row set can never name another account's or obligation's rows. + UNIQUE (row_set_id, account_id, obligation_id), + FOREIGN KEY (rows_shared_from_row_set_id, account_id, obligation_id) + REFERENCES adcp_reporting_row_sets (row_set_id, account_id, obligation_id) +); +CREATE INDEX IF NOT EXISTS adcp_reporting_row_sets_obligation + ON adcp_reporting_row_sets (obligation_id, recorded_at, row_set_id); +CREATE INDEX IF NOT EXISTS adcp_reporting_row_sets_state + ON adcp_reporting_row_sets (account_id, rows_state, recorded_at) + WHERE rows_state <> 'pruned'; +CREATE INDEX IF NOT EXISTS adcp_reporting_row_sets_shared + ON adcp_reporting_row_sets (rows_shared_from_row_set_id) + WHERE rows_shared_from_row_set_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS adcp_reporting_row_chunks ( + row_set_id TEXT COLLATE "C" NOT NULL, + chunk_index INTEGER NOT NULL CHECK (chunk_index >= 0), + account_id TEXT COLLATE "C" NOT NULL, + obligation_id TEXT COLLATE "C" NOT NULL, + first_ordinal BIGINT NOT NULL CHECK (first_ordinal >= 0), + row_count INTEGER NOT NULL CHECK (row_count > 0), + canonical_size_bytes BIGINT NOT NULL CHECK (canonical_size_bytes > 0), + sha256 TEXT COLLATE "C" NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'), + segments JSONB NOT NULL CHECK (jsonb_typeof(segments) = 'array'), + object_ref TEXT COLLATE "C" CONSTRAINT adcp_reporting_row_chunks_object_ref_bounds + CHECK (object_ref IS NULL OR char_length(object_ref) BETWEEN 1 AND 1024), + native_version_ref TEXT CONSTRAINT adcp_reporting_row_chunks_native_version_ref_bounds + CHECK (native_version_ref IS NULL OR char_length(native_version_ref) BETWEEN 1 AND 1024), + compression TEXT NOT NULL DEFAULT 'none' CHECK (compression IN ('none', 'gzip')), + physical_sha256 TEXT COLLATE "C" CHECK (physical_sha256 IS NULL OR physical_sha256 ~ '^[0-9a-f]{64}$'), + size_bytes BIGINT CHECK (size_bytes IS NULL OR size_bytes > 0), + PRIMARY KEY (row_set_id, chunk_index), + FOREIGN KEY (row_set_id, account_id, obligation_id) + REFERENCES adcp_reporting_row_sets (row_set_id, account_id, obligation_id), + CHECK ((object_ref IS NULL) = (native_version_ref IS NULL)), + CHECK ((object_ref IS NULL) = (physical_sha256 IS NULL)), + CHECK ((object_ref IS NULL) = (size_bytes IS NULL)) +); +CREATE INDEX IF NOT EXISTS adcp_reporting_row_chunks_object + ON adcp_reporting_row_chunks (object_ref, native_version_ref) + WHERE object_ref IS NOT NULL; +CREATE INDEX IF NOT EXISTS adcp_reporting_row_chunks_body + ON adcp_reporting_row_chunks (account_id, obligation_id, sha256) + WHERE object_ref IS NULL; + +CREATE TABLE IF NOT EXISTS adcp_reporting_chunk_bodies ( + account_id TEXT COLLATE "C" NOT NULL, + obligation_id TEXT COLLATE "C" NOT NULL, + sha256 TEXT COLLATE "C" NOT NULL, + body BYTEA NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (account_id, obligation_id, sha256), + CHECK (sha256 = encode(sha256(body), 'hex')) +); + +CREATE TABLE IF NOT EXISTS adcp_reporting_row_write_intents ( + row_binding_id TEXT COLLATE "C" NOT NULL REFERENCES adcp_reporting_row_bindings(row_binding_id), + account_id TEXT COLLATE "C" NOT NULL, + row_set_id TEXT COLLATE "C" NOT NULL, + content_sha256 TEXT COLLATE "C" NOT NULL CHECK (content_sha256 ~ '^[0-9a-f]{64}$'), + state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'sweeping')), + owner_token TEXT COLLATE "C" NOT NULL, + created_objects JSONB NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(created_objects) = 'array'), + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + expires_at TIMESTAMPTZ NOT NULL, + sweep_attempts INTEGER NOT NULL DEFAULT 0, + next_sweep_at TIMESTAMPTZ, + PRIMARY KEY (row_binding_id, row_set_id, content_sha256) +); +CREATE INDEX IF NOT EXISTS adcp_reporting_row_write_intents_expiry + ON adcp_reporting_row_write_intents (expires_at, account_id) + WHERE state = 'open'; + +CREATE OR REPLACE FUNCTION adcp_reporting_row_storage_immutable() RETURNS trigger +LANGUAGE plpgsql AS $immutable$ +BEGIN + IF TG_TABLE_NAME = 'adcp_reporting_row_bindings' THEN + IF (NEW.row_binding_id, NEW.kind, NEW.provider, NEW.identity_config, NEW.identity_sha256, NEW.namespace_key) + IS DISTINCT FROM + (OLD.row_binding_id, OLD.kind, OLD.provider, OLD.identity_config, OLD.identity_sha256, OLD.namespace_key) THEN + RAISE EXCEPTION 'reporting row binding identity is immutable' USING ERRCODE = '23514'; + END IF; + ELSIF TG_TABLE_NAME = 'adcp_reporting_row_sets' THEN + IF (NEW.row_set_id, NEW.row_set_kind, NEW.account_id, NEW.obligation_id, NEW.format, NEW.algorithm, + NEW.digest_profile, NEW.content_sha256, NEW.content_size_bytes, NEW.row_count, NEW.row_manifest_sha256, + NEW.chunk_count, NEW.rows_shared_from_row_set_id, NEW.recorded_at) + IS DISTINCT FROM + (OLD.row_set_id, OLD.row_set_kind, OLD.account_id, OLD.obligation_id, OLD.format, OLD.algorithm, + OLD.digest_profile, OLD.content_sha256, OLD.content_size_bytes, OLD.row_count, OLD.row_manifest_sha256, + OLD.chunk_count, OLD.rows_shared_from_row_set_id, OLD.recorded_at) THEN + RAISE EXCEPTION 'reporting row set content is immutable' USING ERRCODE = '23514'; + END IF; + IF NEW.row_binding_id IS DISTINCT FROM OLD.row_binding_id + AND NEW.row_location_version <> OLD.row_location_version + 1 THEN + RAISE EXCEPTION 'reporting row set moves must advance row_location_version' USING ERRCODE = '23514'; + END IF; + ELSIF TG_TABLE_NAME = 'adcp_reporting_row_chunks' THEN + IF (NEW.row_set_id, NEW.chunk_index, NEW.account_id, NEW.obligation_id, NEW.first_ordinal, NEW.row_count, + NEW.canonical_size_bytes, NEW.sha256, NEW.segments) + IS DISTINCT FROM + (OLD.row_set_id, OLD.chunk_index, OLD.account_id, OLD.obligation_id, OLD.first_ordinal, OLD.row_count, + OLD.canonical_size_bytes, OLD.sha256, OLD.segments) THEN + RAISE EXCEPTION 'reporting row chunk content is immutable' USING ERRCODE = '23514'; + END IF; + ELSIF TG_TABLE_NAME = 'adcp_reporting_chunk_bodies' THEN + RAISE EXCEPTION 'reporting chunk bodies are immutable' USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END +$immutable$; + +DO $triggers$ +DECLARE + target TEXT; +BEGIN + FOREACH target IN ARRAY ARRAY[ + 'adcp_reporting_row_bindings', + 'adcp_reporting_row_sets', + 'adcp_reporting_row_chunks', + 'adcp_reporting_chunk_bodies' + ] LOOP + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger + WHERE tgname = target || '_immutable' + AND tgrelid = to_regclass(quote_ident(current_schema()) || '.' || quote_ident(target)) + ) THEN + EXECUTE format( + 'CREATE TRIGGER %I BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION adcp_reporting_row_storage_immutable()', + target || '_immutable', + target + ); + END IF; + END LOOP; +END +$triggers$; + +-- Per-intent sweep backoff (1 minute doubling to 6 hours), so one failing intent cannot hold the +-- head of every pass. The same columns the JavaScript SDK adds, for installs created before. +ALTER TABLE adcp_reporting_row_write_intents + ADD COLUMN IF NOT EXISTS sweep_attempts INTEGER NOT NULL DEFAULT 0; +ALTER TABLE adcp_reporting_row_write_intents ADD COLUMN IF NOT EXISTS next_sweep_at TIMESTAMPTZ; + +-- AdCP bounds on installs created before they existed (a fresh install has them inline). +DO $bounds$ +DECLARE + offending BIGINT; +BEGIN + SELECT count(*) INTO offending FROM adcp_reporting_row_chunks + WHERE object_ref IS NOT NULL + AND (char_length(object_ref) NOT BETWEEN 1 AND 1024 + OR char_length(native_version_ref) NOT BETWEEN 1 AND 1024); + IF offending > 0 THEN + RAISE EXCEPTION 'reporting row storage: % chunk(s) hold an object_ref or native_version_ref outside AdCP 1 to 1024 characters; re-sync them before applying this migration', offending + USING ERRCODE = '23514'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'adcp_reporting_row_chunks_object_ref_bounds' + AND conrelid = to_regclass(quote_ident(current_schema()) || '.adcp_reporting_row_chunks') + ) THEN + ALTER TABLE adcp_reporting_row_chunks ADD CONSTRAINT adcp_reporting_row_chunks_object_ref_bounds + CHECK (object_ref IS NULL OR char_length(object_ref) BETWEEN 1 AND 1024); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'adcp_reporting_row_chunks_native_version_ref_bounds' + AND conrelid = to_regclass(quote_ident(current_schema()) || '.adcp_reporting_row_chunks') + ) THEN + ALTER TABLE adcp_reporting_row_chunks ADD CONSTRAINT adcp_reporting_row_chunks_native_version_ref_bounds + CHECK (native_version_ref IS NULL OR char_length(native_version_ref) BETWEEN 1 AND 1024); + END IF; +END +$bounds$; + +-- Open write intents written before the rename name objects objectKey / nativeVersion. +UPDATE adcp_reporting_row_write_intents + SET created_objects = ( + SELECT COALESCE(jsonb_agg( + CASE WHEN jsonb_typeof(created) = 'object' AND created ? 'objectKey' + THEN jsonb_build_object('object_ref', created -> 'objectKey', + 'native_version_ref', created -> 'nativeVersion') + ELSE created END + ORDER BY ordinality + ), '[]'::jsonb) + FROM jsonb_array_elements(created_objects) WITH ORDINALITY AS element(created, ordinality) + ) + WHERE EXISTS ( + SELECT 1 FROM jsonb_array_elements(created_objects) AS element(created) + WHERE jsonb_typeof(created) = 'object' AND created ? 'objectKey' + ); diff --git a/tests/test_reporting_row_binding_bounds.py b/tests/test_reporting_row_binding_bounds.py new file mode 100644 index 0000000000..39d72eb1d3 --- /dev/null +++ b/tests/test_reporting_row_binding_bounds.py @@ -0,0 +1,164 @@ +"""``row_binding_id`` and ``credential_ref`` accept 1 to 255 characters (adcp-client-python #1498). + +Both were capped at 128, tighter than every comparable AdCP identifier (``reporting_revision_id``, +``resource_ref`` and ``destination_ref`` use ``^[A-Za-z0-9_.:-]{1,255}$``). Ported from the +JavaScript SDK (adcp-client #3595, ``reporting-row-binding-bounds``). No database needed: this is +the configuration check and the provider ``clients`` map. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +from adcp_reporting_storage.ledger.row_storage import ( + ReportingObjectRowBindingV1, + ReportingPostgresRowBindingV1, + ReportingRowStorageOptionsV1, + ReportingRowStorageV1, +) +from adcp_reporting_storage.ledger.row_storage_object import ( + REPORTING_ROW_BINDING_ID_MAX_LENGTH, + REPORTING_ROW_CREDENTIAL_REF_MAX_LENGTH, + create_filesystem_reporting_row_object_provider_v1, +) +from adcp_reporting_storage.ledger.row_storage_provider_io import client_map, is_row_identifier + +from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError + +HOSTILE = ( + "", + "has space", + "tab\there", + "trailing-newline\n", + "nul\x00byte", + "afullwidth", # fullwidth "a" + "аcyrillic", # Cyrillic "a" next to the Latin one + "٣arabic-indic-digit", + "semi;colon", + "slash/er", +) + + +def test_the_bounds_are_the_adcp_identifier_bound() -> None: + assert REPORTING_ROW_BINDING_ID_MAX_LENGTH == 255 + assert REPORTING_ROW_CREDENTIAL_REF_MAX_LENGTH == 255 + + +@pytest.mark.parametrize("length", [1, 128, 129, 254, 255]) +def test_an_identifier_up_to_255_characters_is_accepted(length: int) -> None: + assert is_row_identifier("a" * length) + + +@pytest.mark.parametrize("length", [256, 300, 5000]) +def test_an_identifier_beyond_255_characters_is_refused(length: int) -> None: + assert not is_row_identifier("a" * length) + + +def test_every_character_of_the_identifier_alphabet_is_accepted() -> None: + assert is_row_identifier("Az09_.:-") + + +@pytest.mark.parametrize("value", [*HOSTILE, None, 7, b"abc", ["a"]]) +def test_hostile_and_non_string_identifiers_are_refused(value: Any) -> None: + assert not is_row_identifier(value) + + +def storage(tmp_path: Path, binding_id: str = "reports", **binding: Any) -> ReportingRowStorageV1: + providers = { + "filesystem": create_filesystem_reporting_row_object_provider_v1( + roots={"rows": tmp_path / "rows"} + ) + } + defaults: dict[str, Any] = { + "provider": "filesystem", + "location": {"root": "rows"}, + "prefix": "adcp-rows", + } + return ReportingRowStorageV1( + ReportingRowStorageOptionsV1( + bindings={binding_id: ReportingObjectRowBindingV1(**{**defaults, **binding})}, + providers=providers, + ) + ) + + +@pytest.mark.parametrize("length", [128, 129, 255]) +def test_a_binding_id_up_to_255_characters_passes_the_configuration_check( + tmp_path: Path, length: int +) -> None: + storage(tmp_path, "b" * length) + ReportingRowStorageV1( + ReportingRowStorageOptionsV1(bindings={"p" * length: ReportingPostgresRowBindingV1()}) + ) + + +def test_a_binding_id_beyond_255_characters_is_refused_and_not_echoed(tmp_path: Path) -> None: + with pytest.raises(ReportingRowStoreError) as failure: + storage(tmp_path, "x" * 5000) + assert failure.value.code == "INVALID_INPUT" + message = str(failure.value) + assert "1 to 255 characters of A-Za-z0-9_.:-" in message + assert len(message) < 300, "a hostile identifier is not echoed whole" + assert "x" * 100 not in message + + +def test_a_short_invalid_binding_id_is_quoted_with_the_bound(tmp_path: Path) -> None: + with pytest.raises(ReportingRowStoreError) as failure: + storage(tmp_path, "bad id") + assert '"bad id"' in str(failure.value) + assert "1 to 255 characters" in str(failure.value) + + +@pytest.mark.parametrize("binding_id", HOSTILE) +def test_hostile_binding_ids_are_refused(tmp_path: Path, binding_id: str) -> None: + with pytest.raises(ReportingRowStoreError) as failure: + storage(tmp_path, binding_id) + assert failure.value.code == "INVALID_INPUT" + + +@pytest.mark.parametrize("key", [7, b"abc", ("a", "b"), None, 1.5, b"x" * 5000, object]) +def test_a_non_string_binding_id_is_refused_not_a_type_error(key: Any) -> None: + with pytest.raises(ReportingRowStoreError) as failure: + ReportingRowStorageV1( + ReportingRowStorageOptionsV1( + bindings={key: ReportingPostgresRowBindingV1()} # type: ignore[dict-item] + ) + ) + assert failure.value.code == "INVALID_INPUT" + assert len(str(failure.value)) < 300, "a hostile key is not echoed whole" + + +@pytest.mark.parametrize("length", [128, 129, 255]) +def test_a_credential_ref_up_to_255_characters_passes_the_configuration_check( + tmp_path: Path, length: int +) -> None: + storage(tmp_path, credential_ref="c" * length) + + +def test_a_credential_ref_beyond_255_characters_is_refused(tmp_path: Path) -> None: + with pytest.raises(ReportingRowStoreError) as failure: + storage(tmp_path, credential_ref="c" * 256) + assert failure.value.code == "INVALID_INPUT" + assert "1 to 255 characters of A-Za-z0-9_.:-" in str(failure.value) + assert "c" * 100 not in str(failure.value) + + +@pytest.mark.parametrize("credential_ref", HOSTILE) +def test_hostile_credential_refs_are_refused(tmp_path: Path, credential_ref: str) -> None: + with pytest.raises(ReportingRowStoreError) as failure: + storage(tmp_path, credential_ref=credential_ref) + assert failure.value.code == "INVALID_INPUT" + + +def test_a_provider_clients_map_holds_every_reference_a_binding_can_name() -> None: + client = object() + for length in (128, 129, 255): + assert client_map({"r" * length: client}) == {"r" * length: client} + for bad in ("r" * 256, *HOSTILE): + with pytest.raises(ReportingRowStoreError) as failure: + client_map({bad: client}) + assert failure.value.code == "INVALID_INPUT" + with pytest.raises(ReportingRowStoreError): + client_map({"ok": None}) From 4449bc94cdd47900f39e15f5e99a35a9699d0c30 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 2 Nov 2026 22:27:13 -0500 Subject: [PATCH 20/28] feat(reporting-storage): order the account change feed by xid and seq (#1827) Account feeds now order by (xid, seq) behind xid < pg_snapshot_xmin, as the deployment feed does, replacing the page truncation from #1483. A lower seq that commits late is returned on a later page, never skipped, and no feed depends on the account lock. Account cursors carry xid; a seq-only cursor is mapped to a position that skips nothing and still expires exactly through pruned_through_seq. Consumer holds, change pruning and period retirement compare (xid, seq), prune victims are taken in that order, and the migration adds the horizon xid columns, the consumer check constraint and the account_id, xid, seq index idempotently. Mirrors adcp-client#3289. Closes #1825. Co-authored-by: Claude Sonnet 5.5 --- docs/reporting-release-notes.md | 17 + docs/reporting-row-storage.md | 63 +- .../ledger/change_feed.py | 338 +++++++--- .../ledger/period_retirement.py | 8 +- .../src/adcp_reporting_storage/ledger/pg.py | 5 +- .../ledger/reporting_changes.sql | 65 +- ...test_reporting_change_feed_consumers_pg.py | 102 ++- .../test_reporting_change_feed_horizon_pg.py | 7 +- .../test_reporting_change_feed_pg.py | 598 ++++++++++++++++-- 9 files changed, 1016 insertions(+), 187 deletions(-) diff --git a/docs/reporting-release-notes.md b/docs/reporting-release-notes.md index 40577a7117..7b656ce232 100644 --- a/docs/reporting-release-notes.md +++ b/docs/reporting-release-notes.md @@ -18,6 +18,23 @@ restart across the upgrade does not turn a replay into a conflict. This is part the shared layout in adcontextprotocol/adcp#7996 (Stage 2, tracked in [#1457](https://github.com/adcontextprotocol/adcp-client-python/issues/1457)). +## Account change feed orders by `(xid, seq)` + +The account change feed (`changes_after(account_id=...)`) now orders by `(xid, seq)` behind +`xid < pg_snapshot_xmin(pg_current_snapshot())`, as the deployment-wide feed already did, matching the JavaScript SDK +([adcp-client#3289](https://github.com/adcontextprotocol/adcp-client/pull/3289)). It replaces the page truncation at +the first in-flight change from #1483, and no longer depends on the account lock for its order: a lower `seq` that +commits late is returned on a later page, never skipped. Account cursors now carry `xid`. A seq-only account cursor +saved by an earlier release is still accepted and mapped to a position that skips nothing, at the cost of redelivering +a few changes, and it still expires exactly (`pruned_through_seq` is kept). `reporting_changes.sql` is re-applied by +`create_schema` and is idempotent: it adds the account horizon's xid columns, replaces the consumer check constraint, +and creates `adcp_reporting_changes_account_xid`. Create that index with `CREATE INDEX CONCURRENTLY` first on a large +changes table, stop older hosts' change maintenance before the new reader runs, and do not roll back after a consumer +saved a new-format account cursor. Not affected: deployment-wide cursors, the buyer-facing `changes_after` projection and +anyone who never enabled `change_feed`. See +[host change feed and consumers](reporting-row-storage.md#host-change-feed-and-consumers) +([#1825](https://github.com/adcontextprotocol/adcp-client-python/issues/1825)). + ## Feed consumer checkpoints are forward-only `save_feed_consumer_cursor` used to replace the saved position unconditionally. It now ignores a cursor behind the diff --git a/docs/reporting-row-storage.md b/docs/reporting-row-storage.md index 8c9e3317ca..e51ff68a2a 100644 --- a/docs/reporting-row-storage.md +++ b/docs/reporting-row-storage.md @@ -403,7 +403,7 @@ missing. The JavaScript SDK has no replay of `python-written.json`; the live tes `PgReportingLedgerStore(pool=pool, change_feed=True)` also appends one row to the shared `adcp_reporting_changes` table (PostgreSQL 13 or later, `xid8`) for every obligation, revision and adjustment, in the transaction that writes -the record and after the account lock. The JavaScript SDK writes the same table. It is separate from +the record. The JavaScript SDK writes the same table. It is separate from `reporting_ledger_changes`, the buyer-facing feed behind `changes_checkpoint` and `changes_after`, until the shared Core layout. @@ -415,18 +415,44 @@ await store.save_feed_consumer_cursor("warehouse-sink", page.cursor) # holds ba await store.changes_after(account_id="acct_a", cursor=page.cursor) ``` -- **Account feeds** order by `seq` and truncate each page before the first change whose transaction is not below - every transaction still in flight (`xid >= pg_snapshot_xmin(pg_current_snapshot())`): a page is the committed prefix - in `seq` order and stops there, and its cursor never moves past a held-back change. `seq` is taken under the account - lock, but ordering does not rely on that lock. Truncation is deliberate: a plain `xid < xmin` *filter* would be - wrong, because `seq` order and `xid` order can differ, and it would hand out a later change while skipping an earlier - one it excluded. `xmin` is cluster-wide, so **any older open transaction anywhere in the database holds account pages - back until it ends, and an empty page can mean "caught up" or "held back"**; poll again rather than concluding - nothing is new. The row scan is still served by the `(account_id, seq)` index. This narrows the window for a second - writer but does not close it for every interleaving: one writer SDK per database remains the contract. -- **The deployment-wide feed** (no `account_id`) orders by `(xid, seq)` and returns only changes whose transaction is - older than every transaction still in flight, so a slow commit is never skipped. A long-running transaction delays - this feed; it never makes it lose a record. +- **Every feed orders by `(xid, seq)`**, account-scoped or deployment-wide (no `account_id`), and returns only changes + whose transaction is older than every transaction still in flight (`xid < pg_snapshot_xmin(pg_current_snapshot())`), + so a slow commit is never skipped. The order does not depend on the account lock, on the order a writer drew `seq` + in, or on which lock key a writer took: a change with a lower `seq` and a higher `xid` that commits late is returned + on a later page, after the cursor. The cost is that `xmin` is cluster-wide, so **any older open transaction anywhere + in the database holds every feed back until it ends, and an empty page can mean "caught up" or "held back"**; poll + again rather than concluding nothing is new, and keep transactions short. The account scan is served by the + `(account_id, xid, seq)` index that `create_schema` creates. One writer SDK per database remains the contract: a + second SDK can write rows the first SDK's lifecycle, retention and idempotency logic does not know about. +- **Account cursors carry `xid`** (`{"v":1,"account":...,"seq":...,"xid":...}`), the same bytes the JavaScript SDK + writes. Store the cursor string opaquely. Cursor `seq` and `xid` are range-checked, so a value the database would + reject raises `ValueError`, never a driver error. +- **Upgrading from account cursors that carry only `seq`.** Account feeds used to order by `seq` and truncate each + page before the first in-flight change. A cursor saved then has no `xid`. `changes_after` accepts it and maps it to + the position `(X, S)`, where `X` is the smaller of the oldest `xid` among the account's changes after `S` and the + reading snapshot's `xmin`. Every change after `S` has an `xid` at or above both (a change that has not committed yet + belongs to a transaction at or above `xmin`), so nothing is skipped. The cost is redelivery: changes the consumer + already read can come back (those with a lower `seq` and a higher `xid`, and, if a long transaction was open at that + moment, every change committed since it began), so treat redelivery as normal, as for any at-least-once consumer. + The returned cursor is in the new format even on an empty page, so a sink saves it with its next batch. A seq-only + cursor raises `CURSOR_EXPIRED` when **any** change with a higher `seq` was pruned: `pruned_through_seq` still records + the highest `seq` pruned, whatever its `xid`, for exactly this check, because a tuple compare alone would miss a + pruned change with a higher `seq` and a lower `xid`. A consumer registered before the upgrade has a NULL `cursor_xid` + and holds retention by `seq` until its next `save_feed_consumer_cursor`; saving a seq-only cursor registers the + mapped position. +- **Rolling out the `(xid, seq)` account feed.** `create_schema` applies `reporting_changes.sql`, which is idempotent: + it adds `pruned_through_xid` and `pruned_through_xid_seq` to `adcp_reporting_change_account_horizon`, replaces the + `adcp_reporting_feed_consumers` check constraint with `adcp_reporting_feed_consumers_cursor_scope` (an account + consumer may now carry an `xid`; a pre-upgrade account row keeps a NULL one), and creates + `adcp_reporting_changes_account_xid`. On a large `adcp_reporting_changes` table create the index first with + `CREATE INDEX CONCURRENTLY IF NOT EXISTS adcp_reporting_changes_account_xid ON adcp_reporting_changes + (account_id, xid, seq)`, because a plain `CREATE INDEX` blocks appends while it builds; the migration then skips it. + The old `(account_id, seq)` index stays, because it serves the seq-only mapping; drop it once no older host runs. + **Do not run an older host's change maintenance (`prune_changes`, `retire_expired_periods`) alongside the new + reader.** The older code holds an account consumer's changes by `seq` alone and records only `pruned_through_seq`, + so against a consumer holding new-format cursors it can prune a change the consumer has not read without advancing + the horizon the new reader checks. Stop the older hosts' maintenance, or the hosts, first. Rolling back after a + consumer saved a new-format account cursor is not supported (the older SDK rejects it): reset those consumers. - A cursor older than the retained history raises `ReportingChangeCursorExpiredError` (`code == "CURSOR_EXPIRED"`). Resynchronize from `list_current_revisions` and resume from the cursor returned with that read. - **A read with no cursor can raise `CURSOR_EXPIRED` too.** `changes_after` reads the prune horizon, reads the page, @@ -435,12 +461,15 @@ await store.changes_after(account_id="acct_a", cursor=page.cursor) The page is rejected instead. Retrying the read starts from the new, later horizon, which is the oldest retained change; a consumer that had been reading from a cursor resynchronizes from `list_current_revisions` as usual. Handle `CURSOR_EXPIRED` on every `changes_after` call, including a first read, by matching `error.code`. -- A registered consumer holds back `prune_changes` until it has passed those changes in its own feed order, up to - `max_feed_hold_days` (default 7) after its last update, so a dead consumer cannot block retention. Schedule - `prune_changes(change_retention_days=30)` next to the other maintenance. Cursors are the same bytes in both SDKs. +- A registered consumer holds back `prune_changes` and `retire_expired_periods` until it has passed those changes in + `(xid, seq)` order (an account consumer only within its account), up to `max_feed_hold_days` (default 7) after its + last update, so a dead consumer cannot block retention. `prune_changes` takes its victims in `(xid, seq)` order and + records a per-account `(xid, seq)` horizon, so a batch cut by its limit never leaves a retained change below the + horizon. Schedule `prune_changes(change_retention_days=30)` next to the other maintenance. Cursors are the same bytes + in both SDKs. - **Checkpoints are forward-only within a feed scope.** `save_feed_consumer_cursor` ignores a cursor behind the saved one, so a delayed or repeated acknowledgment cannot pull the consumer, or its retention hold, backward and make it - reprocess changes. Account consumers compare `seq`; deployment consumers compare `(xid, seq)`. The call returns + reprocess changes. Every consumer compares `(xid, seq)`. The call returns `True` when it wrote the checkpoint and `False` when it ignored it (behind the saved position, or equal with `refresh_unchanged=False`). An equal cursor by default rewrites the checkpoint and renews the hold, which is the heartbeat for an idle consumer. A change of scope (account to deployment, deployment to account, or between diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/change_feed.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/change_feed.py index ac1e7b6ca0..5df003e57d 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/change_feed.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/change_feed.py @@ -2,13 +2,17 @@ ``PgReportingLedgerStore(change_feed=True)`` appends one row to ``adcp_reporting_changes`` per committed obligation, revision, adjustment and retirement, in the transaction that -writes the record and after the account lock, so per-account sequence order is commit -order. The JavaScript SDK writes the same table, so a host reads one feed. - -* **Account feeds** order by ``seq``. -* **The deployment-wide feed** orders by ``(xid, seq)`` and returns only changes whose - transaction precedes every transaction still in flight, so a slow commit is never - skipped. A long-running transaction delays this feed; it never makes it lose a record. +writes the record. The JavaScript SDK writes the same table, so a host reads one feed. + +* **Every feed**, account-scoped or deployment-wide, orders by ``(xid, seq)`` and returns + only changes whose transaction precedes every transaction still in flight + (``xid < pg_snapshot_xmin(pg_current_snapshot())``), so a slow commit is never skipped. + The order does not depend on the account lock, on the order a writer drew ``seq`` in, or + on which lock key a writer took: a change with a lower ``seq`` and a higher ``xid`` that + commits late is returned on a later page, after the cursor. A long-running transaction + delays every feed; it never makes one lose a record. +* **Account cursors carry ``xid``.** A cursor from before that, which carries only ``seq``, + is accepted and mapped to a position that skips nothing (see :func:`changes_after`). * Cursors are opaque, versioned and stable across restarts, processes and SDKs. A cursor older than the retained history raises :class:`ReportingChangeCursorExpiredError`; the consumer then resynchronizes from ``list_current_revisions`` and resumes from the @@ -16,7 +20,8 @@ commits mid-read raises the same error (for a cursorless read too) instead of returning a page that skipped the pruned changes. * A named consumer registered with ``save_feed_consumer_cursor`` holds back change pruning - (and period retirement) until it has passed those changes, up to ``max_feed_hold_days`` + (and period retirement) until it has passed those changes in ``(xid, seq)`` order, up to + ``max_feed_hold_days`` after its last update, so a dead consumer cannot block retention indefinitely. Its checkpoint is forward-only within a feed scope; ``rewind=True`` replays deliberately and ``remove_feed_consumer`` unregisters it. @@ -35,7 +40,7 @@ from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, NamedTuple if TYPE_CHECKING: from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore @@ -49,6 +54,7 @@ "changes_after", "decode_reporting_change_cursor_v1", "encode_reporting_change_cursor_v1", + "is_legacy_account_change_cursor", "prune_changes", "remove_feed_consumer", "save_feed_consumer_cursor", @@ -93,11 +99,34 @@ def __init__(self) -> None: ) +_INT64_MAX = 2**63 - 1 +_UINT64_MAX = 2**64 - 1 + + +def _fits_unsigned(value: object, maximum: int) -> bool: + """A decimal string PostgreSQL's ``bigint`` / ``xid8`` cast would accept. + + A value it would reject must fail as ``ValueError`` here, not as a raw database error + mid-query. ASCII digits only: ``\\d`` also matches other scripts' digits, which ``int`` + accepts and the database cast does not. + """ + return ( + isinstance(value, str) + and re.fullmatch(r"[0-9]{1,20}", value) is not None + and int(value) <= maximum + ) + + def encode_reporting_change_cursor_v1( *, account: str | None, seq: str, xid: str | None = None ) -> str: + """Encode a cursor. Every cursor carries ``xid``; only a seq-only account cursor omits it. + + Omitting ``xid`` for an account is how a cursor from before the account feed ordered by + ``(xid, seq)`` is spelled; :func:`changes_after` maps one to a position that skips nothing. + """ cursor: dict[str, Any] = {"v": 1, "account": account, "seq": seq} - if account is None: + if account is None or xid is not None: cursor["xid"] = xid raw = json.dumps(cursor, separators=(",", ":"), ensure_ascii=False).encode("utf-8") return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") @@ -114,18 +143,26 @@ def decode_reporting_change_cursor_v1(value: str, account: str | None) -> dict[s not isinstance(parsed, dict) or parsed.get("v") != 1 or parsed.get("account") != account - or not isinstance(parsed.get("seq"), str) - or not re.fullmatch(r"\d{1,19}", parsed["seq"]) + or not _fits_unsigned(parsed.get("seq"), _INT64_MAX) or ( - not isinstance(parsed.get("xid"), str) or not re.fullmatch(r"\d{1,20}", parsed["xid"]) - if account is None - else "xid" in parsed + account is None + if "xid" not in parsed + else not _fits_unsigned(parsed["xid"], _UINT64_MAX) ) ): raise ValueError("change cursor does not belong to this feed") return parsed +def is_legacy_account_change_cursor(cursor: dict[str, Any]) -> bool: + """True for an account cursor issued before account feeds ordered by ``(xid, seq)``. + + It carries a ``seq`` and no ``xid``. :func:`changes_after` accepts it and maps it to a + position that cannot skip a change. + """ + return cursor.get("account") is not None and "xid" not in cursor + + def _positive(value: int, name: str, maximum: int) -> None: if type(value) is not int or not 1 <= value <= maximum: raise ValueError(f"{name} must be between 1 and {maximum}") @@ -136,8 +173,31 @@ def _require_feed(store: PgReportingLedgerStore) -> None: raise ValueError("this operation requires the store change_feed option") +def feed_consumer_behind_change(consumer: str, change: str) -> str: + """SQL condition: the registered consumer row has not yet passed the change row. + + In the consumer's own feed order: every consumer reads ``(xid, seq)`` order, an account + consumer only within its account. A row saved before account feeds carried an ``xid`` has + a NULL ``cursor_xid`` and holds by ``seq`` until the consumer saves its next cursor. + Shared by change pruning, period retirement and anything else that honours consumer holds. + """ + return ( + f"(({consumer}.account_id IS NULL OR {consumer}.account_id = {change}.account_id)" + f" AND CASE WHEN {consumer}.cursor_xid IS NULL" + f" THEN {change}.seq > {consumer}.cursor_seq" + f" ELSE ({change}.xid, {change}.seq) > ({consumer}.cursor_xid, {consumer}.cursor_seq)" + " END)" + ) + + def _page_query(*, account: bool) -> str: - """The change page query for an account feed (``seq`` order) or the deployment feed. + """The change page query for an account feed or the deployment feed. + + Both feeds order by ``(xid, seq)`` and stop below the reading snapshot's ``xmin``. Every + change with a higher ``xid`` than a returned one either committed before it or is not + returned yet, so a cursor never passes a change that commits late, with no assumption about + the lock a writer took or the order it drew ``seq`` in. The account scan is served by the + ``(account_id, xid, seq)`` index, in order. Every column is qualified with the table alias, in the projection and in ORDER BY. ``seq::text`` is *named* ``seq``, and PostgreSQL resolves a bare ORDER BY name to the output @@ -150,57 +210,84 @@ def _page_query(*, account: bool) -> str: " to_char(change.recorded_at AT TIME ZONE 'UTC'," ' \'YYYY-MM-DD"T"HH24:MI:SS.US"Z"\')' ) - if account: - return ( - f"SELECT {columns}," # noqa: S608 # nosec B608 - " change.xid >= pg_snapshot_xmin(pg_current_snapshot()) AS in_flight" - " FROM adcp_reporting_changes change" - " WHERE change.account_id = %s AND change.seq > %s::bigint" - " AND (%s::text[] IS NULL OR change.record_kind = ANY(%s::text[]))" - " ORDER BY change.seq LIMIT %s" - ) + scope = " change.account_id = %s AND" if account else "" return ( f"SELECT {columns} FROM adcp_reporting_changes change" # noqa: S608 # nosec B608 - " WHERE (change.xid, change.seq) > (%s::text::xid8, %s::bigint)" + f" WHERE{scope} (change.xid, change.seq) > (%s::text::xid8, %s::bigint)" " AND change.xid < pg_snapshot_xmin(pg_current_snapshot())" " AND (%s::text[] IS NULL OR change.record_kind = ANY(%s::text[]))" " ORDER BY change.xid, change.seq LIMIT %s" ) -async def _read_horizon(connection: Any, account_id: str | None) -> tuple[str, str]: - """The pruned horizon ``(xid, seq)`` in this feed's order; ``xid`` is ``"0"`` for accounts.""" +class _Horizon(NamedTuple): + xid: str + #: The ``seq`` of the highest ``(xid, seq)`` pruned. + seq: str + #: The highest ``seq`` pruned whatever its ``xid``: the exact check for a seq-only cursor. + max_seq: str + + +async def _read_horizon(connection: Any, account_id: str | None) -> _Horizon: + """The pruned horizon in this feed's own order.""" if account_id is not None: row = await ( await connection.execute( - "SELECT pruned_through_seq::text FROM adcp_reporting_change_account_horizon" + "SELECT pruned_through_xid::text, pruned_through_xid_seq::text," + " pruned_through_seq::text FROM adcp_reporting_change_account_horizon" " WHERE account_id = %s", (account_id,), ) ).fetchone() - return "0", (row[0] if row else "0") - row = await ( - await connection.execute( - "SELECT pruned_through_xid::text, pruned_through_seq::text" - " FROM adcp_reporting_change_horizon WHERE singleton" - ) - ).fetchone() - return (row[0], row[1]) if row else ("0", "0") + else: + row = await ( + await connection.execute( + "SELECT pruned_through_xid::text, pruned_through_seq::text," + " pruned_through_seq::text FROM adcp_reporting_change_horizon WHERE singleton" + ) + ).fetchone() + return _Horizon(*row) if row else _Horizon("0", "0", "0") + +def _assert_retained(position: tuple[str | None, str], horizon: _Horizon) -> None: + """Raise unless ``position`` ``(xid, seq)`` is at or past the pruned ``horizon``. -def _assert_retained( - account_id: str | None, position: tuple[str, str], horizon: tuple[str, str] -) -> None: - """Raise unless ``position`` ``(xid, seq)`` is at or past the pruned ``horizon``.""" + A position without an ``xid`` is a seq-only account cursor, checked against the highest + ``seq`` ever pruned: a tuple compare would miss a pruned change with a higher ``seq`` and + a lower ``xid``. + """ + xid, seq = position behind = ( - int(horizon[1]) > int(position[1]) - if account_id is not None - else (int(horizon[0]), int(horizon[1])) > (int(position[0]), int(position[1])) + int(horizon.max_seq) > int(seq) + if xid is None + else (int(horizon.xid), int(horizon.seq)) > (int(xid), int(seq)) ) if behind: raise ReportingChangeCursorExpiredError() +async def _legacy_account_cursor_xid(connection: Any, account_id: str, seq: str) -> str: + """Map a seq-only account cursor to a transaction id that cannot skip a change. + + Every change after the cursor's ``seq`` has an ``xid`` at or above both the oldest such + change that is visible now and the reading snapshot's ``xmin`` (a change that is not + visible yet belongs to a transaction that was in flight, or started after the snapshot, + whose id is at or above ``xmin``), so the position ``(xid, seq)`` below the smaller of the + two hands them all out. + """ + row = await ( + await connection.execute( + "SELECT LEAST(" + " pg_snapshot_xmin(pg_current_snapshot())," + " COALESCE((SELECT change.xid FROM adcp_reporting_changes change" + " WHERE change.account_id = %s AND change.seq > %s::bigint" + " ORDER BY change.xid LIMIT 1), pg_snapshot_xmin(pg_current_snapshot())))::text", + (account_id, seq), + ) + ).fetchone() + return str(row[0]) + + async def changes_after( store: PgReportingLedgerStore, *, @@ -211,6 +298,19 @@ async def changes_after( ) -> ReportingChangesPageV1: """Read the host change feed. Omit ``cursor`` to start at the oldest retained change. + Every feed, account-scoped or deployment-wide, orders by ``(xid, seq)`` and returns only + changes whose transaction precedes every transaction still in flight, so no commit is ever + skipped and the order does not depend on the account lock. An empty page can therefore + mean caught up or held back by an older open transaction (``xmin`` is cluster-wide). + + An account cursor issued before account feeds carried an ``xid`` holds only a ``seq``. It + is accepted and mapped to a position that cannot skip a change: the lowest transaction id + any change after its ``seq`` could have (the smaller of the oldest such change's ``xid`` + and the reading snapshot's ``xmin``). That can hand out a few changes the consumer already + read, those with a lower ``seq`` and a higher ``xid``, so consumers must treat redelivery + as normal. The page returns a cursor in the new format, even when it is empty. A seq-only + cursor raises ``CURSOR_EXPIRED`` when any change with a higher ``seq`` was pruned. + Raises :class:`ReportingChangeCursorExpiredError` (``code == "CURSOR_EXPIRED"``) when the starting position is older than the retained history. A read **without** a ``cursor`` can raise it too: the pruned horizon is read again after the page, so a prune that commits @@ -222,23 +322,31 @@ async def changes_after( if kinds is not None and any(kind not in _KINDS for kind in kinds): raise ValueError("unknown change kind") decoded = decode_reporting_change_cursor_v1(cursor, account_id) if cursor is not None else None + legacy = decoded is not None and is_legacy_account_change_cursor(decoded) selected = list(kinds) if kinds else None async with store._pool.connection() as connection: # The pruned horizon in this feed's own order. A supplied cursor below it may have # missed a pruned change and fails closed; a consumer with no cursor begins at it. - horizon_xid, horizon_seq = await _read_horizon(connection, account_id) + horizon = await _read_horizon(connection, account_id) + checked: tuple[str | None, str] | None = None if decoded is not None: - _assert_retained( - account_id, (decoded.get("xid", "0"), decoded["seq"]), (horizon_xid, horizon_seq) + checked = (decoded.get("xid"), decoded["seq"]) + _assert_retained(checked, horizon) + position_seq = decoded["seq"] + position_xid = ( + await _legacy_account_cursor_xid(connection, account_id, position_seq) + if legacy and account_id is not None + else decoded["xid"] ) - position_seq = decoded["seq"] if decoded else horizon_seq - position_xid = decoded.get("xid", "0") if decoded else horizon_xid - query = _page_query(account=account_id is not None) - if account_id is not None: - params: tuple[Any, ...] = (account_id, position_seq, selected, selected, limit) else: - params = (position_xid, position_seq, selected, selected, limit) - rows = await (await connection.execute(query, params)).fetchall() + position_xid, position_seq = horizon.xid, horizon.seq + query = _page_query(account=account_id is not None) + scope: tuple[Any, ...] = (account_id,) if account_id is not None else () + rows = await ( + await connection.execute( + query, (*scope, position_xid, position_seq, selected, selected, limit) + ) + ).fetchall() # Pruning can commit between the horizon read and the page read, and a prune deletes rows # and advances the horizon in one transaction. Read the horizon again, as a new statement # after the page, and compare it with the position this page started from: a page read @@ -246,31 +354,21 @@ async def changes_after( # skip them silently. This covers a read with no cursor too. Retrying starts from the # new, later horizon. A prune that commits after this read cannot have affected the page. _assert_retained( - account_id, (position_xid, position_seq), await _read_horizon(connection, account_id) + checked or (position_xid, position_seq), await _read_horizon(connection, account_id) ) - if account_id is not None: - # The account feed orders by ``seq``, which equals commit order only while every writer - # takes the account lock before ``nextval``. Defense in depth for a writer that does not: - # stop the page at the first change whose transaction is not below every in-flight - # transaction (``xid >= xmin``). A plain ``xid < xmin`` filter would not do, because seq - # order and xid order can differ, and it would hand out a later change while skipping an - # earlier one it excluded. Truncating keeps the page a prefix of seq order. This narrows - # the window for a second writer but does not close it for every interleaving: one - # writer SDK per database remains the contract. ``xmin`` is cluster-wide, so any older - # open transaction holds account pages back until it ends; an empty page can mean - # "caught up" or "held back". The row scan is still served by the (account_id, seq) index. - blocked = next((index for index, row in enumerate(rows) if row[7]), None) - if blocked is not None: - rows = rows[:blocked] records = tuple(ReportingChangeRecordV1(row[3], row[4], row[5], row[2], row[6]) for row in rows) if rows: last = rows[-1] next_cursor = encode_reporting_change_cursor_v1( - account=account_id, seq=last[0], xid=last[1] if account_id is None else None + account=account_id, seq=last[0], xid=last[1] ) + elif cursor is not None and not legacy: + next_cursor = cursor else: - next_cursor = cursor or encode_reporting_change_cursor_v1( - account=account_id, seq=position_seq, xid=position_xid if account_id is None else None + # A seq-only cursor is replaced by its mapped position even on an empty page, so the + # consumer saves a cursor in the new format. + next_cursor = encode_reporting_change_cursor_v1( + account=account_id, seq=position_seq, xid=position_xid ) return ReportingChangesPageV1(records, next_cursor) @@ -290,8 +388,8 @@ async def save_feed_consumer_cursor( Checkpoints are forward-only within a feed scope: a cursor behind the saved one is ignored, so a delayed or repeated acknowledgment cannot pull the retention hold back or - make the consumer reprocess changes. Account consumers compare ``seq``; deployment - consumers compare ``(xid, seq)``. Returns ``True`` when the checkpoint was written and + make the consumer reprocess changes. Every consumer compares ``(xid, seq)``. Returns + ``True`` when the checkpoint was written and ``False`` when it was ignored (behind the saved position, or equal with ``refresh_unchanged=False``). @@ -306,6 +404,12 @@ async def save_feed_consumer_cursor( ``refresh_unchanged=False`` recovers an acknowledgment without renewing the retention hold when its position has not advanced. + + A seq-only account cursor (from before account feeds carried an ``xid``) is mapped to the + position :func:`changes_after` would read it from, so the hold sits below every unread + change. A consumer row saved before that upgrade has a NULL ``cursor_xid`` and holds by + ``seq`` until its next save; a cursor in the new format replaces it unless its ``seq`` is + behind. """ _require_feed(store) if not isinstance(name, str) or not _CONSUMER_NAME.match(name): @@ -320,8 +424,16 @@ async def save_feed_consumer_cursor( decoded = decode_reporting_change_cursor_v1(cursor, account) # One atomic upsert: the WHERE of DO UPDATE is re-evaluated against the latest committed # row version under the row lock, so concurrent writers cannot interleave a stale position - # over a newer one. No row is returned when the update is skipped. + # over a newer one. No row is returned when the update is skipped. Forward-only in + # (xid, seq) order for every scope. A row saved before account feeds carried an xid (NULL + # cursor_xid) has only a seq to compare: a cursor in the new format replaces it unless its + # seq is behind. async with store._pool.connection() as connection: + xid = ( + await _legacy_account_cursor_xid(connection, account, decoded["seq"]) + if account is not None and is_legacy_account_change_cursor(decoded) + else decoded["xid"] + ) result = await connection.execute( "INSERT INTO adcp_reporting_feed_consumers" " (consumer_name, account_id, cursor_xid, cursor_seq)" @@ -331,22 +443,20 @@ async def save_feed_consumer_cursor( " cursor_seq = EXCLUDED.cursor_seq, updated_at = clock_timestamp()" " WHERE %(rewind)s::boolean" " OR adcp_reporting_feed_consumers.account_id IS DISTINCT FROM EXCLUDED.account_id" - " OR CASE WHEN EXCLUDED.account_id IS NULL" - " THEN (adcp_reporting_feed_consumers.cursor_xid," - " adcp_reporting_feed_consumers.cursor_seq)" - " < (EXCLUDED.cursor_xid, EXCLUDED.cursor_seq)" - " OR (%(refresh)s::boolean AND" - " (adcp_reporting_feed_consumers.cursor_xid," - " adcp_reporting_feed_consumers.cursor_seq)" - " = (EXCLUDED.cursor_xid, EXCLUDED.cursor_seq))" - " ELSE adcp_reporting_feed_consumers.cursor_seq < EXCLUDED.cursor_seq" - " OR (%(refresh)s::boolean" - " AND adcp_reporting_feed_consumers.cursor_seq = EXCLUDED.cursor_seq) END" + " OR (adcp_reporting_feed_consumers.cursor_xid IS NULL" + " AND adcp_reporting_feed_consumers.cursor_seq <= EXCLUDED.cursor_seq)" + " OR (adcp_reporting_feed_consumers.cursor_xid," + " adcp_reporting_feed_consumers.cursor_seq)" + " < (EXCLUDED.cursor_xid, EXCLUDED.cursor_seq)" + " OR (%(refresh)s::boolean AND" + " (adcp_reporting_feed_consumers.cursor_xid," + " adcp_reporting_feed_consumers.cursor_seq)" + " = (EXCLUDED.cursor_xid, EXCLUDED.cursor_seq))" " RETURNING consumer_name", { "name": name, "account": account, - "xid": decoded.get("xid") if account is None else None, + "xid": xid, "seq": decoded["seq"], "refresh": refresh_unchanged, "rewind": rewind, @@ -393,18 +503,16 @@ async def prune_changes( async with store._pool.connection() as connection, connection.transaction(): victims = await ( await connection.execute( - "WITH victims AS (" + "WITH victims AS (" # noqa: S608 # nosec B608 " SELECT change.seq FROM adcp_reporting_changes change" " WHERE change.recorded_at < clock_timestamp() - (%s::integer * INTERVAL '1 day')" " AND NOT EXISTS (SELECT 1 FROM adcp_reporting_feed_consumers consumer" " WHERE consumer.updated_at >" " clock_timestamp() - (%s::integer * INTERVAL '1 day')" - " AND ((consumer.account_id IS NULL" - " AND (change.xid, change.seq) >" - " (consumer.cursor_xid, consumer.cursor_seq))" - " OR (consumer.account_id = change.account_id" - " AND change.seq > consumer.cursor_seq)))" - " ORDER BY change.seq LIMIT %s)" + f" AND {feed_consumer_behind_change('consumer', 'change')})" + # Victims in (xid, seq) order, so a batch cut by the limit leaves nothing + # retained below the per-account (xid, seq) horizon recorded below. + " ORDER BY change.xid, change.seq LIMIT %s)" " DELETE FROM adcp_reporting_changes change USING victims" " WHERE change.seq = victims.seq" " RETURNING change.seq::text, change.xid::text, change.account_id", @@ -421,16 +529,38 @@ async def prune_changes( " (pruned_through_xid, pruned_through_seq)", (highest[1], highest[0], highest[1], highest[0]), ) - per_account: dict[str, int] = {} - for seq, _, account in victims: - per_account[account] = max(per_account.get(account, 0), int(seq)) - for account, seq in per_account.items(): + # Per account: the highest (xid, seq) pruned, which a cursor is compared with, and the + # highest seq pruned whatever its xid, which is the exact check for a seq-only cursor. + per_account: dict[str, tuple[int, int, int]] = {} + for seq_text, xid_text, account in victims: + seq, xid = int(seq_text), int(xid_text) + top_xid, top_seq, max_seq = per_account.get(account, (xid, seq, seq)) + if (xid, seq) > (top_xid, top_seq): + top_xid, top_seq = xid, seq + per_account[account] = (top_xid, top_seq, max(max_seq, seq)) + for account, (top_xid, top_seq, max_seq) in per_account.items(): await connection.execute( - "INSERT INTO adcp_reporting_change_account_horizon (account_id, pruned_through_seq)" - " VALUES (%s, %s::bigint) ON CONFLICT (account_id) DO UPDATE" - " SET pruned_through_seq = GREATEST(" - " adcp_reporting_change_account_horizon.pruned_through_seq," - " EXCLUDED.pruned_through_seq)", - (account, seq), + "INSERT INTO adcp_reporting_change_account_horizon AS horizon" + " (account_id, pruned_through_seq, pruned_through_xid, pruned_through_xid_seq)" + " VALUES (%(account)s, %(max_seq)s::bigint, %(xid)s::text::xid8," + " %(xid_seq)s::bigint)" + " ON CONFLICT (account_id) DO UPDATE" + " SET pruned_through_seq = GREATEST(horizon.pruned_through_seq," + " EXCLUDED.pruned_through_seq)," + " pruned_through_xid = CASE" + " WHEN (EXCLUDED.pruned_through_xid, EXCLUDED.pruned_through_xid_seq)" + " > (horizon.pruned_through_xid, horizon.pruned_through_xid_seq)" + " THEN EXCLUDED.pruned_through_xid ELSE horizon.pruned_through_xid END," + " pruned_through_xid_seq = CASE" + " WHEN (EXCLUDED.pruned_through_xid, EXCLUDED.pruned_through_xid_seq)" + " > (horizon.pruned_through_xid, horizon.pruned_through_xid_seq)" + " THEN EXCLUDED.pruned_through_xid_seq" + " ELSE horizon.pruned_through_xid_seq END", + { + "account": account, + "max_seq": str(max_seq), + "xid": str(top_xid), + "xid_seq": str(top_seq), + }, ) return len(victims) diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/period_retirement.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/period_retirement.py index 20354f3ace..21707be5ce 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/period_retirement.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/period_retirement.py @@ -44,6 +44,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any +from adcp_reporting_storage.ledger.change_feed import feed_consumer_behind_change from adcp_reporting_storage.ledger.row_retention import NoLongerEligibleError from adcp_reporting_storage.ledger.row_storage import ReportingRowStoreError @@ -122,15 +123,12 @@ async def retire_expired_periods( connection, "adcp_reporting_feed_consumers" ): holds.append( - " AND NOT EXISTS (SELECT 1 FROM adcp_reporting_changes change" + " AND NOT EXISTS (SELECT 1 FROM adcp_reporting_changes change" # noqa: S608 # nosec B608 " JOIN adcp_reporting_feed_consumers consumer" " ON consumer.updated_at > clock_timestamp()" " - (%(hold_days)s::integer * INTERVAL '1 day')" " WHERE change.obligation_id = o.reporting_obligation_id" - " AND ((consumer.account_id IS NULL AND (change.xid, change.seq) >" - " (consumer.cursor_xid, consumer.cursor_seq))" - " OR (consumer.account_id = change.account_id" - " AND change.seq > consumer.cursor_seq)))" + f" AND {feed_consumer_behind_change('consumer', 'change')})" ) parameters = { "account": account_id, diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/pg.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/pg.py index 8ab1f7ee27..fc5c442d40 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/pg.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/pg.py @@ -572,8 +572,9 @@ async def changes_after( ) -> ReportingChangesPageV1: """Read the shared host change feed; see :mod:`adcp_reporting_storage.ledger.change_feed`. - Account feeds order by ``seq``; the deployment-wide feed orders by ``(xid, seq)`` - below the reading snapshot's ``xmin``. A cursor older than the retained history + Every feed, account-scoped or deployment-wide, orders by ``(xid, seq)`` below the + reading snapshot's ``xmin``; an account cursor from before that carries only ``seq`` + and is mapped to a position that skips nothing. A cursor older than the retained history raises ``ReportingChangeCursorExpiredError``, and so does a read with no cursor when a prune commits while the page is being read (the horizon is read again after the page). Requires ``change_feed=True``. diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_changes.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_changes.sql index e114507b98..a3ae811163 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_changes.sql +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_changes.sql @@ -8,9 +8,16 @@ -- PgReportingLedgerStore(change_feed=True) applies it from create_schema. -- -- A row is written in the same transaction as each obligation, revision, adjustment --- and retirement, taking nextval only after the account lock, so per-account sequence --- order is commit order. Deployment-wide readers order by (xid, seq) and stop below --- the reading snapshot's xmin, so a slow commit is never skipped. +-- and retirement. Every reader, account-scoped or deployment-wide, orders by (xid, seq) +-- and stops below the reading snapshot's xmin, so a slow commit is never skipped and no +-- feed depends on the account lock or on the order a writer drew seq in. Account cursors +-- carry xid; a seq-only cursor from before that is mapped to a position that skips +-- nothing (adcp-client#3289, adcp-client-python#1825). +-- +-- Re-applying this file upgrades an install from before the account feed ordered by +-- (xid, seq): it adds the account horizon's xid columns, replaces the consumer table's +-- check constraint and creates the (account_id, xid, seq) index. On a large changes table +-- create that index first with CREATE INDEX CONCURRENTLY; this file then skips it. -- -- This table is separate from reporting_ledger_changes, the buyer-facing feed behind -- the protocol's changes_checkpoint and changes_after. They converge with the shared @@ -34,8 +41,13 @@ CREATE TABLE IF NOT EXISTS adcp_reporting_changes ( obligation_id TEXT NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() ); +-- Superseded by adcp_reporting_changes_account_xid for the account feed; kept so +-- re-applying this file never drops an index, and because it serves the mapping of a +-- seq-only account cursor. Drop it once no older host runs and no such cursor remains. CREATE INDEX IF NOT EXISTS adcp_reporting_changes_account ON adcp_reporting_changes (account_id, seq); +CREATE INDEX IF NOT EXISTS adcp_reporting_changes_account_xid + ON adcp_reporting_changes (account_id, xid, seq); CREATE INDEX IF NOT EXISTS adcp_reporting_changes_deployment ON adcp_reporting_changes (xid, seq); CREATE INDEX IF NOT EXISTS adcp_reporting_changes_recorded @@ -50,19 +62,56 @@ CREATE TABLE IF NOT EXISTS adcp_reporting_change_horizon ( ); INSERT INTO adcp_reporting_change_horizon (singleton) VALUES (TRUE) ON CONFLICT DO NOTHING; --- Highest seq ever pruned per account: an account cursor below it fails closed. +-- Highest (xid, seq) ever pruned per account: an account cursor below it fails closed. +-- pruned_through_seq is the highest seq pruned whatever its xid; account feeds no longer +-- compare it, it exists so a seq-only cursor from before the (xid, seq) order can still be +-- checked exactly (a tuple compare alone would miss a pruned change with a higher seq and +-- a lower xid). CREATE TABLE IF NOT EXISTS adcp_reporting_change_account_horizon ( account_id TEXT PRIMARY KEY, - pruned_through_seq BIGINT NOT NULL + pruned_through_seq BIGINT NOT NULL, + pruned_through_xid xid8 NOT NULL DEFAULT '0', + pruned_through_xid_seq BIGINT NOT NULL DEFAULT 0 ); +ALTER TABLE adcp_reporting_change_account_horizon + ADD COLUMN IF NOT EXISTS pruned_through_xid xid8 NOT NULL DEFAULT '0'; +ALTER TABLE adcp_reporting_change_account_horizon + ADD COLUMN IF NOT EXISTS pruned_through_xid_seq BIGINT NOT NULL DEFAULT 0; --- Registered consumers hold back pruning in their own feed order: account --- consumers by seq within their account, deployment consumers by (xid, seq). +-- Registered consumers hold back pruning in (xid, seq) order, per account for an account +-- consumer. A row saved before the account feed carried an xid has a NULL cursor_xid and holds +-- by seq until the consumer saves its next cursor. CREATE TABLE IF NOT EXISTS adcp_reporting_feed_consumers ( consumer_name TEXT PRIMARY KEY CHECK (consumer_name ~ '^[A-Za-z0-9_.:-]{1,128}$'), account_id TEXT, cursor_xid xid8, cursor_seq BIGINT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), - CHECK ((account_id IS NULL) = (cursor_xid IS NOT NULL)) + CONSTRAINT adcp_reporting_feed_consumers_cursor_scope + CHECK (account_id IS NOT NULL OR cursor_xid IS NOT NULL) ); +-- An install from before the account feed carried an xid has the anonymous check +-- (account_id IS NULL) = (cursor_xid IS NOT NULL), which rejects an account consumer that has +-- an xid. Drop it and add the named one; both steps are guarded, so re-applying is a no-op. +DO $adcp_feed_consumers$ +DECLARE stale TEXT; +BEGIN + FOR stale IN + SELECT conname FROM pg_constraint + WHERE conrelid = 'adcp_reporting_feed_consumers'::regclass AND contype = 'c' + AND conname <> 'adcp_reporting_feed_consumers_cursor_scope' + AND strpos(pg_get_constraintdef(oid), 'account_id IS NULL') > 0 + LOOP + EXECUTE format('ALTER TABLE adcp_reporting_feed_consumers DROP CONSTRAINT %I', stale); + END LOOP; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'adcp_reporting_feed_consumers'::regclass + AND conname = 'adcp_reporting_feed_consumers_cursor_scope' + ) THEN + ALTER TABLE adcp_reporting_feed_consumers + ADD CONSTRAINT adcp_reporting_feed_consumers_cursor_scope + CHECK (account_id IS NOT NULL OR cursor_xid IS NOT NULL); + END IF; +END +$adcp_feed_consumers$; diff --git a/tests/conformance/reporting/test_reporting_change_feed_consumers_pg.py b/tests/conformance/reporting/test_reporting_change_feed_consumers_pg.py index b7f7195944..ef1fabbb1a 100644 --- a/tests/conformance/reporting/test_reporting_change_feed_consumers_pg.py +++ b/tests/conformance/reporting/test_reporting_change_feed_consumers_pg.py @@ -1,7 +1,8 @@ """Feed consumer checkpoints are forward-only within a scope (adcp-client-python #1509). Ported from the JavaScript SDK (adcp-client #3197): a delayed or repeated acknowledgment -must not move a consumer, or its retention hold, backward. +must not move a consumer, or its retention hold, backward. Account consumers compare +``(xid, seq)`` like deployment consumers (adcp-client #3289, adcp-client-python #1825). """ from __future__ import annotations @@ -24,7 +25,15 @@ async def feed_store(pool: Any) -> PgReportingLedgerStore: return store -def account_cursor(seq: int, account: str = "acct_a") -> str: +def account_cursor(seq: int, account: str = "acct_a", xid: int | None = None) -> str: + """A new-format account cursor; ``xid`` defaults to ``seq`` so the two orders agree.""" + return encode_reporting_change_cursor_v1( + account=account, seq=str(seq), xid=str(seq if xid is None else xid) + ) + + +def legacy_account_cursor(seq: int, account: str = "acct_a") -> str: + """The cursor an older SDK saved: a ``seq`` and no ``xid``.""" return encode_reporting_change_cursor_v1(account=account, seq=str(seq)) @@ -66,29 +75,29 @@ async def test_an_account_consumer_never_moves_backward_unless_asked_to() -> Non store = await feed_store(pool) assert await store.save_feed_consumer_cursor("sink", account_cursor(10)) is True assert await store.save_feed_consumer_cursor("sink", account_cursor(20)) is True - assert await saved(pool, "sink") == ("acct_a", None, 20) + assert await saved(pool, "sink") == ("acct_a", "20", 20) # A delayed acknowledgment is ignored and says so. applied = await store.save_feed_consumer_cursor("sink", account_cursor(15)) - assert await saved(pool, "sink") == ("acct_a", None, 20) + assert await saved(pool, "sink") == ("acct_a", "20", 20) assert applied is False # A deliberate replay rewinds, with or without the refresh option. assert await store.save_feed_consumer_cursor("sink", account_cursor(5), rewind=True) is True - assert await saved(pool, "sink") == ("acct_a", None, 5) + assert await saved(pool, "sink") == ("acct_a", "5", 5) assert ( await store.save_feed_consumer_cursor( "sink", account_cursor(3), rewind=True, refresh_unchanged=False ) is True ) - assert await saved(pool, "sink") == ("acct_a", None, 3) + assert await saved(pool, "sink") == ("acct_a", "3", 3) # Compared numerically, not as text: 100 is ahead of 95. await store.save_feed_consumer_cursor("sink", account_cursor(95)) assert await store.save_feed_consumer_cursor("sink", account_cursor(100)) is True assert await store.save_feed_consumer_cursor("sink", account_cursor(95)) is False - assert await saved(pool, "sink") == ("acct_a", None, 100) + assert await saved(pool, "sink") == ("acct_a", "100", 100) async def test_a_repeated_acknowledgment_renews_the_hold_unless_told_not_to() -> None: @@ -151,11 +160,11 @@ async def test_a_change_of_feed_scope_replaces_the_position() -> None: await store.save_feed_consumer_cursor("sink", account_cursor(500, "acct_a")) # Positions in different feeds are not comparable, so a scope change always applies. assert await store.save_feed_consumer_cursor("sink", account_cursor(1, "acct_b")) is True - assert await saved(pool, "sink") == ("acct_b", None, 1) + assert await saved(pool, "sink") == ("acct_b", "1", 1) assert await store.save_feed_consumer_cursor("sink", deployment_cursor(5, 1)) is True assert await saved(pool, "sink") == (None, "5", 1) assert await store.save_feed_consumer_cursor("sink", account_cursor(900, "acct_a")) is True - assert await saved(pool, "sink") == ("acct_a", None, 900) + assert await saved(pool, "sink") == ("acct_a", "900", 900) async def test_concurrent_writers_cannot_interleave_a_stale_position_over_a_newer_one( @@ -216,7 +225,7 @@ async def connection(*args: Any, **kwargs: Any): task.cancel() await asyncio.gather(*tasks, return_exceptions=True) - assert await saved(pool, "sink") == ("acct_a", None, 9), "a stale write landed last" + assert await saved(pool, "sink") == ("acct_a", "9", 9), "a stale write landed last" assert results == (True, False) @@ -243,6 +252,77 @@ async def test_remove_feed_consumer_releases_the_hold() -> None: assert await store.remove_feed_consumer("sink") is False # Saving under the same name registers it again, at the position given. assert await store.save_feed_consumer_cursor("sink", account_cursor(2)) is True - assert await saved(pool, "sink") == ("acct_a", None, 2) + assert await saved(pool, "sink") == ("acct_a", "2", 2) with pytest.raises(ValueError): await store.remove_feed_consumer("bad name") + + +async def test_an_account_consumer_compares_xid_then_seq() -> None: + async with isolated_reporting_pool() as pool: + store = await feed_store(pool) + assert await store.save_feed_consumer_cursor("sink", account_cursor(9, xid=50)) is True + # A later transaction with a lower seq is ahead in (xid, seq) order; an earlier + # transaction with a higher seq is behind. + assert await store.save_feed_consumer_cursor("sink", account_cursor(2, xid=60)) is True + assert await saved(pool, "sink") == ("acct_a", "60", 2) + assert await store.save_feed_consumer_cursor("sink", account_cursor(99, xid=55)) is False + assert await saved(pool, "sink") == ("acct_a", "60", 2) + # Same transaction: seq decides. + assert await store.save_feed_consumer_cursor("sink", account_cursor(3, xid=60)) is True + assert await store.save_feed_consumer_cursor("sink", account_cursor(1, xid=60)) is False + assert await saved(pool, "sink") == ("acct_a", "60", 3) + + +async def test_a_null_xid_consumer_row_is_replaced_by_a_new_format_cursor_unless_its_seq_is_behind() -> ( + None +): + async with isolated_reporting_pool() as pool: + store = await feed_store(pool) + async with pool.connection() as connection: + await connection.execute( + "INSERT INTO adcp_reporting_feed_consumers" + " (consumer_name, account_id, cursor_xid, cursor_seq)" + " VALUES ('old', 'acct_a', NULL, 9000)" + ) + # A consumer row saved before the upgrade holds by seq; a cursor behind it is ignored. + assert await store.save_feed_consumer_cursor("old", account_cursor(5, xid=7)) is False + assert await saved(pool, "old") == ("acct_a", None, 9000) + assert await store.save_feed_consumer_cursor("old", account_cursor(9000, xid=7)) is True + assert await saved(pool, "old") == ("acct_a", "7", 9000) + assert await store.save_feed_consumer_cursor("old", account_cursor(5, xid=7)) is False + assert ( + await store.save_feed_consumer_cursor("old", account_cursor(5, xid=7), rewind=True) + is True + ) + + +async def test_saving_a_seq_only_account_cursor_registers_the_position_changes_after_reads() -> ( + None +): + async with isolated_reporting_pool() as pool: + store = await feed_store(pool) + async with pool.connection() as connection: + await connection.execute( + "INSERT INTO adcp_reporting_changes (xid, account_id, record_kind, record_id," + " obligation_id) VALUES ('10'::xid8, 'acct_a', 'revision', 'r1', 'o')," + " ('20'::xid8, 'acct_a', 'revision', 'r2', 'o')," + " ('30'::xid8, 'acct_a', 'revision', 'r3', 'o')" + ) + first_seq = (await fetch_all(pool, "SELECT seq FROM adcp_reporting_changes ORDER BY seq"))[ + 0 + ][0] + # The hold sits below every unread change: the oldest one after the cursor, xid 20. + assert await store.save_feed_consumer_cursor("sink", legacy_account_cursor(first_seq)) + assert await saved(pool, "sink") == ("acct_a", "20", first_seq) + page = await store.changes_after( + account_id="acct_a", cursor=legacy_account_cursor(first_seq) + ) + assert [r.record_id for r in page.records] == ["r2", "r3"] + # A new-format cursor replaces it, forward only. + assert await store.save_feed_consumer_cursor("sink", page.cursor) is True + row = await saved(pool, "sink") + assert row is not None and row[1] == "30" + # A mapped seq-only cursor is forward-only against a new-format registration. + assert ( + await store.save_feed_consumer_cursor("sink", legacy_account_cursor(first_seq)) is False + ) diff --git a/tests/conformance/reporting/test_reporting_change_feed_horizon_pg.py b/tests/conformance/reporting/test_reporting_change_feed_horizon_pg.py index 9948666ec6..b1c76c34b9 100644 --- a/tests/conformance/reporting/test_reporting_change_feed_horizon_pg.py +++ b/tests/conformance/reporting/test_reporting_change_feed_horizon_pg.py @@ -89,6 +89,7 @@ def __getattr__(self, name: str) -> Any: def feed_cases() -> list[Any]: return [ pytest.param(ACCOUNT, "with-cursor", id="account-with-cursor"), + pytest.param(ACCOUNT, "legacy-cursor", id="account-seq-only-cursor"), pytest.param(ACCOUNT, "no-cursor", id="account-without-cursor"), pytest.param(None, "with-cursor", id="deployment-with-cursor"), pytest.param(None, "no-cursor", id="deployment-without-cursor"), @@ -99,7 +100,9 @@ def starting_cursor(account: str | None, mode: str) -> str | None: if mode == "no-cursor": return None if account is not None: - return encode_reporting_change_cursor_v1(account=account, seq="0") + # A seq-only cursor is the form an older SDK saved: the horizon check is exact for it. + xid = None if mode == "legacy-cursor" else "0" + return encode_reporting_change_cursor_v1(account=account, seq="0", xid=xid) return encode_reporting_change_cursor_v1(account=None, seq="0", xid="0") @@ -150,7 +153,7 @@ async def test_a_prune_that_commits_before_the_read_starts_is_not_a_race( await seed_changes(pool) assert await store.prune_changes() == 2 - if mode == "with-cursor": + if mode != "no-cursor": with pytest.raises(ReportingChangeCursorExpiredError): await store.changes_after(account_id=account, cursor=starting_cursor(account, mode)) else: diff --git a/tests/conformance/reporting/test_reporting_change_feed_pg.py b/tests/conformance/reporting/test_reporting_change_feed_pg.py index fb3bcbe4a0..9d41968d37 100644 --- a/tests/conformance/reporting/test_reporting_change_feed_pg.py +++ b/tests/conformance/reporting/test_reporting_change_feed_pg.py @@ -2,7 +2,9 @@ from __future__ import annotations +import asyncio import base64 +import json from typing import Any import pytest @@ -11,12 +13,17 @@ ReportingChangeCursorExpiredError, decode_reporting_change_cursor_v1, encode_reporting_change_cursor_v1, + is_legacy_account_change_cursor, ) from ._generation_support import configuration, isolated_reporting_pool, obligation_for from .test_reporting_row_storage_pg import fetch_all, many_rows, typed_revision +def b64(body: dict[str, Any]) -> str: + return base64.urlsafe_b64encode(json.dumps(body).encode()).decode() + + async def feed_store(pool: Any) -> PgReportingLedgerStore: store = PgReportingLedgerStore(pool=pool, change_feed=True) await store.create_schema() @@ -42,10 +49,22 @@ def test_cursors_use_the_format_the_javascript_sdk_reads_and_writes() -> None: "seq": "7", "xid": "42", } + # A seq-only account cursor is what an older SDK saved; it is still accepted. js_account = ( base64.urlsafe_b64encode(b'{"v":1,"account":"acct_a","seq":"9"}').rstrip(b"=").decode() ) assert encode_reporting_change_cursor_v1(account="acct_a", seq="9") == js_account + assert is_legacy_account_change_cursor(decode_reporting_change_cursor_v1(js_account, "acct_a")) + # The current account cursor carries the xid of the last change, as the JavaScript SDK writes it. + js_account_xid = ( + base64.urlsafe_b64encode(b'{"v":1,"account":"acct_a","seq":"9","xid":"42"}') + .rstrip(b"=") + .decode() + ) + assert encode_reporting_change_cursor_v1(account="acct_a", seq="9", xid="42") == js_account_xid + decoded_account = decode_reporting_change_cursor_v1(js_account_xid, "acct_a") + assert decoded_account["xid"] == "42" + assert not is_legacy_account_change_cursor(decoded_account) for bad, account in ( ("not base64!", None), (js_account, "acct_b"), @@ -53,6 +72,15 @@ def test_cursors_use_the_format_the_javascript_sdk_reads_and_writes() -> None: (js_deployment, "acct_a"), (base64.urlsafe_b64encode(b'{"v":2,"account":null,"seq":"1","xid":"1"}').decode(), None), (base64.urlsafe_b64encode(b'{"v":1,"account":null,"seq":"x","xid":"1"}').decode(), None), + # A deployment cursor always needs an xid; values the database cast rejects fail here. + (base64.urlsafe_b64encode(b'{"v":1,"account":null,"seq":"1"}').decode(), None), + (b64({"v": 1, "account": "a", "seq": "9223372036854775808"}), "a"), + (b64({"v": 1, "account": "a", "seq": "1", "xid": "18446744073709551616"}), "a"), + (b64({"v": 1, "account": None, "seq": "1", "xid": "99999999999999999999"}), None), + (b64({"v": 1, "account": "a", "seq": ""}), "a"), + (b64({"v": 1, "account": "a", "seq": "1", "xid": "x"}), "a"), + (b64({"v": 1, "account": "a", "seq": "1", "xid": None}), "a"), + (b64({"v": 1, "account": "a", "seq": "\u0663"}), "a"), # a non-ASCII digit ): with pytest.raises(ValueError): decode_reporting_change_cursor_v1(bad, account) @@ -244,6 +272,8 @@ async def test_pages_cross_99_to_100_in_numeric_order_on_both_feeds() -> None: (xid,) = (await fetch_all(pool, "SELECT xid::text FROM adcp_reporting_changes LIMIT 1"))[0] for account, start in ( + ("acct_a", encode_reporting_change_cursor_v1(account="acct_a", seq="94", xid=xid)), + # A seq-only account cursor maps to a position below every later change. ("acct_a", encode_reporting_change_cursor_v1(account="acct_a", seq="94")), (None, encode_reporting_change_cursor_v1(account=None, seq="94", xid=xid)), ): @@ -256,11 +286,12 @@ async def test_pages_cross_99_to_100_in_numeric_order_on_both_feeds() -> None: assert seen == list(range(95, 131)), (account, seen) -# -- account feed ordering, ported from the JavaScript SDK (adcp-client #3228) --------------- +# -- account feed ordering by (xid, seq), ported from the JavaScript SDK (adcp-client #3289) ---- +ACCOUNT = "acct_hol_feed" INSERT_CHANGE = ( "INSERT INTO adcp_reporting_changes (account_id, record_kind, record_id, obligation_id)" - " VALUES ('acct_hol_feed', 'revision', %s, 'robl_hol_feed')" + " VALUES (%s, 'revision', %s, 'robl_hol_feed')" ) @@ -268,16 +299,50 @@ def ids(page: Any) -> list[str]: return [record.record_id for record in page.records] -async def committed(pool: Any, record_id: str) -> None: +async def committed(pool: Any, record_id: str, account: str = ACCOUNT) -> None: async with pool.connection() as connection: - await connection.execute(INSERT_CHANGE, (record_id,)) + await connection.execute(INSERT_CHANGE, (account, record_id)) + + +async def insert_at( + pool: Any, account: str, record_id: str, xid: int, *, age_days: int = 0 +) -> None: + """A committed change with a chosen (historical, so always below the snapshot's xmin) + transaction id, and optionally an old ``recorded_at``, for ordering and pruning cases that + need exact values.""" + async with pool.connection() as connection: + await connection.execute( + "INSERT INTO adcp_reporting_changes (xid, account_id, record_kind, record_id," + " obligation_id, recorded_at) VALUES (%s::text::xid8, %s, 'revision', %s," + " 'robl_hol_feed', clock_timestamp() - (%s::integer * INTERVAL '1 day'))", + (str(xid), account, record_id, age_days), + ) + + +async def seq_of(pool: Any, record_id: str) -> str: + rows = await fetch_all( + pool, "SELECT seq::text FROM adcp_reporting_changes WHERE record_id = %s", (record_id,) + ) + return str(rows[0][0]) + + +async def remaining(pool: Any, account: str) -> list[str]: + rows = await fetch_all( + pool, + "SELECT record_id FROM adcp_reporting_changes WHERE account_id = %s ORDER BY seq", + (account,), + ) + return [row[0] for row in rows] + + +def legacy_cursor(account: str, seq: str) -> str: + """The cursor an older SDK saved: a ``seq`` and no ``xid``.""" + return encode_reporting_change_cursor_v1(account=account, seq=seq) async def until(read: Any, expected: list[str]) -> Any: """Other tests share the cluster, so an unrelated older transaction can briefly hold a page back: positive reads poll. The assertions that a page is held back do not need it.""" - import asyncio - for _ in range(200): page = await read() if ids(page) == expected: @@ -289,30 +354,30 @@ async def until(read: Any, expected: list[str]) -> Any: async def test_a_lower_seq_that_commits_after_a_higher_one_is_never_passed_over() -> None: async with isolated_reporting_pool() as pool: store = await feed_store(pool) - start = await store.changes_after(account_id="acct_hol_feed") + start = await store.changes_after(account_id=ACCOUNT) slow = await pool.getconn() try: - await slow.execute(INSERT_CHANGE, ("rrev_low",)) # the lower seq, commits last + await slow.execute(INSERT_CHANGE, (ACCOUNT, "rrev_low")) # the lower seq, commits last await committed(pool, "rrev_high") # a higher seq, committed already - during = await store.changes_after(account_id="acct_hol_feed", cursor=start.cursor) + during = await store.changes_after(account_id=ACCOUNT, cursor=start.cursor) assert ids(during) == [], "the page stops before the unfinished transaction" assert during.cursor == start.cursor, "the cursor does not move past it" await slow.commit() finally: await pool.putconn(slow) after = await until( - lambda: store.changes_after(account_id="acct_hol_feed", cursor=start.cursor), + lambda: store.changes_after(account_id=ACCOUNT, cursor=start.cursor), ["rrev_low", "rrev_high"], ) assert ids(after) == ["rrev_low", "rrev_high"] -async def test_a_held_back_change_is_never_skipped_when_a_later_one_passes_a_plain_xid_filter() -> ( +async def test_a_lower_xid_with_a_higher_seq_is_returned_first_and_the_held_back_one_after_it() -> ( None ): async with isolated_reporting_pool() as pool: store = await feed_store(pool) - start = await store.changes_after(account_id="acct_hol_feed") + start = await store.changes_after(account_id=ACCOUNT) early = await pool.getconn() blocker = await pool.getconn() try: @@ -320,23 +385,88 @@ async def test_a_held_back_change_is_never_skipped_when_a_later_one_passes_a_pla await early.execute("SELECT pg_current_xact_id()") await blocker.execute("SELECT pg_current_xact_id()") await committed(pool, "rrev_first") # lower seq, but a transaction after `blocker` - await early.execute(INSERT_CHANGE, ("rrev_second",)) # higher seq, xid before `blocker` + await early.execute( + INSERT_CHANGE, (ACCOUNT, "rrev_second") + ) # higher seq, xid < blocker await early.commit() - # `blocker` is the oldest transaction in flight, so rrev_first is held back while - # rrev_second sits below it. Filtering on xid alone would return rrev_second and a - # cursor past rrev_first, which would then never be seen. - during = await store.changes_after(account_id="acct_hol_feed", cursor=start.cursor) - assert ids(during) == [] - assert during.cursor == start.cursor, "the cursor does not move past the held change" + # `blocker` is the oldest transaction in flight, so rrev_first (a later xid) is held + # back while rrev_second sits below it. The feed orders by (xid, seq), so rrev_second + # comes first and the cursor stops at it, before rrev_first. + during = await until( + lambda: store.changes_after(account_id=ACCOUNT, cursor=start.cursor), + ["rrev_second"], + ) + assert ids(during) == ["rrev_second"] + assert during.cursor != start.cursor await blocker.commit() + rest = await until( + lambda: store.changes_after(account_id=ACCOUNT, cursor=during.cursor), + ["rrev_first"], + ) + assert ids(rest) == ["rrev_first"], "the held-back change is delivered once, after it" finally: await pool.putconn(early) await pool.putconn(blocker) + everything = await until( + lambda: store.changes_after(account_id=ACCOUNT, cursor=start.cursor), + ["rrev_second", "rrev_first"], + ) + assert ids(everything) == ["rrev_second", "rrev_first"], "a fresh reader sees the order" + + +async def test_a_lower_seq_with_a_higher_xid_that_commits_late_is_delivered_after_the_cursor() -> ( + None +): + """No account lock is taken anywhere here: two writers under different lock keys.""" + async with isolated_reporting_pool() as pool: + store = await feed_store(pool) + start = await store.changes_after(account_id=ACCOUNT) + quick = await pool.getconn() + slow = await pool.getconn() + try: + await quick.execute("SELECT pg_current_xact_id()") # xid Q + await slow.execute("SELECT pg_current_xact_id()") # xid S > Q + await slow.execute(INSERT_CHANGE, (ACCOUNT, "rrev_late")) # lower seq, higher xid + await quick.execute(INSERT_CHANGE, (ACCOUNT, "rrev_quick")) # higher seq, lower xid + await quick.commit() + during = await until( + lambda: store.changes_after(account_id=ACCOUNT, cursor=start.cursor), + ["rrev_quick"], + ) + assert ids(during) == ["rrev_quick"], "the late transaction is still open" + await slow.commit() + rest = await until( + lambda: store.changes_after(account_id=ACCOUNT, cursor=during.cursor), + ["rrev_late"], + ) + assert ids(rest) == ["rrev_late"] + assert ( + ids(await store.changes_after(account_id=ACCOUNT, cursor=rest.cursor)) == [] + ), "and nothing is delivered twice" + finally: + await pool.putconn(quick) + await pool.putconn(slow) + + +async def test_an_open_transaction_on_another_account_holds_a_later_change_back() -> None: + other = "acct_hol_feed_other" + async with isolated_reporting_pool() as pool: + store = await feed_store(pool) + start = await store.changes_after(account_id=ACCOUNT) + opened = await pool.getconn() + try: + await opened.execute(INSERT_CHANGE, (other, "rrev_other_inflight")) # in flight + await committed(pool, "rrev_after_other") + # xmin is cluster-wide, so a later xid is held back whichever account the open one wrote. + assert ids(await store.changes_after(account_id=ACCOUNT, cursor=start.cursor)) == [] + await opened.commit() + finally: + await pool.putconn(opened) after = await until( - lambda: store.changes_after(account_id="acct_hol_feed", cursor=start.cursor), - ["rrev_first", "rrev_second"], + lambda: store.changes_after(account_id=ACCOUNT, cursor=start.cursor), + ["rrev_after_other"], ) - assert ids(after) == ["rrev_first", "rrev_second"] + assert ids(after) == ["rrev_after_other"] async def test_a_committed_prefix_is_returned_and_the_cursor_stops_before_the_first_in_flight() -> ( @@ -344,20 +474,20 @@ async def test_a_committed_prefix_is_returned_and_the_cursor_stops_before_the_fi ): async with isolated_reporting_pool() as pool: store = await feed_store(pool) - start = await store.changes_after(account_id="acct_hol_feed") + start = await store.changes_after(account_id=ACCOUNT) await committed(pool, "rrev_prefix_a") opened = await pool.getconn() try: - await opened.execute(INSERT_CHANGE, ("rrev_prefix_b",)) # in flight + await opened.execute(INSERT_CHANGE, (ACCOUNT, "rrev_prefix_b")) # in flight await committed(pool, "rrev_prefix_c") # committed after it during = await until( - lambda: store.changes_after(account_id="acct_hol_feed", cursor=start.cursor), + lambda: store.changes_after(account_id=ACCOUNT, cursor=start.cursor), ["rrev_prefix_a"], ) - assert ids(during) == ["rrev_prefix_a"], "truncated, not filtered" + assert ids(during) == ["rrev_prefix_a"], "c is not handed out past b" await opened.commit() rest = await until( - lambda: store.changes_after(account_id="acct_hol_feed", cursor=during.cursor), + lambda: store.changes_after(account_id=ACCOUNT, cursor=during.cursor), ["rrev_prefix_b", "rrev_prefix_c"], ) assert ids(rest) == ["rrev_prefix_b", "rrev_prefix_c"] @@ -365,23 +495,415 @@ async def test_a_committed_prefix_is_returned_and_the_cursor_stops_before_the_fi await pool.putconn(opened) -async def test_the_account_feed_query_is_served_by_the_account_seq_index() -> None: - from adcp_reporting_storage.ledger.change_feed import _page_query +async def test_the_account_cursor_carries_the_xid_of_the_last_change_and_is_bound_to_its_account() -> ( + None +): + scoped = "acct_hol_feed_cursor" + async with isolated_reporting_pool() as pool: + store = await feed_store(pool) + await insert_at(pool, scoped, "rrev_cursor_a", 20) + page = await store.changes_after(account_id=scoped) + decoded = decode_reporting_change_cursor_v1(page.cursor, scoped) + assert decoded["xid"] == "20" + assert not is_legacy_account_change_cursor(decoded) + with pytest.raises(ValueError): + decode_reporting_change_cursor_v1(page.cursor, "acct_hol_feed_elsewhere") + with pytest.raises(ValueError, match="does not belong to this feed"): + await store.changes_after(cursor=page.cursor) + + +async def test_cursor_values_the_database_would_reject_fail_as_value_error() -> None: + async with isolated_reporting_pool() as pool: + store = await feed_store(pool) + for body, account in ( + ({"v": 1, "account": "a", "seq": "9223372036854775808"}, "a"), + ({"v": 1, "account": "a", "seq": "1", "xid": "18446744073709551616"}, "a"), + ({"v": 1, "account": None, "seq": "1", "xid": "99999999999999999999"}, None), + ({"v": 1, "account": None, "seq": "1"}, None), + ({"v": 1, "account": "a", "seq": ""}, "a"), + ({"v": 1, "account": "a", "seq": "1", "xid": "x"}, "a"), + ): + with pytest.raises(ValueError): + await store.changes_after(account_id=account, cursor=b64(body)) + decode_reporting_change_cursor_v1( + b64( + { + "v": 1, + "account": "a", + "seq": "9223372036854775807", + "xid": "18446744073709551615", + } + ), + "a", + ) + + +# -- account cursors issued before the (xid, seq) order -------------------------------------- + + +async def test_a_seq_only_cursor_maps_to_a_position_that_skips_nothing_and_upgrades() -> None: + scoped = "acct_hol_legacy_map" + async with isolated_reporting_pool() as pool: + store = await feed_store(pool) + # r2 took a higher seq but a lower xid than r1: the late commit the old order could skip. + await insert_at(pool, scoped, "r0", 5) # read long ago, older than any unread change + await insert_at(pool, scoped, "r1", 20) + await insert_at(pool, scoped, "r2", 10) + await insert_at(pool, scoped, "r3", 30) + page = await store.changes_after( + account_id=scoped, cursor=legacy_cursor(scoped, await seq_of(pool, "r1")) + ) + # r2 is delivered; r1 is redelivered because its xid is above r2's; r0 is not, so the + # position is not simply xid 0. + assert ids(page) == ["r2", "r1", "r3"] + upgraded = decode_reporting_change_cursor_v1(page.cursor, scoped) + assert upgraded["xid"] == "30" + assert ids(await store.changes_after(account_id=scoped, cursor=page.cursor)) == [] + + # A seq-only cursor at the end of the feed has nothing unread: it maps to the snapshot's + # xmin, and an empty page still upgrades the cursor. + end = await store.changes_after( + account_id=scoped, cursor=legacy_cursor(scoped, await seq_of(pool, "r3")) + ) + assert ids(end) == [] + mapped = decode_reporting_change_cursor_v1(end.cursor, scoped) + assert mapped["seq"] == await seq_of(pool, "r3") + assert int(mapped["xid"]) > 30 + assert not is_legacy_account_change_cursor(mapped) + + +async def test_a_caught_up_seq_only_cursor_does_not_skip_a_change_from_an_in_flight_transaction() -> ( + None +): + scoped = "acct_hol_legacy_inflight" + async with isolated_reporting_pool() as pool: + store = await feed_store(pool) + await insert_at(pool, scoped, "caught_up", 10) + head = await seq_of(pool, "caught_up") + opened = await pool.getconn() + try: + await opened.execute(INSERT_CHANGE, (scoped, "inflight")) # at or above xmin + during = await store.changes_after( + account_id=scoped, cursor=legacy_cursor(scoped, head) + ) + assert ids(during) == [] + await opened.commit() + rest = await until( + lambda: store.changes_after(account_id=scoped, cursor=during.cursor), ["inflight"] + ) + assert ids(rest) == ["inflight"], "the mapped position is below the open transaction" + finally: + await pool.putconn(opened) + + +async def test_a_seq_only_cursor_expires_when_any_change_with_a_higher_seq_was_pruned() -> None: + scoped = "acct_hol_legacy_pruned" + async with isolated_reporting_pool() as pool: + store = await feed_store(pool) + # Pruned: (xid 20, seq s1) and (xid 10, seq s2). The highest (xid, seq) is s1's, but s2 > s1. + await insert_at(pool, scoped, "p1", 20, age_days=40) + await insert_at(pool, scoped, "p2", 10, age_days=40) + await insert_at(pool, scoped, "p3", 30) + s1, s2 = await seq_of(pool, "p1"), await seq_of(pool, "p2") + assert await store.prune_changes(change_retention_days=30) >= 2 + + async def expired(cursor: str) -> bool: + try: + await store.changes_after(account_id=scoped, cursor=cursor) + except ReportingChangeCursorExpiredError as error: + assert error.code == "CURSOR_EXPIRED" + return True + return False + + # p2 was pruned after seq s1: a tuple compare would miss it. + assert await expired(legacy_cursor(scoped, s1)) is True + assert await expired(legacy_cursor(scoped, "0")) is True + assert await expired(legacy_cursor(scoped, s2)) is False + # A cursor at the horizon has passed everything pruned; one below it in (xid, seq) order + # may have missed a pruned change. + at_horizon = encode_reporting_change_cursor_v1(account=scoped, seq=s1, xid="20") + assert await expired(at_horizon) is False + below = encode_reporting_change_cursor_v1(account=scoped, seq=s2, xid="10") + assert await expired(below) is True + assert ids(await store.changes_after(account_id=scoped)) == ["p3"], "no cursor: horizon" + + +async def test_a_seq_only_consumer_registration_holds_by_seq_until_it_saves_a_new_cursor() -> None: + scoped = "acct_hol_legacy_consumer" + async with isolated_reporting_pool() as pool: + store = await feed_store(pool) + await insert_at(pool, scoped, "c1", 10, age_days=40) + await insert_at(pool, scoped, "c2", 20, age_days=40) + await insert_at(pool, scoped, "c3", 30, age_days=40) + s1 = await seq_of(pool, "c1") + async with pool.connection() as connection: + await connection.execute( + "INSERT INTO adcp_reporting_feed_consumers" + " (consumer_name, account_id, cursor_xid, cursor_seq)" + " VALUES ('legacy-sink', %s, NULL, %s::bigint)", + (scoped, s1), + ) + + async def registered() -> tuple[Any, ...]: + return ( + await fetch_all( + pool, + "SELECT cursor_xid::text, cursor_seq::text FROM adcp_reporting_feed_consumers" + " WHERE consumer_name = 'legacy-sink'", + ) + )[0] + + await store.prune_changes(change_retention_days=30) + assert await remaining(pool, scoped) == ["c2", "c3"], "c1 was passed; c2 and c3 are held" + # Saving a seq-only cursor registers a mapped position, below every unread change. + assert await store.save_feed_consumer_cursor("legacy-sink", legacy_cursor(scoped, s1)) + assert (await registered())[0] == "20", "the oldest unread change is c2, at xid 20" + # A cursor in the new format replaces it, forward only. + page = await store.changes_after(account_id=scoped, cursor=legacy_cursor(scoped, s1)) + assert ids(page) == ["c2", "c3"] + assert await store.save_feed_consumer_cursor("legacy-sink", page.cursor) is True + assert await registered() == ("30", await seq_of(pool, "c3")) + + +# -- pruning in (xid, seq) order ------------------------------------------------------------- + + +async def test_an_account_consumer_holds_every_change_after_its_cursor_in_xid_seq_order() -> None: + scoped = "acct_hol_prune_order" + async with isolated_reporting_pool() as pool: + store = await feed_store(pool) + await insert_at(pool, scoped, "o1", 10, age_days=40) + await insert_at(pool, scoped, "o2", 30, age_days=40) + await insert_at(pool, scoped, "o3", 20, age_days=40) # higher seq than o2, lower xid + o1, o3 = await seq_of(pool, "o1"), await seq_of(pool, "o3") + cursor = encode_reporting_change_cursor_v1(account=scoped, seq=o3, xid="20") + await store.save_feed_consumer_cursor("prune-order", cursor) + await store.prune_changes(change_retention_days=30) + assert await remaining(pool, scoped) == ["o2"], "o2 follows the cursor in xid order" + horizon = await fetch_all( + pool, + "SELECT pruned_through_xid::text, pruned_through_xid_seq::text," + " pruned_through_seq::text FROM adcp_reporting_change_account_horizon" + " WHERE account_id = %s", + (scoped,), + ) + assert horizon == [("20", o3, o3)] + # The consumer reads on from where it was and is not told it missed anything. + assert ids(await store.changes_after(account_id=scoped, cursor=cursor)) == ["o2"] + # A consumer that had only passed o1 has missed o3 (pruned): expired. + behind = encode_reporting_change_cursor_v1(account=scoped, seq=o1, xid="10") + with pytest.raises(ReportingChangeCursorExpiredError): + await store.changes_after(account_id=scoped, cursor=behind) + + +async def test_the_horizon_only_moves_forward_and_another_accounts_consumer_holds_nothing() -> None: + scoped = "acct_hol_prune_forward" + async with isolated_reporting_pool() as pool: + store = await feed_store(pool) + await insert_at(pool, scoped, "f1", 50, age_days=40) + await store.save_feed_consumer_cursor( + "prune-elsewhere", + encode_reporting_change_cursor_v1(account="acct_hol_prune_elsewhere", seq="1", xid="1"), + ) + await store.prune_changes(change_retention_days=30) + assert await remaining(pool, scoped) == [] + + async def horizon_xid() -> str: + rows = await fetch_all( + pool, + "SELECT pruned_through_xid::text FROM adcp_reporting_change_account_horizon" + " WHERE account_id = %s", + (scoped,), + ) + return str(rows[0][0]) + + assert await horizon_xid() == "50" + await insert_at(pool, scoped, "f2", 40, age_days=40) # lower xid than the horizon + await store.prune_changes(change_retention_days=30) + assert await horizon_xid() == "50", "a lower (xid, seq) never lowers the horizon" + + +async def test_a_pruning_batch_cut_by_its_limit_leaves_nothing_retained_below_the_horizon() -> None: + scoped = "acct_hol_prune_batch" + async with isolated_reporting_pool() as pool: + store = await feed_store(pool) + await insert_at(pool, scoped, "a", 20, age_days=40) + await insert_at(pool, scoped, "b", 30, age_days=40) + await insert_at(pool, scoped, "c", 10, age_days=40) # the highest seq, the lowest xid + await store.prune_changes(change_retention_days=30, limit=2) + kept = await remaining(pool, scoped) + assert kept == ["b"], "victims are taken in (xid, seq) order: c then a" + assert ( + ids(await store.changes_after(account_id=scoped)) == kept + ), "a cold start loses nothing" + + +async def test_period_retirement_holds_a_period_in_xid_seq_order_for_account_consumers() -> None: + from adcp_reporting_storage.ledger.change_feed import feed_consumer_behind_change + + async with isolated_reporting_pool() as pool: + store = await feed_store(pool) + async with pool.connection() as connection: + await connection.execute( + "INSERT INTO adcp_reporting_changes (xid, account_id, record_kind, record_id," + " obligation_id) VALUES ('30'::xid8, 'acct_a', 'revision', 'k1', 'o')," + " ('20'::xid8, 'acct_a', 'revision', 'k2', 'o')" + ) + k1, k2 = await seq_of(pool, "k1"), await seq_of(pool, "k2") + sql = ( + "SELECT change.record_id FROM adcp_reporting_changes change" + " JOIN adcp_reporting_feed_consumers consumer ON TRUE WHERE " + + feed_consumer_behind_change("consumer", "change") + + " ORDER BY change.xid, change.seq" + ) + # The consumer passed (xid 20, seq k2): k1 (xid 30, a lower seq) is still ahead of it. + await store.save_feed_consumer_cursor( + "hold", encode_reporting_change_cursor_v1(account="acct_a", seq=k2, xid="20") + ) + assert [row[0] for row in await fetch_all(pool, sql)] == ["k1"] + # A seq-only registration holds by seq alone. + async with pool.connection() as connection: + await connection.execute( + "UPDATE adcp_reporting_feed_consumers SET cursor_xid = NULL, cursor_seq = %s::bigint", + (k1,), + ) + assert [row[0] for row in await fetch_all(pool, sql)] == ["k2"] + + +# -- the migration ----------------------------------------------------------------------------- + +#: The change tables as the previous release created them. +PREVIOUS_CHANGES_DDL = """ +CREATE TABLE adcp_reporting_changes ( + seq BIGSERIAL PRIMARY KEY, xid xid8 NOT NULL DEFAULT pg_current_xact_id(), + account_id TEXT NOT NULL, + record_kind TEXT NOT NULL + CHECK (record_kind IN ('obligation', 'revision', 'adjustment', 'retirement')), + record_id TEXT NOT NULL, obligation_id TEXT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()); +CREATE INDEX adcp_reporting_changes_account ON adcp_reporting_changes (account_id, seq); +CREATE TABLE adcp_reporting_change_account_horizon ( + account_id TEXT PRIMARY KEY, pruned_through_seq BIGINT NOT NULL); +INSERT INTO adcp_reporting_change_account_horizon VALUES ('acct_upgrade', 77); +CREATE TABLE adcp_reporting_feed_consumers ( + consumer_name TEXT PRIMARY KEY CHECK (consumer_name ~ '^[A-Za-z0-9_.:-]{1,128}$'), + account_id TEXT, cursor_xid xid8, cursor_seq BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + CHECK ((account_id IS NULL) = (cursor_xid IS NOT NULL))); +INSERT INTO adcp_reporting_feed_consumers (consumer_name, account_id, cursor_xid, cursor_seq) + VALUES ('old-account-sink', 'acct_upgrade', NULL, 5); +""" + + +async def test_concurrent_appliers_of_the_changes_migration_all_succeed() -> None: + from adcp_reporting_storage.ledger.change_feed import REPORTING_CHANGES_DDL_PATH async with isolated_reporting_pool() as pool: + await PgReportingLedgerStore(pool=pool).create_schema() # the ledger the index needs + ddl = REPORTING_CHANGES_DDL_PATH.read_text() + + async def apply() -> None: + async with pool.connection() as connection: + await connection.execute(ddl) + + results = await asyncio.gather(*(apply() for _ in range(8)), return_exceptions=True) + assert [r for r in results if isinstance(r, BaseException)] == [] + + +async def test_reapplying_the_changes_migration_upgrades_an_older_install_in_place() -> None: + from adcp_reporting_storage.ledger.change_feed import REPORTING_CHANGES_DDL_PATH + from psycopg.errors import CheckViolation + + async with isolated_reporting_pool() as pool: + await PgReportingLedgerStore(pool=pool).create_schema() + async with pool.connection() as connection: + await connection.execute(PREVIOUS_CHANGES_DDL) + with pytest.raises(CheckViolation): + async with pool.connection() as connection: + await connection.execute( + "INSERT INTO adcp_reporting_feed_consumers" + " (consumer_name, account_id, cursor_xid, cursor_seq)" + " VALUES ('old-deployment-sink', NULL, NULL, 5)" + ) + ddl = REPORTING_CHANGES_DDL_PATH.read_text() + for _ in range(2): # idempotent + async with pool.connection() as connection: + await connection.execute(ddl) + + assert await fetch_all( + pool, + "SELECT account_id, pruned_through_seq::text, pruned_through_xid::text," + " pruned_through_xid_seq::text FROM adcp_reporting_change_account_horizon", + ) == [("acct_upgrade", "77", "0", "0")], "the existing seq horizon is kept" + assert await fetch_all( + pool, + "SELECT consumer_name, cursor_xid::text, cursor_seq::text FROM" + " adcp_reporting_feed_consumers", + ) == [("old-account-sink", None, "5")], "a seq-only account consumer is kept" + # The deployment feed still needs an xid; the old check is gone, the new one stays. + with pytest.raises(CheckViolation): + async with pool.connection() as connection: + await connection.execute( + "INSERT INTO adcp_reporting_feed_consumers" + " (consumer_name, account_id, cursor_xid, cursor_seq)" + " VALUES ('bad-deployment', NULL, NULL, 1)" + ) + async with pool.connection() as connection: + await connection.execute( + "INSERT INTO adcp_reporting_feed_consumers" + " (consumer_name, account_id, cursor_xid, cursor_seq)" + " VALUES ('new-account-sink', 'acct_upgrade', '9'::xid8, 1)" + ) + checks = await fetch_all( + pool, + "SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint" + " WHERE conrelid = 'adcp_reporting_feed_consumers'::regclass AND contype = 'c'" + " ORDER BY conname", + ) + assert [name for name, _ in checks if "account_id IS NULL) =" in _] == [] + assert [name for name, _ in checks].count("adcp_reporting_feed_consumers_cursor_scope") == 1 + indexes = { + row[0] + for row in await fetch_all( + pool, + "SELECT indexname FROM pg_indexes WHERE schemaname = current_schema()" + " AND tablename = 'adcp_reporting_changes'", + ) + } + assert "adcp_reporting_changes_account_xid" in indexes + assert "adcp_reporting_changes_account" in indexes, "re-applying never drops an index" + + +async def test_the_account_feed_query_is_served_by_the_account_xid_seq_index_in_order() -> None: + from adcp_reporting_storage.ledger.change_feed import _page_query + + # Autocommit: rows written inside one open transaction carry that transaction's own xid, at or + # above the snapshot's xmin, and the planner would see a histogram nothing satisfies. + async with isolated_reporting_pool(autocommit=True) as pool: await feed_store(pool) async with pool.connection() as connection: await connection.execute( "INSERT INTO adcp_reporting_changes (account_id, record_kind, record_id," - " obligation_id) SELECT 'acct_filler_' || n, 'revision', 'rrev_filler_' || n," - " 'robl_filler' FROM generate_series(1, 2000) AS n" + " obligation_id) SELECT 'acct_filler_' || (n % 2000), 'revision'," + " 'rrev_filler_' || n, 'robl_filler' FROM generate_series(1, 100000) AS n" + ) + # Enough history for the account that a page of 500 is a small slice of it. + await connection.execute( + "INSERT INTO adcp_reporting_changes (account_id, record_kind, record_id," + " obligation_id) SELECT %s, 'revision', 'rrev_history_' || n, 'robl_history'" + " FROM generate_series(1, 3000) AS n", + (ACCOUNT,), ) await connection.execute("ANALYZE adcp_reporting_changes") # The very query the store runs for an account page, planned with its parameters. - plan = await ( - await connection.execute( - "EXPLAIN " + _page_query(account=True), - ("acct_hol_feed", 0, None, None, 500), - ) - ).fetchall() - assert "adcp_reporting_changes_account" in "\n".join(row[0] for row in plan) + plan = "\n".join( + row[0] + for row in await ( + await connection.execute( + "EXPLAIN " + _page_query(account=True), + (ACCOUNT, "0", 0, None, None, 500), + ) + ).fetchall() + ) + assert "adcp_reporting_changes_account_xid" in plan, plan + assert "Sort" not in plan, "the index already yields (xid, seq) order" From e004f1bc55eff3bc32065a9c785f451af3813f79 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 3 Nov 2026 01:27:24 -0500 Subject: [PATCH 21/28] feat(reporting-storage): collect orphaned row objects by listing with a clone fence (#1835) * feat(reporting-storage): collect orphaned row objects by listing with a clone fence Opt-in collector for row objects the write-intent sweep cannot see, mirroring the JavaScript SDK. Providers gain an optional list, implemented for the filesystem, GCS, S3 and Azure providers and checked by the conformance suite. collect_orphaned_row_objects lists each configured object binding under its owned prefix and deletes an object only when it is a well-formed row key, old enough, named by no record or upload at any version under any binding, and unreferenced under the per-object lock of every binding. A real pass first claims the namespace in a create-only epoch chain in the bucket, recorded in adcp_reporting_orphan_collector_fences, re-checks it every 25 deletes and refuses when another database holds a newer claim, so a cloned or restored ledger cannot delete the live database's objects. take_over_orphan_collector_fence is the deliberate step after a restore. The store option orphan_collection schedules it in the production worker, which reports fenced and unsupported bindings. Mirrors adcp-client#3291 and adcp-client#3593. Closes #1829. Co-Authored-By: Claude Sonnet 5.5 * test(reporting-storage): assert the cancelled claim outcome instead of awaiting under raises Co-Authored-By: Claude Sonnet 5.5 * test(reporting-storage): record the orphan collector names in the public API snapshot Co-Authored-By: Claude Sonnet 5.5 * test(reporting-storage): skip the store option test where psycopg is not installed The base test matrix has no pg extra, and constructing the store needs it. Co-Authored-By: Claude Sonnet 5.5 * test(reporting-storage): skip the migration upgrade test where psycopg is not installed The test imported psycopg errors before the database skip, so the base test matrix failed with ModuleNotFoundError on main. Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 --- docs/reporting-release-notes.md | 23 + docs/reporting-row-storage.md | 112 +- .../src/adcp_reporting_storage/__init__.py | 37 + .../ledger/orphan_collector.py | 987 ++++++++++++++++++ .../ledger/orphan_fence.py | 323 ++++++ .../src/adcp_reporting_storage/ledger/pg.py | 96 +- .../ledger/reporting_row_storage.sql | 21 + .../ledger/row_storage.py | 386 ++++++- .../ledger/row_storage_azure.py | 68 ++ .../ledger/row_storage_gcs.py | 67 ++ .../ledger/row_storage_object.py | 308 +++++- .../ledger/row_storage_s3.py | 94 ++ .../src/adcp_reporting_storage/migration.py | 3 + src/adcp/reporting/production/_diagnostics.py | 4 +- src/adcp/reporting/production/service.py | 13 +- .../conformance/reporting/_orphan_support.py | 429 ++++++++ .../test_reporting_change_feed_pg.py | 7 +- .../test_reporting_orphan_collector_pg.py | 469 +++++++++ .../test_reporting_orphan_fence_pg.py | 650 ++++++++++++ tests/fixtures/public_api_snapshot.json | 7 + tests/test_reporting_orphan_fence.py | 207 ++++ tests/test_reporting_orphan_maintenance.py | 201 ++++ .../test_reporting_production_maintenance.py | 48 + tests/test_reporting_row_storage_azure.py | 111 +- .../test_reporting_row_storage_filesystem.py | 257 +++++ tests/test_reporting_row_storage_gcs.py | 147 +++ tests/test_reporting_row_storage_s3.py | 91 +- 27 files changed, 5146 insertions(+), 20 deletions(-) create mode 100644 packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/orphan_collector.py create mode 100644 packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/orphan_fence.py create mode 100644 tests/conformance/reporting/_orphan_support.py create mode 100644 tests/conformance/reporting/test_reporting_orphan_collector_pg.py create mode 100644 tests/conformance/reporting/test_reporting_orphan_fence_pg.py create mode 100644 tests/test_reporting_orphan_fence.py create mode 100644 tests/test_reporting_orphan_maintenance.py diff --git a/docs/reporting-release-notes.md b/docs/reporting-release-notes.md index 7b656ce232..94f82b3ef4 100644 --- a/docs/reporting-release-notes.md +++ b/docs/reporting-release-notes.md @@ -53,6 +53,29 @@ Reads handle both forms. `migrate_inline_rows` moves legacy rows with verificati rows do not reproduce its binding, and `retire_legacy_inline_rows` then renames the legacy table so older binaries fail closed. See [row storage](reporting-row-storage.md). +## Opt-in orphaned-object collector, fenced against clones + +`PgReportingLedgerStore(orphan_collection=ReportingOrphanCollectionOptionsV1(...))` and +`collect_orphaned_row_objects` list each object binding under its owned prefix and delete the objects no ledger record +or open upload names, which the write-intent sweep cannot see (an upload that succeeded before its intent recorded it, +intents for a binding no host configures, intents lost to a restore). It mirrors the JavaScript SDK +([adcp-client#3291](https://github.com/adcontextprotocol/adcp-client/pull/3291) and the clone fence in +[#3593](https://github.com/adcontextprotocol/adcp-client/pull/3593)). Nothing changes unless you opt in. An object is +deleted only when it is a well-formed row key under `{prefix}/{namespace_key}/`, older than `min_age_seconds` (default +7 days, minimum 1 day), named by no record or upload at any version under any binding, and unreferenced under the +per-object lock of every binding. A real pass first claims the namespace in a create-only fence in the bucket and +refuses to delete while another database holds a newer claim, so a clone or restore of the ledger database that shares +its namespace key and its opt-in cannot delete the live database's newer objects; `take_over_orphan_collector_fence` +is the deliberate step after a restore. + +Providers gain an optional `list`. The provider conformance suite checks it when present, so its result now ends with a +`list` entry for providers that implement it; compare accordingly. A provider without `list` keeps working and is +reported `unsupported`. `create_schema` adds the shared `adcp_reporting_orphan_collector_fences` table, additively and +outside the required-schema manifest. The production worker reports `fenced` bindings as stalled work and `unsupported` +bindings as pending work. **Roll out:** start with `dry_run=True`, claim from the live database first, and never enable +the collector on a copy of the deployment. See [the listing collector](reporting-row-storage.md#orphaned-objects-the-opt-in-listing-collector) +([#1829](https://github.com/adcontextprotocol/adcp-client-python/issues/1829)). + ## Row binding IDs and credential references accept 255 characters `row_binding_id` and a binding's `credential_ref` were capped at 128 characters. Every comparable AdCP identifier diff --git a/docs/reporting-row-storage.md b/docs/reporting-row-storage.md index e51ff68a2a..76c30bb088 100644 --- a/docs/reporting-row-storage.md +++ b/docs/reporting-row-storage.md @@ -327,6 +327,111 @@ stays as a small marker that makes reads answer as expired; the records are alre whose horizon excludes periods past retention. Versioned buckets, soft delete and retention locks are allowed as backstops, but a `pruned` period is not "erased" while they keep the old bytes. +## Orphaned objects: the opt-in listing collector + +The intent sweep deletes only what an intent lists, so it cannot see three cases: an upload whose `PUT` succeeded before +the intent recorded it, intents for a binding no running host configures, and intents lost to a database restore. +`collect_orphaned_row_objects` covers them by listing, and it is **off unless you ask for it**. It mirrors the +JavaScript SDK's collector and fence ([adcp-client#3291](https://github.com/adcontextprotocol/adcp-client/pull/3291), +[#3593](https://github.com/adcontextprotocol/adcp-client/pull/3593)); the design record is +`specs/shared-sdk-persistence-proposal.md` section 4.2. + +```python +from adcp_reporting_storage import ReportingOrphanCollectionOptionsV1 + +store = PgReportingLedgerStore( + pool=pool, + row_storage=options, + # Start with dry_run=True and read the candidates in the task result. A real pass needs the + # acknowledgement below. + orphan_collection=ReportingOrphanCollectionOptionsV1(acknowledge_exclusive_namespace=True), +) +# or, from your own scheduler: +result = await store.collect_orphaned_row_objects(dry_run=True) +``` + +It lists each configured object binding under `{prefix}/{namespace_key}/` and deletes an object only when **all** of +these hold: + +1. Its key is a well-formed row object (`.jsonl` or `.jsonl.gz`, the grammar `render_reporting_row_object_key_v1` + produces, no `.` or `..` segment) under that prefix. Anything else, or anything a provider returns outside the + prefix, is dropped and counted as `foreign`; `delete_objects` independently refuses keys outside the owned prefix. +2. It was written more than `min_age_seconds` ago (default 7 days, **minimum 1 day**, against a write deadline of at + most 10 minutes). An unreadable or non-RFC 3339 write time counts as young. Providers report the later of creation + and modification time. +3. A version- and binding-blind pre-check finds no chunk of a not-yet-`pruned` row set and no write intent (any state, + old or new JSON keys) naming the key. Two bindings can share a bucket and prefix (a retired `read_only` binding kept + next to its successor), so the check is not scoped to the collecting binding. +4. Under the per-object advisory lock of **every** binding, taken in a fixed order so the delete is serialized with an + adoption through any of them, no `live` or `unavailable` row set references that exact version and no open intent + created it. The provider then deletes that exact version. + +`read_only` bindings are skipped unless `include_read_only`. `dry_run` deletes nothing and reports up to 1000 +`candidates`. Deleting requires `acknowledge_exclusive_namespace=True` (a dry run does not). `deleted` counts delete +requests that found nothing referenced; an object another host removed first still counts. A delete that fails is +counted and the pass moves on, and is retried on the next full listing; a listing failure retries the same page. + +**Providers.** A provider gains an optional `list` (`ReportingRowObjectListInputV1` in, a page of +`ReportingRowObjectListEntryV1` out: `object_ref`, `native_version_ref`, `size_bytes`, `created_at`), implemented by the +filesystem, GCS, S3 and Azure Blob providers (on versioned stores it lists noncurrent versions too and never delete +markers). `run_reporting_row_object_provider_conformance_v1` checks it when present and then returns a `list` entry, so +code that compares the whole list must expect it. A provider without `list` keeps working; its binding is reported +`unsupported`. `list` must be read-after-write consistent for keys `put_if_absent` returned: the fence below reads the +newest claim by listing. The filesystem provider's `list` reads and sorts each directory in full and neither lists nor follows symlinks, so it is +for tests and single hosts. + +**Budget and resumability.** `limit` bounds the objects examined per binding per pass; a page cap bounds empty pages; a +repeated page token or an oversized page fails the binding instead of looping; a failure on the first page of a resumed +cursor drops the cursor so it cannot wedge. Pass `result.cursors` back as `cursors` and `result.completed` as +`skip_bindings`. As a store option, `interval_seconds` (default one day) spaces full listings, a listing that does not +finish within `limit` continues on every pass, and the position lives in the process, so a restart begins a new +listing. + +### The clone fence + +A clone or point-in-time restore of the ledger database shares the live database's installation identity, namespace +key and configuration, **including `acknowledge_exclusive_namespace`**, but sees an older set of references, so its +collector would delete the live database's newer objects once they pass the age threshold. A real pass is therefore +fenced: + +- **Fence.** `{prefix}/{namespace_key}/_collector-fence/{epoch:016d}.json`: one create-only object per epoch, + `{v, namespace_key, epoch, token, previous_token, claimed_at}`, the bytes the JavaScript SDK writes. The highest epoch + is the current claim. The database records its last `(epoch, token, previous_token)` in + `adcp_reporting_orphan_collector_fences`, keyed by a hash of provider, location, prefix and namespace key, so + bindings that reach the same bucket and prefix share one fence. +- **Claim.** A real pass takes a session advisory lock, proceeds only if the bucket's highest epoch is exactly the + recorded claim, commits the intent for `epoch + 1` under a fresh token, writes that object create-only, verifies the + token at the key and prunes all but the predecessor. It holds the lock through the write, so a sibling host of the + same database cannot interleave, and it does this before the first delete. A claim holds one pool connection across + the object-store reads and write. +- **Check.** The fence is read again before the first delete of every page and every 25 deletes within one. Any + mismatch stops the pass; `deleted` keeps what went before. +- **Refusal.** A refused binding has `status == "fenced"` and `fence` (`cause`, `detail`, `fence_epoch`, + `recorded_epoch`); the result lists it in `fenced`. Causes: `claimed_elsewhere`, `fence_advanced`, `token_mismatch`, + `fence_missing`, `fence_regressed`, `fence_unreadable`, `claim_lost`, `migration_required`. Everything unexpected + fails closed, including a fence-directory listing in which the provider left out an entry it could not address (it could be + the newest claim). A provider or database error is `failed`, not `fenced`. `dry_run` reads the fence without claiming it + and reports fenced bindings too. +- **Crash safety.** The intent commits before the object is written. A crash between the two leaves the database one + epoch ahead of the bucket with the matching previous token; the next pass finishes the claim under a **fresh** token, + so a copy taken in that window cannot adopt the same token as the original. +- **Takeover.** After a restore, or when the previous owner is gone, `await store.take_over_orphan_collector_fence( + acknowledge_previous_owner_gone=True)` claims `max(epoch) + 1` and supersedes every older object and version, + including a damaged chain. It refuses without the acknowledgement. Stop the previous owner's collector first: a pass + already running notices the new epoch at its next check, at most 25 deletes later. + +Known limits. **First claim wins:** nothing in a database distinguishes a copy from the original until one of them +claims, so a clone that collects before the original has run a pass since the copy claims first and the original is +fenced (and reported). The clone still cannot delete an object the original wrote after the copy until it is +`min_age_seconds` old. **Claim from the live database first** when you roll this out: one real pass, or a takeover. +The fence needs write access to each collected binding (a `read_only` binding collected with `include_read_only` +included), and a lifecycle rule must not expire `_collector-fence/`. + +**Rollout.** The fence table is created by `reporting_row_storage.sql` (`create_schema`), outside the required-schema +manifest, so a deployment that never enables the collector needs nothing from it. Until it exists a pass reports every +binding `fenced` with `migration_required`, a dry run included, and deletes nothing. Python supports one writer SDK per +ledger database; if a JavaScript deployment shares the database, claim from one SDK only. + ## Stored names follow AdCP Stored values use the names and bounds AdCP already defines (`reporting-file-manifest.json`, @@ -556,6 +661,7 @@ Tombstones live in `adcp_reporting_period_tombstones`, created with row storage | `row_storage` | `prune_expired_row_sets` | 20 periods | | `row_storage` or `change_feed` | `retire_expired_periods` | 20 periods | | `change_feed` | `prune_changes` | 10,000 rows | +| `orphan_collection` | `collect_orphaned_row_objects` | `limit` objects per binding per pass (default 1000), full listings spaced by `interval_seconds` | Each operation is bounded and resumable. A failing operation never stops the worker: the next interval retries it. Only the closed operation name and integer counters are logged, never an exception, provider message or identifier: @@ -563,11 +669,13 @@ Only the closed operation name and integer counters are logged, never an excepti - `REPORTING_MAINTENANCE_FAILED` (warning): the operation raised or reported failed items (a binding that cannot delete, for example). Read `causes` from the result for the stable per-item code. - `REPORTING_MAINTENANCE_PENDING` (info): work was left in place on purpose, with the counters `protected`, `held`, - `deferred` and `skipped_unconfigured`. A retirement that does nothing because every period is held shows up here + `deferred`, `skipped_unconfigured` and `unsupported` (object bindings whose provider cannot list). A retirement that does nothing because every period is held shows up here instead of looking like a healthy idle worker. - `REPORTING_MAINTENANCE_STALLED` (warning): work nobody is reaching, with `overdue` (intents expired for more than `overdue_after_seconds`, default 24 hours, and still unswept: a binding no host configures any more, or one that - keeps failing, so objects leak) and `stuck` (periods with five or more failed retirement attempts). + keeps failing, so objects leak), `stuck` (periods with five or more failed retirement attempts) and `fenced` + (object bindings the orphan collector refused to delete from because another database holds the namespace's claim; + see [the clone fence](#the-clone-fence)). The last pass's counters are also on `ReportingProductionSupport.maintenance_report`, per operation. `prune_expired_row_sets` is scheduled so row bytes are reclaimed even while a period's records stay; its only hold is diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/__init__.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/__init__.py index 8979c0afc8..0eef67eb4c 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/__init__.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/__init__.py @@ -57,6 +57,27 @@ from adcp_reporting_storage.ledger.delivery_pg import ( PgReportingReconciliationStore as PgReportingReconciliationStore, ) + from adcp_reporting_storage.ledger.orphan_collector import ( + ReportingOrphanBindingResultV1 as ReportingOrphanBindingResultV1, + ) + from adcp_reporting_storage.ledger.orphan_collector import ( + ReportingOrphanCandidateV1 as ReportingOrphanCandidateV1, + ) + from adcp_reporting_storage.ledger.orphan_collector import ( + ReportingOrphanCollectionOptionsV1 as ReportingOrphanCollectionOptionsV1, + ) + from adcp_reporting_storage.ledger.orphan_collector import ( + ReportingOrphanCollectionResultV1 as ReportingOrphanCollectionResultV1, + ) + from adcp_reporting_storage.ledger.orphan_collector import ( + ReportingOrphanCollectionStateV1 as ReportingOrphanCollectionStateV1, + ) + from adcp_reporting_storage.ledger.orphan_collector import ( + ReportingOrphanFenceTakeoverV1 as ReportingOrphanFenceTakeoverV1, + ) + from adcp_reporting_storage.ledger.orphan_fence import ( + ReportingOrphanFenceRefusalV1 as ReportingOrphanFenceRefusalV1, + ) from adcp_reporting_storage.ledger.period_retirement import ( ReportingPeriodRetirementV1 as ReportingPeriodRetirementV1, ) @@ -257,6 +278,22 @@ "ReportingRowBindingSelectionInputV1", ), "ReportingRowIntentSweepV1": ("ledger.row_retention", "ReportingRowIntentSweepV1"), + "ReportingOrphanBindingResultV1": ("ledger.orphan_collector", "ReportingOrphanBindingResultV1"), + "ReportingOrphanCandidateV1": ("ledger.orphan_collector", "ReportingOrphanCandidateV1"), + "ReportingOrphanCollectionOptionsV1": ( + "ledger.orphan_collector", + "ReportingOrphanCollectionOptionsV1", + ), + "ReportingOrphanCollectionResultV1": ( + "ledger.orphan_collector", + "ReportingOrphanCollectionResultV1", + ), + "ReportingOrphanCollectionStateV1": ( + "ledger.orphan_collector", + "ReportingOrphanCollectionStateV1", + ), + "ReportingOrphanFenceTakeoverV1": ("ledger.orphan_collector", "ReportingOrphanFenceTakeoverV1"), + "ReportingOrphanFenceRefusalV1": ("ledger.orphan_fence", "ReportingOrphanFenceRefusalV1"), "ReportingRowPruneV1": ("ledger.row_retention", "ReportingRowPruneV1"), "ReportingPeriodRetirementV1": ("ledger.period_retirement", "ReportingPeriodRetirementV1"), "ReportingRowMigrationBatchV1": ("ledger.row_migration", "ReportingRowMigrationBatchV1"), diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/orphan_collector.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/orphan_collector.py new file mode 100644 index 0000000000..3f16d92f2c --- /dev/null +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/orphan_collector.py @@ -0,0 +1,987 @@ +"""Opt-in collector for orphaned row objects, fenced against a cloned or restored database. + +Shared SDK persistence spec, adcontextprotocol/adcp#7996 section 4.2; mirrors the JavaScript SDK's +``collectOrphanedRowObjects`` (adcp-client#3291) and its ownership fence (adcp-client#3593). + +The write-intent sweep (:mod:`adcp_reporting_storage.ledger.row_retention`) deletes only what an +intent lists. It cannot see an object whose ``PUT`` succeeded before the intent recorded it, an +intent for a binding no running host configures, or an intent lost to a database restore. This +collector lists each configured object binding under its owned prefix, +``{prefix}/{namespace_key}/``, and deletes an object only when all of these hold: + +* its key is a well-formed row object (``.jsonl`` or ``.jsonl.gz``) under that prefix; +* it was written more than ``min_age_seconds`` ago (default 7 days, at least 1 day), so no writer + that still holds a lease can be mid-upload; +* no row set that is not already ``pruned`` has a chunk at its key, and no write intent lists it, + at any version and under any binding (a pre-check that errs towards leaking, because two bindings + can share a bucket and prefix); +* under the per-object advisory lock of **every** binding, no ``live`` or ``unavailable`` row set + under any binding references that exact version and no open intent created it (the check every + other delete makes, widened to every binding), and the provider then deletes that exact version. + +A clone or point-in-time restore of the ledger database shares the live database's installation +identity, namespace key and configuration, so it would also share the opt-in. A real pass therefore +first claims the namespace in an ownership fence +(:mod:`adcp_reporting_storage.ledger.orphan_fence`) and refuses to delete while another database +holds a newer claim. See ``docs/reporting-row-storage.md``. +""" + +from __future__ import annotations + +import asyncio +import math +import time +import uuid +from collections.abc import Awaitable, Callable, Mapping, Sequence +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, Any, Literal + +from adcp_reporting_storage.ledger.orphan_fence import ( + OrphanFenceDecision, + OrphanFenceEntry, + OrphanFenceRecord, + OrphanFenceRow, + ReportingOrphanFenceRefusalV1, + decide_orphan_fence, +) +from adcp_reporting_storage.ledger.row_storage import ReportingRowStoreError +from adcp_reporting_storage.ledger.row_storage_object import ( + format_row_instant, + parse_row_instant, +) + +if TYPE_CHECKING: + from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore + +__all__ = [ + "ReportingOrphanBindingResultV1", + "ReportingOrphanCandidateV1", + "ReportingOrphanCollectionOptionsV1", + "ReportingOrphanCollectionResultV1", + "ReportingOrphanCollectionStateV1", + "ReportingOrphanFenceTakeoverV1", + "collect_orphaned_row_objects", + "run_orphan_collection_pass", + "take_over_orphan_collector_fence", +] + +DEFAULT_ORPHAN_MIN_AGE_SECONDS = 7 * 24 * 3600 +MIN_ORPHAN_MIN_AGE_SECONDS = 24 * 3600 +DEFAULT_ORPHAN_INTERVAL_SECONDS = 86_400 +#: A fenced namespace looks again within the hour, so applying the migration or taking the +#: namespace over does not leave collection idle for the rest of a day. +FENCED_RECHECK_SECONDS = 3_600 +MAX_ORPHAN_CANDIDATES = 1_000 +#: Deletes between reads of the collector fence within one listing page. +ORPHAN_FENCE_RECHECK_DELETES = 25 +_FENCE_LOCK_TIMEOUT = "120s" +#: The suffixes ``render_reporting_row_object_key_v1`` gives every key: nothing else under the +#: prefix is a row object. +_ROW_OBJECT_SUFFIXES = (".jsonl", ".jsonl.gz") + +_BindingStatus = Literal["complete", "partial", "failed", "unsupported", "read_only", "fenced"] + + +@dataclass(frozen=True, slots=True) +class ReportingOrphanCandidateV1: + """An object a dry run would delete.""" + + binding_id: str + object_ref: str + native_version_ref: str + size_bytes: int + created_at: str + + +@dataclass(slots=True) +class ReportingOrphanBindingResultV1: + """What one binding's listing did. + + ``status``: ``complete`` (the listing reached its end), ``partial`` (stopped at ``limit``; + resume from ``cursors``), ``failed`` (the listing or its provider failed; ``error`` is the + stable code), ``unsupported`` (the provider has no ``list``), ``read_only`` (skipped because + the binding is ``read_only``) or ``fenced`` (another database holds the namespace's collector + fence, or this database cannot prove it holds it, so no further object was deleted; ``deleted`` + counts any that went before a mid-pass refusal and ``fence`` says why). + """ + + binding_id: str + status: _BindingStatus = "complete" + #: Objects the provider listed, including ones dropped as foreign. + listed: int = 0 + #: Old enough, but named by a ledger record or open upload; left alone. + referenced: int = 0 + #: Younger than ``min_age_seconds``, or with no readable write time. + too_young: int = 0 + #: Outside the owned prefix or not shaped like a row object; never touched. + foreign: int = 0 + deleted: int = 0 + #: Dry run only: orphans that a real pass would delete. + would_delete: int = 0 + failed: int = 0 + error: str | None = None + fence: ReportingOrphanFenceRefusalV1 | None = None + + +@dataclass(slots=True) +class ReportingOrphanCollectionResultV1: + """A collection pass. ``deleted`` counts delete requests that found nothing referenced; an + object another host removed first still counts.""" + + dry_run: bool + listed: int = 0 + referenced: int = 0 + too_young: int = 0 + foreign: int = 0 + deleted: int = 0 + would_delete: int = 0 + #: Delete failures plus bindings whose listing failed. + failed: int = 0 + #: Bindings whose provider cannot list: not covered by this collector. + unsupported: tuple[str, ...] = () + #: ``read_only`` bindings skipped. + skipped_read_only: tuple[str, ...] = () + #: Bindings whose collector fence another database holds: no further object was deleted there. + #: See each binding's ``fence``. + fenced: tuple[str, ...] = () + #: Dry run only: the orphans a real pass would delete, at most 1000. + candidates: tuple[ReportingOrphanCandidateV1, ...] = () + #: Resume tokens for bindings whose listing is not finished. Pass back as ``cursors``. + cursors: Mapping[str, str] = field(default_factory=dict) + #: Bindings whose listing reached its end this pass. Pass them back as ``skip_bindings`` until + #: every binding has. + completed: tuple[str, ...] = () + bindings: tuple[ReportingOrphanBindingResultV1, ...] = () + + +@dataclass(frozen=True, slots=True) +class ReportingOrphanFenceTakeoverV1: + """One fence a takeover claimed.""" + + #: The bindings that share the fence. + binding_ids: tuple[str, ...] + #: The epoch now held. + epoch: int + #: Highest epoch found in the object store before the takeover; ``None`` when the namespace + #: was never claimed. + previous_epoch: int | None + + +@dataclass(frozen=True, slots=True) +class ReportingOrphanCollectionOptionsV1: + """Opt-in scheduling of :func:`collect_orphaned_row_objects` (``PgReportingLedgerStore( + orphan_collection=...)``). + + Enable it only on the ledger database that owns the namespace. A clone or restore of that + database pointed at the same bucket shares its namespace, so a real pass is fenced: it claims + the namespace and refuses, reporting the binding ``fenced``, when the object store holds a newer + claim than this database recorded. After a restore, or when the original is gone, call + ``take_over_orphan_collector_fence``. Start with ``dry_run=True`` and read the candidates. + """ + + #: Report what would be deleted and delete nothing. + dry_run: bool = False + #: Required unless ``dry_run``: your statement that this ledger database is the only one that + #: owns the namespace, and not a clone or restore pointed at the same bucket. A copy of the + #: configuration carries this flag too; the ownership fence is what stops such a copy. + acknowledge_exclusive_namespace: bool = False + #: Minimum time between full listings, counted from when the previous one finished. Passes in + #: between do nothing; a listing that has not finished (it stops at ``limit``) continues on + #: every pass. + interval_seconds: int = DEFAULT_ORPHAN_INTERVAL_SECONDS + #: Objects younger than this are never deleted. At least 1 day; keep it well above the longest + #: write deadline plus intent expiry. + min_age_seconds: int = DEFAULT_ORPHAN_MIN_AGE_SECONDS + #: Objects examined per binding per pass. + limit: int = 1_000 + #: Also collect from ``read_only`` bindings, which are skipped by default. + include_read_only: bool = False + + def __post_init__(self) -> None: + for flag in ("dry_run", "acknowledge_exclusive_namespace", "include_read_only"): + if type(getattr(self, flag)) is not bool: + raise ValueError(f"orphan_collection.{flag} must be a boolean") + if not self.dry_run and not self.acknowledge_exclusive_namespace: + raise ValueError( + "orphan_collection deletes objects: set acknowledge_exclusive_namespace=True to " + "confirm this ledger database is the only one that owns the namespace (not a " + "clone or restore pointed at the same bucket), or set dry_run=True" + ) + for name in ("interval_seconds", "limit"): + value = getattr(self, name) + if type(value) is not int or value < 1: + raise ValueError(f"orphan_collection.{name} must be a positive integer") + if ( + type(self.min_age_seconds) is not int + or self.min_age_seconds < MIN_ORPHAN_MIN_AGE_SECONDS + ): + raise ValueError( + "orphan_collection.min_age_seconds must be an integer of at least 86400 (1 day): " + "a younger object may belong to an upload still in flight" + ) + + +@dataclass(slots=True) +class ReportingOrphanCollectionStateV1: + """Progress of the collector between passes, kept in the process. + + It is not persisted, so a restart begins a new listing. + """ + + cursors: dict[str, str] = field(default_factory=dict) + #: Bindings whose listing finished in the current cycle; not listed again until every binding + #: has. + completed: list[str] = field(default_factory=list) + #: ``time.monotonic()`` seconds before which no new full listing starts. + next_cycle_at: float | None = None + + +@dataclass(frozen=True, slots=True) +class _Gate: + refusal: ReportingOrphanFenceRefusalV1 | None = None + + @property + def ok(self) -> bool: + return self.refusal is None + + +# -- the collector --------------------------------------------------------------------------- + + +def _positive(value: int, name: str, maximum: int = 1_000_000) -> None: + if type(value) is not int or not 1 <= value <= maximum: + raise ValueError(f"{name} must be between 1 and {maximum}") + + +def _names(value: Sequence[str] | None, name: str) -> tuple[str, ...] | None: + """Binding IDs as a tuple. A bare string would otherwise act as a substring test.""" + if value is None: + return None + if isinstance(value, (str, bytes)) or not all(isinstance(item, str) for item in value): + raise ValueError(f"{name} must be a sequence of binding IDs") + return tuple(value) + + +def _code(error: Exception) -> str: + return error.code if isinstance(error, ReportingRowStoreError) else "INTERNAL_ERROR" + + +async def _present_tables(store: PgReportingLedgerStore) -> set[str]: + async with store._pool.connection() as connection: + rows = await ( + await connection.execute( + "SELECT table_name FROM information_schema.tables" + " WHERE table_schema = current_schema() AND table_name = ANY(%s::text[])", + ( + [ + "adcp_reporting_row_sets", + "adcp_reporting_row_write_intents", + "adcp_reporting_orphan_collector_fences", + ], + ), + ) + ).fetchall() + return {row[0] for row in rows} + + +async def _keys_named_by_records(store: PgReportingLedgerStore, keys: Sequence[str]) -> set[str]: + """Of these object keys, the ones a ledger record names at any version, under any binding. + + A chunk of a row set that is not yet ``pruned``, or an object any write intent lists. Version- + and binding-blind on purpose: two bindings can share a bucket and prefix (a retired binding kept + ``read_only`` next to its successor), and the orphan collector would rather leak than delete + near a record. + """ + if not keys: + return set() + async with store._pool.connection() as connection: + rows = await ( + await connection.execute( + "SELECT chunk.object_ref FROM adcp_reporting_row_chunks chunk" + " JOIN adcp_reporting_row_sets row_set ON row_set.row_set_id = chunk.row_set_id" + " WHERE chunk.object_ref = ANY(%(keys)s::text[]) AND row_set.rows_state <> 'pruned'" + " UNION" + " SELECT COALESCE(item ->> 'object_ref', item ->> 'objectKey')" + " FROM adcp_reporting_row_write_intents intent," + " jsonb_array_elements(intent.created_objects) item" + " WHERE COALESCE(item ->> 'object_ref', item ->> 'objectKey')" + " = ANY(%(keys)s::text[])", + {"keys": list(keys)}, + ) + ).fetchall() + return {row[0] for row in rows} + + +async def collect_orphaned_row_objects( + store: PgReportingLedgerStore, + *, + min_age_seconds: int | None = None, + limit: int = 1_000, + page_size: int | None = None, + dry_run: bool = False, + include_read_only: bool = False, + bindings: Sequence[str] | None = None, + cursors: Mapping[str, str] | None = None, + skip_bindings: Sequence[str] | None = None, + acknowledge_exclusive_namespace: bool = False, + now: datetime | None = None, +) -> ReportingOrphanCollectionResultV1: + """Delete row objects that no ledger record or open upload names. + + Opt-in, listing-based and read-only unless it deletes. See the module docstring for the full + list of conditions an object must meet. ``read_only`` bindings are skipped unless + ``include_read_only``. A binding whose provider has no ``list`` is reported in + ``unsupported``. ``dry_run`` deletes nothing and reports what would go in ``candidates``. + ``limit`` bounds the objects examined per binding per pass; pass ``result.cursors`` back as + ``cursors`` to resume where the last pass stopped (a binding absent from ``cursors`` starts from + the beginning, which is also where a binding that finished its listing starts next time), and + ``result.completed`` as ``skip_bindings`` so a cycle that spans several passes does not rescan + bindings that already finished. + + Deleting needs ``acknowledge_exclusive_namespace=True``: enable it only on the ledger database + that owns the namespace. A clone or restore of that database that still points at the same + bucket shares its ``namespace_key``, and would delete the objects of every row set the original + wrote after the clone was taken. That flag is copied with the configuration, so it is backed by + a fence: a real pass first claims the namespace (a create-only object under + ``{prefix}/{namespace_key}/_collector-fence/`` and a row in + ``adcp_reporting_orphan_collector_fences``) and deletes only while the object store's highest + claim is exactly the one this database recorded. A database copied before the original's last + pass sees a newer claim and refuses: its binding is reported ``fenced`` with a ``cause``, and + nothing more is deleted (a fence that advances mid-pass stops the pass at the next check, within + 25 deletes, and ``deleted`` keeps what went before). A ``dry_run`` checks the fence without + claiming it and reports fenced bindings the same way. After a restore, or when the original is + gone, take the namespace over deliberately with ``take_over_orphan_collector_fence``. Everything + the listing finds under the owned prefix with a row-object key is treated as written by this + SDK, so do not store other ``.jsonl`` files there. + """ + storage = store._row_storage + if storage is None: + raise ReportingRowStoreError( + "INVALID_INPUT", "collect_orphaned_row_objects requires row storage" + ) + bindings = _names(bindings, "bindings") + skip_bindings = _names(skip_bindings, "skip_bindings") + if min_age_seconds is None: + min_age_seconds = DEFAULT_ORPHAN_MIN_AGE_SECONDS + if type(min_age_seconds) is not int or min_age_seconds < MIN_ORPHAN_MIN_AGE_SECONDS: + raise ValueError( + f"min_age_seconds must be an integer of at least {MIN_ORPHAN_MIN_AGE_SECONDS} " + "(1 day): an object younger than the longest write deadline plus intent expiry may " + "belong to an upload still in flight" + ) + _positive(limit, "limit") + page_size = min(limit, 500) if page_size is None else page_size + _positive(page_size, "page_size") + if dry_run is not True: + dry_run = False + if not dry_run and acknowledge_exclusive_namespace is not True: + raise ValueError( + "collect_orphaned_row_objects deletes objects: pass " + "acknowledge_exclusive_namespace=True to confirm this ledger database is the only one " + "that owns the namespace (not a clone or restore pointed at the same bucket), or pass " + "dry_run=True" + ) + clock = now if now is not None else datetime.now(timezone.utc) + if not isinstance(clock, datetime) or clock.tzinfo is None: + raise ValueError("now must be a timezone-aware datetime") + await store.ready_row_storage() + summaries = storage.object_binding_summaries() + for binding_id in bindings or (): + if not any(summary.id == binding_id for summary in summaries): + raise ValueError( + f"bindings names {binding_id}, which is not a configured object binding" + ) + + result = ReportingOrphanCollectionResultV1(dry_run=dry_run) + outcomes: list[ReportingOrphanBindingResultV1] = [] + candidates: list[ReportingOrphanCandidateV1] = [] + completed: list[str] = [] + unsupported: list[str] = [] + skipped_read_only: list[str] = [] + fenced: list[str] = [] + next_cursors: dict[str, str] = {} + + def finish() -> ReportingOrphanCollectionResultV1: + for outcome in outcomes: + result.listed += outcome.listed + result.referenced += outcome.referenced + result.too_young += outcome.too_young + result.foreign += outcome.foreign + result.deleted += outcome.deleted + result.would_delete += outcome.would_delete + result.failed += outcome.failed + result.unsupported = tuple(unsupported) + result.skipped_read_only = tuple(skipped_read_only) + result.fenced = tuple(fenced) + result.candidates = tuple(candidates) + result.cursors = next_cursors + result.completed = tuple(completed) + result.bindings = tuple(outcomes) + return result + + tables = await _present_tables(store) + if "adcp_reporting_row_sets" not in tables or "adcp_reporting_row_write_intents" not in tables: + return finish() + has_fence_table = "adcp_reporting_orphan_collector_fences" in tables + # One fence check or claim per physical fence per pass: bindings that share a bucket and prefix + # share it. + gates: dict[str, _Gate] = {} + + async def gate(binding_id: str, mode: Literal["claim", "check"]) -> _Gate: + scope, _, _ = await storage.collector_fence_location(binding_id) + if mode == "claim" and scope in gates: + return gates[scope] + outcome = await _orphan_fence_gate( + store, + binding_id, + scope, + "check" if mode == "claim" and dry_run else mode, + has_fence_table, + ) + if mode == "claim": + gates[scope] = outcome + return outcome + + cutoff = clock - timedelta(seconds=min_age_seconds) + for summary in summaries: + if bindings is not None and summary.id not in bindings: + continue + if skip_bindings is not None and summary.id in skip_bindings: + continue + outcome = ReportingOrphanBindingResultV1(summary.id) + outcomes.append(outcome) + if summary.read_only and not include_read_only: + outcome.status = "read_only" + skipped_read_only.append(summary.id) + continue + if not summary.listable: + outcome.status = "unsupported" + unsupported.append(summary.id) + continue + token = cursors.get(summary.id) if cursors is not None else None + # The ownership fence comes before any listing: a clone or restore must not even enumerate + # for deletion. + try: + claim = await gate(summary.id, "claim") + except Exception as error: + outcome.status = "failed" + outcome.error = _code(error) + outcome.failed += 1 + # The listing never started, so the position it had stays where it was. + if token is not None: + next_cursors[summary.id] = token + continue + if not claim.ok: + outcome.status = "fenced" + outcome.fence = claim.refusal + fenced.append(summary.id) + continue + # A failure on the first page of a resumed listing drops the cursor: a stale or invalid + # token must not wedge the binding, and restarting from the beginning is always safe. + resumed = token is not None + drop_cursor = False + examined = 0 + pages = 0 + # Pages that carry no objects (S3 delete markers, unversioned entries) do not move + # ``examined``, so the pass also stops after a bounded number of requests. + max_pages = math.ceil(limit / page_size) * 4 + try: + while True: + page = await storage.list_objects( + summary.id, page_size=min(page_size, limit - examined), page_token=token + ) + resumed = False + pages += 1 + examined += len(page.objects) + page.foreign + outcome.listed += len(page.objects) + page.foreign + outcome.foreign += page.foreign + old = [] + for entry in page.objects: + if not entry.object_ref.endswith(_ROW_OBJECT_SUFFIXES): + outcome.foreign += 1 + continue + written = parse_row_instant(entry.created_at) + # An unreadable write time is treated as young: never delete on a guess. + if written is None or written > cutoff: + outcome.too_young += 1 + continue + old.append(entry) + named = await _keys_named_by_records(store, [entry.object_ref for entry in old]) + # Another database may claim the namespace while this pass runs: read the fence + # again before the first delete of every page and then every + # ORPHAN_FENCE_RECHECK_DELETES deletes after it. + since_fence_check = ORPHAN_FENCE_RECHECK_DELETES + fenced_mid_pass = False + for entry in old: + if entry.object_ref in named: + outcome.referenced += 1 + continue + if dry_run: + outcome.would_delete += 1 + if len(candidates) < MAX_ORPHAN_CANDIDATES: + candidates.append( + ReportingOrphanCandidateV1( + summary.id, + entry.object_ref, + entry.native_version_ref, + entry.size_bytes, + entry.created_at, + ) + ) + continue + if since_fence_check >= ORPHAN_FENCE_RECHECK_DELETES: + again = await gate(summary.id, "check") + if not again.ok: + outcome.status = "fenced" + outcome.fence = again.refusal + fenced.append(summary.id) + fenced_mid_pass = True + break + since_fence_check = 0 + since_fence_check += 1 + try: + # The per-object lock and exact-version reference check every other + # delete makes, widened to every binding. + removed = await storage.delete_unreferenced( + store._pool, + summary.id, + [(entry.object_ref, entry.native_version_ref)], + any_binding=True, + ) + if removed > 0: + outcome.deleted += 1 + else: + outcome.referenced += 1 + except Exception: + outcome.failed += 1 + if fenced_mid_pass: + break + if page.next_page_token is not None and page.next_page_token == token: + drop_cursor = True + raise ReportingRowStoreError( + "PROVIDER_UNAVAILABLE", "provider returned the same page token twice" + ) + token = page.next_page_token + if token is None: + completed.append(summary.id) + break + if examined >= limit or pages >= max_pages: + outcome.status = "partial" + next_cursors[summary.id] = token + break + except Exception as error: + outcome.status = "failed" + outcome.error = _code(error) + outcome.failed += 1 + # A listing failure retries the same page next pass. (A delete that fails is counted + # and the pass moves on, so one stuck object cannot hold up the rest; it is retried on + # the next full listing.) + if token is not None and not resumed and not drop_cursor: + next_cursors[summary.id] = token + return finish() + + +# -- the fence: claim, check, take over ----------------------------------------------------- + + +async def _fence_row(connection: Any, scope: str) -> tuple[OrphanFenceRow, str] | None: + row = await ( + await connection.execute( + "SELECT epoch, token, previous_token, claimed_at" + " FROM adcp_reporting_orphan_collector_fences WHERE fence_scope = %s", + (scope,), + ) + ).fetchone() + if row is None: + return None + return OrphanFenceRow(int(row[0]), row[1], row[2]), format_row_instant(row[3]) + + +async def _latest_orphan_fence( + store: PgReportingLedgerStore, binding_id: str +) -> tuple[list[OrphanFenceEntry], tuple[int, str] | None]: + """The object store's current claim: the highest epoch listed, read and verified.""" + storage = store._row_storage + assert storage is not None + entries = await storage.list_collector_fence(binding_id) + if not entries: + return entries, None + record = await storage.read_collector_fence(binding_id, entries[-1]) + return entries, (record.epoch, record.token) + + +def _unreadable() -> _Gate: + return _Gate( + ReportingOrphanFenceRefusalV1( + "fence_unreadable", + "the collector fence objects under this namespace are malformed, oversized, " + "duplicated or name another namespace. Inspect them; once the namespace is known to " + "be owned by this database alone, call " + "take_over_orphan_collector_fence(acknowledge_previous_owner_gone=True).", + ) + ) + + +async def _orphan_fence_gate( + store: PgReportingLedgerStore, + binding_id: str, + scope: str, + mode: Literal["claim", "check"], + fences_table: bool, +) -> _Gate: + """Whether this database may collect orphans under a binding's namespace. + + For a real pass this is also the claim that makes it so. ``check`` only reads: it is what + ``dry_run`` and the per-page recheck use. ``claim`` runs under a per-fence advisory lock: it + verifies the object store's highest epoch is exactly the claim this database recorded, records + the next epoch and a fresh token in the database, and only then writes that epoch's object + create-only. The intent is committed before the object is written, so a crash between the two + is finished by the next pass instead of looking like another database's claim. + + Each real pass claims a new epoch, so a cycle spread over many partial passes writes and prunes + one tiny object per pass. The claim holds a pool connection and the advisory lock across the + object-store listing and read, so a slow bucket pins one connection for up to the read deadline. + """ + if not fences_table: + return _Gate( + ReportingOrphanFenceRefusalV1( + "migration_required", + "the orphan collector fence table does not exist: apply " + "reporting_row_storage.sql (create_schema does), then orphan collection can " + "claim its namespace", + ) + ) + storage = store._row_storage + assert storage is not None + try: + if mode == "check": + # The bucket first, then the row. A claim commits its intent to the row before it + # writes the object, so a row read after the bucket is never behind a claim of this + # database that the bucket already shows (a sibling host claiming between the two + # reads would otherwise look like another database's claim). + _, latest = await _latest_orphan_fence(store, binding_id) + async with store._pool.connection() as connection: + stored = await _fence_row(connection, scope) + decision = decide_orphan_fence(stored[0] if stored else None, latest) + if decision.action == "fenced": + return _Gate(decision.refusal) + return _Gate() + + async def plan(stored: tuple[OrphanFenceRow, str] | None) -> _Plan: + entries, latest = await _latest_orphan_fence(store, binding_id) + decision = decide_orphan_fence(stored[0] if stored else None, latest) + if decision.action == "fenced": + return _Plan(refusal=decision.refusal) + # An unfinished claim is finished under a fresh token, never the recorded one: a copy + # of this database taken while the claim was unfinished carries the same token, and + # both could then "adopt" the same object. With distinct tokens, create-only leaves + # exactly one of them the owner. + completing = decision.action == "complete" + assert stored is not None or not completing + return _Plan( + record=OrphanFenceRecord( + stored[0].epoch if completing and stored else _epoch(decision), + str(uuid.uuid4()), + stored[0].previous_token if completing and stored else decision.previous_token, + format_row_instant(datetime.now(timezone.utc)), + ), + entries=entries, + previous_epoch=latest[0] if latest else None, + ) + + claimed = await _claim_orphan_fence(store, binding_id, scope, True, plan) + if claimed.kind == "fenced": + return _Gate(claimed.refusal) + if claimed.kind == "lost": + assert claimed.record is not None and claimed.found is not None + return _Gate( + ReportingOrphanFenceRefusalV1( + "claim_lost", + f"another database claimed epoch {claimed.record.epoch} first. If this " + "database is the only one that owns this namespace, call " + "take_over_orphan_collector_fence(acknowledge_previous_owner_gone=True); " + "otherwise leave orphan collection off on this copy.", + claimed.found.epoch, + claimed.record.epoch, + ) + ) + return _Gate() + except ReportingRowStoreError as error: + # Fence objects that are not exactly what this collector writes fail closed. + if error.code == "ROWS_INTEGRITY_FAILED": + return _unreadable() + raise + + +def _epoch(decision: OrphanFenceDecision) -> int: + assert decision.epoch is not None + return decision.epoch + + +@dataclass(slots=True) +class _Plan: + refusal: ReportingOrphanFenceRefusalV1 | None = None + record: OrphanFenceRecord | None = None + entries: list[OrphanFenceEntry] = field(default_factory=list) + previous_epoch: int | None = None + + +@dataclass(slots=True) +class _Claim: + kind: Literal["fenced", "lost", "claimed"] + refusal: ReportingOrphanFenceRefusalV1 | None = None + record: OrphanFenceRecord | None = None + found: OrphanFenceRecord | None = None + previous_epoch: int | None = None + + +async def _claim_orphan_fence( + store: PgReportingLedgerStore, + binding_id: str, + scope: str, + keep_predecessor: bool, + plan: Callable[[tuple[OrphanFenceRow, str] | None], Awaitable[_Plan]], +) -> _Claim: + """Claim one fence epoch for this database, under a session-level advisory lock held from the + decision to the object write. + + ``plan`` runs inside the transaction with the stored row, reads the bucket and returns either a + refusal or the claim to make (and the entries it supersedes). The claim's intent (epoch, token) + is committed before the object is written, so a crash between the two is finished by the next + pass instead of looking like another database's claim; holding the lock through the write keeps + a sibling host of this database from finishing that claim under another token while the first + write is still in flight. A clone has its own database and its own lock, which is exactly why + the object store's create-only write decides between them. + """ + storage = store._row_storage + assert storage is not None + lock = f"adcp.orphan_fence:{scope}" + async with store._pool.connection() as connection: + original_autocommit = connection.autocommit + await connection.set_autocommit(True) + locked = False + try: + # A sibling holds the lock across its object-store write, which has its own deadline. + await connection.execute(f"SET lock_timeout = '{_FENCE_LOCK_TIMEOUT}'") + # Marked before the call: a cancellation after the server grants the lock but before + # the client reads the reply must still unlock (unlocking a lock never taken only + # returns false), or the connection would go back to the pool holding it. + locked = True + await connection.execute("SELECT pg_advisory_lock(hashtextextended(%s, 0))", (lock,)) + await connection.execute("RESET lock_timeout") + async with connection.transaction(): + planned = await plan(await _fence_row(connection, scope)) + if planned.record is not None: + await connection.execute( + "INSERT INTO adcp_reporting_orphan_collector_fences" + " (fence_scope, epoch, token, previous_token, claimed_at)" + " VALUES (%(scope)s, %(epoch)s, %(token)s, %(previous)s, %(claimed)s)" + " ON CONFLICT (fence_scope) DO UPDATE" + " SET epoch = EXCLUDED.epoch, token = EXCLUDED.token," + " previous_token = EXCLUDED.previous_token," + " claimed_at = EXCLUDED.claimed_at", + { + "scope": scope, + "epoch": planned.record.epoch, + "token": planned.record.token, + "previous": planned.record.previous_token, + "claimed": parse_row_instant(planned.record.claimed_at), + }, + ) + if planned.refusal is not None: + return _Claim("fenced", refusal=planned.refusal) + assert planned.record is not None + _, written = await storage.put_collector_fence(binding_id, planned.record) + if written.token != planned.record.token: + return _Claim("lost", record=planned.record, found=written) + # A claim keeps its predecessor (an unfinished claim is recognised by it) and prunes + # everything older. A takeover has just written and confirmed its object, so it + # supersedes every object and version it saw. + await storage.delete_collector_fence( + binding_id, + [ + entry + for entry in planned.entries + if entry.epoch < planned.record.epoch - (1 if keep_predecessor else 0) + ], + ) + return _Claim("claimed", record=planned.record, previous_epoch=planned.previous_epoch) + finally: + try: + await connection.execute("RESET lock_timeout") + if locked: + await connection.execute( + "SELECT pg_advisory_unlock(hashtextextended(%s, 0))", (lock,) + ) + await connection.set_autocommit(original_autocommit) + except BaseException: + # A connection that may still hold the session lock must not go back to the pool. + await asyncio.shield(connection.close()) + raise + + +async def take_over_orphan_collector_fence( + store: PgReportingLedgerStore, + *, + acknowledge_previous_owner_gone: bool, + bindings: Sequence[str] | None = None, + include_read_only: bool = False, +) -> list[ReportingOrphanFenceTakeoverV1]: + """Take over the orphan collector's namespace fence. + + The deliberate step after a restore, or when the database that held the fence is gone for + good. It claims the next epoch for this database, so the previous holder (and every other copy + of its state) is fenced out, and this database's collector can delete again. + + Do this only when this database is the one that should own the namespace. If the original is + still running, taking over from a clone fences the original and lets the clone delete its + objects: the acknowledgement exists because nothing else can tell them apart. + ``acknowledge_previous_owner_gone=True`` is required. + + A takeover does not stop a collector that is already mid-pass: that pass notices the new epoch + at its next fence check, which is at most 25 deletes later. Stop the previous owner's + collector before you take over. + + Takes over every writable (or, with ``include_read_only``, every) object binding whose provider + can list, or only the ``bindings`` named; bindings that share a bucket, prefix and installation + share one fence and are reported together. Requires the fence table + (``reporting_row_storage.sql``). + """ + if acknowledge_previous_owner_gone is not True: + raise ValueError( + "take_over_orphan_collector_fence fences out the namespace's current owner: pass " + "acknowledge_previous_owner_gone=True to confirm that database is gone or must never " + "collect again" + ) + storage = store._row_storage + if storage is None: + raise ReportingRowStoreError( + "INVALID_INPUT", "take_over_orphan_collector_fence requires row storage" + ) + bindings = _names(bindings, "bindings") + await store.ready_row_storage() + summaries = storage.object_binding_summaries() + for binding_id in bindings or (): + summary = next((item for item in summaries if item.id == binding_id), None) + if summary is None: + raise ValueError( + f"bindings names {binding_id}, which is not a configured object binding" + ) + if not summary.listable: + raise ValueError(f"bindings names {binding_id}, whose provider cannot list objects") + if "adcp_reporting_orphan_collector_fences" not in await _present_tables(store): + raise ReportingRowStoreError( + "STATE_UNAVAILABLE", + "the orphan collector fence table does not exist: apply reporting_row_storage.sql", + ) + selected = [ + summary + for summary in summaries + if summary.listable + and ( + summary.id in bindings + if bindings is not None + else include_read_only or not summary.read_only + ) + ] + by_scope: dict[str, list[str]] = {} + for summary in selected: + scope, _, _ = await storage.collector_fence_location(summary.id) + by_scope.setdefault(scope, []).append(summary.id) + taken: list[ReportingOrphanFenceTakeoverV1] = [] + for scope, binding_ids in by_scope.items(): + binding_id = binding_ids[0] + + async def plan( + stored: tuple[OrphanFenceRow, str] | None, binding_id: str = binding_id + ) -> _Plan: + entries = await storage.list_collector_fence(binding_id, tolerant=True) + newest = entries[-1] if entries else None + # A malformed newest object is exactly what a takeover is for: its key still gives the + # epoch to supersede. Several versions of the newest epoch are ambiguous, so none of + # them names the previous token. + previous: OrphanFenceRecord | None = None + if newest is not None and sum(1 for item in entries if item.epoch == newest.epoch) == 1: + try: + previous = await storage.read_collector_fence(binding_id, newest) + except ReportingRowStoreError as error: + if error.code != "ROWS_INTEGRITY_FAILED": + raise + return _Plan( + record=OrphanFenceRecord( + max(newest.epoch if newest else 0, stored[0].epoch if stored else 0) + 1, + str(uuid.uuid4()), + previous.token if previous else None, + format_row_instant(datetime.now(timezone.utc)), + ), + entries=entries, + previous_epoch=newest.epoch if newest else None, + ) + + claimed = await _claim_orphan_fence(store, binding_id, scope, False, plan) + if claimed.kind != "claimed" or claimed.record is None: + lost = claimed.kind == "lost" and claimed.record is not None + raise ReportingRowStoreError( + "STATE_UNAVAILABLE", + ( + f"another database claimed epoch {claimed.record.epoch} while this takeover " + "ran; run it again" + if lost and claimed.record is not None + else "the takeover was refused" + ), + ) + taken.append( + ReportingOrphanFenceTakeoverV1( + tuple(binding_ids), claimed.record.epoch, claimed.previous_epoch + ) + ) + return taken + + +# -- scheduling ------------------------------------------------------------------------------ + + +def _idle_result(options: ReportingOrphanCollectionOptionsV1) -> ReportingOrphanCollectionResultV1: + return ReportingOrphanCollectionResultV1(dry_run=options.dry_run) + + +async def run_orphan_collection_pass( + store: PgReportingLedgerStore, + options: ReportingOrphanCollectionOptionsV1, + state: ReportingOrphanCollectionStateV1, +) -> ReportingOrphanCollectionResultV1: + """One scheduled pass: resumes the listing where the last one stopped and honours the interval. + + ``state`` is owned by the caller and updated in place, so a large listing resumes where the + last pass stopped and ``interval_seconds`` is honoured. + """ + if state.next_cycle_at is not None and time.monotonic() < state.next_cycle_at: + # Between full listings: nothing to do this pass. + return _idle_result(options) + outcome = await collect_orphaned_row_objects( + store, + dry_run=options.dry_run, + acknowledge_exclusive_namespace=options.acknowledge_exclusive_namespace, + min_age_seconds=options.min_age_seconds, + limit=options.limit, + include_read_only=options.include_read_only, + cursors=dict(state.cursors), + skip_bindings=list(state.completed), + ) + state.cursors = dict(outcome.cursors) + # A finished, clean cycle rests for the interval; anything else carries on next pass, without + # listing again the bindings that already finished. (A delete that keeps failing does not stop + # the rest: it is tried again on the next listing.) + if not outcome.cursors and not any(item.status == "failed" for item in outcome.bindings): + state.completed = [] + # A fenced binding looks again within the hour, so applying the migration or taking the + # namespace over does not leave collection idle for the rest of a day. + rest = options.interval_seconds + if any(item.status == "fenced" for item in outcome.bindings): + rest = min(rest, FENCED_RECHECK_SECONDS) + state.next_cycle_at = time.monotonic() + rest + else: + state.completed = list(dict.fromkeys([*state.completed, *outcome.completed])) + state.next_cycle_at = None + return outcome diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/orphan_fence.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/orphan_fence.py new file mode 100644 index 0000000000..db2c984a94 --- /dev/null +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/orphan_fence.py @@ -0,0 +1,323 @@ +"""The orphan collector's ownership fence (adcp-client#3593, adcp-client-python#1829). + +The collector deletes objects under ``{prefix}/{namespace_key}/`` that no ledger record names. +A clone or point-in-time restore of the ledger database carries the same configuration, +installation identity and namespace key, but an older set of references, so its collector would +delete the live database's newer objects. Nothing inside the database can tell the two apart; the +object store they share can. + +The fence is a chain of create-only objects, one per epoch, under the binding's owned prefix:: + + {prefix}/{namespace_key}/_collector-fence/0000000000000007.json + +Each holds ``{v, namespace_key, epoch, token, previous_token, claimed_at}``. The highest epoch is +the current claim. The database records the ``(epoch, token)`` it last claimed in +``adcp_reporting_orphan_collector_fences``. A real pass deletes only when the highest epoch in the +store is exactly the claim this database recorded, then claims the next epoch (create-only, so +exactly one claimant wins) before it deletes anything. Every other state refuses with a cause. A +database that was copied before the original's last claim sees a higher epoch and refuses; so does +a restore, until an operator calls ``take_over_orphan_collector_fence``. + +This module is the pure part: the decision table, the key and record formats and the scope. The +I/O and the transaction live in :mod:`adcp_reporting_storage.ledger.orphan_collector`. The bytes +and the scope hash are the JavaScript SDK's, so either SDK reads the other's fence. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Literal + +import rfc8785 + +from adcp_reporting_storage.ledger.row_storage_object import is_row_instant + +__all__ = [ + "ORPHAN_FENCE_DIRECTORY", + "ORPHAN_FENCE_MAX_BYTES", + "OrphanFenceDecision", + "OrphanFenceEntry", + "OrphanFenceRecord", + "OrphanFenceRow", + "ReportingOrphanFenceCauseV1", + "ReportingOrphanFenceRefusalV1", + "decide_orphan_fence", + "encode_orphan_fence_record", + "is_orphan_fence_key", + "orphan_fence_directory", + "orphan_fence_epoch_of_key", + "orphan_fence_key", + "orphan_fence_scope", + "parse_orphan_fence_record", +] + +#: Object-store directory (under the owned prefix) that holds the fence chain. +ORPHAN_FENCE_DIRECTORY = "_collector-fence" +#: Fence objects are tiny; anything larger is not one. +ORPHAN_FENCE_MAX_BYTES = 4096 + +_EPOCH_DIGITS = 16 +_UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") +_KEY_NAME = re.compile(rf"([0-9]{{{_EPOCH_DIGITS}}})\.json") +_FIELDS = frozenset({"claimed_at", "epoch", "namespace_key", "previous_token", "token", "v"}) + +#: Why a binding's collector refused to delete. +#: +#: * ``claimed_elsewhere``: this database never claimed the namespace, but the object store holds +#: a claim. A clone that has not collected before, a database whose fence table was lost, or +#: another installation. +#: * ``fence_advanced``: the object store holds a newer claim than this database recorded. Another +#: database (usually the live one this was cloned from) has collected since this database's +#: state was taken. +#: * ``token_mismatch``: the object store holds this database's epoch under a different token. Two +#: databases claimed the same epoch; the other one won. +#: * ``fence_missing``: this database recorded a claim and the object store holds none: the bucket +#: or prefix was reset or is a different one. +#: * ``fence_regressed``: the object store holds an older claim than this database recorded: a +#: database restored from a newer state, or a bucket restored from an older one. +#: * ``fence_unreadable``: the fence objects are malformed, oversized, duplicated or name another +#: namespace. +#: * ``claim_lost``: this pass tried to claim the next epoch and another database got there first. +#: * ``migration_required``: the fence table does not exist; apply ``reporting_row_storage.sql``. +ReportingOrphanFenceCauseV1 = Literal[ + "claimed_elsewhere", + "fence_advanced", + "token_mismatch", + "fence_missing", + "fence_regressed", + "fence_unreadable", + "claim_lost", + "migration_required", +] + + +@dataclass(frozen=True, slots=True) +class ReportingOrphanFenceRefusalV1: + """Why a binding was fenced, reported on its collection outcome.""" + + cause: ReportingOrphanFenceCauseV1 + #: What to check, and how to take the namespace over if this database really is its only owner. + detail: str + #: Highest epoch found in the object store, when readable. + fence_epoch: int | None = None + #: Epoch this database last recorded, when it has one. + recorded_epoch: int | None = None + + +@dataclass(frozen=True, slots=True) +class OrphanFenceEntry: + """One fence object found by a listing.""" + + epoch: int + object_ref: str + native_version_ref: str + + +@dataclass(frozen=True, slots=True) +class OrphanFenceRecord: + """The contents of one fence object.""" + + epoch: int + token: str + previous_token: str | None + claimed_at: str + + +@dataclass(frozen=True, slots=True) +class OrphanFenceRow: + """What this database recorded for a fence scope.""" + + epoch: int + token: str + previous_token: str | None + + +@dataclass(frozen=True, slots=True) +class OrphanFenceDecision: + """``claim``: this database may collect. A real pass claims ``epoch`` first (the next epoch + after its own claim, or 1 when the namespace was never claimed); a read-only check just + proceeds. ``complete``: this database recorded a claim and crashed or failed before its object + was written: write it now. ``fenced``: refuse, with ``refusal``.""" + + action: Literal["claim", "complete", "fenced"] + epoch: int | None = None + previous_token: str | None = None + refusal: ReportingOrphanFenceRefusalV1 | None = None + + +_TAKEOVER_HINT = ( + "If this database is the only one that owns this namespace (for example after restoring it " + "from a backup, and the original is gone), call " + "take_over_orphan_collector_fence(acknowledge_previous_owner_gone=True). Otherwise leave " + "orphan collection off on this copy." +) + + +def decide_orphan_fence( + row: OrphanFenceRow | None, latest: tuple[int, str] | None +) -> OrphanFenceDecision: + """Decide, from what this database recorded and what the object store holds, whether it may + collect. Pure: the caller does the I/O and the transaction. + + ``latest`` is the highest ``(epoch, token)`` in the object store, or ``None`` when it holds no + fence. + """ + + def refuse(cause: ReportingOrphanFenceCauseV1, detail: str) -> OrphanFenceDecision: + return OrphanFenceDecision( + "fenced", + refusal=ReportingOrphanFenceRefusalV1( + cause, + f"{detail}. {_TAKEOVER_HINT}", + latest[0] if latest else None, + row.epoch if row else None, + ), + ) + + if row is None: + if latest is None: + return OrphanFenceDecision("claim", epoch=1) + return refuse( + "claimed_elsewhere", + f"the namespace is already claimed (epoch {latest[0]}) by a database this one has no " + "record of: it is a clone or restore of that database, or another installation that " + "shares the namespace", + ) + if latest is None: + # An unconfirmed first claim: this database recorded epoch 1 and did not write the object. + if row.epoch == 1 and row.previous_token is None: + return OrphanFenceDecision("complete") + return refuse( + "fence_missing", + f"this database recorded epoch {row.epoch} and the object store holds no fence: the " + "bucket or prefix was reset or is a different one", + ) + latest_epoch, latest_token = latest + if latest_epoch == row.epoch: + if latest_token == row.token: + return OrphanFenceDecision("claim", epoch=row.epoch + 1, previous_token=row.token) + return refuse( + "token_mismatch", + f"epoch {row.epoch} in the object store belongs to another claim: two databases " + "claimed the same epoch and the other one won", + ) + if latest_epoch > row.epoch: + return refuse( + "fence_advanced", + f"the object store holds epoch {latest_epoch} and this database recorded epoch " + f"{row.epoch}: another database that shares this namespace has collected since this " + "database was copied or restored", + ) + # latest_epoch < row.epoch. One behind with the recorded previous token is an unconfirmed + # claim of this database (it wrote the intent and not the object); anything else is a + # regression. + if ( + latest_epoch == row.epoch - 1 + and row.previous_token is not None + and latest_token == row.previous_token + ): + return OrphanFenceDecision("complete") + return refuse( + "fence_regressed", + f"the object store holds epoch {latest_epoch}, older than the epoch {row.epoch} this " + "database recorded: the database was restored from a newer state than the bucket, or " + "the bucket from an older one", + ) + + +def orphan_fence_directory(owned_prefix: str) -> str: + """The listing prefix of the fence chain under a binding's owned prefix.""" + return f"{owned_prefix}{ORPHAN_FENCE_DIRECTORY}/" + + +def orphan_fence_key(owned_prefix: str, epoch: int) -> str: + """Object key of one epoch's fence object under ``{prefix}/{namespace_key}/``.""" + return f"{orphan_fence_directory(owned_prefix)}{epoch:0{_EPOCH_DIGITS}d}.json" + + +def is_orphan_fence_key(owned_prefix: str, key: str) -> bool: + """Whether a listed key is part of the fence chain.""" + return key.startswith(orphan_fence_directory(owned_prefix)) + + +def orphan_fence_epoch_of_key(owned_prefix: str, key: str) -> int | None: + """The epoch a fence key names, or ``None`` for any other key under the fence directory.""" + directory = orphan_fence_directory(owned_prefix) + if not key.startswith(directory): + return None + match = _KEY_NAME.fullmatch(key[len(directory) :]) + if match is None: + return None + epoch = int(match.group(1)) + return epoch if epoch >= 1 else None + + +def encode_orphan_fence_record(namespace_key: str, record: OrphanFenceRecord) -> bytes: + """The fence object's bytes: the JSON the JavaScript SDK writes, one line.""" + return ( + json.dumps( + { + "v": 1, + "namespace_key": namespace_key, + "epoch": record.epoch, + "token": record.token, + "previous_token": record.previous_token, + "claimed_at": record.claimed_at, + }, + separators=(",", ":"), + ensure_ascii=False, + ) + + "\n" + ).encode("utf-8") + + +def parse_orphan_fence_record( + data: bytes, *, namespace_key: str, epoch: int +) -> OrphanFenceRecord | None: + """Parse a fence object strictly. + + ``None`` for anything that is not exactly a fence of this namespace and epoch. + """ + if len(data) > ORPHAN_FENCE_MAX_BYTES: + return None + try: + value = json.loads(data.decode("utf-8")) + except (ValueError, UnicodeError): + return None + if not isinstance(value, dict) or set(value) != _FIELDS: + return None + token = value["token"] + previous = value["previous_token"] + if ( + value["v"] != 1 + or type(value["v"]) is not int + or value["namespace_key"] != namespace_key + or type(value["epoch"]) is not int + or value["epoch"] != epoch + or not isinstance(token, str) + or _UUID.fullmatch(token) is None + or not ( + previous is None + or (isinstance(previous, str) and _UUID.fullmatch(previous) is not None) + ) + or not is_row_instant(value["claimed_at"]) + ): + return None + return OrphanFenceRecord(epoch, token, previous, value["claimed_at"]) + + +def orphan_fence_scope( + *, provider: str, location: Mapping[str, str], prefix: str, namespace_key: str +) -> str: + """Identity of one physical fence: the provider, its location, the prefix and the namespace. + + Bindings that share all four share a fence, because a collector listing through either one + reaches the same objects. + """ + return hashlib.sha256( + rfc8785.dumps(["adcp.orphan_fence.v1", provider, dict(location), prefix, namespace_key]) + ).hexdigest() diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/pg.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/pg.py index fc5c442d40..c84109c437 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/pg.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/pg.py @@ -76,7 +76,7 @@ import base64 import hashlib import json -from collections.abc import AsyncIterator, Awaitable, Callable, Sequence +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence from contextlib import asynccontextmanager from contextvars import ContextVar from copy import deepcopy @@ -170,6 +170,11 @@ ReportingChangeKindV1, ReportingChangesPageV1, ) + from adcp_reporting_storage.ledger.orphan_collector import ( + ReportingOrphanCollectionOptionsV1, + ReportingOrphanCollectionResultV1, + ReportingOrphanFenceTakeoverV1, + ) from adcp_reporting_storage.ledger.period_retirement import ReportingPeriodRetirementV1 from adcp_reporting_storage.ledger.row_migration import ReportingRowMigrationBatchV1 from adcp_reporting_storage.ledger.row_retention import ( @@ -251,6 +256,7 @@ def __init__( notifications: bool = False, row_storage: bool | ReportingRowStorageOptionsV1 | None = None, change_feed: bool = False, + orphan_collection: ReportingOrphanCollectionOptionsV1 | None = None, ) -> None: if not PG_AVAILABLE: raise ImportError(_INSTALL_HINT) @@ -264,6 +270,20 @@ def __init__( row_storage if isinstance(row_storage, ReportingRowStorageOptionsV1) else None ) ) + # Opt-in listing collector for row objects the write-intent sweep cannot see, scheduled by + # the production worker. It needs row storage; its position lives in this process. + if orphan_collection is not None: + from adcp_reporting_storage.ledger.orphan_collector import ( + ReportingOrphanCollectionOptionsV1, + ReportingOrphanCollectionStateV1, + ) + + if not isinstance(orphan_collection, ReportingOrphanCollectionOptionsV1): + raise TypeError("orphan_collection must be ReportingOrphanCollectionOptionsV1") + if self._row_storage is None: + raise ValueError("orphan_collection requires row_storage") + self._orphan_state = ReportingOrphanCollectionStateV1() + self._orphan_collection = orphan_collection self._row_sets_known = False self._tombstones_known = False # Opt-in shared host change feed (adcp_reporting_changes), PostgreSQL 13+. @@ -425,8 +445,16 @@ def maintenance_operations(self) -> tuple[tuple[str, Callable[[], Awaitable[Any] operations.append(("period_retirement", lambda: self.retire_expired_periods(limit=20))) if self._change_feed: operations.append(("change_pruning", lambda: self.prune_changes())) + if self._orphan_collection is not None: + operations.append(("orphan_collection", self._run_orphan_collection)) return tuple(operations) + async def _run_orphan_collection(self) -> ReportingOrphanCollectionResultV1: + from adcp_reporting_storage.ledger.orphan_collector import run_orphan_collection_pass + + assert self._orphan_collection is not None + return await run_orphan_collection_pass(self, self._orphan_collection, self._orphan_state) + @asynccontextmanager async def source_publication( self, account_id: str, *, seals: ReportingSealStore | None = None @@ -539,6 +567,72 @@ async def sweep_row_write_intents( self, limit=limit, account_id=account_id, overdue_after_seconds=overdue_after_seconds ) + async def collect_orphaned_row_objects( + self, + *, + min_age_seconds: int | None = None, + limit: int = 1_000, + page_size: int | None = None, + dry_run: bool = False, + include_read_only: bool = False, + bindings: Sequence[str] | None = None, + cursors: Mapping[str, str] | None = None, + skip_bindings: Sequence[str] | None = None, + acknowledge_exclusive_namespace: bool = False, + now: datetime | None = None, + ) -> ReportingOrphanCollectionResultV1: + """Delete row objects that no ledger record or open upload names. + + Opt-in and listing-based: it covers what the write-intent sweep cannot see (an upload + whose ``PUT`` succeeded before its intent recorded it, intents for a binding no running + host configures, intents lost to a database restore). It lists each configured object + binding under ``{prefix}/{namespace_key}/`` and deletes an object only when it is a + well-formed row object, older than ``min_age_seconds`` (default 7 days, at least 1 day), + and no ledger record or open upload names it, under the per-object lock of every binding. + Deleting needs ``acknowledge_exclusive_namespace=True`` and is fenced against a clone or + restore of this database; ``dry_run`` deletes nothing. See + :func:`adcp_reporting_storage.ledger.orphan_collector.collect_orphaned_row_objects`. + """ + from adcp_reporting_storage.ledger.orphan_collector import collect_orphaned_row_objects + + return await collect_orphaned_row_objects( + self, + min_age_seconds=min_age_seconds, + limit=limit, + page_size=page_size, + dry_run=dry_run, + include_read_only=include_read_only, + bindings=bindings, + cursors=cursors, + skip_bindings=skip_bindings, + acknowledge_exclusive_namespace=acknowledge_exclusive_namespace, + now=now, + ) + + async def take_over_orphan_collector_fence( + self, + *, + acknowledge_previous_owner_gone: bool, + bindings: Sequence[str] | None = None, + include_read_only: bool = False, + ) -> list[ReportingOrphanFenceTakeoverV1]: + """Claim the orphan collector's namespace for this database, fencing every other copy out. + + The deliberate step after a restore, or when the database that held the fence is gone for + good. Stop the previous owner's collector first. See + :func:`adcp_reporting_storage.ledger.orphan_collector.take_over_orphan_collector_fence`. + """ + from adcp_reporting_storage.ledger.orphan_collector import ( + take_over_orphan_collector_fence, + ) + + return await take_over_orphan_collector_fence( + self, + acknowledge_previous_owner_gone=acknowledge_previous_owner_gone, + bindings=bindings, + include_read_only=include_read_only, + ) + async def prune_expired_row_sets( self, *, diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_row_storage.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_row_storage.sql index 1181ce05f3..1fe2a715d4 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_row_storage.sql +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_row_storage.sql @@ -369,3 +369,24 @@ UPDATE adcp_reporting_row_write_intents SELECT 1 FROM jsonb_array_elements(created_objects) AS element(created) WHERE jsonb_typeof(created) = 'object' AND created ? 'objectKey' ); + +-- The orphan collector's ownership fence (adcp-client#3593, adcp-client-python#1829): one row per +-- fence, recording the (epoch, token) this database last claimed in the object store under +-- {prefix}/{namespace_key}/_collector-fence/. collect_orphaned_row_objects deletes only while the +-- object store's highest fence epoch is exactly the claim recorded here, so a clone or restore of +-- this database that the original has since out-claimed refuses to delete. The same table and +-- columns the JavaScript SDK ships, so either SDK reads the other's claim. +-- +-- Deliberately outside the required-schema manifest and REPORTING_ROW_STORAGE_TABLES: the collector +-- is opt-in, so a database that never enables it needs nothing from this table. Until it exists a +-- collector pass refuses every binding as fenced (migration_required) and deletes nothing. +CREATE TABLE IF NOT EXISTS adcp_reporting_orphan_collector_fences ( + fence_scope TEXT COLLATE "C" PRIMARY KEY CHECK (fence_scope ~ '^[0-9a-f]{64}$'), + epoch BIGINT NOT NULL CHECK (epoch >= 1), + token TEXT COLLATE "C" NOT NULL + CHECK (token ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'), + previous_token TEXT COLLATE "C" + CHECK (previous_token IS NULL + OR previous_token ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'), + claimed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() +); diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage.py index a7415ddb2d..af9f32f31d 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage.py @@ -53,18 +53,35 @@ ReportingRowStoreError, ReportingRowStoreErrorCode, ) +from adcp_reporting_storage.ledger.orphan_fence import ( + ORPHAN_FENCE_MAX_BYTES, + OrphanFenceEntry, + OrphanFenceRecord, + encode_orphan_fence_record, + is_orphan_fence_key, + orphan_fence_directory, + orphan_fence_epoch_of_key, + orphan_fence_key, + orphan_fence_scope, + parse_orphan_fence_record, +) from adcp_reporting_storage.ledger.row_storage_object import ( REPORTING_ROW_BINDING_ID_MAX_LENGTH, REPORTING_ROW_CREDENTIAL_REF_MAX_LENGTH, REPORTING_ROW_DEFAULT_KEY_TEMPLATE, + REPORTING_ROW_OBJECT_REF_MAX_LENGTH, ReportingRowObjectContextV1, ReportingRowObjectDeleteInputV1, ReportingRowObjectGetInputV1, + ReportingRowObjectListEntryV1, + ReportingRowObjectListInputV1, ReportingRowObjectProviderV1, ReportingRowObjectPutInputV1, ReportingRowObjectStatInputV1, assert_reporting_row_key_template_v1, assert_reporting_row_prefix_v1, + is_row_instant, + is_row_object_key_shape, render_reporting_row_object_key_v1, ) from adcp_reporting_storage.ledger.row_storage_provider_io import is_row_identifier @@ -373,6 +390,28 @@ def _inflate(physical: bytes, compression: Compression, byte_count: int) -> byte return None +@dataclass(frozen=True, slots=True) +class ReportingObjectBindingSummaryV1: + """An object binding this host configures, as the orphan collector sees it.""" + + id: str + read_only: bool + #: Whether the binding's provider implements ``list``. + listable: bool + + +@dataclass(frozen=True, slots=True) +class ReportingRowObjectListingV1: + """One page of the objects under a binding's owned prefix, and nothing else.""" + + owned_prefix: str + objects: tuple[ReportingRowObjectListEntryV1, ...] + #: Entries dropped: outside the owned prefix, malformed, or left out by the provider as + #: unaddressable. + foreign: int + next_page_token: str | None = None + + class ReportingRowStorageV1: """Reads and writes revision row sets over a caller-owned connection. @@ -1052,6 +1091,321 @@ async def delete_objects(self, binding_id: str, objects: Sequence[tuple[str, str self._options.write_deadline_seconds, ) + # -- listing and the collector fence ---------------------------------- + + def object_binding_ids(self) -> list[str]: + """The object bindings this host configures, read-only ones included.""" + return [ + binding_id + for binding_id, binding in self._bindings.items() + if isinstance(binding, ReportingObjectRowBindingV1) + ] + + def object_binding_summaries(self) -> list[ReportingObjectBindingSummaryV1]: + """Each object binding: whether it is ``read_only`` and whether its provider can list.""" + return [ + ReportingObjectBindingSummaryV1( + binding_id, + binding.read_only, + callable(getattr(self._providers.get(binding.provider), "list", None)), + ) + for binding_id, binding in self._bindings.items() + if isinstance(binding, ReportingObjectRowBindingV1) + ] + + def _lister_binding( + self, binding_id: str + ) -> tuple[ReportingObjectRowBindingV1, ReportingRowObjectProviderV1, Any]: + """The binding, its provider and the provider's ``list``; refuses one that cannot list.""" + if self._installation is None: + raise ReportingRowStoreError("STATE_UNAVAILABLE", "row storage is not ready") + binding = self._bindings.get(binding_id) + if not isinstance(binding, ReportingObjectRowBindingV1): + raise ReportingRowStoreError( + "STATE_UNAVAILABLE", f"row binding {binding_id} is not configured for objects" + ) + provider = self._providers[binding.provider] + lister = getattr(provider, "list", None) + if not callable(lister): + raise ReportingRowStoreError( + "INVALID_INPUT", f"row binding {binding_id}'s provider cannot list objects" + ) + return binding, provider, lister + + async def list_objects( + self, binding_id: str, *, page_size: int, page_token: str | None = None + ) -> ReportingRowObjectListingV1: + """One page of the objects under a binding's owned prefix, ``{prefix}/{namespace_key}/``. + + An entry the provider returns outside it, or one that is not a well-formed object + reference, is dropped and counted in ``foreign`` instead of being returned. The collector's + own fence chain lives under the owned prefix; it is not a row object and not foreign. + Raises ``INVALID_INPUT`` when the binding's provider has no ``list``. + """ + binding, _, lister = self._lister_binding(binding_id) + assert self._installation is not None + owned_prefix = f"{binding.prefix}/{self._installation[1]}/" + page = await self._provider_call( + lister( + ReportingRowObjectListInputV1( + binding.location, owned_prefix, page_size, page_token, binding.credential_ref + ), + ReportingRowObjectContextV1(self._options.read_deadline_seconds), + ), + self._options.read_deadline_seconds, + ) + if page is None or not isinstance(getattr(page, "objects", None), (tuple, list)): + raise ReportingRowStoreError( + "PROVIDER_UNAVAILABLE", "provider returned an invalid listing" + ) + token = getattr(page, "next_page_token", None) + if token is not None and (not isinstance(token, str) or not token): + raise ReportingRowStoreError( + "PROVIDER_UNAVAILABLE", "provider returned an invalid page token" + ) + if len(page.objects) > page_size: + raise ReportingRowStoreError( + "PROVIDER_UNAVAILABLE", "provider returned more objects than the page size" + ) + skipped = getattr(page, "skipped", 0) + foreign = skipped if type(skipped) is int and skipped > 0 else 0 + objects: list[ReportingRowObjectListEntryV1] = [] + for entry in page.objects: + object_ref = getattr(entry, "object_ref", None) + if isinstance(object_ref, str) and is_orphan_fence_key(owned_prefix, object_ref): + continue + version = getattr(entry, "native_version_ref", None) + size = getattr(entry, "size_bytes", None) + if ( + not is_row_object_key_shape(object_ref, REPORTING_ROW_OBJECT_REF_MAX_LENGTH) + or not isinstance(object_ref, str) + or not object_ref.startswith(owned_prefix) + or not isinstance(version, str) + or not 1 <= len(version) <= _NATIVE_VERSION_REF_MAX_LENGTH + or type(size) is not int + or size < 0 + or not is_row_instant(getattr(entry, "created_at", None)) + ): + foreign += 1 + continue + objects.append(entry) + return ReportingRowObjectListingV1(owned_prefix, tuple(objects), foreign, token) + + async def collector_fence_location(self, binding_id: str) -> tuple[str, str, str]: + """``(scope, directory, namespace_key)`` of an object binding's collector fence. + + ``scope`` keys the database row; ``directory`` holds the chain in the object store. + Bindings that share provider, location, prefix and installation share a fence. Raises + ``STATE_UNAVAILABLE`` for a binding that is not an object binding and ``INVALID_INPUT`` + when its provider cannot list (the fence is read by listing). + """ + binding, provider, _ = self._lister_binding(binding_id) + assert self._installation is not None + namespace_key = self._installation[1] + return ( + orphan_fence_scope( + # The provider's own name, not this host's registry key: two registry entries of + # one kind that reach the same bucket and prefix are one physical fence. + provider=provider.name, + location=binding.location, + prefix=binding.prefix, + namespace_key=namespace_key, + ), + orphan_fence_directory(f"{binding.prefix}/{namespace_key}/"), + namespace_key, + ) + + async def list_collector_fence( + self, binding_id: str, *, tolerant: bool = False + ) -> list[OrphanFenceEntry]: + """Every fence object of a binding's namespace, ordered by epoch. + + Keys under the fence directory that are not an epoch name are ignored. Two live versions + of one epoch's key (a versioned store after a manual delete and re-create) are ambiguous + and fail closed with ``ROWS_INTEGRITY_FAILED``, as does a chain too long to be one this + collector wrote (it prunes to two objects). ``tolerant`` is for a takeover, which must be + able to supersede a damaged chain: every version of every epoch is returned, so it can all + be removed, and the page bound is wider. + """ + _, directory, _ = await self.collector_fence_location(binding_id) + binding, _, lister = self._lister_binding(binding_id) + assert self._installation is not None + owned_prefix = f"{binding.prefix}/{self._installation[1]}/" + entries: dict[int, OrphanFenceEntry] = {} + versions: list[OrphanFenceEntry] = [] + page_size = 50 + token: str | None = None + page_number = 0 + while True: + if page_number >= (200 if tolerant else 20): + raise ReportingRowStoreError( + "ROWS_INTEGRITY_FAILED", "the collector fence directory holds too many objects" + ) + page_number += 1 + listed = await self._provider_call( + lister( + ReportingRowObjectListInputV1( + binding.location, directory, page_size, token, binding.credential_ref + ), + ReportingRowObjectContextV1(self._options.read_deadline_seconds), + ), + self._options.read_deadline_seconds, + ) + if ( + listed is None + or not isinstance(getattr(listed, "objects", None), (tuple, list)) + or len(listed.objects) > page_size + ): + raise ReportingRowStoreError( + "PROVIDER_UNAVAILABLE", "provider returned an invalid listing" + ) + skipped = getattr(listed, "skipped", 0) + if not tolerant and type(skipped) is int and skipped > 0: + # The provider left out an entry it could not address or age. In a directory that + # holds only this collector's own objects that could be the newest claim, and a + # check cannot tell "no claim" from "a claim I cannot see": fail closed. + raise ReportingRowStoreError( + "ROWS_INTEGRITY_FAILED", "the collector fence listing skipped an object" + ) + for item in listed.objects: + object_ref = getattr(item, "object_ref", None) + version = getattr(item, "native_version_ref", None) + if not isinstance(object_ref, str) or not isinstance(version, str): + continue + if not 1 <= len(version) <= _NATIVE_VERSION_REF_MAX_LENGTH: + continue + epoch = orphan_fence_epoch_of_key(owned_prefix, object_ref) + if epoch is None or object_ref != orphan_fence_key(owned_prefix, epoch): + continue + entry = OrphanFenceEntry(epoch, object_ref, version) + if tolerant: + versions.append(entry) + if epoch in entries: + if tolerant: + continue + raise ReportingRowStoreError( + "ROWS_INTEGRITY_FAILED", + "the collector fence holds two versions of one epoch", + ) + entries[epoch] = entry + following = getattr(listed, "next_page_token", None) + if following is None: + break + if not isinstance(following, str) or not following or following == token: + raise ReportingRowStoreError( + "PROVIDER_UNAVAILABLE", "provider returned an invalid page token" + ) + token = following + return sorted(versions if tolerant else entries.values(), key=lambda item: item.epoch) + + async def read_collector_fence( + self, binding_id: str, entry: OrphanFenceEntry + ) -> OrphanFenceRecord: + """Read and verify one fence object. + + A missing version is ``PROVIDER_UNAVAILABLE`` (it was listed a moment ago, so this is a + race to retry); content that is not exactly a fence of this namespace and epoch is + ``ROWS_INTEGRITY_FAILED``. + """ + binding, provider = self._object_binding(binding_id) + assert self._installation is not None + try: + data = await self._provider_call( + provider.get( + ReportingRowObjectGetInputV1( + binding.location, + entry.object_ref, + entry.native_version_ref, + ORPHAN_FENCE_MAX_BYTES, + None, + binding.credential_ref, + ), + ReportingRowObjectContextV1(self._options.read_deadline_seconds), + ), + self._options.read_deadline_seconds, + ) + except ReportingRowStoreError as error: + # An object over the read cap is the same finding as bad content. + if error.code == "ROWS_INTEGRITY_FAILED": + raise ReportingRowStoreError( + "ROWS_INTEGRITY_FAILED", "the collector fence object is malformed" + ) from None + raise + if data is None: + raise ReportingRowStoreError( + "PROVIDER_UNAVAILABLE", "a collector fence object disappeared while it was read" + ) + record = parse_orphan_fence_record( + data, namespace_key=self._installation[1], epoch=entry.epoch + ) + if record is None: + raise ReportingRowStoreError( + "ROWS_INTEGRITY_FAILED", "the collector fence object is malformed" + ) + return record + + async def put_collector_fence( + self, binding_id: str, record: OrphanFenceRecord + ) -> tuple[bool, OrphanFenceRecord]: + """Create-only write of one epoch's fence object. + + When the key is taken, returns the record already there (verified), so the caller can tell + its own earlier write from another database's claim: ``(created, record)``. + """ + binding, provider = self._object_binding(binding_id) + assert self._installation is not None + installation_id, namespace_key = self._installation + object_ref = orphan_fence_key(f"{binding.prefix}/{namespace_key}/", record.epoch) + put = await self._provider_call( + provider.put_if_absent( + ReportingRowObjectPutInputV1( + binding.location, + object_ref, + encode_orphan_fence_record(namespace_key, record), + "application/x-ndjson", + { + "adcp-installation": installation_id, + "adcp-collector-fence": str(record.epoch), + }, + binding.credential_ref, + ), + ReportingRowObjectContextV1(self._options.write_deadline_seconds), + ), + self._options.write_deadline_seconds, + ) + _assert_native_version_ref(put.native_version_ref) + if put.created: + return True, record + return False, await self.read_collector_fence( + binding_id, OrphanFenceEntry(record.epoch, object_ref, put.native_version_ref) + ) + + async def delete_collector_fence( + self, binding_id: str, entries: Sequence[OrphanFenceEntry] + ) -> None: + """Delete superseded fence objects. Best effort: a failure leaves a stale object that the + next claim prunes.""" + if not isinstance(self._bindings.get(binding_id), ReportingObjectRowBindingV1): + return + binding, provider = self._object_binding(binding_id) + context = ReportingRowObjectContextV1(self._options.write_deadline_seconds) + for entry in entries: + try: + await self._provider_call( + provider.delete( + ReportingRowObjectDeleteInputV1( + binding.location, + entry.object_ref, + entry.native_version_ref, + binding.credential_ref, + ), + context, + ), + self._options.write_deadline_seconds, + ) + except ReportingRowStoreError: + continue + # -- object lifetime -------------------------------------------------- # # Chunks are adopted create-only, so a custom key template that omits an identity @@ -1142,6 +1496,7 @@ async def delete_unreferenced( *, obligation_id: str | None = None, row_set_id: str | None = None, + any_binding: bool = False, ) -> int: """Delete each object nothing outside the operation's scope still needs. @@ -1152,20 +1507,37 @@ async def delete_unreferenced( the reference check and the provider delete happen together, so a concurrent commit adopting the object either already references it (it is kept) or finds it gone and fails closed. Returns the number of objects deleted. + + ``any_binding`` widens both checks to every binding and takes every binding's object lock, + in a fixed order: two bindings can share a bucket and prefix, and a writer adopting an + object takes the lock of its own binding only, so the delete must be serialized with an + adoption through any of them. Writers hold one such lock, so no cycle forms. The orphan + collector deletes this way. """ deleted = 0 for object_ref, native_version_ref in objects: async with pool.connection() as connection, connection.transaction(): - await connection.execute( - "SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", - (self._object_lock_text(binding_id, object_ref),), - ) + lock_bindings = [binding_id] + if any_binding: + known = await ( + await connection.execute( + "SELECT row_binding_id FROM adcp_reporting_row_bindings" + ) + ).fetchall() + lock_bindings = sorted( + {binding_id, *self.object_binding_ids(), *(row[0] for row in known)} + ) + for lock_binding in lock_bindings: + await connection.execute( + "SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", + (self._object_lock_text(lock_binding, object_ref),), + ) referenced = await ( await connection.execute( "(SELECT 1 FROM adcp_reporting_row_chunks chunk" " JOIN adcp_reporting_row_sets row_set" " ON row_set.row_set_id = chunk.row_set_id" - " WHERE row_set.row_binding_id = %(binding)s" + " WHERE (%(any_binding)s::boolean OR row_set.row_binding_id = %(binding)s)" " AND chunk.object_ref = %(key)s" " AND chunk.native_version_ref = %(version)s" " AND row_set.rows_state IN ('live', 'unavailable')" @@ -1175,12 +1547,14 @@ async def delete_unreferenced( " LIMIT 1)" " UNION ALL" " (SELECT 1 FROM adcp_reporting_row_write_intents intent" - " WHERE intent.row_binding_id = %(binding)s AND intent.state = 'open'" + " WHERE (%(any_binding)s::boolean OR intent.row_binding_id = %(binding)s)" + " AND intent.state = 'open'" " AND (intent.created_objects @> %(created)s::jsonb" " OR intent.created_objects @> %(created_legacy)s::jsonb)" " LIMIT 1)", { "binding": binding_id, + "any_binding": any_binding, "key": object_ref, "version": native_version_ref, "obligation": obligation_id, diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_azure.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_azure.py index a2d12e811c..f585a7f2c2 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_azure.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_azure.py @@ -24,6 +24,7 @@ import re import uuid from collections.abc import Mapping +from datetime import datetime from typing import Any, Literal from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError @@ -31,11 +32,15 @@ ReportingRowObjectContextV1, ReportingRowObjectDeleteInputV1, ReportingRowObjectGetInputV1, + ReportingRowObjectListEntryV1, + ReportingRowObjectListInputV1, + ReportingRowObjectListPageV1, ReportingRowObjectProviderV1, ReportingRowObjectPutInputV1, ReportingRowObjectPutResultV1, ReportingRowObjectStatInputV1, ReportingRowObjectStatResultV1, + format_row_instant, ) from adcp_reporting_storage.ledger.row_storage_provider_io import ( assert_location_keys, @@ -366,6 +371,69 @@ def _stat(blob: Any, version: str) -> ReportingRowObjectStatResultV1 | None: raise ReportingRowStoreError("PROVIDER_UNAVAILABLE", "blob metadata is malformed") return ReportingRowObjectStatResultV1(version, size) + async def list( + self, request: ReportingRowObjectListInputV1, context: ReportingRowObjectContextV1 + ) -> ReportingRowObjectListPageV1: + container = _Budgeted( + self._container(request.location, request.credential_ref), context.timeout_seconds + ) + if not isinstance(request.prefix, str) or len(request.prefix) > 1024: + raise ReportingRowStoreError("INVALID_INPUT", "list prefix is invalid") + if type(request.page_size) is not int or request.page_size < 1: + raise ReportingRowStoreError("INVALID_INPUT", "page_size must be a positive integer") + return await asyncio.to_thread(self._list, container, request) + + @staticmethod + def _list( + container: Any, request: ReportingRowObjectListInputV1 + ) -> ReportingRowObjectListPageV1: + # Versioned listing: every (name, version_id) a delete could name, the current version + # included. + pages = container.list_blobs( + name_starts_with=request.prefix, + include=["versions"], + results_per_page=min(request.page_size, 5000), + ).by_page(continuation_token=request.page_token) + page = next(pages, None) + objects: list[ReportingRowObjectListEntryV1] = [] + skipped = 0 + for item in list(page) if page is not None else []: + name = getattr(item, "name", None) + version = getattr(item, "version_id", None) + size = getattr(item, "size", None) + # The later of creation and last modification: an overwrite can move the second + # without the first. + times = [ + moment + for moment in ( + getattr(item, "creation_time", None), + getattr(item, "last_modified", None), + ) + if isinstance(moment, datetime) and moment.tzinfo is not None + ] + # An entry that cannot be addressed or aged is left out: it is never collected, and + # one bad entry must not stop the listing of the rest. A blob written before + # versioning was enabled has no version ID and is never addressable by a recorded + # one. + if ( + not isinstance(name, str) + or not name.startswith(request.prefix) + or not isinstance(version, str) + or not _VERSION_ID.fullmatch(version) + or type(size) is not int + or size < 0 + or not times + ): + skipped += 1 + continue + objects.append( + ReportingRowObjectListEntryV1(name, version, size, format_row_instant(max(times))) + ) + token = getattr(pages, "continuation_token", None) + return ReportingRowObjectListPageV1( + tuple(objects), token if isinstance(token, str) and token else None, skipped + ) + async def delete( self, delete: ReportingRowObjectDeleteInputV1, context: ReportingRowObjectContextV1 ) -> Literal["deleted", "absent"]: diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_gcs.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_gcs.py index 782465687d..2c1bff8e87 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_gcs.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_gcs.py @@ -17,6 +17,7 @@ import asyncio import re from collections.abc import Mapping +from datetime import datetime from typing import Any, Literal from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError @@ -24,11 +25,15 @@ ReportingRowObjectContextV1, ReportingRowObjectDeleteInputV1, ReportingRowObjectGetInputV1, + ReportingRowObjectListEntryV1, + ReportingRowObjectListInputV1, + ReportingRowObjectListPageV1, ReportingRowObjectProviderV1, ReportingRowObjectPutInputV1, ReportingRowObjectPutResultV1, ReportingRowObjectStatInputV1, ReportingRowObjectStatResultV1, + format_row_instant, ) from adcp_reporting_storage.ledger.row_storage_provider_io import ( assert_key, @@ -294,6 +299,68 @@ def _stat( raise ReportingRowStoreError("PROVIDER_UNAVAILABLE", "object metadata is malformed") return ReportingRowObjectStatResultV1(str(generation), size) + async def list( + self, request: ReportingRowObjectListInputV1, context: ReportingRowObjectContextV1 + ) -> ReportingRowObjectListPageV1: + bucket = self._bucket(request.location, request.credential_ref) + if not isinstance(request.prefix, str) or len(request.prefix.encode()) > 1024: + raise ReportingRowStoreError("INVALID_INPUT", "list prefix is invalid") + if type(request.page_size) is not int or request.page_size < 1: + raise ReportingRowStoreError("INVALID_INPUT", "page_size must be a positive integer") + return await asyncio.to_thread(self._list, bucket, request, context.timeout_seconds) + + @staticmethod + def _list( + bucket: Any, request: ReportingRowObjectListInputV1, timeout: float + ) -> ReportingRowObjectListPageV1: + # ``versions=True`` lists every generation, noncurrent ones included, so each + # (key, generation) a delete could name is visible. On a bucket without versioning it + # lists the live objects. + iterator = bucket.list_blobs( + prefix=request.prefix, + versions=True, + max_results=min(request.page_size, 1000), + page_token=request.page_token, + timeout=timeout, + ) + page = next(iter(iterator.pages), None) + objects: list[ReportingRowObjectListEntryV1] = [] + skipped = 0 + for blob in list(page) if page is not None else []: + name = getattr(blob, "name", None) + size = getattr(blob, "size", None) + generation = str(getattr(blob, "generation", "") or "") + created = getattr(blob, "time_created", None) + updated = getattr(blob, "updated", None) + # The later of the generation's creation and last metadata update: an object is + # never reported older than it is. + times = [ + moment + for moment in (created, updated) + if isinstance(moment, datetime) and moment.tzinfo is not None + ] + # An entry that cannot be addressed or aged is left out: it is never collected, and + # one bad entry must not stop the listing of the rest. + if ( + not isinstance(name, str) + or not name.startswith(request.prefix) + or type(size) is not int + or size < 0 + or not times + or not _GENERATION.fullmatch(generation) + ): + skipped += 1 + continue + objects.append( + ReportingRowObjectListEntryV1( + name, generation, size, format_row_instant(max(times)) + ) + ) + token = getattr(iterator, "next_page_token", None) + return ReportingRowObjectListPageV1( + tuple(objects), token if isinstance(token, str) and token else None, skipped + ) + async def delete( self, delete: ReportingRowObjectDeleteInputV1, context: ReportingRowObjectContextV1 ) -> Literal["deleted", "absent"]: diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_object.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_object.py index cfa1876cf2..704d726967 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_object.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_object.py @@ -22,14 +22,18 @@ import uuid from collections.abc import Mapping from dataclasses import dataclass +from datetime import datetime, timezone from pathlib import Path -from typing import Literal, Protocol, runtime_checkable +from typing import Any, Literal, Protocol, runtime_checkable import rfc8785 from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError __all__ = [ + "ReportingRowObjectListEntryV1", + "ReportingRowObjectListInputV1", + "ReportingRowObjectListPageV1", "REPORTING_ROW_BINDING_ID_MAX_LENGTH", "REPORTING_ROW_CREDENTIAL_REF_MAX_LENGTH", "REPORTING_ROW_DEFAULT_KEY_TEMPLATE", @@ -133,6 +137,43 @@ class ReportingRowObjectStatResultV1: size_bytes: int +@dataclass(frozen=True, slots=True) +class ReportingRowObjectListInputV1: + location: Mapping[str, str] + #: Key prefix to list under. The SDK passes the binding's owned prefix, + #: ``{prefix}/{namespace_key}/``. + prefix: str + #: Upper bound on objects in the page. A provider may return fewer and still return a token. + page_size: int + #: Opaque continuation from the previous page; ``None`` for the first page. + page_token: str | None = None + credential_ref: str | None = None + + +@dataclass(frozen=True, slots=True) +class ReportingRowObjectListEntryV1: + """One stored object version found by a listing.""" + + #: AdCP ``object_ref``: the key, as it would be passed to ``get`` and ``delete``. + object_ref: str + #: AdCP ``native_version_ref``: the exact version, as ``put_if_absent`` would have recorded it. + native_version_ref: str + size_bytes: int + #: When this version was written, as an RFC 3339 UTC instant (``...Z``). Never earlier than + #: the write. + created_at: str + + +@dataclass(frozen=True, slots=True) +class ReportingRowObjectListPageV1: + objects: tuple[ReportingRowObjectListEntryV1, ...] + #: Absent on the last page. + next_page_token: str | None = None + #: Entries the provider left out because they cannot be addressed or aged (no usable version, + #: size or write time). They are never collected; one bad entry must not stop the listing. + skipped: int = 0 + + @runtime_checkable class ReportingRowObjectProviderV1(Protocol): """The port a row object store implements. @@ -192,6 +233,21 @@ async def delete( # versions are write-unique, so the same version at the recorded size is the same bytes) # without downloading it under the account lock. A provider without ``stat`` falls back to # a full read there. Never return a size for a different version. + # + # async def list(self, request: ReportingRowObjectListInputV1, context) + # -> ReportingRowObjectListPageV1 + # + # Every stored version whose key starts with ``prefix``, in a stable order, one page at a + # time. The orphan collector (``collect_orphaned_row_objects``) and its ownership fence use + # it; a binding whose provider has no ``list`` is reported ``unsupported`` there. Return each + # version once as ``(object_ref, native_version_ref)`` exactly as ``put_if_absent`` recorded + # it (on a versioned store, noncurrent versions too, never delete markers), the stored size + # and the time the version was written. Return only keys under ``prefix``. A page may be + # short; ``next_page_token`` is absent on the last page. A token is only valid for the same + # prefix and location. Leave out an entry that cannot be addressed or aged and count it in + # ``skipped`` rather than failing the page. The listing must be strongly consistent for keys + # ``put_if_absent`` has already returned (read-after-write): the collector's fence reads the + # newest claim by listing. #: Placeholders allowed in object key templates. @@ -213,6 +269,54 @@ async def delete( _KEY = re.compile(r"^[A-Za-z0-9_.=-]+(?:/[A-Za-z0-9_.=-]+)*$") _PLACEHOLDER = re.compile(r"\{([a-z0-9_]+)\}") +_INSTANT = re.compile( + r"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})(?:\.([0-9]{1,9}))?Z" +) + + +def is_row_object_key_shape( + key: object, max_length: int = REPORTING_ROW_OBJECT_REF_MAX_LENGTH +) -> bool: + """Whether a key has the shape ``render_reporting_row_object_key_v1`` gives every row object. + + The same character set, no empty, ``.`` or ``..`` segment, and within AdCP's ``object_ref`` + bound. ``fullmatch``: ``$`` would also accept a trailing newline. + """ + return ( + isinstance(key, str) + and len(key) <= max_length + and _KEY.fullmatch(key) is not None + and not any(segment in (".", "..") for segment in key.split("/")) + ) + + +def parse_row_instant(value: object) -> datetime | None: + """A strict RFC 3339 UTC instant (``2026-10-01T00:00:00.123Z``) as an aware datetime. + + Anything else, including an offset other than ``Z``, a date-only value or an impossible + date, is ``None``: an object whose write time cannot be read is treated as young, never + deleted on a guess. + """ + if not isinstance(value, str): + return None + match = _INSTANT.fullmatch(value) + if match is None: + return None + fraction = (match.group(2) or "").ljust(6, "0")[:6] + try: + return datetime.fromisoformat(f"{match.group(1)}.{fraction}+00:00") + except ValueError: + return None + + +def is_row_instant(value: object) -> bool: + return parse_row_instant(value) is not None + + +def format_row_instant(moment: datetime) -> str: + """An aware datetime as the millisecond RFC 3339 UTC instant providers report.""" + utc = moment.astimezone(timezone.utc) + return utc.strftime("%Y-%m-%dT%H:%M:%S.") + f"{utc.microsecond // 1000:03d}Z" _REVISION_KEY_REQUIRED = ( @@ -629,6 +733,101 @@ def _stat(target: str, native_version_ref: str) -> ReportingRowObjectStatResultV return None return ReportingRowObjectStatResultV1(version, int(version.rsplit(":", 1)[1])) + async def list( + self, request: ReportingRowObjectListInputV1, context: ReportingRowObjectContextV1 + ) -> ReportingRowObjectListPageV1: + """List objects under ``prefix`` in key order, one page at a time. + + Reads and sorts each directory it walks in full, so keep directories small: it is for + tests and single hosts, not millions of objects per prefix. + """ + if type(request.page_size) is not int or request.page_size < 1: + raise ReportingRowStoreError("INVALID_INPUT", "page_size must be a positive integer") + root = self.roots.get(request.location.get("root", "")) + if root is None: + raise ReportingRowStoreError("UNSAFE_BINDING", "filesystem root is not configured") + if not isinstance(request.prefix, str) or ".." in request.prefix.split("/"): + raise ReportingRowStoreError("INVALID_INPUT", "list prefix is invalid") + return await asyncio.to_thread(self._list, root, request) + + @staticmethod + def _list(root: str, request: ReportingRowObjectListInputV1) -> ReportingRowObjectListPageV1: + after = None if request.page_token is None else tuple(request.page_token.split("/")) + objects: list[ReportingRowObjectListEntryV1] = [] + more = False + + def walk(directory: tuple[str, ...]) -> bool: + nonlocal more + try: + with os.scandir(os.path.join(root, *directory)) as scanner: + # A symlink is neither listed nor followed: a link inside the root must not + # make a key resolve outside it. + entries = sorted( + (entry for entry in scanner if not entry.is_symlink()), + key=lambda entry: entry.name, + ) + except (FileNotFoundError, NotADirectoryError): + return True + except OSError: + raise ReportingRowStoreError( + "PROVIDER_UNAVAILABLE", "filesystem list failed" + ) from None + for entry in entries: + name = entry.name + segments = (*directory, name) + path = entry.path + is_directory = entry.is_dir(follow_symlinks=False) + if after is not None: + head = after[: len(segments)] + if segments < head: + continue + if segments == head and not is_directory and len(segments) == len(after): + continue + if is_directory: + # Keep only subtrees that can hold a key under the prefix. + rendered = "/".join(segments) + "/" + if not rendered.startswith(request.prefix) and not request.prefix.startswith( + rendered + ): + continue + if not walk(segments): + return False + continue + if name.endswith(".tmp") or not entry.is_file(follow_symlinks=False): + continue + key = "/".join(segments) + if not key.startswith(request.prefix): + continue + if len(objects) >= request.page_size: + more = True + return False + try: + stat = os.stat(path) + version = _filesystem_version(path) + birth = _birth_time_ns(path, stat) + except FileNotFoundError: + continue + except OSError: + raise ReportingRowStoreError( + "PROVIDER_UNAVAILABLE", "filesystem list failed" + ) from None + written_ns = max(birth, stat.st_mtime_ns) + objects.append( + ReportingRowObjectListEntryV1( + key, + version, + stat.st_size, + format_row_instant( + datetime.fromtimestamp(written_ns / 1_000_000_000, tz=timezone.utc) + ), + ) + ) + return True + + walk(()) + token = objects[-1].object_ref if more and objects else None + return ReportingRowObjectListPageV1(tuple(objects), token) + async def delete( self, delete: ReportingRowObjectDeleteInputV1, context: ReportingRowObjectContextV1 ) -> Literal["deleted", "absent"]: @@ -785,4 +984,111 @@ def stat_input(version: str) -> ReportingRowObjectStatInputV1: outcome = await provider.delete(delete(recreated.native_version_ref), context) assert outcome == "deleted", "delete did not remove the re-created version" passed.append("write-unique-version") + lister = getattr(provider, "list", None) + if lister is not None: + await _check_listing(lister, location, prefix, credential_ref, provider, context) + passed.append("list") return passed + + +async def _check_listing( + lister: Any, + location: Mapping[str, str], + prefix: str, + credential_ref: str | None, + provider: ReportingRowObjectProviderV1, + context: ReportingRowObjectContextV1, +) -> None: + """The listing contract the orphan collector depends on. + + Every object under the prefix exactly once across pages, each with the version, size and + write time ``put_if_absent`` reports, nothing outside the prefix, and deleted objects gone. + """ + folder = f"{prefix}/conformance/{uuid.uuid4()}/" + bodies = { + f"{folder}a.jsonl": b'{"a":1}\n', + f"{folder}b.jsonl.gz": b'{"b":2}\n{"b":3}\n', + f"{folder}nested/c.jsonl": b'{"c":4}\n', + } + metadata = {"adcp-installation": "conformance", "adcp-intent": "conformance"} + created: dict[str, str] = {} + started = datetime.now(timezone.utc) + + async def list_all(under: str, page_size: int) -> list[ReportingRowObjectListEntryV1]: + found: list[ReportingRowObjectListEntryV1] = [] + token: str | None = None + for _ in range(100): + page = await lister( + ReportingRowObjectListInputV1(location, under, page_size, token, credential_ref), + context, + ) + assert ( + isinstance(page.objects, tuple) and len(page.objects) <= page_size + ), "list returned more objects than page_size" + found.extend(page.objects) + previous, token = token, page.next_page_token + if token is None: + return found + assert ( + isinstance(token, str) and token and token != previous + ), "list returned an invalid or repeated token" + raise AssertionError("list did not finish within 100 pages") + + try: + for key, data in bodies.items(): + put = await provider.put_if_absent( + ReportingRowObjectPutInputV1( + location, + key, + data, + "application/gzip" if key.endswith(".gz") else "application/x-ndjson", + metadata, + credential_ref, + ), + context, + ) + assert put.created, "list: put_if_absent did not create a fresh object" + created[key] = put.native_version_ref + for page_size in (1, 2, 100): + found = await list_all(folder, page_size) + keys = [entry.object_ref for entry in found] + assert len(set(keys)) == len( + keys + ), f"list returned an object twice (page_size {page_size})" + assert set(keys) == set( + bodies + ), f"list did not return exactly the objects under the prefix (page_size {page_size})" + for entry in found: + assert ( + entry.native_version_ref == created[entry.object_ref] + ), "list reported a native version that put_if_absent did not" + assert entry.size_bytes == len( + bodies[entry.object_ref] + ), "list reported a wrong size" + written = parse_row_instant(entry.created_at) + # Object-store clocks differ from this host's; a wide margin still catches a + # missing or zero time. + assert ( + written is not None and abs((written - started).total_seconds()) <= 600 + ), "list did not report when the version was written" + assert len(await list_all(f"{folder}nested/", 10)) == 1, "list ignored the prefix" + assert ( + len(await list_all(f"{folder}zz", 10)) == 0 + ), "list returned objects for an unused prefix" + first = next(iter(created)) + outcome = await provider.delete( + ReportingRowObjectDeleteInputV1(location, first, created[first], credential_ref), + context, + ) + assert outcome == "deleted", "list: delete did not remove the object" + del created[first] + remaining = sorted(entry.object_ref for entry in await list_all(folder, 2)) + assert remaining == sorted(created), "list returned a deleted object, or lost a live one" + finally: + for key, version in created.items(): + try: + await provider.delete( + ReportingRowObjectDeleteInputV1(location, key, version, credential_ref), context + ) + except Exception: # noqa: S110 # nosec B110 + pass diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_s3.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_s3.py index d3d96e4232..9484172ab2 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_s3.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_s3.py @@ -20,9 +20,13 @@ from __future__ import annotations import asyncio +import base64 +import binascii +import json import re import uuid from collections.abc import Mapping +from datetime import datetime from typing import Any, Literal from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError @@ -30,11 +34,15 @@ ReportingRowObjectContextV1, ReportingRowObjectDeleteInputV1, ReportingRowObjectGetInputV1, + ReportingRowObjectListEntryV1, + ReportingRowObjectListInputV1, + ReportingRowObjectListPageV1, ReportingRowObjectProviderV1, ReportingRowObjectPutInputV1, ReportingRowObjectPutResultV1, ReportingRowObjectStatInputV1, ReportingRowObjectStatResultV1, + format_row_instant, ) from adcp_reporting_storage.ledger.row_storage_provider_io import ( assert_key, @@ -458,6 +466,92 @@ def _stat( raise ReportingRowStoreError("PROVIDER_UNAVAILABLE", "object metadata is malformed") return ReportingRowObjectStatResultV1(version, size) + async def list( + self, request: ReportingRowObjectListInputV1, context: ReportingRowObjectContextV1 + ) -> ReportingRowObjectListPageV1: + client, bucket = self._target(request.location, request.credential_ref) + if not isinstance(request.prefix, str) or len(request.prefix.encode()) > 1024: + raise ReportingRowStoreError("INVALID_INPUT", "list prefix is invalid") + if type(request.page_size) is not int or request.page_size < 1: + raise ReportingRowStoreError("INVALID_INPUT", "page_size must be a positive integer") + markers: tuple[str, str] | None = None + if request.page_token is not None: + try: + decoded = json.loads( + base64.urlsafe_b64decode( + request.page_token + "=" * (-len(request.page_token) % 4) + ) + ) + if not isinstance(decoded.get("k"), str) or not isinstance(decoded.get("v"), str): + raise ValueError("invalid") + markers = (decoded["k"], decoded["v"]) + except (ValueError, binascii.Error, UnicodeError, AttributeError): + raise ReportingRowStoreError( + "INVALID_INPUT", "list page token is invalid" + ) from None + return await asyncio.to_thread(self._list, client, bucket, request, markers) + + @staticmethod + def _list( + client: Any, + bucket: str, + request: ReportingRowObjectListInputV1, + markers: tuple[str, str] | None, + ) -> ReportingRowObjectListPageV1: + # Row buckets are versioned and the native version is the VersionId, so list versions: + # each (Key, VersionId) is what a delete would name. Delete markers are not objects. + parameters: dict[str, Any] = { + "Bucket": bucket, + "Prefix": request.prefix, + "MaxKeys": min(request.page_size, 1000), + } + if markers is not None: + parameters["KeyMarker"], parameters["VersionIdMarker"] = markers + output = client.list_object_versions(**parameters) + objects: list[ReportingRowObjectListEntryV1] = [] + skipped = 0 + for version in output.get("Versions") or []: + key = version.get("Key") + version_id = version.get("VersionId") + size = version.get("Size") + modified = version.get("LastModified") + # An entry that cannot be addressed or aged is left out: it is never collected, and + # one bad entry must not stop the listing of the rest. A version written before + # versioning was enabled is ``null`` and is never addressable by a recorded version. + if ( + not isinstance(key, str) + or not key.startswith(request.prefix) + or not isinstance(version_id, str) + or version_id == "null" + or not _VERSION_ID.fullmatch(version_id) + or type(size) is not int + or size < 0 + or not isinstance(modified, datetime) + or modified.tzinfo is None + ): + skipped += 1 + continue + objects.append( + ReportingRowObjectListEntryV1(key, version_id, size, format_row_instant(modified)) + ) + next_key = output.get("NextKeyMarker") + next_version = output.get("NextVersionIdMarker") + token: str | None = None + if output.get("IsTruncated"): + if not isinstance(next_key, str) or not isinstance(next_version, str): + # Ending here would report a partial listing as complete. + raise ReportingRowStoreError( + "PROVIDER_UNAVAILABLE", "object listing is truncated without a marker" + ) + token = ( + base64.urlsafe_b64encode( + json.dumps({"k": next_key, "v": next_version}, separators=(",", ":")).encode() + ) + .rstrip(b"=") + .decode("ascii") + ) + return ReportingRowObjectListPageV1(tuple(objects), token, skipped) + async def delete( self, delete: ReportingRowObjectDeleteInputV1, context: ReportingRowObjectContextV1 ) -> Literal["deleted", "absent"]: diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/migration.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/migration.py index 75cca712ad..f9753f3e63 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/migration.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/migration.py @@ -123,6 +123,9 @@ class _Statement: _STARTUP_ROWS = { "adcp_reporting_change_horizon": "pruned_through_seq = 0 AND pruned_through_xid = '0'", "adcp_reporting_row_bindings": "TRUE", + # An opt-in orphan collector's claim on its bucket namespace. A replacement also drops the + # installation identity, so the namespace it fenced is not this ledger's any more. + "adcp_reporting_orphan_collector_fences": "TRUE", } _DEPENDENT_OBJECTS_STILL_EXIST = "2BP01" _SAVEPOINT = "adcp_reporting_replacement" diff --git a/src/adcp/reporting/production/_diagnostics.py b/src/adcp/reporting/production/_diagnostics.py index 366574dd8e..5bf91843ba 100644 --- a/src/adcp/reporting/production/_diagnostics.py +++ b/src/adcp/reporting/production/_diagnostics.py @@ -44,7 +44,7 @@ def _worker_stopped(*, boundary: _WorkerBoundary) -> None: _MAINTENANCE_OPERATIONS = frozenset( - {"intent_sweep", "row_pruning", "period_retirement", "change_pruning"} + {"intent_sweep", "row_pruning", "period_retirement", "change_pruning", "orphan_collection"} ) #: The only counters a maintenance event may carry: integers, never identifiers. _MAINTENANCE_COUNTERS = frozenset( @@ -58,6 +58,8 @@ def _worker_stopped(*, boundary: _WorkerBoundary) -> None: "stuck", "skipped_unconfigured", "overdue", + "unsupported", + "fenced", } ) diff --git a/src/adcp/reporting/production/service.py b/src/adcp/reporting/production/service.py index d3600c997e..75630545cf 100644 --- a/src/adcp/reporting/production/service.py +++ b/src/adcp/reporting/production/service.py @@ -714,14 +714,17 @@ async def _maintain(self) -> None: # is visible instead of looking like a healthy idle worker. pending = { key: counts[key] - for key in ("protected", "held", "deferred", "skipped_unconfigured") + for key in ("protected", "held", "deferred", "skipped_unconfigured", "unsupported") if counts.get(key) } if pending: _maintenance_pending(operation=name, counts=pending) - # Work nobody is reaching: intents expired past the overdue threshold, or periods that - # keep failing (five or more attempts). The same signals the JavaScript SDK reports. - stalled = {key: counts[key] for key in ("overdue", "stuck") if counts.get(key)} + # Work nobody is reaching: intents expired past the overdue threshold, periods that + # keep failing (five or more attempts), or orphan collection refused by another + # database's claim on its namespace. The same signals the JavaScript SDK reports. + stalled = { + key: counts[key] for key in ("overdue", "stuck", "fenced") if counts.get(key) + } if stalled: _maintenance_stalled(operation=name, counts=stalled) @@ -837,6 +840,8 @@ def _counts(result: Any) -> dict[str, int]: "stuck", "skipped_unconfigured", "overdue", + "unsupported", + "fenced", ): value = getattr(result, name, None) if isinstance(value, (tuple, list)): diff --git a/tests/conformance/reporting/_orphan_support.py b/tests/conformance/reporting/_orphan_support.py new file mode 100644 index 0000000000..92a9c73120 --- /dev/null +++ b/tests/conformance/reporting/_orphan_support.py @@ -0,0 +1,429 @@ +"""Shared fixture for the orphan collector and its ownership fence. + +A real PostgreSQL schema and the filesystem provider, with hooks on the provider: fail a delete, +fail or replace a listing page, make every object look older, or count fence reads. A "clone" is a +point-in-time copy of every table in the live schema, which is how a database clone or restore looks +to the collector: the same installation identity, namespace key, bindings and fence row. +""" + +from __future__ import annotations + +import os +import secrets +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass, field, replace +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import pytest +from adcp_reporting_storage import PgReportingLedgerStore, ReportingRowStorageOptionsV1 +from adcp_reporting_storage.ledger.orphan_collector import ( + ReportingOrphanBindingResultV1, + ReportingOrphanCollectionResultV1, +) +from adcp_reporting_storage.ledger.row_storage import ( + ReportingObjectRowBindingV1, + ReportingPostgresRowBindingV1, +) +from adcp_reporting_storage.ledger.row_storage_object import ( + ReportingRowObjectContextV1, + ReportingRowObjectDeleteInputV1, + ReportingRowObjectListEntryV1, + ReportingRowObjectListInputV1, + ReportingRowObjectListPageV1, + ReportingRowObjectPutInputV1, + create_filesystem_reporting_row_object_provider_v1, + format_row_instant, + parse_row_instant, +) + +from ._generation_support import isolated_reporting_pool +from .test_reporting_row_storage_pg import fetch_all, many_rows, seeded, typed_revision + +DAY = timedelta(days=1) +CONTEXT = ReportingRowObjectContextV1(timeout_seconds=10.0) +LOCATION = {"root": "rows"} +# In foreign-key order. +OWNED_TABLES = ( + "adcp_persistence_installation", + "adcp_reporting_row_bindings", + "adcp_reporting_row_sets", + "adcp_reporting_row_chunks", + "adcp_reporting_chunk_bodies", + "adcp_reporting_row_write_intents", + "adcp_reporting_orphan_collector_fences", +) + + +def later() -> datetime: + """A clock past the age threshold, so every object written by the test is old enough.""" + return datetime.now(timezone.utc) + 8 * DAY + + +class HookedProvider: + """The filesystem provider with test hooks on ``delete`` and ``list``.""" + + name = "filesystem" + ranged_reads = True + + def __init__(self, inner: Any) -> None: + self.inner = inner + self.failing_keys: set[str] = set() + self.list_failures: list[BaseException] = [] + self.delete_calls: list[str] = [] + self.list_calls: list[str] = [] + self.list_override: ( + Callable[ + [ReportingRowObjectListInputV1, Callable[..., Awaitable[Any]]], + Awaitable[ReportingRowObjectListPageV1], + ] + | None + ) = None + self.age_shift: timedelta = timedelta(0) + self.put_failures: list[BaseException] = [] + self.on_put: Callable[[ReportingRowObjectPutInputV1], Awaitable[None]] | None = None + self.on_delete: Callable[[str], Awaitable[None]] | None = None + self.fence_list_failures: list[BaseException] = [] + self.fence_list_override: ReportingRowObjectListPageV1 | None = None + + def validate_location(self, location: Any) -> None: + self.inner.validate_location(location) + + async def probe(self, *args: Any, **kwargs: Any) -> None: + await self.inner.probe(*args, **kwargs) + + async def put_if_absent(self, put: ReportingRowObjectPutInputV1, context: Any) -> Any: + if self.put_failures: + raise self.put_failures.pop(0) + if self.on_put is not None: + await self.on_put(put) + return await self.inner.put_if_absent(put, context) + + async def get(self, *args: Any, **kwargs: Any) -> Any: + return await self.inner.get(*args, **kwargs) + + async def stat(self, *args: Any, **kwargs: Any) -> Any: + return await self.inner.stat(*args, **kwargs) + + async def delete(self, delete: ReportingRowObjectDeleteInputV1, context: Any) -> Any: + if self.on_delete is not None: + await self.on_delete(delete.object_ref) + if delete.object_ref in self.failing_keys: + raise RuntimeError("provider unavailable") + self.delete_calls.append(f"{delete.object_ref}@{delete.native_version_ref}") + return await self.inner.delete(delete, context) + + async def list( + self, request: ReportingRowObjectListInputV1, context: Any + ) -> ReportingRowObjectListPageV1: + self.list_calls.append(request.prefix) + # The hooks aim at the collector's object listing, not at the fence chain it also lists. + collecting = "_collector-fence" not in request.prefix + if not collecting and self.fence_list_failures: + raise self.fence_list_failures.pop(0) + if not collecting and self.fence_list_override is not None: + return self.fence_list_override + if collecting and self.list_failures: + raise self.list_failures.pop(0) + + async def real(page: ReportingRowObjectListInputV1) -> ReportingRowObjectListPageV1: + listed = await self.inner.list(page, context) + if not self.age_shift: + return listed # type: ignore[no-any-return] + shifted = [] + for entry in listed.objects: + moment = parse_row_instant(entry.created_at) + assert moment is not None + shifted.append( + replace(entry, created_at=format_row_instant(moment - self.age_shift)) + ) + return replace(listed, objects=tuple(shifted)) + + if collecting and self.list_override is not None: + return await self.list_override(request, real) + return await real(request) + + +class NoListProvider: + """The same provider without ``list``: a binding that cannot be collected.""" + + name = "filesystem" + ranged_reads = True + + def __init__(self, inner: Any) -> None: + for method in ("validate_location", "probe", "put_if_absent", "get", "stat", "delete"): + setattr(self, method, getattr(inner, method)) + + +@dataclass +class Fixture: + pool: Any + store: PgReportingLedgerStore + root: Path + provider: HookedProvider + namespace_key: str + options: ReportingRowStorageOptionsV1 + obligation: Any = None + revisions: int = 0 + extras: dict[str, Any] = field(default_factory=dict) + + def key(self, name: str, prefix: str = "orph") -> str: + return f"{prefix}/{self.namespace_key}/{name}" + + def on_disk(self, object_ref: str) -> bool: + return (self.root / "rows" / object_ref).exists() + + async def put_object(self, object_ref: str, body: bytes = b'{"a":1}\n') -> str: + created = await self.provider.inner.put_if_absent( + ReportingRowObjectPutInputV1( + LOCATION, + object_ref, + body, + "application/x-ndjson", + {"adcp-installation": "x", "adcp-intent": "y"}, + ), + CONTEXT, + ) + return str(created.native_version_ref) + + async def add_intent( + self, + row_set_id: str, + object_ref: str | None, + native_version_ref: str | None, + *, + binding: str = "objects", + state: str = "open", + legacy_names: bool = False, + ) -> None: + import json + + created = [] + if object_ref is not None: + created = [ + ( + {"objectKey": object_ref, "nativeVersion": native_version_ref} + if legacy_names + else {"object_ref": object_ref, "native_version_ref": native_version_ref} + ) + ] + async with self.pool.connection() as connection: + await connection.execute( + "INSERT INTO adcp_reporting_row_write_intents (row_binding_id, account_id," + " row_set_id, content_sha256, state, owner_token, created_objects, expires_at)" + " VALUES (%s, 'acct_orphans', %s, %s, %s, 'owner', %s::jsonb," + " clock_timestamp() + INTERVAL '1 hour')", + (binding, row_set_id, "e" * 64, state, json.dumps(created)), + ) + + async def commit_revision(self, suffix: str) -> tuple[str, str]: + """Commit an object-bound revision; returns the chunk's ``(object_ref, native_version)``.""" + if self.obligation is None: + self.obligation = await seeded(self.store) + async with self.pool.connection() as connection: + await connection.execute( + "UPDATE reporting_configurations SET lease_worker_id = 'worker-1'," + " lease_expires_at = clock_timestamp() + INTERVAL '10 minutes'" + ) + rows = many_rows(4) + revision = typed_revision(self.obligation, rows, suffix=suffix) + assert await self.store.stage_revision_rows(revision, rows) is True + await self.store.commit_revision(revision, rows) + found = await fetch_all( + self.pool, + "SELECT object_ref, native_version_ref FROM adcp_reporting_row_chunks" + " WHERE row_set_id = %s", + (revision.reporting_revision_id,), + ) + return str(found[0][0]), str(found[0][1]) + + def fence_dir(self) -> Path: + return self.root / "rows" / "orph" / self.namespace_key / "_collector-fence" + + def fence_files(self) -> list[str]: + directory = self.fence_dir() + return sorted(item.name for item in directory.iterdir()) if directory.exists() else [] + + def write_fence( + self, + epoch: int, + *, + token: str | None = None, + previous: str | None = None, + namespace: str | None = None, + raw: bytes | None = None, + ) -> str: + """Put a fence object in the bucket by hand, as another database would have.""" + import uuid + + from adcp_reporting_storage.ledger.orphan_fence import ( + OrphanFenceRecord, + encode_orphan_fence_record, + orphan_fence_key, + ) + + key = orphan_fence_key(f"orph/{self.namespace_key}/", epoch) + path = self.root / "rows" / key + path.parent.mkdir(parents=True, exist_ok=True) + chosen = token or str(uuid.uuid4()) + path.write_bytes( + raw + if raw is not None + else encode_orphan_fence_record( + namespace or self.namespace_key, + OrphanFenceRecord(epoch, chosen, previous, "2026-01-01T00:00:00.000Z"), + ) + ) + return chosen + + async def fence_row(self, pool: Any = None) -> tuple[Any, ...] | None: + rows = await fetch_all( + pool or self.pool, + "SELECT epoch, token, previous_token FROM adcp_reporting_orphan_collector_fences", + ) + return rows[0] if rows else None + + async def collect(self, **overrides: Any) -> ReportingOrphanCollectionResultV1: + arguments: dict[str, Any] = {"now": later(), "acknowledge_exclusive_namespace": True} + arguments.update(overrides) + return await self.store.collect_orphaned_row_objects(**arguments) + + +def binding_result( + result: ReportingOrphanCollectionResultV1, binding_id: str +) -> ReportingOrphanBindingResultV1: + return next(item for item in result.bindings if item.binding_id == binding_id) + + +def row_storage_options(tmp_path: Path, provider: HookedProvider) -> ReportingRowStorageOptionsV1: + return ReportingRowStorageOptionsV1( + deployment_namespace="orphans", + bindings={ + "inline": ReportingPostgresRowBindingV1(), + "objects": ReportingObjectRowBindingV1( + provider="filesystem", location=LOCATION, prefix="orph" + ), + "legacy": ReportingObjectRowBindingV1( + provider="filesystem", location=LOCATION, prefix="legacy", read_only=True + ), + "nolist": ReportingObjectRowBindingV1( + provider="nolist", location=LOCATION, prefix="nolist" + ), + # Shares ``objects``' root and prefix, as a renamed or successor binding does. + "twin": ReportingObjectRowBindingV1( + provider="filesystem", + location=LOCATION, + prefix="orph", + key_template="{prefix}/{namespace_key}/twin/{revision_key}/{content_sha256}/{chunk_index}", + read_only=True, + ), + }, + providers={ + "filesystem": provider, # type: ignore[dict-item] + "nolist": NoListProvider(provider.inner), # type: ignore[dict-item] + }, + select_row_binding=lambda selection: "objects", + ) + + +@asynccontextmanager +async def orphan_fixture(tmp_path: Path, **store_options: Any) -> AsyncIterator[Fixture]: + async with isolated_reporting_pool() as pool: + provider = HookedProvider( + create_filesystem_reporting_row_object_provider_v1(roots={"rows": tmp_path / "rows"}) + ) + options = row_storage_options(tmp_path, provider) + store = PgReportingLedgerStore(pool=pool, row_storage=options, **store_options) + await store.create_schema() + assert store._row_storage is not None + namespace_key = store._row_storage.installation_identity[1] + yield Fixture(pool, store, tmp_path, provider, namespace_key, options) + + +async def clone_schema(pool: Any) -> str: + """Copy every row-storage table into a new schema: a point-in-time clone of the ledger. + + Returns the schema name; ``cloned_store`` opens a store on it. The copy keeps the installation + identity, the namespace key, the bindings and the fence row the live database had when it was + taken. The row-storage DDL is applied first so the clone carries the same triggers and + constraints the catalog check requires. + """ + from adcp_reporting_storage.ledger.row_storage import REPORTING_ROW_STORAGE_DDL_PATH + from psycopg import AsyncConnection, sql + + url = os.environ["ADCP_PG_TEST_URL"] + async with pool.connection() as live: + source = (await (await live.execute("SELECT current_schema()")).fetchone())[0] + target = f"adcp_reporting_clone_{secrets.token_hex(6)}" + async with await AsyncConnection.connect(url, autocommit=True) as admin: + await admin.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(target))) + await admin.execute(sql.SQL("SET search_path TO {}").format(sql.Identifier(target))) + await admin.execute(REPORTING_ROW_STORAGE_DDL_PATH.read_text()) + await admin.execute("DELETE FROM adcp_persistence_installation") + for table in OWNED_TABLES: + await admin.execute( + sql.SQL("INSERT INTO {}.{} SELECT * FROM {}.{}").format( + sql.Identifier(target), + sql.Identifier(table), + sql.Identifier(source), + sql.Identifier(table), + ) + ) + return target + + +@asynccontextmanager +async def cloned_store(fixture: Fixture, schema: str) -> AsyncIterator[PgReportingLedgerStore]: + """A second store on the clone: same configuration, its own database state.""" + url = os.environ["ADCP_PG_TEST_URL"] + from psycopg import AsyncConnection, sql + from psycopg_pool import AsyncConnectionPool + + async with AsyncConnectionPool( + url, + kwargs={ + "options": f"-csearch_path={schema} -cstatement_timeout=15000", + "autocommit": False, + }, + min_size=1, + max_size=4, + open=False, + ) as pool: + await pool.wait(timeout=10) + store = PgReportingLedgerStore(pool=pool, row_storage=fixture.options) + await store.ready_row_storage() + try: + yield store + finally: + async with await AsyncConnection.connect(url, autocommit=True) as admin: + await admin.execute( + sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format(sql.Identifier(schema)) + ) + + +def require_database() -> None: + if not os.environ.get("ADCP_PG_TEST_URL"): + pytest.skip("ADCP_PG_TEST_URL not set — requires real PostgreSQL") + + +async def count_changes(fixture: Fixture) -> int: + rows = await fetch_all(fixture.pool, "SELECT count(*) FROM adcp_reporting_row_write_intents") + return int(rows[0][0]) + + +__all__ = [ + "CONTEXT", + "DAY", + "LOCATION", + "Fixture", + "HookedProvider", + "NoListProvider", + "binding_result", + "clone_schema", + "cloned_store", + "later", + "orphan_fixture", + "ReportingRowObjectListEntryV1", +] diff --git a/tests/conformance/reporting/test_reporting_change_feed_pg.py b/tests/conformance/reporting/test_reporting_change_feed_pg.py index 9d41968d37..5250559552 100644 --- a/tests/conformance/reporting/test_reporting_change_feed_pg.py +++ b/tests/conformance/reporting/test_reporting_change_feed_pg.py @@ -812,13 +812,14 @@ async def apply() -> None: async def test_reapplying_the_changes_migration_upgrades_an_older_install_in_place() -> None: from adcp_reporting_storage.ledger.change_feed import REPORTING_CHANGES_DDL_PATH - from psycopg.errors import CheckViolation + + psycopg = pytest.importorskip("psycopg") async with isolated_reporting_pool() as pool: await PgReportingLedgerStore(pool=pool).create_schema() async with pool.connection() as connection: await connection.execute(PREVIOUS_CHANGES_DDL) - with pytest.raises(CheckViolation): + with pytest.raises(psycopg.errors.CheckViolation): async with pool.connection() as connection: await connection.execute( "INSERT INTO adcp_reporting_feed_consumers" @@ -841,7 +842,7 @@ async def test_reapplying_the_changes_migration_upgrades_an_older_install_in_pla " adcp_reporting_feed_consumers", ) == [("old-account-sink", None, "5")], "a seq-only account consumer is kept" # The deployment feed still needs an xid; the old check is gone, the new one stays. - with pytest.raises(CheckViolation): + with pytest.raises(psycopg.errors.CheckViolation): async with pool.connection() as connection: await connection.execute( "INSERT INTO adcp_reporting_feed_consumers" diff --git a/tests/conformance/reporting/test_reporting_orphan_collector_pg.py b/tests/conformance/reporting/test_reporting_orphan_collector_pg.py new file mode 100644 index 0000000000..497a161f70 --- /dev/null +++ b/tests/conformance/reporting/test_reporting_orphan_collector_pg.py @@ -0,0 +1,469 @@ +"""The opt-in listing collector for orphaned row objects, on PostgreSQL and the filesystem provider. + +Ported from the JavaScript SDK (adcp-client #3291, ``reporting-row-orphan-collector-pg``). +""" + +from __future__ import annotations + +import asyncio +from dataclasses import replace +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import pytest +from adcp_reporting_storage.ledger.row_storage import ReportingRowStoreError +from adcp_reporting_storage.ledger.row_storage_object import ( + ReportingRowObjectListEntryV1, + ReportingRowObjectListInputV1, + ReportingRowObjectListPageV1, +) + +from ._orphan_support import binding_result, orphan_fixture +from .test_reporting_row_storage_pg import fetch_all + + +async def test_deletes_an_orphan_and_keeps_committed_intent_named_and_young_objects( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + committed, _ = await fx.commit_revision("orphans_commit") + # An upload that succeeded and was never recorded anywhere. + orphan = fx.key("acct/orphan.jsonl.gz") + await fx.put_object(orphan) + # Named by an open upload that has not committed yet. + in_flight = fx.key("acct/in-flight.jsonl.gz") + await fx.add_intent("rrev_in_flight", in_flight, await fx.put_object(in_flight)) + young = fx.key("acct/young.jsonl") + await fx.put_object(young) + + everything = [orphan, committed, in_flight, young] + dry = await fx.collect(dry_run=True, bindings=["objects"]) + assert dry.dry_run is True + assert sorted(c.object_ref for c in dry.candidates) == sorted([orphan, young]) + assert {c.binding_id for c in dry.candidates} == {"objects"} + assert dry.would_delete == 2 and dry.deleted == 0 + assert all(fx.on_disk(ref) for ref in everything), "a dry run deletes nothing" + + # At the real clock nothing is old enough. + early = await fx.store.collect_orphaned_row_objects(acknowledge_exclusive_namespace=True) + assert early.deleted == 0 + assert binding_result(early, "objects").too_young == 4 + assert all(fx.on_disk(ref) for ref in everything) + + real = await fx.collect(bindings=["objects"]) + assert real.deleted == 2 + assert not fx.on_disk(orphan) and not fx.on_disk(young) + assert fx.on_disk(committed) and fx.on_disk(in_flight) + assert binding_result(real, "objects").referenced == 2 + assert binding_result(real, "objects").status == "complete" + assert real.completed == ("objects",) + + +async def test_never_touches_keys_outside_the_owned_prefix_or_objects_that_are_not_rows( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + for name in ("a.txt", "b.json", "nested/c.bin"): + await fx.put_object(fx.key(name)) + elsewhere = "orph/not-the-namespace/x.jsonl" + sibling = f"orphan-extra/{fx.namespace_key}/y.jsonl" + for ref in (elsewhere, sibling): + await fx.put_object(ref) + result = await fx.collect(bindings=["objects"]) + assert result.deleted == 0 and result.foreign >= 3 + assert fx.on_disk(elsewhere) and fx.on_disk(sibling) + assert all(fx.on_disk(fx.key(name)) for name in ("a.txt", "b.json", "nested/c.bin")) + + +async def test_skips_read_only_bindings_unless_asked_and_reports_a_provider_that_cannot_list( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + legacy = fx.key("acct/legacy.jsonl", "legacy") + await fx.put_object(legacy) + skipped = await fx.collect() + assert "legacy" in skipped.skipped_read_only + assert binding_result(skipped, "legacy").status == "read_only" + assert fx.on_disk(legacy) + assert skipped.unsupported == ("nolist",) + assert binding_result(skipped, "nolist").status == "unsupported" + assert skipped.fenced == () + + included = await fx.collect(include_read_only=True, bindings=["legacy"]) + assert binding_result(included, "legacy").deleted == 1 + assert not fx.on_disk(legacy) + + +async def test_pages_with_a_per_pass_budget_and_resumes_from_the_returned_cursors( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + refs = [fx.key(f"acct/o{index}.jsonl") for index in range(7)] + for ref in refs: + await fx.put_object(ref) + cursors: dict[str, str] = {} + deleted = 0 + for _ in range(10): + result = await fx.collect(bindings=["objects"], limit=3, page_size=2, cursors=cursors) + deleted += result.deleted + cursors = dict(result.cursors) + if not cursors: + break + assert deleted == 7 and not any(fx.on_disk(ref) for ref in refs) + + +async def test_reports_the_bindings_whose_listing_finished_and_skips_the_ones_it_is_told_to( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + await fx.put_object(fx.key("acct/a.jsonl")) + first = await fx.collect(bindings=["objects", "legacy"], include_read_only=True) + assert set(first.completed) == {"objects", "legacy"} + fx.provider.list_calls.clear() + again = await fx.collect( + bindings=["objects", "legacy"], include_read_only=True, skip_bindings=["objects"] + ) + assert again.completed == ("legacy",) + assert [b.binding_id for b in again.bindings] == ["legacy"] + assert all( + "/legacy/" in call or call.startswith("legacy/") for call in fx.provider.list_calls + ) + + +async def test_a_delete_that_fails_is_counted_and_does_not_stop_the_rest(tmp_path: Path) -> None: + async with orphan_fixture(tmp_path) as fx: + stuck, fine = fx.key("acct/a-stuck.jsonl"), fx.key("acct/b-fine.jsonl") + await fx.put_object(stuck) + await fx.put_object(fine) + fx.provider.failing_keys.add(stuck) + result = await fx.collect(bindings=["objects"]) + assert result.deleted == 1 and result.failed == 1 + assert fx.on_disk(stuck) and not fx.on_disk(fine) + assert binding_result(result, "objects").status == "complete" + # It is retried on the next full listing. + fx.provider.failing_keys.clear() + retried = await fx.collect(bindings=["objects"]) + assert retried.deleted == 1 and not fx.on_disk(stuck) + + +async def test_a_listing_failure_keeps_its_cursor_and_names_the_cause(tmp_path: Path) -> None: + async with orphan_fixture(tmp_path) as fx: + for index in range(4): + await fx.put_object(fx.key(f"acct/o{index}.jsonl")) + partial = await fx.collect(bindings=["objects"], limit=2, page_size=2) + assert binding_result(partial, "objects").status == "partial" + cursor = partial.cursors["objects"] + fx.provider.list_failures.append(ReportingRowStoreError("PROVIDER_UNAVAILABLE")) + # The first page of a resumed cursor fails: the stale cursor is dropped. + failed = await fx.collect(bindings=["objects"], cursors={"objects": cursor}) + outcome = binding_result(failed, "objects") + assert outcome.status == "failed" and outcome.error == "PROVIDER_UNAVAILABLE" + assert "objects" not in failed.cursors + # A failure after the first page keeps the position it reached. + calls = 0 + real_list = fx.provider.list + + async def second_page_fails( + request: ReportingRowObjectListInputV1, real: Any + ) -> ReportingRowObjectListPageV1: + nonlocal calls + calls += 1 + if calls == 2: + raise ReportingRowStoreError("DEADLINE_EXCEEDED") + return await real(request) # type: ignore[no-any-return] + + fx.provider.list_override = second_page_fails + mid = await fx.collect(bindings=["objects"], limit=100, page_size=2) + assert binding_result(mid, "objects").error == "DEADLINE_EXCEEDED" + assert "objects" in mid.cursors + assert real_list is not None + + +async def test_a_record_naming_the_key_at_another_version_or_an_unpruned_row_set_keeps_it( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + ref, version = await fx.commit_revision("named_other_version") + # The same key, now at a different version, is still named by a record at the old one. + await fx.provider.inner.delete( + __import__( + "adcp_reporting_storage.ledger.row_storage_object", fromlist=["x"] + ).ReportingRowObjectDeleteInputV1({"root": "rows"}, ref, version), + __import__( + "adcp_reporting_storage.ledger.row_storage_object", fromlist=["x"] + ).ReportingRowObjectContextV1(), + ) + await fx.put_object(ref) # a new version at the recorded key + result = await fx.collect(bindings=["objects"]) + assert result.deleted == 0 + assert binding_result(result, "objects").referenced == 1 + assert fx.on_disk(ref) + + +async def test_a_row_set_that_is_pruning_or_unavailable_keeps_its_object_and_a_pruned_one_does_not( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + ref, _ = await fx.commit_revision("states") + for state in ("pruning", "unavailable"): + async with fx.pool.connection() as connection: + await connection.execute( + "UPDATE adcp_reporting_row_sets SET rows_state = %s", (state,) + ) + kept = await fx.collect(bindings=["objects"]) + assert kept.deleted == 0 and fx.on_disk(ref), state + async with fx.pool.connection() as connection: + await connection.execute("UPDATE adcp_reporting_row_sets SET rows_state = 'pruned'") + freed = await fx.collect(bindings=["objects"]) + assert freed.deleted == 1 and not fx.on_disk(ref) + + +async def test_an_intent_written_by_an_older_sdk_keeps_its_object(tmp_path: Path) -> None: + async with orphan_fixture(tmp_path) as fx: + ref = fx.key("acct/legacy-intent.jsonl.gz") + await fx.add_intent("rrev_old", ref, await fx.put_object(ref), legacy_names=True) + result = await fx.collect(bindings=["objects"]) + assert result.deleted == 0 and fx.on_disk(ref) + for state in ("sweeping",): + async with fx.pool.connection() as connection: + await connection.execute( + "UPDATE adcp_reporting_row_write_intents SET state = %s", (state,) + ) + # The pre-check is blind to the intent state: an intent in any state names its key. + assert (await fx.collect(bindings=["objects"])).deleted == 0 and fx.on_disk(ref) + + +async def test_a_record_under_another_binding_sharing_the_bucket_and_prefix_keeps_the_object( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + ref = fx.key("acct/shared.jsonl.gz") + version = await fx.put_object(ref) + # An open upload under the twin binding (same bucket and prefix) names the object. + await fx.add_intent("rrev_twin", ref, version, binding="twin") + result = await fx.collect(bindings=["objects"]) + assert result.deleted == 0 and fx.on_disk(ref) + + +async def test_a_hostile_provider_cannot_make_it_delete_outside_the_owned_prefix( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + outside = "other-tenant/data.jsonl" + await fx.put_object(outside) + owned = fx.key("acct/owned.jsonl") + await fx.put_object(owned) + old = datetime(2020, 1, 1, tzinfo=timezone.utc) + stamp = old.strftime("%Y-%m-%dT%H:%M:%S.000Z") + + def entry(ref: Any, **changes: Any) -> Any: + base = ReportingRowObjectListEntryV1(ref, "1:2:3:4", 5, stamp) + return replace(base, **changes) + + hostile = [ + entry(outside), + entry(f"{fx.key('x')}/../../{outside}"), + entry(fx.key("a/./b.jsonl")), + entry(fx.key("a//b.jsonl")), + entry(fx.key("bad key.jsonl")), + entry(fx.key("acct/newline.jsonl\n")), + entry(fx.key("acct/no-version.jsonl"), native_version_ref=""), + entry(fx.key("acct/long-version.jsonl"), native_version_ref="v" * 1025), + entry(fx.key("acct/negative.jsonl"), size_bytes=-1), + entry(fx.key("acct/bad-time.jsonl"), created_at="yesterday"), + entry(fx.key("acct/offset-time.jsonl"), created_at="2020-01-01T00:00:00+02:00"), + entry("x" * 1500 + ".jsonl"), + entry(owned), + ] + + async def hostile_page( + request: ReportingRowObjectListInputV1, real: Any + ) -> ReportingRowObjectListPageV1: + return ReportingRowObjectListPageV1(tuple(hostile), None) + + fx.provider.list_override = hostile_page + result = await fx.collect(bindings=["objects"]) + outcome = binding_result(result, "objects") + assert outcome.status == "complete" + assert outcome.foreign == len(hostile) - 1, "every malformed or foreign entry is dropped" + assert fx.on_disk(outside), "nothing outside the owned prefix is ever touched" + assert all(call.startswith(fx.key("")) for call in fx.provider.delete_calls) + assert len(fx.provider.delete_calls) == 1 + # An entry whose version is not the stored one deletes nothing: the provider pins versions. + assert fx.on_disk(owned) + + +async def test_a_provider_that_repeats_a_token_or_returns_too_many_objects_fails_the_binding( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + await fx.put_object(fx.key("acct/a.jsonl")) + + async def repeat( + request: ReportingRowObjectListInputV1, real: Any + ) -> ReportingRowObjectListPageV1: + return ReportingRowObjectListPageV1((), "same-token") + + fx.provider.list_override = repeat + looped = await fx.collect(bindings=["objects"], limit=100, page_size=10) + outcome = binding_result(looped, "objects") + assert outcome.status == "failed" and outcome.error == "PROVIDER_UNAVAILABLE" + assert "objects" not in looped.cursors, "a repeated token is not a cursor to resume" + + async def too_many( + request: ReportingRowObjectListInputV1, real: Any + ) -> ReportingRowObjectListPageV1: + entry = ReportingRowObjectListEntryV1( + fx.key("a/x.jsonl"), "v", 1, "2020-01-01T00:00:00.000Z" + ) + return ReportingRowObjectListPageV1((entry,) * (request.page_size + 1), None) + + fx.provider.list_override = too_many + many = await fx.collect(bindings=["objects"], page_size=3) + assert binding_result(many, "objects").status == "failed" + assert fx.provider.delete_calls == [] + + +async def test_it_validates_its_inputs_and_requires_the_acknowledgement_to_delete( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + store = fx.store + with pytest.raises(ValueError, match="acknowledge_exclusive_namespace"): + await store.collect_orphaned_row_objects() + for bad in (0, 3600, 86_399, 1.5, "7"): + with pytest.raises(ValueError, match="min_age_seconds"): + await store.collect_orphaned_row_objects( + min_age_seconds=bad, acknowledge_exclusive_namespace=True # type: ignore[arg-type] + ) + for name in ("limit", "page_size"): + for bad in (0, -1, 1.5): + with pytest.raises(ValueError, match=name): + await store.collect_orphaned_row_objects( + dry_run=True, **{name: bad} # type: ignore[arg-type] + ) + with pytest.raises(ValueError, match="not a configured object binding"): + await store.collect_orphaned_row_objects(dry_run=True, bindings=["inline"]) + with pytest.raises(ValueError, match="timezone-aware"): + await store.collect_orphaned_row_objects(dry_run=True, now=datetime(2030, 1, 1)) + # A dry run needs no acknowledgement. + assert (await store.collect_orphaned_row_objects(dry_run=True)).dry_run is True + + +async def test_a_store_without_row_storage_refuses_the_collector() -> None: + from ._generation_support import isolated_reporting_pool + + async with isolated_reporting_pool() as pool: + from adcp_reporting_storage import PgReportingLedgerStore + + store = PgReportingLedgerStore(pool=pool) + with pytest.raises(ReportingRowStoreError) as failure: + await store.collect_orphaned_row_objects(dry_run=True) + assert failure.value.code == "INVALID_INPUT" + + +async def test_the_collector_takes_the_per_object_lock_and_rechecks_under_it( + tmp_path: Path, +) -> None: + """An intent committed while the collector waits on the lock keeps the object.""" + async with orphan_fixture(tmp_path) as fx: + ref = fx.key("acct/raced.jsonl.gz") + version = await fx.put_object(ref) + storage = fx.store._row_storage + assert storage is not None + lock_text = storage._object_lock_text("objects", ref) + holder = await fx.pool.getconn() + try: + await holder.execute( + "SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", (lock_text,) + ) + task = asyncio.create_task(fx.collect(bindings=["objects"])) + await _until_blocked(fx.pool, task) + assert not task.done(), "the collector waits on the object's lock" + # While it waits, an upload that names the object commits. + await holder.execute( + "INSERT INTO adcp_reporting_row_write_intents (row_binding_id, account_id," + " row_set_id, content_sha256, state, owner_token, created_objects, expires_at)" + " VALUES ('objects', 'acct', 'rrev_raced', %s, 'open', 'o', %s::jsonb," + " clock_timestamp() + INTERVAL '1 hour')", + ( + "e" * 64, + f'[{{"object_ref": "{ref}", "native_version_ref": "{version}"}}]', + ), + ) + await holder.commit() + finally: + await fx.pool.putconn(holder) + result = await asyncio.wait_for(task, 20) + # The pre-check ran before the intent existed; the recheck under the lock saw it. + assert result.deleted == 0 and fx.on_disk(ref) + assert binding_result(result, "objects").referenced == 1 + + +async def test_it_serializes_with_an_adoption_through_another_binding_sharing_the_prefix( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + ref = fx.key("acct/adopted-by-twin.jsonl.gz") + version = await fx.put_object(ref) + storage = fx.store._row_storage + assert storage is not None + # A writer adopting through the twin binding holds the twin's lock for this object. + holder = await fx.pool.getconn() + try: + await holder.execute( + "SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", + (storage._object_lock_text("twin", ref),), + ) + task = asyncio.create_task(fx.collect(bindings=["objects"])) + await _until_blocked(fx.pool, task) + assert not task.done(), "the delete waits for an adoption through any binding" + await holder.execute( + "INSERT INTO adcp_reporting_row_write_intents (row_binding_id, account_id," + " row_set_id, content_sha256, state, owner_token, created_objects, expires_at)" + " VALUES ('twin', 'acct', 'rrev_twin', %s, 'open', 'o', %s::jsonb," + " clock_timestamp() + INTERVAL '1 hour')", + ( + "e" * 64, + f'[{{"object_ref": "{ref}", "native_version_ref": "{version}"}}]', + ), + ) + await holder.commit() + finally: + await fx.pool.putconn(holder) + result = await asyncio.wait_for(task, 20) + assert result.deleted == 0 and fx.on_disk(ref) + + +async def _until_blocked(pool: Any, task: asyncio.Task[Any]) -> None: + """Wait until some backend is blocked on an advisory lock, proven with ``pg_locks``.""" + for _ in range(400): + if task.done(): + return + rows = await fetch_all( + pool, + "SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND NOT granted", + ) + if rows[0][0]: + return + await asyncio.sleep(0.025) + pytest.fail("the collector never blocked on the object lock") + + +async def test_dry_run_through_the_store_option_reports_and_deletes_nothing( + tmp_path: Path, +) -> None: + from adcp_reporting_storage.ledger.orphan_collector import ReportingOrphanCollectionOptionsV1 + + async with orphan_fixture( + tmp_path, orphan_collection=ReportingOrphanCollectionOptionsV1(dry_run=True) + ) as fx: + ref = fx.key("acct/maintained.jsonl") + await fx.put_object(ref) + operations = dict(fx.store.maintenance_operations()) + assert "orphan_collection" in operations + # Too young at the real clock: nothing is a candidate yet. + result = await operations["orphan_collection"]() + assert result.dry_run is True and result.would_delete == 0 + assert fx.on_disk(ref) diff --git a/tests/conformance/reporting/test_reporting_orphan_fence_pg.py b/tests/conformance/reporting/test_reporting_orphan_fence_pg.py new file mode 100644 index 0000000000..4159d9d746 --- /dev/null +++ b/tests/conformance/reporting/test_reporting_orphan_fence_pg.py @@ -0,0 +1,650 @@ +"""The orphan collector's ownership fence against a cloned or restored ledger database. + +Ported from the JavaScript SDK (adcp-client #3593, ``reporting-orphan-collector-fence-pg``). A +clone is a point-in-time copy of every row-storage table in the live schema: the same installation +identity, namespace key, bindings and fence row, sharing the live database's bucket. +""" + +from __future__ import annotations + +import asyncio +import json +import time +import uuid +from pathlib import Path +from typing import Any + +import pytest +from adcp_reporting_storage.ledger.orphan_collector import ( + ReportingOrphanCollectionOptionsV1, + ReportingOrphanCollectionStateV1, + run_orphan_collection_pass, +) +from adcp_reporting_storage.ledger.row_storage import ReportingRowStoreError + +from ._orphan_support import ( + HookedProvider, + binding_result, + clone_schema, + cloned_store, + orphan_fixture, +) +from .test_reporting_row_storage_pg import fetch_all + + +def fenced_cause(result: Any, binding_id: str = "objects") -> str | None: + outcome = binding_result(result, binding_id) + return outcome.fence.cause if outcome.fence else None + + +async def test_the_first_real_pass_claims_epoch_one_and_each_later_pass_the_next( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + assert await fx.fence_row() is None + first = await fx.collect(bindings=["objects"]) + assert first.fenced == () + assert fx.fence_files() == ["0000000000000001.json"] + assert (await fx.fence_row())[:1] == (1,) # type: ignore[index] + await fx.collect(bindings=["objects"]) + # A claim keeps its predecessor (an unfinished claim is recognised by it) and prunes the + # rest. + assert fx.fence_files() == ["0000000000000001.json", "0000000000000002.json"] + await fx.collect(bindings=["objects"]) + assert fx.fence_files() == ["0000000000000002.json", "0000000000000003.json"] + row = await fx.fence_row() + assert row is not None and row[0] == 3 + assert row[2] is not None, "a later claim records the token it superseded" + + +async def test_the_fence_chain_is_not_a_row_object_it_is_neither_listed_foreign_nor_deleted( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + await fx.collect(bindings=["objects"]) + assert fx.fence_files() + storage = fx.store._row_storage + assert storage is not None + listing = await storage.list_objects("objects", page_size=100) + assert listing.objects == () and listing.foreign == 0 + result = await fx.collect(bindings=["objects"]) + assert result.deleted == 0 and result.foreign == 0 + assert fx.fence_files(), "the collector never deletes its own fence" + + +async def test_a_dry_run_checks_the_fence_without_claiming_it(tmp_path: Path) -> None: + async with orphan_fixture(tmp_path) as fx: + orphan = fx.key("acct/orphan.jsonl") + await fx.put_object(orphan) + dry = await fx.collect(dry_run=True, bindings=["objects"]) + assert dry.would_delete == 1 and dry.fenced == () + assert fx.fence_files() == [] and await fx.fence_row() is None, "no claim, no object" + # It reports a fenced namespace the same way a real pass does. + fx.write_fence(4) + refused = await fx.collect(dry_run=True, bindings=["objects"]) + assert refused.fenced == ("objects",) + assert fenced_cause(refused) == "claimed_elsewhere" + assert refused.would_delete == 0 + + +async def test_a_clone_taken_before_the_original_last_collected_is_fenced_and_deletes_nothing( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + await fx.collect(bindings=["objects"]) # epoch 1 + schema = await clone_schema(fx.pool) + committed, _ = await fx.commit_revision("clone_live") # written after the copy + await fx.collect(bindings=["objects"]) # the original claims epoch 2 + + async with cloned_store(fx, schema) as clone: + result = await clone.collect_orphaned_row_objects( + now=_later(), acknowledge_exclusive_namespace=True, bindings=["objects"] + ) + assert result.fenced == ("objects",) and result.deleted == 0 + assert fenced_cause(result) == "fence_advanced" + outcome = binding_result(result, "objects") + assert outcome.fence is not None + assert (outcome.fence.fence_epoch, outcome.fence.recorded_epoch) == (2, 1) + assert "take_over_orphan_collector_fence" in outcome.fence.detail + assert fx.on_disk(committed), "the clone cannot delete an object the original wrote" + assert result.cursors == {} + + # Control: the same clone, once it has deliberately taken over, deletes it. The fence + # is the only thing standing between the clone and the live object. + await clone.take_over_orphan_collector_fence(acknowledge_previous_owner_gone=True) + deleted = await clone.collect_orphaned_row_objects( + now=_later(), acknowledge_exclusive_namespace=True, bindings=["objects"] + ) + assert deleted.deleted >= 1 and not fx.on_disk(committed) + + +async def test_a_clone_that_collects_first_claims_the_namespace_and_fences_the_original( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + schema = await clone_schema(fx.pool) # neither side has claimed yet + async with cloned_store(fx, schema) as clone: + first = await clone.collect_orphaned_row_objects( + now=_later(), acknowledge_exclusive_namespace=True, bindings=["objects"] + ) + assert first.fenced == () + original = await fx.collect(bindings=["objects"]) + assert original.fenced == ("objects",) + assert fenced_cause(original) == "claimed_elsewhere", "first claim wins" + + +async def test_a_restore_to_an_older_database_state_is_fenced_until_an_operator_takes_over( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + await fx.collect(bindings=["objects"]) + await fx.collect(bindings=["objects"]) # bucket epoch 2 + async with fx.pool.connection() as connection: + # The restore: the database's own record goes back to epoch 1. + await connection.execute( + "UPDATE adcp_reporting_orphan_collector_fences SET epoch = 1," + " token = %s, previous_token = NULL", + (str(uuid.uuid4()),), + ) + restored = await fx.collect(bindings=["objects"]) + assert fenced_cause(restored) == "fence_advanced" + taken = await fx.store.take_over_orphan_collector_fence( + acknowledge_previous_owner_gone=True + ) + assert [(t.binding_ids, t.epoch, t.previous_epoch) for t in taken] == [(("objects",), 3, 2)] + after = await fx.collect(bindings=["objects"]) + assert after.fenced == () + + +async def test_a_takeover_fences_out_the_previous_owner(tmp_path: Path) -> None: + async with orphan_fixture(tmp_path) as fx: + await fx.collect(bindings=["objects"]) + schema = await clone_schema(fx.pool) + async with cloned_store(fx, schema) as clone: + await clone.take_over_orphan_collector_fence(acknowledge_previous_owner_gone=True) + out = await fx.collect(bindings=["objects"]) + assert fenced_cause(out) == "fence_advanced" + with pytest.raises(ValueError, match="acknowledge_previous_owner_gone"): + await clone.take_over_orphan_collector_fence(acknowledge_previous_owner_gone=False) + + +async def test_a_takeover_validates_its_bindings_and_covers_a_shared_fence_once( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + store = fx.store + for bad, message in ( + (["inline"], "not a configured object binding"), + (["nolist"], "cannot list"), + (["nope"], "not a configured object binding"), + ): + with pytest.raises(ValueError, match=message): + await store.take_over_orphan_collector_fence( + acknowledge_previous_owner_gone=True, bindings=bad + ) + # ``objects`` and its read-only ``twin`` share one bucket and prefix, so one fence. + taken = await store.take_over_orphan_collector_fence( + acknowledge_previous_owner_gone=True, include_read_only=True + ) + shared = [t for t in taken if "objects" in t.binding_ids] + assert len(shared) == 1 and set(shared[0].binding_ids) == {"objects", "twin"} + # Read-only bindings are skipped unless asked for. + default = await store.take_over_orphan_collector_fence(acknowledge_previous_owner_gone=True) + assert all("legacy" not in t.binding_ids for t in default) + only = await store.take_over_orphan_collector_fence( + acknowledge_previous_owner_gone=True, bindings=["legacy"] + ) + assert [t.binding_ids for t in only] == [("legacy",)] + + +async def test_bindings_that_share_a_bucket_and_prefix_share_one_fence_and_one_claim_per_pass( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + result = await fx.collect(bindings=["objects", "twin"], include_read_only=True) + assert result.fenced == () + assert fx.fence_files() == ["0000000000000001.json"], "one claim for both bindings" + rows = await fetch_all( + fx.pool, "SELECT count(*) FROM adcp_reporting_orphan_collector_fences" + ) + assert rows == [(1,)] + + +async def test_a_database_with_no_recorded_claim_refuses_a_namespace_that_already_has_one( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + fx.write_fence(3) + result = await fx.collect(bindings=["objects"]) + assert fenced_cause(result) == "claimed_elsewhere" + outcome = binding_result(result, "objects") + assert outcome.fence is not None and outcome.fence.fence_epoch == 3 + assert outcome.fence.recorded_epoch is None + assert await fx.fence_row() is None, "a refusal never records a claim" + + +async def test_a_recorded_claim_with_no_fence_in_the_bucket_is_refused_as_missing( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + await fx.collect(bindings=["objects"]) + await fx.collect(bindings=["objects"]) + for name in fx.fence_files(): + (fx.fence_dir() / name).unlink() + result = await fx.collect(bindings=["objects"]) + assert fenced_cause(result) == "fence_missing" + assert fx.fence_files() == [], "never silently re-claimed" + + +async def test_a_database_newer_than_its_bucket_is_refused_as_regressed(tmp_path: Path) -> None: + async with orphan_fixture(tmp_path) as fx: + await fx.collect(bindings=["objects"]) + async with fx.pool.connection() as connection: + await connection.execute( + "UPDATE adcp_reporting_orphan_collector_fences SET epoch = 5, token = %s," + " previous_token = %s", + (str(uuid.uuid4()), str(uuid.uuid4())), + ) + result = await fx.collect(bindings=["objects"]) + assert fenced_cause(result) == "fence_regressed" + outcome = binding_result(result, "objects") + assert outcome.fence is not None + assert (outcome.fence.fence_epoch, outcome.fence.recorded_epoch) == (1, 5) + + +async def test_two_claims_of_one_epoch_the_loser_is_fenced_as_a_token_mismatch( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + await fx.collect(bindings=["objects"]) + (fx.fence_dir() / "0000000000000001.json").unlink() + fx.write_fence(1) # another claim of epoch 1, under another token + result = await fx.collect(bindings=["objects"]) + assert fenced_cause(result) == "token_mismatch" + + +async def test_a_lost_race_for_the_next_epoch_is_a_lost_claim(tmp_path: Path) -> None: + async with orphan_fixture(tmp_path) as fx: + await fx.collect(bindings=["objects"]) + raced = False + + async def other_database_claims_first(put: Any) -> None: + nonlocal raced + if "_collector-fence/0000000000000002" in put.object_ref and not raced: + raced = True + fx.write_fence(2) # another database wins the create-only write + + fx.provider.on_put = other_database_claims_first + result = await fx.collect(bindings=["objects"]) + assert fenced_cause(result) == "claim_lost" + outcome = binding_result(result, "objects") + assert outcome.fence is not None and outcome.fence.fence_epoch == 2 + + +@pytest.mark.parametrize( + "damage", + ["garbage", "other_namespace", "oversized", "extra_field", "bad_token", "wrong_epoch"], +) +async def test_malformed_foreign_namespace_or_oversized_fence_objects_fail_closed( + tmp_path: Path, damage: str +) -> None: + async with orphan_fixture(tmp_path) as fx: + await fx.put_object(fx.key("acct/orphan.jsonl")) + good = str(uuid.uuid4()) + if damage == "garbage": + fx.write_fence(1, raw=b"not json\n") + elif damage == "other_namespace": + fx.write_fence(1, namespace="f" * 64) + elif damage == "oversized": + fx.write_fence(1, raw=b"x" * 5000) + elif damage == "extra_field": + fx.write_fence( + 1, + raw=json.dumps( + { + "v": 1, + "namespace_key": fx.namespace_key, + "epoch": 1, + "token": good, + "previous_token": None, + "claimed_at": "2026-01-01T00:00:00.000Z", + "extra": 1, + } + ).encode(), + ) + elif damage == "bad_token": + fx.write_fence(1, token="not-a-uuid") + else: + fx.write_fence(1) + (fx.fence_dir() / "0000000000000001.json").rename( + fx.fence_dir() / "0000000000000002.json" + ) + result = await fx.collect(bindings=["objects"]) + assert fenced_cause(result) == "fence_unreadable", damage + assert result.deleted == 0 and fx.on_disk(fx.key("acct/orphan.jsonl")) + + +async def test_two_versions_of_one_epoch_are_ambiguous_and_fail_closed(tmp_path: Path) -> None: + from adcp_reporting_storage.ledger.row_storage_object import ( + ReportingRowObjectListEntryV1, + ReportingRowObjectListPageV1, + ) + + async with orphan_fixture(tmp_path) as fx: + await fx.collect(bindings=["objects"]) + key = f"orph/{fx.namespace_key}/_collector-fence/0000000000000001.json" + duplicate = ReportingRowObjectListPageV1( + tuple( + ReportingRowObjectListEntryV1(key, f"{index}:1:1:1", 10, "2026-01-01T00:00:00.000Z") + for index in (1, 2) + ), + None, + ) + fx.provider.fence_list_override = duplicate + result = await fx.collect(bindings=["objects"]) + assert fenced_cause(result) == "fence_unreadable" + + +async def test_an_unfinished_claim_is_completed_by_the_next_pass(tmp_path: Path) -> None: + async with orphan_fixture(tmp_path) as fx: + fx.provider.put_failures.append(RuntimeError("bucket unavailable")) + crashed = await fx.collect(bindings=["objects"]) + outcome = binding_result(crashed, "objects") + assert outcome.status == "failed" and outcome.fence is None, "a failure is not a fence" + assert fx.fence_files() == [], "the object was never written" + row = await fx.fence_row() + assert row is not None and row[0] == 1, "the intent was committed first" + recovered = await fx.collect(bindings=["objects"]) + assert recovered.fenced == () and binding_result(recovered, "objects").status == "complete" + assert fx.fence_files() == ["0000000000000001.json"] + done = await fx.fence_row() + assert done is not None and done[1] != row[1], "a finished claim mints a fresh token" + + +async def test_a_copy_taken_while_a_claim_was_unfinished_cannot_adopt_the_same_token( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + fx.provider.put_failures.append(RuntimeError("bucket unavailable")) + await fx.collect(bindings=["objects"]) # intent committed, object never written + schema = await clone_schema(fx.pool) # the copy carries the same unfinished intent + original = await fx.collect(bindings=["objects"]) + assert original.fenced == () + async with cloned_store(fx, schema) as clone: + result = await clone.collect_orphaned_row_objects( + now=_later(), acknowledge_exclusive_namespace=True, bindings=["objects"] + ) + assert fenced_cause(result) == "token_mismatch" + assert result.deleted == 0 + + +async def test_two_hosts_of_the_same_database_collecting_at_once_do_not_fence_each_other( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + for index in range(6): + await fx.put_object(fx.key(f"acct/o{index}.jsonl")) + first, second = await asyncio.gather( + fx.collect(bindings=["objects"]), fx.collect(bindings=["objects"]) + ) + assert first.fenced == () and second.fenced == () + assert first.deleted + second.deleted >= 6 + row = await fx.fence_row() + assert row is not None and row[0] == 2 + + +async def test_another_database_claiming_mid_pass_stops_the_deletes_at_the_next_check( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + refs = [fx.key(f"acct/o{index:03d}.jsonl") for index in range(60)] + for ref in refs: + await fx.put_object(ref) + deleted = 0 + + async def other_database_claims(object_ref: str) -> None: + nonlocal deleted + deleted += 1 + if deleted == 10: + fx.write_fence(99) # a newer claim appears while this pass is running + + fx.provider.on_delete = other_database_claims + result = await fx.collect(bindings=["objects"]) + outcome = binding_result(result, "objects") + assert outcome.status == "fenced" and fenced_cause(result) == "fence_advanced" + assert outcome.deleted == 25, "a check before the first delete and every 25 after it" + assert sum(fx.on_disk(ref) for ref in refs) == 35 + assert result.deleted == 25, "a mid-pass refusal keeps what went before" + + +async def test_the_fence_is_claimed_before_the_first_delete_of_a_pass(tmp_path: Path) -> None: + async with orphan_fixture(tmp_path) as fx: + await fx.put_object(fx.key("acct/o.jsonl")) + seen: list[list[str]] = [] + + async def look(object_ref: str) -> None: + seen.append(fx.fence_files()) + + fx.provider.on_delete = look + await fx.collect(bindings=["objects"]) + assert seen == [["0000000000000001.json"]] + + +async def test_without_the_fence_table_a_real_pass_and_a_dry_run_refuse_and_delete_nothing( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + orphan = fx.key("acct/o.jsonl") + await fx.put_object(orphan) + async with fx.pool.connection() as connection: + await connection.execute("DROP TABLE adcp_reporting_orphan_collector_fences") + for dry_run in (False, True): + result = await fx.collect(bindings=["objects"], dry_run=dry_run) + assert fenced_cause(result) == "migration_required" + assert result.deleted == 0 and result.would_delete == 0 and fx.on_disk(orphan) + with pytest.raises(ReportingRowStoreError, match="fence table does not exist"): + await fx.store.take_over_orphan_collector_fence(acknowledge_previous_owner_gone=True) + + +async def test_the_fence_table_is_part_of_the_row_storage_ddl_and_reapplying_it_changes_nothing( + tmp_path: Path, +) -> None: + from adcp_reporting_storage.ledger.row_storage import REPORTING_ROW_STORAGE_DDL_PATH + + async with orphan_fixture(tmp_path) as fx: + await fx.collect(bindings=["objects"]) + before = await fx.fence_row() + async with fx.pool.connection() as connection: + await connection.execute(REPORTING_ROW_STORAGE_DDL_PATH.read_text()) + assert await fx.fence_row() == before + columns = await fetch_all( + fx.pool, + "SELECT column_name FROM information_schema.columns WHERE table_schema =" + " current_schema() AND table_name = 'adcp_reporting_orphan_collector_fences'" + " ORDER BY column_name", + ) + assert [row[0] for row in columns] == [ + "claimed_at", + "epoch", + "fence_scope", + "previous_token", + "token", + ] + + +async def test_a_provider_failure_while_reading_or_writing_the_fence_is_failed_not_fenced( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + await fx.put_object(fx.key("acct/o.jsonl")) + fx.provider.fence_list_failures.append(ReportingRowStoreError("PROVIDER_UNAVAILABLE")) + read = await fx.collect(bindings=["objects"]) + outcome = binding_result(read, "objects") + assert outcome.status == "failed" and outcome.error == "PROVIDER_UNAVAILABLE" + assert read.fenced == () and read.deleted == 0 + fx.provider.put_failures.append(RuntimeError("down")) + write = await fx.collect(bindings=["objects"]) + assert binding_result(write, "objects").status == "failed" and write.fenced == () + ok = await fx.collect(bindings=["objects"]) + assert ok.deleted == 1 + + +async def test_a_failed_claim_keeps_the_resume_position_of_a_listing(tmp_path: Path) -> None: + async with orphan_fixture(tmp_path) as fx: + for index in range(4): + await fx.put_object(fx.key(f"acct/o{index}.jsonl")) + partial = await fx.collect(bindings=["objects"], limit=2, page_size=2) + cursor = partial.cursors["objects"] + fx.provider.fence_list_failures.append(ReportingRowStoreError("PROVIDER_UNAVAILABLE")) + again = await fx.collect( + bindings=["objects"], limit=2, page_size=2, cursors={"objects": cursor} + ) + assert binding_result(again, "objects").status == "failed" + assert again.cursors == {"objects": cursor}, "the listing never started" + + +async def test_a_takeover_recovers_every_refusal_and_moves_past_a_database_ahead_of_its_bucket( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + # claimed_elsewhere + fx.write_fence(3) + assert fenced_cause(await fx.collect(bindings=["objects"])) == "claimed_elsewhere" + await fx.store.take_over_orphan_collector_fence(acknowledge_previous_owner_gone=True) + assert (await fx.collect(bindings=["objects"])).fenced == () + row = await fx.fence_row() + assert row is not None and row[0] >= 4 + # fence_regressed: the database is ahead of its bucket + async with fx.pool.connection() as connection: + await connection.execute( + "UPDATE adcp_reporting_orphan_collector_fences SET epoch = 50, token = %s", + (str(uuid.uuid4()),), + ) + assert fenced_cause(await fx.collect(bindings=["objects"])) == "fence_regressed" + await fx.store.take_over_orphan_collector_fence(acknowledge_previous_owner_gone=True) + assert (await fx.collect(bindings=["objects"])).fenced == () + row = await fx.fence_row() + assert row is not None and row[0] > 50, "a takeover moves past a database that is ahead" + # fence_unreadable + fx.write_fence(row[0] + 1, raw=b"damaged") + assert fenced_cause(await fx.collect(bindings=["objects"])) == "fence_unreadable" + await fx.store.take_over_orphan_collector_fence(acknowledge_previous_owner_gone=True) + assert (await fx.collect(bindings=["objects"])).fenced == () + assert len(fx.fence_files()) == 2 or len(fx.fence_files()) == 1 + + +async def test_a_fenced_clone_refuses_every_binding_that_shares_the_fence_with_one_check( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + fx.write_fence(7) + fx.provider.list_calls.clear() + result = await fx.collect(bindings=["objects", "twin"], include_read_only=True) + assert set(result.fenced) == {"objects", "twin"} + assert result.cursors == {} + # The fence chain was read once for the shared scope, and no object listing began. + listings = fx.provider.list_calls + assert all("_collector-fence" in prefix for prefix in listings), listings + + +async def test_a_fenced_namespace_never_rests_a_whole_interval_in_the_maintenance_cycle( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + options = ReportingOrphanCollectionOptionsV1( + acknowledge_exclusive_namespace=True, interval_seconds=86_400 + ) + state = ReportingOrphanCollectionStateV1() + fx.write_fence(4) + first = await run_orphan_collection_pass(fx.store, options, state) + assert first.fenced == ("objects",) + assert state.next_cycle_at is not None + assert state.next_cycle_at - time.monotonic() <= 3_600 + 1, "looks again within the hour" + # The operator fixes it; the next cycle (the hour elapsing) resumes collection. + await fx.store.take_over_orphan_collector_fence(acknowledge_previous_owner_gone=True) + state.next_cycle_at = time.monotonic() - 1 + resumed = await run_orphan_collection_pass(fx.store, options, state) + assert resumed.fenced == () + assert state.next_cycle_at is not None + assert state.next_cycle_at - time.monotonic() > 3_600, "a clean cycle rests the interval" + + +def _later() -> Any: + from ._orphan_support import later + + return later() + + +def test_the_hook_provider_is_a_real_provider_shape() -> None: + assert HookedProvider.name == "filesystem" + + +async def test_a_listing_that_skipped_a_fence_entry_fails_closed_rather_than_reading_no_claim( + tmp_path: Path, +) -> None: + from adcp_reporting_storage.ledger.row_storage_object import ReportingRowObjectListPageV1 + + async with orphan_fixture(tmp_path) as fx: + await fx.put_object(fx.key("acct/orphan.jsonl")) + # The provider could not address an entry in the fence directory: it might be the newest + # claim, and an empty-looking chain would otherwise read as "never claimed". + fx.provider.fence_list_override = ReportingRowObjectListPageV1((), None, skipped=1) + result = await fx.collect(bindings=["objects"]) + assert fenced_cause(result) == "fence_unreadable" + assert result.deleted == 0 and fx.on_disk(fx.key("acct/orphan.jsonl")) + + +async def test_a_cancelled_claim_leaves_no_advisory_lock_on_a_pooled_connection( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + await fx.collect(bindings=["objects"]) + storage = fx.store._row_storage + assert storage is not None + scope, _, _ = await storage.collector_fence_location("objects") + holder = await fx.pool.getconn() + try: + await holder.execute( + "SELECT pg_advisory_lock(hashtextextended(%s, 0))", (f"adcp.orphan_fence:{scope}",) + ) + task = asyncio.create_task(fx.collect(bindings=["objects"])) + for _ in range(200): + rows = await fetch_all( + fx.pool, + "SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND NOT granted", + ) + if rows[0][0]: + break + await asyncio.sleep(0.025) + task.cancel() + (outcome,) = await asyncio.gather(task, return_exceptions=True) + assert isinstance(outcome, asyncio.CancelledError) + await holder.execute( + "SELECT pg_advisory_unlock(hashtextextended(%s, 0))", + (f"adcp.orphan_fence:{scope}",), + ) + await holder.commit() + finally: + await fx.pool.putconn(holder) + held = await fetch_all( + fx.pool, + "SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND granted" + " AND pid <> pg_backend_pid()", + ) + assert held == [(0,)], "no pooled connection kept a session lock" + again = await asyncio.wait_for(fx.collect(bindings=["objects"]), 20) + assert again.fenced == () + + +async def test_binding_name_arguments_must_be_sequences_of_ids_not_strings( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + for name in ("bindings", "skip_bindings"): + for bad in ("objects", "", b"objects", [1], ["objects", None]): + with pytest.raises(ValueError, match=name): + await fx.collect(dry_run=True, **{name: bad}) + with pytest.raises(ValueError, match="bindings"): + await fx.store.take_over_orphan_collector_fence( + acknowledge_previous_owner_gone=True, bindings="objects" # type: ignore[arg-type] + ) diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index c176447468..cc4edb3fe2 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -8199,6 +8199,13 @@ "ReportingChangeRecordV1": "adcp_reporting_storage.ledger.change_feed", "ReportingChangesPageV1": "adcp_reporting_storage.ledger.change_feed", "ReportingObjectRowBindingV1": "adcp_reporting_storage.ledger.row_storage", + "ReportingOrphanBindingResultV1": "adcp_reporting_storage.ledger.orphan_collector", + "ReportingOrphanCandidateV1": "adcp_reporting_storage.ledger.orphan_collector", + "ReportingOrphanCollectionOptionsV1": "adcp_reporting_storage.ledger.orphan_collector", + "ReportingOrphanCollectionResultV1": "adcp_reporting_storage.ledger.orphan_collector", + "ReportingOrphanCollectionStateV1": "adcp_reporting_storage.ledger.orphan_collector", + "ReportingOrphanFenceRefusalV1": "adcp_reporting_storage.ledger.orphan_fence", + "ReportingOrphanFenceTakeoverV1": "adcp_reporting_storage.ledger.orphan_collector", "ReportingOwnershipBackfill": "adcp_reporting_storage.migration", "ReportingOwnershipMigrationError": "adcp_reporting_storage.migration", "ReportingPeriodRetirementV1": "adcp_reporting_storage.ledger.period_retirement", diff --git a/tests/test_reporting_orphan_fence.py b/tests/test_reporting_orphan_fence.py new file mode 100644 index 0000000000..6f818b456d --- /dev/null +++ b/tests/test_reporting_orphan_fence.py @@ -0,0 +1,207 @@ +"""The orphan collector's fence decision table, formats and scope, without a database. + +Ported from the JavaScript SDK (adcp-client #3593, ``reporting-orphan-fence``). +""" + +from __future__ import annotations + +import json +import uuid + +import pytest +from adcp_reporting_storage.ledger.orphan_fence import ( + ORPHAN_FENCE_MAX_BYTES, + OrphanFenceRecord, + OrphanFenceRow, + decide_orphan_fence, + encode_orphan_fence_record, + is_orphan_fence_key, + orphan_fence_directory, + orphan_fence_epoch_of_key, + orphan_fence_key, + orphan_fence_scope, + parse_orphan_fence_record, +) + +NAMESPACE = "a" * 64 +OWNED = f"orph/{NAMESPACE}/" +T1, T2, T3 = (str(uuid.uuid4()) for _ in range(3)) + + +def row(epoch: int, token: str = T1, previous: str | None = None) -> OrphanFenceRow: + return OrphanFenceRow(epoch, token, previous) + + +def test_it_claims_epoch_one_when_neither_side_has_a_claim_and_the_next_when_they_match() -> None: + first = decide_orphan_fence(None, None) + assert (first.action, first.epoch, first.previous_token) == ("claim", 1, None) + nxt = decide_orphan_fence(row(4, T1), (4, T1)) + assert (nxt.action, nxt.epoch, nxt.previous_token) == ("claim", 5, T1) + + +def test_it_refuses_a_namespace_claimed_by_a_database_it_has_no_record_of() -> None: + decision = decide_orphan_fence(None, (3, T1)) + assert decision.action == "fenced" and decision.refusal is not None + assert decision.refusal.cause == "claimed_elsewhere" + assert (decision.refusal.fence_epoch, decision.refusal.recorded_epoch) == (3, None) + assert "take_over_orphan_collector_fence" in decision.refusal.detail + + +def test_it_finishes_its_own_unfinished_claim() -> None: + # The object is missing: the first claim was recorded and never written. + assert decide_orphan_fence(row(1, T1, None), None).action == "complete" + # One epoch behind with the recorded previous token: the intent committed, the write did not. + assert decide_orphan_fence(row(5, T2, T1), (4, T1)).action == "complete" + + +@pytest.mark.parametrize( + ("recorded", "latest", "cause"), + [ + (row(3, T1, T2), None, "fence_missing"), + (row(1, T1, T2), None, "fence_missing"), + (row(4, T1), (4, T2), "token_mismatch"), + (row(4, T1), (6, T1), "fence_advanced"), + (row(4, T1), (2, T1), "fence_regressed"), + (row(5, T2, T1), (4, T3), "fence_regressed"), + (row(5, T2, None), (4, T1), "fence_regressed"), + ], +) +def test_it_refuses_every_other_state_with_the_epochs_in_the_refusal( + recorded: OrphanFenceRow, latest: tuple[int, str] | None, cause: str +) -> None: + decision = decide_orphan_fence(recorded, latest) + assert decision.action == "fenced" and decision.refusal is not None + assert decision.refusal.cause == cause + assert decision.refusal.recorded_epoch == recorded.epoch + assert decision.refusal.fence_epoch == (latest[0] if latest else None) + + +def test_no_input_makes_it_delete_when_the_bucket_is_ahead_of_the_record() -> None: + for recorded in (None, row(1), row(7, T1, T2)): + for epoch in range(1, 12): + floor = recorded.epoch if recorded else 0 + if epoch <= floor: + continue + for token in (T1, T2, T3): + assert decide_orphan_fence(recorded, (epoch, token)).action == "fenced" + + +def test_a_record_round_trips_and_a_first_claim_carries_a_null_previous_token() -> None: + record = OrphanFenceRecord(1, T1, None, "2026-10-01T00:00:00.000Z") + data = encode_orphan_fence_record(NAMESPACE, record) + assert data.endswith(b"\n") and json.loads(data)["previous_token"] is None + assert list(json.loads(data)) == [ + "v", + "namespace_key", + "epoch", + "token", + "previous_token", + "claimed_at", + ], "the key order the JavaScript SDK writes" + assert parse_orphan_fence_record(data, namespace_key=NAMESPACE, epoch=1) == record + chained = OrphanFenceRecord(2, T2, T1, "2026-10-02T00:00:00.000Z") + assert ( + parse_orphan_fence_record( + encode_orphan_fence_record(NAMESPACE, chained), namespace_key=NAMESPACE, epoch=2 + ) + == chained + ) + + +def test_it_parses_strictly_and_rejects_anything_that_is_not_exactly_a_fence_of_this_epoch() -> ( + None +): + good = { + "v": 1, + "namespace_key": NAMESPACE, + "epoch": 3, + "token": T1, + "previous_token": T2, + "claimed_at": "2026-10-01T00:00:00.000Z", + } + + def parse(value: object, epoch: int = 3, namespace: str = NAMESPACE) -> object: + data = value if isinstance(value, bytes) else json.dumps(value).encode() + return parse_orphan_fence_record(data, namespace_key=namespace, epoch=epoch) + + assert parse(good) is not None + assert parse(good, epoch=4) is None, "another epoch" + assert parse(good, namespace="b" * 64) is None, "another namespace" + for change in ( + {"v": 2}, + {"v": True}, + {"epoch": 4}, + {"epoch": "3"}, + {"epoch": True}, + {"token": "nope"}, + {"token": T1.upper()}, + {"previous_token": "nope"}, + {"previous_token": 5}, + {"claimed_at": "yesterday"}, + {"claimed_at": "2026-10-01"}, + {"claimed_at": 5}, + {"extra": 1}, + ): + assert parse({**good, **change}) is None, change + missing = dict(good) + del missing["previous_token"] + assert parse(missing) is None + for bad in (b"", b"not json", b"[]", b"null", b"3", b"\xff\xfe"): + assert parse(bad) is None + assert parse(b" " * (ORPHAN_FENCE_MAX_BYTES + 1)) is None + + +def test_keys_sort_by_epoch_and_only_exact_epoch_names_under_the_fence_directory_count() -> None: + assert orphan_fence_directory(OWNED) == f"{OWNED}_collector-fence/" + keys = [orphan_fence_key(OWNED, epoch) for epoch in (2, 10, 1, 9)] + assert sorted(keys) == [orphan_fence_key(OWNED, epoch) for epoch in (1, 2, 9, 10)] + assert orphan_fence_key(OWNED, 7).endswith("/0000000000000007.json") + assert orphan_fence_epoch_of_key(OWNED, orphan_fence_key(OWNED, 12)) == 12 + directory = orphan_fence_directory(OWNED) + for other in ( + f"{directory}7.json", + f"{directory}00000000000000007.json", + f"{directory}0000000000000000.json", + f"{directory}0000000000000007.json.tmp", + f"{directory}0000000000000007.JSON", + f"{directory}٣000000000000007.json", + f"{directory}sub/0000000000000007.json", + f"{OWNED}0000000000000007.json", + ): + assert orphan_fence_epoch_of_key(OWNED, other) is None, other + assert is_orphan_fence_key(OWNED, f"{directory}anything") + assert not is_orphan_fence_key(OWNED, f"{OWNED}acct/row.jsonl") + + +def test_a_fence_is_one_physical_place_provider_location_prefix_and_namespace() -> None: + base = { + "provider": "s3", + "location": {"bucket": "rows", "region": "us-east-1"}, + "prefix": "adcp-rows", + "namespace_key": NAMESPACE, + } + scope = orphan_fence_scope(**base) # type: ignore[arg-type] + assert len(scope) == 64 and int(scope, 16) >= 0 + assert orphan_fence_scope(**{**base, "location": {"region": "us-east-1", "bucket": "rows"}}) == scope # type: ignore[arg-type] + for change in ( + {"provider": "gcs"}, + {"location": {"bucket": "other", "region": "us-east-1"}}, + {"prefix": "other"}, + {"namespace_key": "b" * 64}, + ): + assert orphan_fence_scope(**{**base, **change}) != scope, change # type: ignore[arg-type] + + +def test_the_scope_hash_is_the_one_the_javascript_sdk_computes() -> None: + """sha256 of the RFC 8785 form of ["adcp.orphan_fence.v1", provider, location, prefix, ns].""" + import hashlib + + canonical = ( + '["adcp.orphan_fence.v1","filesystem",{"root":"rows"},"orph","' + NAMESPACE + '"]' + ).encode() + assert ( + orphan_fence_scope( + provider="filesystem", location={"root": "rows"}, prefix="orph", namespace_key=NAMESPACE + ) + == hashlib.sha256(canonical).hexdigest() + ) diff --git a/tests/test_reporting_orphan_maintenance.py b/tests/test_reporting_orphan_maintenance.py new file mode 100644 index 0000000000..6e8034254a --- /dev/null +++ b/tests/test_reporting_orphan_maintenance.py @@ -0,0 +1,201 @@ +"""Scheduling the orphan collector: options, the store option and the cycle state. + +Ported from the JavaScript SDK (adcp-client #3291 and #3593, ``reporting-maintenance-warnings`` and +``reliable-reporting-service``). No database: the collector itself is replaced. +""" + +from __future__ import annotations + +import time +from dataclasses import replace +from typing import Any + +import pytest +from adcp_reporting_storage import PgReportingLedgerStore +from adcp_reporting_storage.ledger import orphan_collector +from adcp_reporting_storage.ledger.orphan_collector import ( + ReportingOrphanBindingResultV1, + ReportingOrphanCollectionOptionsV1, + ReportingOrphanCollectionResultV1, + ReportingOrphanCollectionStateV1, + run_orphan_collection_pass, +) +from adcp_reporting_storage.ledger.orphan_fence import ReportingOrphanFenceRefusalV1 + + +def options(**overrides: Any) -> ReportingOrphanCollectionOptionsV1: + return ReportingOrphanCollectionOptionsV1( + **{"acknowledge_exclusive_namespace": True, **overrides} + ) + + +def test_deleting_needs_the_acknowledgement_and_every_bound_is_checked() -> None: + with pytest.raises(ValueError, match="acknowledge_exclusive_namespace"): + ReportingOrphanCollectionOptionsV1() + assert ReportingOrphanCollectionOptionsV1(dry_run=True).dry_run is True + assert options().interval_seconds == 86_400 and options().min_age_seconds == 7 * 86_400 + assert options(min_age_seconds=86_400).min_age_seconds == 86_400 + for bad in ( + {"min_age_seconds": 86_399}, + {"min_age_seconds": 0}, + {"min_age_seconds": 1.5}, + {"limit": 0}, + {"limit": -1}, + {"limit": 1.5}, + {"interval_seconds": 0}, + {"interval_seconds": True}, + {"dry_run": "yes"}, + {"include_read_only": 1}, + {"acknowledge_exclusive_namespace": "true"}, + ): + with pytest.raises(ValueError): + options(**bad) + + +def test_the_store_option_needs_row_storage_and_adds_the_maintenance_operation() -> None: + pytest.importorskip("psycopg_pool") + pool = object() + with pytest.raises(ValueError, match="requires row_storage"): + PgReportingLedgerStore(pool=pool, orphan_collection=options()) # type: ignore[arg-type] + with pytest.raises(TypeError): + PgReportingLedgerStore( + pool=pool, # type: ignore[arg-type] + row_storage=True, + orphan_collection=True, # type: ignore[arg-type] + ) + plain = PgReportingLedgerStore(pool=pool, row_storage=True) # type: ignore[arg-type] + assert "orphan_collection" not in [name for name, _ in plain.maintenance_operations()] + scheduled = PgReportingLedgerStore( + pool=pool, # type: ignore[arg-type] + row_storage=True, + orphan_collection=options(), + ) + names = [name for name, _ in scheduled.maintenance_operations()] + assert names[-1] == "orphan_collection" and "intent_sweep" in names + + +def result(**changes: Any) -> ReportingOrphanCollectionResultV1: + return replace(ReportingOrphanCollectionResultV1(dry_run=False), **changes) + + +def binding(binding_id: str, status: Any = "complete", **changes: Any) -> Any: + return ReportingOrphanBindingResultV1(binding_id, status, **changes) + + +class Recorder: + def __init__(self, *outcomes: ReportingOrphanCollectionResultV1) -> None: + self.outcomes = list(outcomes) + self.calls: list[dict[str, Any]] = [] + + async def __call__(self, store: Any, **kwargs: Any) -> ReportingOrphanCollectionResultV1: + self.calls.append(kwargs) + return self.outcomes.pop(0) + + +async def test_a_pass_forwards_its_options_replaces_the_saved_state_and_rests_after_a_clean_listing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + recorder = Recorder( + result( + cursors={"objects": "page-2"}, + completed=("legacy",), + bindings=(binding("objects", "partial"),), + ), + result(cursors={}, completed=("objects",), bindings=(binding("objects"),)), + ) + monkeypatch.setattr(orphan_collector, "collect_orphaned_row_objects", recorder) + configured = options( + dry_run=False, + limit=50, + min_age_seconds=3 * 86_400, + include_read_only=True, + interval_seconds=600, + ) + state = ReportingOrphanCollectionStateV1(cursors={"stale": "x"}, completed=["done"]) + await run_orphan_collection_pass(object(), configured, state) # type: ignore[arg-type] + assert recorder.calls[0] == { + "dry_run": False, + "acknowledge_exclusive_namespace": True, + "min_age_seconds": 3 * 86_400, + "limit": 50, + "include_read_only": True, + "cursors": {"stale": "x"}, + "skip_bindings": ["done"], + } + # An unfinished listing carries on next pass, remembering what already finished. + assert state.cursors == {"objects": "page-2"} + assert state.completed == ["done", "legacy"] and state.next_cycle_at is None + await run_orphan_collection_pass(object(), configured, state) # type: ignore[arg-type] + assert recorder.calls[1]["cursors"] == {"objects": "page-2"} + assert state.cursors == {} and state.completed == [] + assert state.next_cycle_at is not None + assert 590 < state.next_cycle_at - time.monotonic() <= 600, "a clean cycle rests the interval" + # Between full listings the pass does nothing, and does not call the collector. + idle = await run_orphan_collection_pass(object(), configured, state) # type: ignore[arg-type] + assert idle.listed == 0 and idle.dry_run is False and len(recorder.calls) == 2 + + +async def test_a_binding_that_finished_is_not_listed_again_while_another_needs_more_passes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + recorder = Recorder( + result( + cursors={"big": "t1"}, + completed=("small",), + bindings=(binding("small"), binding("big", "partial")), + ), + result(cursors={"big": "t2"}, completed=(), bindings=(binding("big", "partial"),)), + result(cursors={}, completed=("big",), bindings=(binding("big"),)), + ) + monkeypatch.setattr(orphan_collector, "collect_orphaned_row_objects", recorder) + state = ReportingOrphanCollectionStateV1() + for _ in range(3): + await run_orphan_collection_pass(object(), options(), state) # type: ignore[arg-type] + assert [call["skip_bindings"] for call in recorder.calls] == [[], ["small"], ["small"]] + assert state.completed == [] and state.cursors == {} and state.next_cycle_at is not None + + +async def test_a_failed_binding_keeps_the_task_retrying_every_pass( + monkeypatch: pytest.MonkeyPatch, +) -> None: + failing = result( + cursors={}, failed=1, bindings=(binding("objects", "failed", error="PROVIDER_UNAVAILABLE"),) + ) + recorder = Recorder(failing, failing) + monkeypatch.setattr(orphan_collector, "collect_orphaned_row_objects", recorder) + state = ReportingOrphanCollectionStateV1() + for _ in range(2): + await run_orphan_collection_pass(object(), options(), state) # type: ignore[arg-type] + assert state.next_cycle_at is None, "a failure does not rest the interval" + assert len(recorder.calls) == 2 + + +async def test_a_fenced_binding_rests_at_most_an_hour_whatever_the_interval( + monkeypatch: pytest.MonkeyPatch, +) -> None: + refusal = ReportingOrphanFenceRefusalV1("fence_advanced", "take over", 5, 3) + fenced = result(fenced=("objects",), bindings=(binding("objects", "fenced", fence=refusal),)) + recorder = Recorder(fenced, fenced) + monkeypatch.setattr(orphan_collector, "collect_orphaned_row_objects", recorder) + state = ReportingOrphanCollectionStateV1() + await run_orphan_collection_pass(object(), options(interval_seconds=86_400), state) # type: ignore[arg-type] + assert state.next_cycle_at is not None + assert 3_590 < state.next_cycle_at - time.monotonic() <= 3_600 + # A short interval is kept as it is. + short = ReportingOrphanCollectionStateV1() + await run_orphan_collection_pass(object(), options(interval_seconds=120), short) # type: ignore[arg-type] + assert short.next_cycle_at is not None + assert 110 < short.next_cycle_at - time.monotonic() <= 120 + + +async def test_a_rejected_collector_propagates_so_the_worker_counts_it_as_a_failed_task( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def explode(store: Any, **kwargs: Any) -> Any: + raise RuntimeError("boom") + + monkeypatch.setattr(orphan_collector, "collect_orphaned_row_objects", explode) + state = ReportingOrphanCollectionStateV1(cursors={"a": "b"}) + with pytest.raises(RuntimeError): + await run_orphan_collection_pass(object(), options(), state) # type: ignore[arg-type] + assert state.cursors == {"a": "b"} and state.next_cycle_at is None diff --git a/tests/test_reporting_production_maintenance.py b/tests/test_reporting_production_maintenance.py index d814590a6b..4341c957fa 100644 --- a/tests/test_reporting_production_maintenance.py +++ b/tests/test_reporting_production_maintenance.py @@ -220,3 +220,51 @@ async def test_a_quiet_pass_logs_nothing_and_still_records_its_counters( "protected": 0, "held": 0, } + + +async def test_orphan_collection_reports_fenced_and_unsupported_bindings_as_integer_counters( + caplog: pytest.LogCaptureFixture, +) -> None: + """A fenced namespace is stalled work; a binding that cannot list is pending work. + + Only the closed operation name and counters are logged, never a binding or bucket name. + """ + from types import SimpleNamespace + + class OrphanStore: + def maintenance_operations(self) -> tuple[tuple[str, Any], ...]: + async def run() -> Any: + return SimpleNamespace( + failed=2, + fenced=("secret-binding", "other-binding"), + unsupported=("nolist-binding",), + deleted=9, + ) + + return (("orphan_collection", run),) + + worker = service(OrphanStore()) + with caplog.at_level(logging.INFO, logger="adcp.reporting.production"): + await worker._maintain() + by_code = { + code: [r for r in caplog.records if getattr(r, "code", "") == code] + for code in ( + "REPORTING_MAINTENANCE_FAILED", + "REPORTING_MAINTENANCE_PENDING", + "REPORTING_MAINTENANCE_STALLED", + ) + } + assert [r.operation for r in by_code["REPORTING_MAINTENANCE_FAILED"]] == ["orphan_collection"] + (pending,) = by_code["REPORTING_MAINTENANCE_PENDING"] + assert pending.operation == "orphan_collection" and pending.unsupported == 1 + (stalled,) = by_code["REPORTING_MAINTENANCE_STALLED"] + assert stalled.operation == "orphan_collection" and stalled.fenced == 2 + assert stalled.levelno == logging.WARNING + assert all( + "binding" not in r.getMessage() and "secret" not in r.getMessage() for r in caplog.records + ) + assert worker.maintenance_report["orphan_collection"] == { + "failed": 2, + "fenced": 2, + "unsupported": 1, + } diff --git a/tests/test_reporting_row_storage_azure.py b/tests/test_reporting_row_storage_azure.py index c42c2b5af2..2bf22c0685 100644 --- a/tests/test_reporting_row_storage_azure.py +++ b/tests/test_reporting_row_storage_azure.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from datetime import datetime, timezone from types import SimpleNamespace from typing import Any @@ -18,6 +19,7 @@ ReportingRowObjectContextV1, ReportingRowObjectDeleteInputV1, ReportingRowObjectGetInputV1, + ReportingRowObjectListInputV1, ReportingRowObjectPutInputV1, run_reporting_row_object_provider_conformance_v1, ) @@ -43,6 +45,7 @@ class _Version: content_type: str | None cache_control: str | None current: bool = True + created: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) @property def etag(self) -> str: @@ -73,6 +76,55 @@ def get_container_properties(self, **transport: Any) -> Any: def get_blob_client(self, key: str) -> Any: return Blob(name, key) + def list_blobs( + self, + name_starts_with: str | None = None, + include: list[str] | None = None, + results_per_page: int | None = None, + **transport: Any, + ) -> Any: + service.transport.append(transport) + entries = [ + SimpleNamespace( + name=key, + version_id=version.version_id, + size=len(version.data), + creation_time=version.created, + last_modified=version.created, + ) + for (container, key), history in sorted(service.blobs.items()) + if container == name and key.startswith(name_starts_with or "") + for version in history + ] + + class Pages: + continuation_token: str | None = None + + def __init__(self, start: int) -> None: + self.start = start + self.served = False + + def __iter__(self) -> Pages: + return self + + def __next__(self) -> Any: + if self.served: + raise StopIteration + self.served = True + size = results_per_page or len(entries) + chunk = entries[self.start : self.start + size] + following = self.start + len(chunk) + self.continuation_token = ( + str(following) if following < len(entries) else None + ) + return iter(chunk) + + class Paged: + def by_page(self, continuation_token: str | None = None) -> Pages: + return Pages(int(continuation_token) if continuation_token else 0) + + return Paged() + class Blob: def __init__(self, container: str, key: str) -> None: self.container, self.key = container, key @@ -216,7 +268,7 @@ async def test_passes_the_shared_provider_conformance_suite() -> None: passed = await run_reporting_row_object_provider_conformance_v1( provider, location=LOCATION, prefix="adcp-rows" ) - assert passed[-1] == "write-unique-version" and "ranged-read" in passed + assert "write-unique-version" in passed and "ranged-read" in passed and passed[-1] == "list" async def test_create_only_upload_sets_headers_and_identifier_safe_metadata() -> None: @@ -395,3 +447,60 @@ def test_the_official_client_exposes_everything_the_provider_calls() -> None: from azure.core import MatchConditions assert hasattr(MatchConditions, "IfNotModified") + + +# -- list --------------------------------------------------------------------------------------- + + +async def test_list_pages_every_version_and_carries_the_call_budget() -> None: + service = FakeBlobService() + provider = create_azure_blob_reporting_row_object_provider_v1(client=service) + keys = [f"adcp-rows/ns/o{index}.jsonl" for index in range(5)] + versions = {} + for key in keys: + versions[key] = (await provider.put_if_absent(put(key, b"abc"), CONTEXT)).native_version_ref + found = [] + token = None + context = ReportingRowObjectContextV1(7.5) + for _ in range(20): + page = await provider.list( + ReportingRowObjectListInputV1(LOCATION, "adcp-rows/ns/", 2, token), context + ) + assert len(page.objects) <= 2 + found.extend(page.objects) + token = page.next_page_token + if token is None: + break + assert {(e.object_ref, e.native_version_ref, e.size_bytes) for e in found} == { + (key, versions[key], 3) for key in keys + } + assert service.transport[-1]["timeout"] == 8 and service.transport[-1]["read_timeout"] == 7.5 + + +async def test_list_skips_a_blob_without_a_version_id_size_or_time_and_counts_it() -> None: + service = FakeBlobService() + provider = create_azure_blob_reporting_row_object_provider_v1(client=service) + await provider.put_if_absent(put("adcp-rows/ns/good.jsonl"), CONTEXT) + # A blob written before versioning was enabled has no version ID. + service.blobs[("rows", "adcp-rows/ns/before-versioning.jsonl")] = [ + type(service.blobs[("rows", "adcp-rows/ns/good.jsonl")][0])( + "not a version id!", b"x", {}, None, None + ) + ] + page = await provider.list( + ReportingRowObjectListInputV1(LOCATION, "adcp-rows/ns/", 10), CONTEXT + ) + assert [e.object_ref for e in page.objects] == ["adcp-rows/ns/good.jsonl"] + assert page.skipped == 1 + + +async def test_list_refuses_a_bad_page_size_and_an_oversized_prefix() -> None: + provider = create_azure_blob_reporting_row_object_provider_v1(client=FakeBlobService()) + for size in (0, -1, 1.5): + with pytest.raises(ReportingRowStoreError) as failure: + await provider.list( + ReportingRowObjectListInputV1(LOCATION, "adcp-rows/", size), CONTEXT # type: ignore[arg-type] + ) + assert failure.value.code == "INVALID_INPUT" + with pytest.raises(ReportingRowStoreError): + await provider.list(ReportingRowObjectListInputV1(LOCATION, "p" * 1025, 5), CONTEXT) diff --git a/tests/test_reporting_row_storage_filesystem.py b/tests/test_reporting_row_storage_filesystem.py index 099f0e0828..cfb4d2a622 100644 --- a/tests/test_reporting_row_storage_filesystem.py +++ b/tests/test_reporting_row_storage_filesystem.py @@ -3,7 +3,10 @@ from __future__ import annotations import os +from dataclasses import replace +from datetime import datetime, timezone from pathlib import Path +from typing import Any import pytest from adcp_reporting_storage.ledger.row_storage_object import ( @@ -11,10 +14,14 @@ ReportingRowObjectContextV1, ReportingRowObjectDeleteInputV1, ReportingRowObjectGetInputV1, + ReportingRowObjectListInputV1, ReportingRowObjectPutInputV1, + ReportingRowObjectStatInputV1, assert_reporting_row_key_template_v1, assert_reporting_row_prefix_v1, create_filesystem_reporting_row_object_provider_v1, + is_row_object_key_shape, + parse_row_instant, render_reporting_row_object_key_v1, reporting_row_account_key_v1, run_reporting_row_object_provider_conformance_v1, @@ -43,6 +50,7 @@ async def test_the_provider_passes_the_shared_conformance_suite(tmp_path: Path) "version-pinned-stat", "exact-version-delete", "write-unique-version", + "list", ] @@ -321,3 +329,252 @@ def key(row_set_id: str) -> str: ) assert key("rrev_1") != key("rrev_2") + + +# -- list (the orphan collector's capability) ------------------------------------------------- + +LIST_NAMESPACE = f"adcp-rows/{NAMESPACE}" + + +async def _put_all(provider: Any, keys: list[str]) -> dict[str, str]: + versions = {} + for key in keys: + created = await provider.put_if_absent( + ReportingRowObjectPutInputV1( + {"root": "rows"}, key, f"{key}\n".encode(), "application/x-ndjson", {} + ), + CONTEXT, + ) + versions[key] = created.native_version_ref + return versions + + +async def _list_all(provider: Any, prefix: str, page_size: int) -> list[Any]: + found: list[Any] = [] + token = None + for _ in range(200): + page = await provider.list( + ReportingRowObjectListInputV1({"root": "rows"}, prefix, page_size, token), CONTEXT + ) + assert len(page.objects) <= page_size + found.extend(page.objects) + token = page.next_page_token + if token is None: + return found + raise AssertionError("listing did not finish") + + +async def test_list_returns_every_object_under_the_prefix_once_in_a_stable_order( + tmp_path: Path, +) -> None: + provider = provider_in(tmp_path) + keys = [ + f"{LIST_NAMESPACE}/a/1.jsonl", + f"{LIST_NAMESPACE}/a/2.jsonl.gz", + f"{LIST_NAMESPACE}/a/b/3.jsonl", + f"{LIST_NAMESPACE}/a.jsonl", + f"{LIST_NAMESPACE}/b.jsonl", + f"{LIST_NAMESPACE}/z/9.jsonl", + ] + await _put_all(provider, keys) + expected = [entry.object_ref for entry in await _list_all(provider, f"{LIST_NAMESPACE}/", 100)] + assert sorted(expected) == sorted(keys) and len(set(expected)) == len(keys) + for page_size in (1, 2, 3, 5, 100): + seen = [e.object_ref for e in await _list_all(provider, f"{LIST_NAMESPACE}/", page_size)] + assert seen == expected, page_size + assert [e.object_ref for e in await _list_all(provider, f"{LIST_NAMESPACE}/a/", 2)] == [ + f"{LIST_NAMESPACE}/a/1.jsonl", + f"{LIST_NAMESPACE}/a/2.jsonl.gz", + f"{LIST_NAMESPACE}/a/b/3.jsonl", + ] + assert await _list_all(provider, f"{LIST_NAMESPACE}/zz", 5) == [] + + +async def test_list_reports_the_version_size_and_write_time_the_other_operations_use( + tmp_path: Path, +) -> None: + provider = provider_in(tmp_path) + key = f"{LIST_NAMESPACE}/acct/x.jsonl" + created = await provider.put_if_absent( + ReportingRowObjectPutInputV1({"root": "rows"}, key, b"0123456789", "application/gzip", {}), + CONTEXT, + ) + (entry,) = await _list_all(provider, f"{LIST_NAMESPACE}/", 10) + assert entry.native_version_ref == created.native_version_ref + stat = await provider.stat( + ReportingRowObjectStatInputV1({"root": "rows"}, key, created.native_version_ref), CONTEXT + ) + assert stat is not None and entry.size_bytes == stat.size_bytes == 10 + written = parse_row_instant(entry.created_at) + assert written is not None and abs((written - datetime.now(timezone.utc)).total_seconds()) < 60 + assert entry.created_at.endswith("Z") + + +async def test_list_never_returns_a_temporary_file_or_a_key_outside_the_prefix( + tmp_path: Path, +) -> None: + provider = provider_in(tmp_path) + await _put_all(provider, [f"{LIST_NAMESPACE}/ok.jsonl", "other/prefix.jsonl"]) + leftover = tmp_path / "rows" / LIST_NAMESPACE / "half-written.jsonl.123.tmp" + leftover.write_bytes(b"x") + keys = [e.object_ref for e in await _list_all(provider, f"{LIST_NAMESPACE}/", 10)] + assert keys == [f"{LIST_NAMESPACE}/ok.jsonl"] + + +async def test_list_resumes_after_the_object_a_previous_page_ended_on_was_deleted( + tmp_path: Path, +) -> None: + provider = provider_in(tmp_path) + keys = [f"{LIST_NAMESPACE}/o{index}.jsonl" for index in range(5)] + versions = await _put_all(provider, keys) + first = await provider.list( + ReportingRowObjectListInputV1({"root": "rows"}, f"{LIST_NAMESPACE}/", 2), CONTEXT + ) + assert first.next_page_token == keys[1] + for key in keys[:2]: + await provider.delete( + ReportingRowObjectDeleteInputV1({"root": "rows"}, key, versions[key]), CONTEXT + ) + rest = await _list_all_from(provider, f"{LIST_NAMESPACE}/", first.next_page_token) + assert rest == keys[2:] + + +async def _list_all_from(provider: Any, prefix: str, token: str | None) -> list[str]: + found: list[str] = [] + while True: + page = await provider.list( + ReportingRowObjectListInputV1({"root": "rows"}, prefix, 2, token), CONTEXT + ) + found.extend(entry.object_ref for entry in page.objects) + token = page.next_page_token + if token is None: + return found + + +async def test_list_refuses_a_bad_page_size_an_unknown_root_and_a_prefix_that_climbs_out( + tmp_path: Path, +) -> None: + provider = provider_in(tmp_path) + for bad in (0, -1, 1.5, "5"): + with pytest.raises(ReportingRowStoreError) as failure: + await provider.list( + ReportingRowObjectListInputV1({"root": "rows"}, "adcp-rows/", bad), CONTEXT # type: ignore[arg-type] + ) + assert failure.value.code == "INVALID_INPUT" + with pytest.raises(ReportingRowStoreError) as unknown: + await provider.list( + ReportingRowObjectListInputV1({"root": "nope"}, "adcp-rows/", 5), CONTEXT + ) + assert unknown.value.code == "UNSAFE_BINDING" + with pytest.raises(ReportingRowStoreError) as climbing: + await provider.list( + ReportingRowObjectListInputV1({"root": "rows"}, "adcp-rows/../x/", 5), CONTEXT + ) + assert climbing.value.code == "INVALID_INPUT" + + +class _BrokenLister: + """A filesystem provider whose ``list`` breaks one rule the collector relies on.""" + + name = "filesystem" + ranged_reads = True + + def __init__(self, inner: Any, defect: str) -> None: + self._inner, self._defect = inner, defect + for method in ("validate_location", "probe", "put_if_absent", "get", "stat", "delete"): + setattr(self, method, getattr(inner, method)) + + async def list(self, request: Any, context: Any) -> Any: + page = await self._inner.list(request, context) + objects = list(page.objects) + if self._defect == "duplicate" and objects: + objects.append(objects[0]) + elif self._defect == "wrong_version" and objects: + objects[0] = replace(objects[0], native_version_ref="9:9:9:9") + elif self._defect == "wrong_size" and objects: + objects[0] = replace(objects[0], size_bytes=objects[0].size_bytes + 1) + elif self._defect == "no_time" and objects: + objects[0] = replace(objects[0], created_at="2000-01-01T00:00:00.000Z") + elif self._defect == "ignores_prefix": + return await self._inner.list(replace(request, prefix=request.prefix[:-1]), context) + elif self._defect == "too_many" and objects: + objects = objects * (request.page_size + 1) + elif self._defect == "repeat_token": + return replace(page, next_page_token="same") + elif self._defect == "drops_last" and len(objects) > 1: + objects.pop() + return replace(page, objects=tuple(objects)) + + +@pytest.mark.parametrize( + "defect", + [ + "duplicate", + "wrong_version", + "wrong_size", + "no_time", + "too_many", + "repeat_token", + "drops_last", + ], +) +async def test_the_conformance_runner_rejects_a_list_that_breaks_the_collectors_contract( + tmp_path: Path, defect: str +) -> None: + provider = _BrokenLister(provider_in(tmp_path), defect) + with pytest.raises(AssertionError): + await run_reporting_row_object_provider_conformance_v1( + provider, location={"root": "rows"}, prefix="adcp-rows" # type: ignore[arg-type] + ) + + +async def test_a_provider_without_list_still_passes_conformance_and_reports_no_list_check( + tmp_path: Path, +) -> None: + inner = provider_in(tmp_path) + + class WithoutList: + name = "filesystem" + ranged_reads = True + + def __init__(self) -> None: + for method in ("validate_location", "probe", "put_if_absent", "get", "stat", "delete"): + setattr(self, method, getattr(inner, method)) + + passed = await run_reporting_row_object_provider_conformance_v1( + WithoutList(), location={"root": "rows"}, prefix="adcp-rows" # type: ignore[arg-type] + ) + assert "list" not in passed and passed[-1] == "write-unique-version" + + +def test_strict_instants_and_key_shapes() -> None: + assert parse_row_instant("2026-10-01T00:00:00Z") is not None + assert parse_row_instant("2026-10-01T00:00:00.123456789Z") is not None + for bad in ( + "2026-10-01", + "2026-10-01T00:00:00", + "2026-10-01T00:00:00+00:00", + "2026-10-01 00:00:00Z", + "2026-13-01T00:00:00Z", + "2026-02-30T00:00:00Z", + "", + None, + 7, + ): + assert parse_row_instant(bad) is None, bad + assert is_row_object_key_shape("adcp-rows/ns/acct/0001.jsonl.gz") + for bad_key in ("", "a//b", "a/./b", "a/../b", "a b", "a\n", "/a", "a/", "x" * 1025): + assert not is_row_object_key_shape(bad_key), bad_key + + +async def test_list_does_not_follow_symlinks_out_of_the_root(tmp_path: Path) -> None: + provider = provider_in(tmp_path) + await _put_all(provider, [f"{LIST_NAMESPACE}/real.jsonl"]) + outside = tmp_path / "outside" + outside.mkdir() + (outside / "secret.jsonl").write_bytes(b"x") + owned = tmp_path / "rows" / LIST_NAMESPACE + (owned / "linked-dir").symlink_to(outside, target_is_directory=True) + (owned / "linked-file.jsonl").symlink_to(outside / "secret.jsonl") + keys = [e.object_ref for e in await _list_all(provider, f"{LIST_NAMESPACE}/", 10)] + assert keys == [f"{LIST_NAMESPACE}/real.jsonl"] diff --git a/tests/test_reporting_row_storage_gcs.py b/tests/test_reporting_row_storage_gcs.py index 59ecb68f4c..05c3502e89 100644 --- a/tests/test_reporting_row_storage_gcs.py +++ b/tests/test_reporting_row_storage_gcs.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from datetime import datetime, timezone from types import SimpleNamespace from typing import Any @@ -18,6 +19,7 @@ ReportingRowObjectContextV1, ReportingRowObjectDeleteInputV1, ReportingRowObjectGetInputV1, + ReportingRowObjectListInputV1, ReportingRowObjectPutInputV1, run_reporting_row_object_provider_conformance_v1, ) @@ -35,6 +37,7 @@ class _Version: metadata: dict[str, str] content_type: str cache_control: str | None + created: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) @dataclass @@ -82,6 +85,39 @@ def lifecycle_rules(self) -> Any: def blob(self, key: str, generation: int | None = None) -> Any: return Blob(name, key, generation) + def list_blobs( + self, + prefix: str | None = None, + versions: bool | None = None, + max_results: int | None = None, + page_token: str | None = None, + timeout: Any = None, + ) -> Any: + storage.calls.append(("list", {"prefix": prefix, "versions": versions})) + entries = [ + (key, version) + for (bucket, key), history in sorted(storage.objects.items()) + if bucket == name and key.startswith(prefix or "") + for version in (history if versions else history[-1:]) + ] + start = int(page_token) if page_token else 0 + chunk = entries[start : start + (max_results or len(entries))] + following = start + len(chunk) + blobs = [ + SimpleNamespace( + name=key, + generation=version.generation, + size=len(version.data), + time_created=version.created, + updated=version.created, + ) + for key, version in chunk + ] + return SimpleNamespace( + pages=iter([blobs]), + next_page_token=str(following) if following < len(entries) else None, + ) + def get_blob(self, key: str, timeout: Any = None) -> Any: live = storage.objects.get((name, key)) if not live: @@ -175,6 +211,7 @@ async def test_passes_the_shared_provider_conformance_suite() -> None: "version-pinned-stat", "exact-version-delete", "write-unique-version", + "list", ] @@ -454,3 +491,113 @@ def bucket(self, name: str) -> Any: ReportingRowObjectDeleteInputV1(LOCATION, "adcp-rows/ns/x", "123"), context ) assert seen == [("reload", 4.5), ("delete", 4.5)] + + +# -- list --------------------------------------------------------------------------------------- + + +async def test_list_lists_every_generation_in_pages_and_leaves_out_what_it_cannot_address() -> None: + storage = FakeStorage().add_bucket() + provider = create_gcs_reporting_row_object_provider_v1(storage=storage) + keys = [f"adcp-rows/ns/o{index}.jsonl" for index in range(5)] + versions = {} + for key in keys: + versions[key] = (await provider.put_if_absent(put(key, b"abc"), CONTEXT)).native_version_ref + # A noncurrent generation of the first key is listed too: it is what a delete could name. + storage.objects[("rows-bucket", keys[0])].append( + type(storage.objects[("rows-bucket", keys[0])][0])( + storage.next_generation, b"later", {}, "application/gzip", None + ) + ) + found = [] + token = None + for _ in range(20): + page = await provider.list( + ReportingRowObjectListInputV1(LOCATION, "adcp-rows/ns/", 2, token), CONTEXT + ) + assert len(page.objects) <= 2 + found.extend(page.objects) + token = page.next_page_token + if token is None: + break + assert [(e.object_ref, e.size_bytes) for e in found].count((keys[0], 3)) == 1 + assert len(found) == 6 and {e.object_ref for e in found} == set(keys) + call = next(options for name, options in storage.calls if name == "list") + assert call["versions"] is True and call["prefix"] == "adcp-rows/ns/" + + +async def test_list_skips_an_entry_without_a_usable_generation_size_or_time_and_counts_it() -> None: + from types import SimpleNamespace + + storage = FakeStorage().add_bucket() + await create_gcs_reporting_row_object_provider_v1(storage=storage).put_if_absent( + put("adcp-rows/ns/good.jsonl"), CONTEXT + ) + + class Wrapped: + def bucket(self, name: str) -> Any: + real = storage.bucket(name) + + class Bucket: + def __getattr__(self, attribute: str) -> Any: + return getattr(real, attribute) + + def list_blobs(self, **options: Any) -> Any: + iterator = real.list_blobs(**options) + blobs = list(next(iter(iterator.pages))) + now = datetime.now(timezone.utc) + prefix = options["prefix"] + blobs += [ + SimpleNamespace( + name=f"{prefix}no-generation.jsonl", + generation=None, + size=1, + time_created=now, + updated=now, + ), + SimpleNamespace( + name=f"{prefix}bad-size.jsonl", + generation=5, + size=-1, + time_created=now, + updated=now, + ), + SimpleNamespace( + name=f"{prefix}no-time.jsonl", + generation=6, + size=1, + time_created=None, + updated=None, + ), + SimpleNamespace( + name="elsewhere/x.jsonl", + generation=7, + size=1, + time_created=now, + updated=now, + ), + ] + return SimpleNamespace( + pages=iter([blobs]), next_page_token=iterator.next_page_token + ) + + return Bucket() + + provider = create_gcs_reporting_row_object_provider_v1(storage=Wrapped()) + page = await provider.list( + ReportingRowObjectListInputV1(LOCATION, "adcp-rows/ns/", 50), CONTEXT + ) + assert [e.object_ref for e in page.objects] == ["adcp-rows/ns/good.jsonl"] + assert page.skipped == 4 + + +async def test_list_refuses_a_bad_page_size_and_an_oversized_prefix() -> None: + provider = create_gcs_reporting_row_object_provider_v1(storage=FakeStorage().add_bucket()) + for size in (0, -1, 1.5): + with pytest.raises(ReportingRowStoreError) as failure: + await provider.list( + ReportingRowObjectListInputV1(LOCATION, "adcp-rows/", size), CONTEXT # type: ignore[arg-type] + ) + assert failure.value.code == "INVALID_INPUT" + with pytest.raises(ReportingRowStoreError): + await provider.list(ReportingRowObjectListInputV1(LOCATION, "p" * 1025, 5), CONTEXT) diff --git a/tests/test_reporting_row_storage_s3.py b/tests/test_reporting_row_storage_s3.py index 0a0ff48abe..913ca84673 100644 --- a/tests/test_reporting_row_storage_s3.py +++ b/tests/test_reporting_row_storage_s3.py @@ -14,6 +14,7 @@ ReportingRowObjectContextV1, ReportingRowObjectDeleteInputV1, ReportingRowObjectGetInputV1, + ReportingRowObjectListInputV1, ReportingRowObjectPutInputV1, run_reporting_row_object_provider_conformance_v1, ) @@ -51,7 +52,7 @@ async def test_passes_the_shared_provider_conformance_suite(s3: Any) -> None: passed = await run_reporting_row_object_provider_conformance_v1( provider, location=LOCATION, prefix="adcp-rows" ) - assert passed[-1] == "write-unique-version" and "ranged-read" in passed + assert "write-unique-version" in passed and "ranged-read" in passed and passed[-1] == "list" async def test_create_only_writes_record_the_version_id_and_adopt_on_conflict(s3: Any) -> None: @@ -278,3 +279,91 @@ async def test_a_client_without_finite_socket_timeouts_is_refused_at_the_probe() with pytest.raises(ReportingRowStoreError) as refused: await provider.probe(LOCATION, "adcp-rows", CONTEXT) assert refused.value.code == "UNSAFE_BINDING" and "read_timeout" in str(refused.value) + + +# -- list --------------------------------------------------------------------------------------- + + +async def _list_pages(provider: Any, prefix: str, page_size: int) -> tuple[list[Any], int]: + found: list[Any] = [] + skipped = 0 + token = None + for _ in range(100): + page = await provider.list( + ReportingRowObjectListInputV1(LOCATION, prefix, page_size, token), CONTEXT + ) + found.extend(page.objects) + skipped += page.skipped + token = page.next_page_token + if token is None: + return found, skipped + raise AssertionError("listing did not finish") + + +async def test_list_returns_noncurrent_versions_and_never_a_delete_marker(s3: Any) -> None: + provider = create_s3_reporting_row_object_provider_v1(client=s3) + key = "adcp-rows/ns/versions.jsonl" + first = s3.put_object(Bucket="rows-bucket", Key=key, Body=b"one\n")["VersionId"] + second = s3.put_object(Bucket="rows-bucket", Key=key, Body=b"three\n")["VersionId"] + s3.delete_object(Bucket="rows-bucket", Key=key) # a delete marker, not an object + other = ( + await provider.put_if_absent(put("adcp-rows/ns/other.jsonl"), CONTEXT) + ).native_version_ref + for page_size in (1, 2, 10): + found, skipped = await _list_pages(provider, "adcp-rows/ns/", page_size) + assert skipped == 0 + assert sorted((e.object_ref, e.native_version_ref) for e in found) == sorted( + [(key, first), (key, second), ("adcp-rows/ns/other.jsonl", other)] + ), page_size + sizes = { + e.native_version_ref: e.size_bytes + for e in (await _list_pages(provider, "adcp-rows/", 5))[0] + } + assert sizes[first] == 4 and sizes[second] == 6 + + +async def test_list_leaves_out_a_version_written_before_versioning_and_counts_it(s3: Any) -> None: + provider = create_s3_reporting_row_object_provider_v1(client=s3) + s3.create_bucket(Bucket="young-bucket") + s3.put_object(Bucket="young-bucket", Key="adcp-rows/ns/before.jsonl", Body=b"x\n") + s3.put_bucket_versioning(Bucket="young-bucket", VersioningConfiguration={"Status": "Enabled"}) + s3.put_object(Bucket="young-bucket", Key="adcp-rows/ns/after.jsonl", Body=b"y\n") + found, skipped = await _list_in(provider, "young-bucket") + assert [e.object_ref for e in found] == ["adcp-rows/ns/after.jsonl"] + assert skipped == 1, "a null-version object is never addressable by a recorded version" + + +async def _list_in(provider: Any, bucket: str) -> tuple[list[Any], int]: + page = await provider.list( + ReportingRowObjectListInputV1({"bucket": bucket}, "adcp-rows/ns/", 10), CONTEXT + ) + return list(page.objects), page.skipped + + +async def test_list_refuses_a_bad_token_a_bad_page_size_and_a_truncation_without_markers( + s3: Any, +) -> None: + from types import SimpleNamespace + + provider = create_s3_reporting_row_object_provider_v1(client=s3) + for token in ("not base64!", "e30", "bm90LWpzb24"): + with pytest.raises(ReportingRowStoreError) as failure: + await provider.list( + ReportingRowObjectListInputV1(LOCATION, "adcp-rows/", 5, token), CONTEXT + ) + assert failure.value.code == "INVALID_INPUT", token + for size in (0, -3, 1.5): + with pytest.raises(ReportingRowStoreError): + await provider.list( + ReportingRowObjectListInputV1(LOCATION, "adcp-rows/", size), CONTEXT # type: ignore[arg-type] + ) + truncated = SimpleNamespace( + put_object=lambda **_: {}, + list_object_versions=lambda **_: {"Versions": [], "IsTruncated": True}, + ) + broken = create_s3_reporting_row_object_provider_v1(client=truncated) + with pytest.raises(ReportingRowStoreError) as partial: + await broken.list(ReportingRowObjectListInputV1(LOCATION, "adcp-rows/", 5), CONTEXT) + assert ( + partial.value.code == "PROVIDER_UNAVAILABLE" + ), "a partial listing is never reported complete" From 536b8f870f518e7ceab3c7a1d64aa0b229b75e8e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 3 Nov 2026 02:18:10 -0500 Subject: [PATCH 22/28] perf(reporting-storage): drop the per-object advisory lock where nothing pairs on it (#1836) Mirrors the cross-SDK contract from adcp-client#3606. Retirement, the intent sweep and a commit that adopts an object its own intent recorded take no object lock and hold no transaction or pooled connection across the provider delete. The lock stays in the two places that pair with each other: the orphan collector delete, which locks every binding key for the object, and a commit that adopts an object its intent did not record. A commit closes its intent with RETURNING created_objects to know which adopted objects it recorded. The lock key text is unchanged, so a Python deleter and a JavaScript adopter agree in one database. Closes #1492. Co-authored-by: Claude Sonnet 5.5 --- docs/reporting-release-notes.md | 12 + docs/reporting-row-storage.md | 32 +- .../ledger/orphan_collector.py | 17 +- .../ledger/row_retention.py | 11 +- .../ledger/row_storage.py | 261 ++++++++++------ .../ledger/row_write.py | 18 +- ...t_reporting_row_object_lock_contract_pg.py | 293 ++++++++++++++++++ 7 files changed, 535 insertions(+), 109 deletions(-) create mode 100644 tests/conformance/reporting/test_reporting_row_object_lock_contract_pg.py diff --git a/docs/reporting-release-notes.md b/docs/reporting-release-notes.md index 94f82b3ef4..088be43984 100644 --- a/docs/reporting-release-notes.md +++ b/docs/reporting-release-notes.md @@ -53,6 +53,18 @@ Reads handle both forms. `migrate_inline_rows` moves legacy rows with verificati rows do not reproduce its binding, and `retire_legacy_inline_rows` then renames the legacy table so older binaries fail closed. See [row storage](reporting-row-storage.md). +## The per-object lock is kept only where it pairs + +Retirement, the intent sweep and a commit that adopts an object its own intent recorded no longer take the per-object +advisory lock, and no longer hold a transaction or pooled connection across the provider delete. The lock stays in the +two places that pair with each other: the orphan collector's delete, which locks every binding's key for the object, and +a commit that adopts an object its intent did not record. The lock text is unchanged, so a Python deleter and a +JavaScript adopter still agree, and rolling upgrades between a version that locks everywhere and this one are safe (every +pair that needs exclusion keeps the lock on both sides). A commit now closes its intent with `RETURNING created_objects` +to know which adopted objects it recorded. No schema or wire change. See +[the object lock contract](reporting-row-storage.md#the-object-lock-contract) +([#1492](https://github.com/adcontextprotocol/adcp-client-python/issues/1492), adcp-client#3606). + ## Opt-in orphaned-object collector, fenced against clones `PgReportingLedgerStore(orphan_collection=ReportingOrphanCollectionOptionsV1(...))` and diff --git a/docs/reporting-row-storage.md b/docs/reporting-row-storage.md index 76c30bb088..5095fd3023 100644 --- a/docs/reporting-row-storage.md +++ b/docs/reporting-row-storage.md @@ -327,6 +327,36 @@ stays as a small marker that makes reads answer as expired; the records are alre whose horizon excludes periods past retention. Versioned buckets, soft delete and retention locks are allowed as backstops, but a `pruned` period is not "erased" while they keep the old bytes. +## The object lock contract + +The per-object advisory lock is kept only where nothing else orders the two sides, in the contract the JavaScript SDK +shares ([adcp-client#3606](https://github.com/adcontextprotocol/adcp-client/pull/3606)). The lock text is +`adcp-reporting-object:` + binding ID + `:` + object ref, passed to `pg_advisory_xact_lock(hashtextextended(text, 0))`, +so a Python deleter and a JavaScript adopter agree in one database. + +1. **No lock:** retirement, the intent sweep and a commit that adopts an object its own intent recorded. Per object, one + statement checks that no `live` or `unavailable` row set outside the operation names the same binding, `object_ref` + and `native_version_ref`, and no `open` intent in the binding lists it in `created_objects` (both the current and the + pre-alignment `objectKey` / `nativeVersion` spellings are read); then the exact version is deleted. No transaction or + pooled connection is held across the provider delete. +2. **A commit** takes the account lock and closes its intent with `DELETE ... WHERE owner_token = ... AND state = + 'open' RETURNING created_objects` (no row fails `STATE_UNAVAILABLE`). For each adopted object whose + `(object_ref, native_version_ref)` is **not** in the returned list it takes its binding's object lock, in object-ref + order. It then proves every adopted object with a version-pinned `stat` (a full read when the provider has none) + before inserting the chunk rows. +3. **The orphan collector** locks every binding's key for the object (configured plus registered, sorted by binding + ID), repeats the reference check across all bindings under those locks, then deletes. It is the only locked delete. +4. Lock order: the account lock, then object locks. Native versions must be write-unique (the conformance suite's + `write-unique-version`). + +Why this is safe without the lock: the account lock orders commits, retirement phase 1, intent takeover and the sweep +claim; `pruning` is committed under it before any delete, and `pruning` row sets neither count as references nor can be +shared; an intent moves `open -> sweeping` against the commit's `DELETE ... WHERE state = 'open'`, in the same transaction +as the chunk insert, so a reader sees the open intent or the chunk rows, never neither; deletes name an exact native +version; and every writable key includes `{revision_key}`, so one row set alone can name it. **Precondition (documented, +not enforced):** two writable bindings must not share one bucket and prefix, because the reference check is per binding +outside the collector. + ## Orphaned objects: the opt-in listing collector The intent sweep deletes only what an intent lists, so it cannot see three cases: an upload whose `PUT` succeeded before @@ -363,7 +393,7 @@ these hold: old or new JSON keys) naming the key. Two bindings can share a bucket and prefix (a retired `read_only` binding kept next to its successor), so the check is not scoped to the collecting binding. 4. Under the per-object advisory lock of **every** binding, taken in a fixed order so the delete is serialized with an - adoption through any of them, no `live` or `unavailable` row set references that exact version and no open intent + adoption through any of them (see [the object lock contract](#the-object-lock-contract)), no `live` or `unavailable` row set references that exact version and no open intent created it. The provider then deletes that exact version. `read_only` bindings are skipped unless `include_read_only`. `dry_run` deletes nothing and reports up to 1000 diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/orphan_collector.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/orphan_collector.py index 3f16d92f2c..060fc645a4 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/orphan_collector.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/orphan_collector.py @@ -15,7 +15,8 @@ * no row set that is not already ``pruned`` has a chunk at its key, and no write intent lists it, at any version and under any binding (a pre-check that errs towards leaking, because two bindings can share a bucket and prefix); -* under the per-object advisory lock of **every** binding, no ``live`` or ``unavailable`` row set +* under the per-object advisory lock of **every** binding (taken only here and by a commit that + adopts an object its own intent did not record), no ``live`` or ``unavailable`` row set under any binding references that exact version and no open intent created it (the check every other delete makes, widened to every binding), and the provider then deletes that exact version. @@ -541,15 +542,13 @@ async def gate(binding_id: str, mode: Literal["claim", "check"]) -> _Gate: since_fence_check = 0 since_fence_check += 1 try: - # The per-object lock and exact-version reference check every other - # delete makes, widened to every binding. - removed = await storage.delete_unreferenced( - store._pool, - summary.id, - [(entry.object_ref, entry.native_version_ref)], - any_binding=True, + # The only delete that takes the per-object lock: no record names this + # object, so a commit may be adopting it unrecorded. The reference check + # across every binding runs under the lock of every binding. + removed = await storage.delete_orphaned_object( + store._pool, summary.id, entry.object_ref, entry.native_version_ref ) - if removed > 0: + if removed: outcome.deleted += 1 else: outcome.referenced += 1 diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_retention.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_retention.py index 1834aa4b29..bb1690547f 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_retention.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_retention.py @@ -378,8 +378,9 @@ async def delete_period_objects( An object that a live chunk of another obligation still references is kept: the default key template cannot collide across obligations, but a custom template can, and a - delete must never take bytes another revision still needs. See - ``ReportingRowStorageV1.delete_unreferenced`` for the locking contract. + delete must never take bytes another revision still needs. No object lock is taken: see the + contract in ``ReportingRowStorageV1`` (``# -- object lifetime``) and + ``ReportingRowStorageV1.delete_unreferenced``. """ storage = store._row_storage assert storage is not None @@ -397,8 +398,10 @@ async def delete_period_objects( for binding_id, object_ref, native_version_ref in objects: by_binding.setdefault(binding_id, []).append((object_ref, native_version_ref)) for binding_id, pairs in by_binding.items(): - # Objects a chunk of any other row set still references are kept, checked - # atomically with the delete so a concurrent commit cannot adopt a doomed object. + # Objects a chunk of any other row set still references are kept. The check needs no lock + # against a committing writer: the period's row sets are already ``pruning`` (committed + # under the account lock in phase 1), which orders this delete against every other + # deleter. await storage.delete_unreferenced( store._pool, binding_id, pairs, obligation_id=obligation_id ) diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage.py index af9f32f31d..35c0806411 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage.py @@ -26,7 +26,7 @@ import inspect import json import zlib -from collections.abc import Awaitable, Callable, Mapping, Sequence +from collections.abc import Awaitable, Callable, Collection, Mapping, Sequence from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, TypeVar @@ -371,6 +371,25 @@ def _assert_native_version_ref(value: object) -> None: ) +def recorded_intent_objects(created_objects: object) -> set[tuple[str, str]]: + """The ``(object_ref, native_version_ref)`` pairs a write intent recorded. + + Reads both the current keys and an older writer's pre-alignment ``objectKey`` and + ``nativeVersion``, which a writer still running during a rolling upgrade records. + """ + pairs: set[tuple[str, str]] = set() + if not isinstance(created_objects, list): + return pairs + for item in created_objects: + if not isinstance(item, dict): + continue + object_ref = item.get("object_ref", item.get("objectKey")) + version = item.get("native_version_ref", item.get("nativeVersion")) + if isinstance(object_ref, str) and isinstance(version, str): + pairs.add((object_ref, version)) + return pairs + + def _inflate(physical: bytes, compression: Compression, byte_count: int) -> bytes | None: """Decompress to exactly ``byte_count`` bytes, refusing trailing data and extra members.""" if compression == "none": @@ -1408,13 +1427,25 @@ async def delete_collector_fence( # -- object lifetime -------------------------------------------------- # - # Chunks are adopted create-only, so a custom key template that omits an identity - # placeholder lets two row sets name the same (binding, key, version). Every path that - # deletes an object and every commit that adopts one serialize on a transaction-scoped - # advisory lock per object: the deleter checks references and deletes under it, and the - # committer re-verifies the adopted object under it, so whichever runs second sees the - # other's effect. The lock text is shared with the JavaScript SDK and hashed in SQL so - # both SDKs derive the same lock id in one database. + # The cross-SDK contract (adcp-client#3606, adcp-client-python#1492). The per-object advisory + # lock ``adcp-reporting-object:{binding}:{object_ref}`` is kept in exactly two places that pair + # with each other, because nothing else orders them: the orphan collector's delete + # (``delete_orphaned_object``) and a commit that adopts an object its own intent did not record + # (``lock_and_verify_adopted``). Retirement, the intent sweep and a commit that adopts an object + # its own intent recorded take no object lock and hold no connection across a provider delete. + # Lock order everywhere: the account lock, then object locks. The lock text is hashed in SQL so + # both SDKs derive one lock id from it in one database. + # + # Fences that exist without the lock: (A) the account lock held by a commit, retirement phase 1, + # an intent takeover and the sweep claim; (B) ``rows_state`` moves to ``pruning`` under the + # account lock before any delete, and ``pruning`` row sets neither count as references nor can + # be shared; (C) the intent moves ``open -> sweeping`` against the commit's ``DELETE ... WHERE + # state = 'open'``, in the same transaction as the chunk insert, so a reader sees the open + # intent or the chunk rows, never neither; (D) deletes name an exact native version, so native + # versions must be write-unique; (E) every writable key includes ``{revision_key}`` and + # ``{content_sha256}``, so only one row set can name a key through a writable binding. + # Precondition, documented and not enforced: two writable bindings must not share one bucket and + # prefix, because the reference check is per binding outside the collector. @staticmethod def _object_lock_text(binding_id: str, object_ref: str) -> str: @@ -1425,19 +1456,33 @@ async def lock_and_verify_adopted( connection: Any, binding_id: str, locators: Sequence[ReportingRowChunkLocatorV1], + recorded: Collection[tuple[str, str]] = (), ) -> None: - """Hold the object lock for every adopted object and prove each still exists. - - Call inside the ledger transaction, after the account lock and before the chunk rows - are inserted. Objects this upload created are protected by its open write intent - instead. (A writer's own just-created object is not locked: the window between its - create and the intent record is the same one the JavaScript SDK leaves open.) + """Prove every adopted object is still there, locking the ones no record names. + + Call inside the ledger transaction, after the account lock and the intent close, before + the chunk rows are inserted. ``recorded`` is the ``(object_ref, native_version_ref)`` pairs + the closed intent listed in ``created_objects`` (``recorded_intent_objects``). + + An object the writer's own intent recorded as created (an earlier attempt at this row set + that was taken over) needs no lock: until this transaction commits the open intent names + it, so no delete skips the reference check, the sweep cannot claim an intent this commit + just closed or one a takeover renewed, and the key is unique to this row set. An adopted + object the intent does not record (an earlier attempt's PUT that landed before it was + recorded, or one created after a takeover) is named by no record; only the orphan collector + deletes such an object, and it deletes under the per-object lock, so take it here, in key + order, until this transaction ends: a delete that held it finished first and the + version-pinned ``stat`` fails this commit, and any later delete sees the chunk rows + inserted next and skips. (A writer's own just-created object is not locked: the window + between its create and the intent record is the same one the JavaScript SDK leaves open.) """ adopted = sorted((x for x in locators if not x.created), key=lambda x: x.object_ref) if not adopted: return binding, provider = self._object_binding(binding_id) for locator in adopted: + if (locator.object_ref, locator.native_version_ref) in recorded: + continue await connection.execute( "SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", (self._object_lock_text(binding_id, locator.object_ref),), @@ -1496,91 +1541,125 @@ async def delete_unreferenced( *, obligation_id: str | None = None, row_set_id: str | None = None, - any_binding: bool = False, ) -> int: """Delete each object nothing outside the operation's scope still needs. - An object is kept while a chunk of a live or unavailable row set of the same binding - references it (outside ``obligation_id`` / ``row_set_id``, the thing being deleted), - or another open write intent created it. Row sets already pruning or pruned do not - count: their own operation deletes the object. One transaction per object: the lock, - the reference check and the provider delete happen together, so a concurrent commit - adopting the object either already references it (it is kept) or finds it gone and - fails closed. Returns the number of objects deleted. - - ``any_binding`` widens both checks to every binding and takes every binding's object lock, - in a fixed order: two bindings can share a bucket and prefix, and a writer adopting an - object takes the lock of its own binding only, so the delete must be serialized with an - adoption through any of them. Writers hold one such lock, so no cycle forms. The orphan - collector deletes this way. + Takes no advisory lock and holds no transaction or pooled connection across the provider + delete: per object, one statement checks the references, then the exact version is + deleted. An object is kept while a chunk of a live or unavailable row set of the same + binding references it (outside ``obligation_id`` / ``row_set_id``, the thing being + deleted), or another open write intent created it. Row sets already ``pruning`` or + ``pruned`` do not count: their own operation deletes the object, and counting them would + let two concurrent retirements each skip the object and leak it. Each deleter commits + ``pruning`` before its check, so for any two at least one deletes. See the contract above + (``# -- object lifetime``) for why no lock is needed against a committing writer. A skipped + object stays for the row set that owns it: its own retirement or sweep deletes it. + Returns the number of objects deleted. """ deleted = 0 for object_ref, native_version_ref in objects: - async with pool.connection() as connection, connection.transaction(): - lock_bindings = [binding_id] - if any_binding: - known = await ( - await connection.execute( - "SELECT row_binding_id FROM adcp_reporting_row_bindings" - ) - ).fetchall() - lock_bindings = sorted( - {binding_id, *self.object_binding_ids(), *(row[0] for row in known)} - ) - for lock_binding in lock_bindings: - await connection.execute( - "SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", - (self._object_lock_text(lock_binding, object_ref),), - ) - referenced = await ( - await connection.execute( - "(SELECT 1 FROM adcp_reporting_row_chunks chunk" - " JOIN adcp_reporting_row_sets row_set" - " ON row_set.row_set_id = chunk.row_set_id" - " WHERE (%(any_binding)s::boolean OR row_set.row_binding_id = %(binding)s)" - " AND chunk.object_ref = %(key)s" - " AND chunk.native_version_ref = %(version)s" - " AND row_set.rows_state IN ('live', 'unavailable')" - " AND (%(obligation)s::text IS NULL" - " OR chunk.obligation_id <> %(obligation)s)" - " AND (%(row_set)s::text IS NULL OR chunk.row_set_id <> %(row_set)s)" - " LIMIT 1)" - " UNION ALL" - " (SELECT 1 FROM adcp_reporting_row_write_intents intent" - " WHERE (%(any_binding)s::boolean OR intent.row_binding_id = %(binding)s)" - " AND intent.state = 'open'" - " AND (intent.created_objects @> %(created)s::jsonb" - " OR intent.created_objects @> %(created_legacy)s::jsonb)" - " LIMIT 1)", - { - "binding": binding_id, - "any_binding": any_binding, - "key": object_ref, - "version": native_version_ref, - "obligation": obligation_id, - "row_set": row_set_id, - "created": json.dumps( - [ - { - "object_ref": object_ref, - "native_version_ref": native_version_ref, - } - ] - ), - # A writer from before the AdCP field alignment, still running during a - # rolling upgrade, records the earlier keys. - "created_legacy": json.dumps( - [{"objectKey": object_ref, "nativeVersion": native_version_ref}] - ), - }, - ) - ).fetchone() - if referenced is not None: - continue - await self.delete_objects(binding_id, [(object_ref, native_version_ref)]) - deleted += 1 + async with pool.connection() as connection: + referenced = await self._is_referenced( + connection, + binding_id, + object_ref, + native_version_ref, + obligation_id=obligation_id, + row_set_id=row_set_id, + ) + if referenced: + continue + await self.delete_objects(binding_id, [(object_ref, native_version_ref)]) + deleted += 1 return deleted + async def delete_orphaned_object( + self, pool: Any, binding_id: str, object_ref: str, native_version_ref: str + ) -> bool: + """Delete one object no ledger record named when the collector listed it. + + Under the advisory lock ``adcp-reporting-object:{binding}:{object_ref}`` of every binding + that can name it: the only delete that takes the lock. An object no record names can be + adopted, unrecorded, by a writer's commit (a retry that finds an object an earlier attempt + created but never recorded), and that commit takes the same lock before it proves the + object and inserts its chunk rows. So either this delete wins and the commit's + version-pinned ``stat`` fails it cleanly, or the commit wins and this check sees its chunk + rows and skips. A writer takes the lock of its own binding only, so every binding (those + this host configures and those the database has registered, which can share a bucket and + prefix) is locked, sorted by binding ID. The reference check then runs across all bindings. + Lock order: the account lock, then object locks; this never waits on the account lock while + holding an object lock. Returns whether the object was deleted. + """ + async with pool.connection() as connection, connection.transaction(): + known = await ( + await connection.execute("SELECT row_binding_id FROM adcp_reporting_row_bindings") + ).fetchall() + for lock_binding in sorted( + {binding_id, *self.object_binding_ids(), *(r[0] for r in known)} + ): + await connection.execute( + "SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", + (self._object_lock_text(lock_binding, object_ref),), + ) + if await self._is_referenced( + connection, binding_id, object_ref, native_version_ref, any_binding=True + ): + return False + await self.delete_objects(binding_id, [(object_ref, native_version_ref)]) + return True + + async def _is_referenced( + self, + connection: Any, + binding_id: str, + object_ref: str, + native_version_ref: str, + *, + obligation_id: str | None = None, + row_set_id: str | None = None, + any_binding: bool = False, + ) -> bool: + """Whether a live or unavailable row set outside the scope, or an open write intent, still + names this exact object version. ``any_binding`` widens the check to every binding.""" + found = await ( + await connection.execute( + "(SELECT 1 FROM adcp_reporting_row_chunks chunk" + " JOIN adcp_reporting_row_sets row_set" + " ON row_set.row_set_id = chunk.row_set_id" + " WHERE (%(any_binding)s::boolean OR row_set.row_binding_id = %(binding)s)" + " AND chunk.object_ref = %(key)s" + " AND chunk.native_version_ref = %(version)s" + " AND row_set.rows_state IN ('live', 'unavailable')" + " AND (%(obligation)s::text IS NULL OR chunk.obligation_id <> %(obligation)s)" + " AND (%(row_set)s::text IS NULL OR chunk.row_set_id <> %(row_set)s)" + " LIMIT 1)" + " UNION ALL" + " (SELECT 1 FROM adcp_reporting_row_write_intents intent" + " WHERE (%(any_binding)s::boolean OR intent.row_binding_id = %(binding)s)" + " AND intent.state = 'open'" + " AND (intent.created_objects @> %(created)s::jsonb" + " OR intent.created_objects @> %(created_legacy)s::jsonb)" + " LIMIT 1)", + { + "binding": binding_id, + "any_binding": any_binding, + "key": object_ref, + "version": native_version_ref, + "obligation": obligation_id, + "row_set": row_set_id, + "created": json.dumps( + [{"object_ref": object_ref, "native_version_ref": native_version_ref}] + ), + # A writer from before the AdCP field alignment, still running during a + # rolling upgrade, records the earlier keys. + "created_legacy": json.dumps( + [{"objectKey": object_ref, "nativeVersion": native_version_ref}] + ), + }, + ) + ).fetchone() + return found is not None + # -- read ------------------------------------------------------------- async def find( diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_write.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_write.py index 7f36249938..01a986da0b 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_write.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_write.py @@ -44,6 +44,7 @@ ReportingPreparedRowSetV1, ReportingRowChunkLocatorV1, ReportingRowStoreError, + recorded_intent_objects, ) if TYPE_CHECKING: @@ -338,16 +339,25 @@ async def persist_revision_row_set( "STATE_UNAVAILABLE", "the shared row set is no longer live" ) else: + # Closing the intent and inserting the chunk rows are one transaction, so a reader of the + # ledger sees an object named by this open intent or by committed chunk rows, never neither. closed = await connection.execute( "DELETE FROM adcp_reporting_row_write_intents" " WHERE row_binding_id = %s AND row_set_id = %s AND content_sha256 = %s" - " AND owner_token = %s AND state = 'open'", + " AND owner_token = %s AND state = 'open' RETURNING created_objects", (prepared.binding_id, prepared.row_set_id, prepared.content_sha256, staged.owner_token), ) - if closed.rowcount != 1: + closed_row = await closed.fetchone() + if closed_row is None: raise ReportingRowStoreError( "STATE_UNAVAILABLE", "the row upload intent was taken over or swept" ) - if staged.shared_from is None: - await storage.lock_and_verify_adopted(connection, prepared.binding_id, staged.locators) + # Adopted objects the intent recorded take no lock; any other adopted object takes its + # binding's object lock, then every adopted object is proven by a version-pinned stat. + await storage.lock_and_verify_adopted( + connection, + prepared.binding_id, + staged.locators, + recorded_intent_objects(closed_row[0]), + ) await storage.write_in_transaction(connection, prepared, staged.locators, staged.shared_from) diff --git a/tests/conformance/reporting/test_reporting_row_object_lock_contract_pg.py b/tests/conformance/reporting/test_reporting_row_object_lock_contract_pg.py new file mode 100644 index 0000000000..e1d031fc2a --- /dev/null +++ b/tests/conformance/reporting/test_reporting_row_object_lock_contract_pg.py @@ -0,0 +1,293 @@ +"""The per-object advisory lock is kept only where nothing else orders the two sides. + +Ported from the JavaScript SDK (adcp-client #3606, ``reporting-row-shared-object-pg``). Retirement, +the intent sweep and a commit that adopts an object its own intent recorded take no object lock; the +orphan collector's delete and a commit that adopts an object no record names pair on it. +""" + +from __future__ import annotations + +import asyncio +import hashlib +from pathlib import Path +from typing import Any + +import pytest +from adcp_reporting_storage.ledger.row_storage import ( + ReportingRowChunkLocatorV1, + ReportingRowStoreError, + recorded_intent_objects, +) + +from ._orphan_support import Fixture, orphan_fixture +from .test_reporting_row_storage_pg import fetch_all + +BODY = b'{"a":1}\n' + + +async def lock_holder(fx: Fixture, binding: str, object_ref: str) -> Any: + """A connection holding the per-object lock of ``binding`` until it commits.""" + storage = fx.store._row_storage + assert storage is not None + holder = await fx.pool.getconn() + await holder.execute( + "SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", + (storage._object_lock_text(binding, object_ref),), + ) + return holder + + +async def release(fx: Fixture, holder: Any) -> None: + await holder.commit() + await fx.pool.putconn(holder) + + +def locator(object_ref: str, version: str) -> ReportingRowChunkLocatorV1: + return ReportingRowChunkLocatorV1( + object_ref, version, "gzip", hashlib.sha256(BODY).hexdigest(), len(BODY), created=False + ) + + +async def verify( + fx: Fixture, binding: str, loc: ReportingRowChunkLocatorV1, recorded: Any = () +) -> None: + storage = fx.store._row_storage + assert storage is not None + async with fx.pool.connection() as connection, connection.transaction(): + await storage.lock_and_verify_adopted(connection, binding, [loc], recorded) + + +async def blocks( + fx: Fixture, binding: str, loc: ReportingRowChunkLocatorV1, recorded: Any = () +) -> bool: + """Whether the adoption waits on the object lock (and then completes once it is released).""" + task = asyncio.create_task(verify(fx, binding, loc, recorded)) + done, _ = await asyncio.wait({task}, timeout=0.6) + waited = not done + if waited: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + return waited + + +async def idle_in_transaction(fx: Fixture) -> int: + rows = await fetch_all( + fx.pool, + "SELECT count(*) FROM pg_stat_activity WHERE state LIKE 'idle in transaction%%'" + " AND datname = current_database() AND pid <> pg_backend_pid()", + ) + return int(rows[0][0]) + + +async def test_a_delete_of_a_referenced_object_takes_no_lock_and_holds_no_connection_across_the_provider_delete( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + ref, version = await fx.commit_revision("lock_free_retire") + storage = fx.store._row_storage + assert storage is not None + holder = await lock_holder(fx, "objects", ref) # the lock is held elsewhere throughout + seen: list[int] = [] + + async def during_delete(object_ref: str) -> None: + seen.append(await idle_in_transaction(fx)) + + fx.provider.on_delete = during_delete + try: + # Retirement scopes the delete to its own obligation, so its own references do not count. + removed = await asyncio.wait_for( + storage.delete_unreferenced( + fx.pool, + "objects", + [(ref, version)], + obligation_id=fx.obligation.reporting_obligation_id, + ), + 10, + ) + assert removed == 1 and not fx.on_disk(ref) + # Only the lock holder is idle in a transaction: nothing of the deleter's is open. + assert seen == [1] + finally: + await release(fx, holder) + + +async def test_a_referenced_object_is_kept_and_a_pruning_row_set_does_not_count( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + ref, version = await fx.commit_revision("kept") + storage = fx.store._row_storage + assert storage is not None + assert await storage.delete_unreferenced(fx.pool, "objects", [(ref, version)]) == 0 + assert fx.on_disk(ref) + async with fx.pool.connection() as connection: + await connection.execute("UPDATE adcp_reporting_row_sets SET rows_state = 'pruning'") + # Each deleter commits ``pruning`` before its check, so for any two one of them deletes. + assert await storage.delete_unreferenced(fx.pool, "objects", [(ref, version)]) == 1 + assert not fx.on_disk(ref) + + +async def test_the_intent_sweep_takes_no_object_lock_while_the_lock_is_held_elsewhere( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + ref = fx.key("acct/swept.jsonl.gz") + version = await fx.put_object(ref) + await fx.add_intent("rrev_expired", ref, version) + async with fx.pool.connection() as connection: + await connection.execute( + "UPDATE adcp_reporting_row_write_intents" + " SET expires_at = clock_timestamp() - INTERVAL '1 hour'" + ) + holder = await lock_holder(fx, "objects", ref) + try: + swept = await asyncio.wait_for(fx.store.sweep_row_write_intents(), 15) + assert swept.swept == 1 and swept.objects_deleted == 1 and not fx.on_disk(ref) + finally: + await release(fx, holder) + + +async def test_an_adoption_of_an_object_the_intent_recorded_takes_no_lock(tmp_path: Path) -> None: + async with orphan_fixture(tmp_path) as fx: + ref = fx.key("acct/own.jsonl.gz") + version = await fx.put_object(ref, BODY) + holder = await lock_holder(fx, "objects", ref) + try: + recorded = {(ref, version)} + await asyncio.wait_for(verify(fx, "objects", locator(ref, version), recorded), 5) + finally: + await release(fx, holder) + + +async def test_the_pre_alignment_intent_keys_are_read_so_an_older_writers_object_takes_no_lock() -> ( + None +): + pairs = recorded_intent_objects( + [ + {"object_ref": "a", "native_version_ref": "1"}, + {"objectKey": "b", "nativeVersion": "2"}, + {"object_ref": "c", "nativeVersion": "3"}, # mixed spellings are tolerated + {"object_ref": "d"}, + {"unrelated": True}, + "not an object", + {"object_ref": 5, "native_version_ref": "9"}, + ] + ) + assert pairs == {("a", "1"), ("b", "2"), ("c", "3")} + assert recorded_intent_objects(None) == set() and recorded_intent_objects({}) == set() + + +async def test_an_adoption_takes_the_lock_for_a_different_version_or_an_object_the_intent_does_not_name( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + ref = fx.key("acct/adopted.jsonl.gz") + version = await fx.put_object(ref, BODY) + other = fx.key("acct/other.jsonl.gz") + holder = await lock_holder(fx, "objects", ref) + try: + # The intent names the key at another native version: that is not this object. + assert await blocks(fx, "objects", locator(ref, version), {(ref, "9:9:9:9")}) + # The intent names another object, or none. + assert await blocks(fx, "objects", locator(ref, version), {(other, version)}) + assert await blocks(fx, "objects", locator(ref, version)) + finally: + await release(fx, holder) + # Once the lock is free the adoption proves the object and succeeds. + await asyncio.wait_for(verify(fx, "objects", locator(ref, version)), 5) + + +async def test_an_orphan_delete_in_flight_blocks_an_adopter_of_the_unrecorded_object_which_then_fails_cleanly( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + ref = fx.key("acct/raced.jsonl.gz") + version = await fx.put_object(ref, BODY) + storage = fx.store._row_storage + assert storage is not None + deleting = asyncio.Event() + proceed = asyncio.Event() + + async def pause(object_ref: str) -> None: + deleting.set() + await proceed.wait() + + fx.provider.on_delete = pause + orphan = asyncio.create_task( + storage.delete_orphaned_object(fx.pool, "objects", ref, version) + ) + await asyncio.wait_for(deleting.wait(), 10) + adopter = asyncio.create_task(verify(fx, "objects", locator(ref, version))) + done, _ = await asyncio.wait({adopter}, timeout=0.6) + assert not done, "the adoption waits for the delete that holds the object lock" + proceed.set() + assert await asyncio.wait_for(orphan, 10) is True + with pytest.raises(ReportingRowStoreError) as failure: + await asyncio.wait_for(adopter, 10) + assert failure.value.code == "STATE_UNAVAILABLE", "the version-pinned stat fails it cleanly" + + +async def test_an_orphan_delete_takes_every_bindings_lock_for_the_object(tmp_path: Path) -> None: + async with orphan_fixture(tmp_path) as fx: + ref = fx.key("acct/shared.jsonl.gz") + version = await fx.put_object(ref, BODY) + storage = fx.store._row_storage + assert storage is not None + # An adopter through the twin binding (same bucket and prefix) holds only the twin's lock. + holder = await lock_holder(fx, "twin", ref) + task = asyncio.create_task(storage.delete_orphaned_object(fx.pool, "objects", ref, version)) + done, _ = await asyncio.wait({task}, timeout=0.6) + assert not done and fx.on_disk(ref) + await release(fx, holder) + assert await asyncio.wait_for(task, 10) is True and not fx.on_disk(ref) + + +async def test_an_orphan_delete_keeps_an_object_a_record_in_another_binding_names( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + ref = fx.key("acct/named.jsonl.gz") + version = await fx.put_object(ref, BODY) + await fx.add_intent("rrev_twin", ref, version, binding="twin") + storage = fx.store._row_storage + assert storage is not None + assert await storage.delete_orphaned_object(fx.pool, "objects", ref, version) is False + assert fx.on_disk(ref) + + +async def test_a_writer_whose_intent_expired_before_its_commit_cannot_commit_the_swept_objects( + tmp_path: Path, +) -> None: + async with orphan_fixture(tmp_path) as fx: + from .test_reporting_row_storage_pg import many_rows, seeded, typed_revision + + fx.obligation = await seeded(fx.store) + async with fx.pool.connection() as connection: + await connection.execute( + "UPDATE reporting_configurations SET lease_worker_id = 'worker-1'," + " lease_expires_at = clock_timestamp() + INTERVAL '10 minutes'" + ) + rows = many_rows(4) + revision = typed_revision(fx.obligation, rows, suffix="swept_writer") + assert await fx.store.stage_revision_rows(revision, rows) is True + async with fx.pool.connection() as connection: + await connection.execute( + "UPDATE adcp_reporting_row_write_intents" + " SET expires_at = clock_timestamp() - INTERVAL '1 hour'" + ) + swept = await fx.store.sweep_row_write_intents() + assert swept.swept == 1 and swept.objects_deleted == 1 + # Exactly one of the sweep claim and the commit's intent close wins; the writer lost. + with pytest.raises(ReportingRowStoreError) as failure: + await fx.store.commit_revision(revision, rows) + assert failure.value.code == "STATE_UNAVAILABLE" + assert await fetch_all(fx.pool, "SELECT count(*) FROM adcp_reporting_row_sets") == [(0,)] + + +def test_the_lock_text_is_unchanged_and_shared_with_the_javascript_sdk() -> None: + from adcp_reporting_storage.ledger.row_storage import ReportingRowStorageV1 + + assert ( + ReportingRowStorageV1._object_lock_text("reports", "adcp-rows/ns/a/b") + == "adcp-reporting-object:reports:adcp-rows/ns/a/b" + ) From 2bd7751cb1312efda5aefc4a11fecdfab0933c9e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 3 Nov 2026 07:42:45 -0500 Subject: [PATCH 23/28] fix(server): preserve valid creatives in lenient legacy batches (#1841) Honor explicit lenient processing for legacy creative format projection: dispatch supported siblings and return ordered per-creative failures for unprojectable formats. Preserve strict atomic rejection, original idempotency identity, rejected-assignment safety, and SDK-managed task completion results. Materialize SDK-owned failures before mutation and preserve accepted typed rows, including schema-valid assignment error keys. Unsafe destructive/filter/external-async combinations fail before dispatch and are documented. Validated with 18,863 passing repository tests and 92.16% coverage, 110 focused tests, all GitHub checks, installed-wheel probes, and independent protocol and Ladon approvals. Fixes #1839. --- docs/canonical-format-kinds-migration.md | 37 ++ src/adcp/canonical_formats/projection.py | 3 +- src/adcp/decisioning/handler.py | 14 + src/adcp/server/_creative_batch.py | 202 +++++++ src/adcp/server/idempotency/store.py | 16 + src/adcp/server/mcp_tools.py | 84 ++- ...test_mixed_legacy_projection_regression.py | 568 ++++++++++++++++++ 7 files changed, 915 insertions(+), 9 deletions(-) create mode 100644 src/adcp/server/_creative_batch.py create mode 100644 tests/test_mixed_legacy_projection_regression.py diff --git a/docs/canonical-format-kinds-migration.md b/docs/canonical-format-kinds-migration.md index 2eef9268af..2608ea96c9 100644 --- a/docs/canonical-format-kinds-migration.md +++ b/docs/canonical-format-kinds-migration.md @@ -52,3 +52,40 @@ against the selected product's declared `format_options`; use Adopter-defined formats can use `custom` with a corresponding declaration's `format_shape` and `format_schema`. Choose it when the creative satisfies that contract, rather than changing an unknown value to `custom` without validation. + +## Lenient legacy creative uploads + +For AdCP 3.0/3.1 legacy creative uploads, set `validation_mode: "lenient"` +on `sync_creatives` when supported creatives should proceed even if another +creative's `format_id` cannot be projected. The dispatcher passes the supported +creatives to the seller and returns the unprojectable creatives as +`action: "failed"` with `UNSUPPORTED_FEATURE` errors naming their original +input fields. Results retain the submitted creative order. The default mode +is `strict`; strict and omitted modes continue to reject the entire request +before the handler runs. + +Malformed envelopes and duplicate creative IDs remain request errors. The +dispatcher preserves `dry_run` and the original idempotency identity, including +rejected inputs. Deprecated `assignments` targeting rejected submissions are +refused and reported in those creatives' `assignment_errors`, so a stale stored +version cannot be assigned instead. `assignment_operations` requires strict +mode and cannot be partially processed. + +A partially projected batch with `delete_missing: true` is refused before the +seller runs. The dispatcher cannot safely perform library reconciliation on +the seller's behalf, and removing failed rows would otherwise archive existing +creatives whose IDs the buyer did submit. Upload without `delete_missing`, +resolve the rejected formats, then reconcile with the complete submitted ID set. +Partially projected batches also refuse `creative_ids` filtering; submit the +selected creatives directly instead. If every upload is unprojectable, unrelated +valid assignments are refused before dispatch and must be retried separately. + +`PlatformHandler` retains projection failures in inline responses and managed +`TaskHandoff` completion results before task persistence and webhook publication. +`WorkflowHandoff` is refused before workflow enqueue for these partial batches. +Custom `ADCPHandler` implementations with push configuration or overridden task +polling tools are refused before dispatch because the SDK cannot safely overlay +their external completion results. These checks conservatively identify custom +async surfaces; they do not make hand-rolled `submitted` responses supported. +Custom handlers using partial admission must finish inline, or use the managed +`PlatformHandler` task path. diff --git a/src/adcp/canonical_formats/projection.py b/src/adcp/canonical_formats/projection.py index 3bf9d382eb..91c6790231 100644 --- a/src/adcp/canonical_formats/projection.py +++ b/src/adcp/canonical_formats/projection.py @@ -662,6 +662,7 @@ def normalize_legacy_creative_request( *, legacy_format_converter: LegacyFormatConverter | None = None, projection_sources: list[Any] | None = None, + field_path: str = "request", ) -> dict[str, Any]: """Upgrade legacy selectors before a primary server handler runs. @@ -766,7 +767,7 @@ def visit(item: Any, field_path: str) -> Any: } return result - return cast(dict[str, Any], visit(value, "request")) + return cast(dict[str, Any], visit(value, field_path)) def resolve_legacy_format_refs( diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index c4d33fcdc9..bb9389e4b2 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -2879,6 +2879,10 @@ async def _merge_overlay_hook(_update_result: Any) -> None: self._maybe_auto_emit_sync_completion("update_media_buy", params, result) return cast("UpdateMediaBuySuccessResponse", result) + # Partial creative admission is finalized at the managed completion seam, + # before task persistence, completion webhooks, or the inline response. + _supports_partial_creative_completion = True + async def sync_creatives( # type: ignore[override] self, params: SyncCreativesRequest, @@ -2887,6 +2891,14 @@ async def sync_creatives( # type: ignore[override] tool_ctx = context or ToolContext() account = await self._resolve_account(params.account, tool_ctx, tool_name="sync_creatives") ctx = self._build_ctx(tool_ctx, account) + from adcp.server._creative_batch import partial_creative_batch + + batch = partial_creative_batch.get() + + async def finalize_batch(result: Any) -> None: + if batch is not None: + batch.merge(result) + result = await _invoke_platform_method( self._platform, "sync_creatives", @@ -2894,6 +2906,8 @@ async def sync_creatives( # type: ignore[override] ctx, executor=self._executor, registry=self._registry, + on_complete=finalize_batch if batch is not None else None, + allow_workflow_handoff=batch is None, **await self._handoff_webhook_kwargs(params, tool_ctx), ) self._maybe_auto_emit_sync_completion("sync_creatives", params, result) diff --git a/src/adcp/server/_creative_batch.py b/src/adcp/server/_creative_batch.py new file mode 100644 index 0000000000..c30b19f349 --- /dev/null +++ b/src/adcp/server/_creative_batch.py @@ -0,0 +1,202 @@ +"""Partial admission for explicitly lenient legacy creative uploads. + +The dispatcher owns format projection, not the seller's creative library. +Never reconcile a destructive library replacement from a filtered request. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from contextvars import ContextVar +from copy import deepcopy +from dataclasses import dataclass, field +from typing import Any + +from pydantic import ValidationError + +from adcp.canonical_formats.projection import ( + LegacyCreativeProjectionError, + LegacyFormatConverter, + normalize_legacy_creative_request, +) + + +@dataclass(frozen=True) +class PartialCreativeBatch: + submitted_ids: tuple[str, ...] + failures: tuple[dict[str, Any], ...] + _typed_failures: dict[str, Any] = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + from adcp.types import SyncCreativeResult + + # Materialize SDK-owned failures BEFORE dispatch can mutate anything. + # The wire assignment_errors map permits arbitrary package IDs through + # additionalProperties; its generated model over-restricts every key. + # Validate our remaining fields, then retain our known string-valued + # map without applying that extra generated key restriction. + prepared = {} + for failure in self.failures: + row = SyncCreativeResult.model_validate( + {key: value for key, value in failure.items() if key != "assignment_errors"} + ) + if "assignment_errors" in failure: + row = row.model_copy( + update={"assignment_errors": deepcopy(failure["assignment_errors"])} + ) + prepared[failure["creative_id"]] = row + object.__setattr__(self, "_typed_failures", prepared) + + def merge(self, result: Any) -> None: + """Overlay once per terminal result, before persistence or delivery. + + Idempotency replays may already contain our rejection rows. Replacing + them rather than appending makes repeated overlays idempotent too. + Handler request-level errors retain their own terminal error branch. + """ + payload = ( + result.model_dump(mode="json", exclude_none=True) + if hasattr(result, "model_dump") + else result + ) + if not isinstance(payload, dict) or "creatives" not in payload: + return + rows = payload["creatives"] + if not isinstance(rows, list): + raise ValueError("sync_creatives completion must contain a creatives array") + by_id = {row["creative_id"]: row for row in rows} + if len(by_id) != len(rows): + raise ValueError("sync_creatives completion contains duplicate creative IDs") + by_id.update((row["creative_id"], deepcopy(row)) for row in self.failures) + if any(creative_id not in by_id for creative_id in self.submitted_ids): + raise ValueError("sync_creatives completion omits an admitted creative") + ordered = [by_id[creative_id] for creative_id in self.submitted_ids] + submitted = set(self.submitted_ids) + ordered.extend(row for row in rows if row["creative_id"] not in submitted) + if isinstance(result, dict): + result["creatives"] = ordered + else: + # Preserve the adopter's already-typed rows, including private + # metadata. Rebuilding them from serialized data can reject a + # conforming response or discard same-process projection state. + original = { + row["creative_id"] if isinstance(row, dict) else row.creative_id: row + for row in result.creatives + } + result.creatives = [ + ( + self._typed_failures[row["creative_id"]].model_copy(deep=True) + if row["creative_id"] in self._typed_failures + else original[row["creative_id"]] + ) + for row in ordered + ] + + +partial_creative_batch: ContextVar[PartialCreativeBatch | None] = ContextVar( + "adcp_partial_creative_batch", default=None +) + + +def normalize_lenient_creative_batch( + value: Mapping[str, Any], + *, + legacy_format_converter: LegacyFormatConverter | None, + projection_sources: list[Any], +) -> tuple[dict[str, Any], PartialCreativeBatch | None]: + """Validate the whole envelope before isolating unprojectable formats.""" + from adcp.types.legacy import LegacySyncCreativesRequest + + try: + LegacySyncCreativesRequest.model_validate(value) + except ValidationError as exc: + first = exc.errors(include_input=False, include_context=False, include_url=False)[0] + path = ".".join(str(part) for part in first["loc"]) + raise LegacyCreativeProjectionError( + f"Invalid sync_creatives request at {path or 'request'}: {first['msg']}" + ) from exc + + creatives = value.get("creatives") + if not creatives: + return ( + normalize_legacy_creative_request( + value, + legacy_format_converter=legacy_format_converter, + projection_sources=projection_sources, + ), + None, + ) + submitted_ids = tuple(row["creative_id"] for row in creatives) + if len(set(submitted_ids)) != len(submitted_ids): + raise LegacyCreativeProjectionError("sync_creatives requires unique creative IDs") + + normalized = normalize_legacy_creative_request( + {key: child for key, child in value.items() if key != "creatives"}, + legacy_format_converter=legacy_format_converter, + projection_sources=projection_sources, + ) + admitted: list[dict[str, Any]] = [] + failures: list[dict[str, Any]] = [] + for index, creative in enumerate(creatives): + creative_sources: list[Any] = [] + try: + projected = normalize_legacy_creative_request( + creative, + legacy_format_converter=legacy_format_converter, + projection_sources=creative_sources, + field_path=f"request.creatives[{index}]", + ) + except LegacyCreativeProjectionError as exc: + message = str(exc) + failures.append( + { + "creative_id": creative["creative_id"], + "action": "failed", + "errors": [ + { + "code": "UNSUPPORTED_FEATURE", + "message": message, + "field": message.split(" cannot be projected", 1)[0].removeprefix( + "request." + ), + } + ], + } + ) + else: + admitted.append(projected) + projection_sources.extend(creative_sources) + normalized["creatives"] = admitted + if not failures: + return normalized, None + if value.get("delete_missing"): + raise LegacyCreativeProjectionError( + "delete_missing cannot reconcile a partially projected creative batch; " + "retry the uploads without delete_missing, then reconcile the complete library" + ) + if value.get("creative_ids"): + raise LegacyCreativeProjectionError( + "creative_ids filtering cannot be combined with a partially projected batch; " + "submit only the selected creatives instead" + ) + + rejected = {row["creative_id"]: row for row in failures} + assignments = [] + for assignment in normalized.get("assignments", []): + failure = rejected.get(assignment["creative_id"]) + if failure is None: + assignments.append(assignment) + else: + failure.setdefault("assignment_errors", {})[ + assignment["package_id"] + ] = "Assignment refused because this submitted creative's format cannot be projected" + if assignments: + if not admitted: + raise LegacyCreativeProjectionError( + "A batch with no projectable creatives cannot also apply unrelated assignments; " + "retry the assignments separately" + ) + normalized["assignments"] = assignments + else: + normalized.pop("assignments", None) + return normalized, PartialCreativeBatch(submitted_ids, tuple(failures)) diff --git a/src/adcp/server/idempotency/store.py b/src/adcp/server/idempotency/store.py index f256280269..254c60d41b 100644 --- a/src/adcp/server/idempotency/store.py +++ b/src/adcp/server/idempotency/store.py @@ -63,6 +63,9 @@ # also covers middleware whose wrapped callable is named call_next/execute. _RECEIPT_BATCH_DISPATCH: ContextVar[bool] = ContextVar("receipt_batch_dispatch", default=False) _FROZEN_FEED_DISPATCH: ContextVar[bool] = ContextVar("frozen_feed_dispatch", default=False) +_CREATIVE_BATCH_HASH_SOURCE: ContextVar[dict[str, Any] | None] = ContextVar( + "creative_batch_hash_source", default=None +) # Registry of functions returned by IdempotencyStore.wrap. Read by # adcp.decisioning.validate_idempotency.is_wrapped() to reconcile the @@ -344,6 +347,19 @@ async def _wrapped(*args: Any, **kwargs: Any) -> Any: # transparent on the no-key path. return await handler(*args, **kwargs) + # Only the first matching boundary owns the original ingress + # identity. Consuming this hint prevents nested business calls + # (including same-key calls to another backend) inheriting it. + # Scope/key extraction always uses the actual incoming request, + # including an idempotency key supplied by a pre-validation hook. + creative_source = _CREATIVE_BATCH_HASH_SOURCE.get() + if ( + operation == "sync_creatives" + and creative_source is not None + and creative_source.get("idempotency_key") == idempotency_key + ): + params_dict = creative_source + _CREATIVE_BATCH_HASH_SOURCE.set(None) payload_hash = self._hash_fn(params_dict) def _replay_cached(cached: CachedResponse) -> Any: diff --git a/src/adcp/server/mcp_tools.py b/src/adcp/server/mcp_tools.py index de3688d579..7e500ff839 100644 --- a/src/adcp/server/mcp_tools.py +++ b/src/adcp/server/mcp_tools.py @@ -3384,7 +3384,12 @@ def create_tool_caller( log_conversion_summary, ) from adcp.exceptions import ADCPFeatureUnsupportedError, ADCPTaskError + from adcp.server._creative_batch import ( + normalize_lenient_creative_batch, + partial_creative_batch, + ) from adcp.server.helpers import inject_context + from adcp.server.idempotency.store import _CREATIVE_BATCH_HASH_SOURCE from adcp.server.version_policy import project_supported_versions, resolve_supported_versions from adcp.types import Error from adcp.validation.envelope import ( @@ -3807,6 +3812,22 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None) post_adapter_validator_version = None ctx.metadata[CONVERSION_METADATA_KEY] = {"wire_version": wire_version, "losses": []} + if ( + method_name == "sync_creatives" + and unwrap_enum_value(params.get("validation_mode")) == "lenient" + and params.get("assignment_operations") + ): + raise ADCPTaskError( + operation=method_name, + errors=[ + Error( + code="INVALID_REQUEST", + message="assignment_operations requires strict validation", + field="validation_mode", + ) + ], + ) + creative_boundary_tools = { "create_media_buy", "get_creative_delivery", @@ -3818,6 +3839,7 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None) "update_media_buy", } legacy_projection_sources: list[Any] = [] + creative_batch = None if method_name in creative_boundary_tools and isinstance(params, dict) and wire_version: try: dialect = ( @@ -3830,11 +3852,40 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None) ) ) if dialect is CreativeDialect.LEGACY: - params = normalize_legacy_creative_request( - params, - legacy_format_converter=getattr(handler, "legacy_format_converter", None), - projection_sources=legacy_projection_sources, - ) + if ( + method_name == "sync_creatives" + and unwrap_enum_value(params.get("validation_mode")) == "lenient" + ): + params, creative_batch = normalize_lenient_creative_batch( + params, + legacy_format_converter=getattr( + handler, "legacy_format_converter", None + ), + projection_sources=legacy_projection_sources, + ) + if ( + creative_batch is not None + and params.get("creatives") + and not getattr(handler, "_supports_partial_creative_completion", False) + and ( + params.get("push_notification_config") is not None + or type(handler).get_task_status is not ADCPHandler.get_task_status + or type(handler).list_tasks is not ADCPHandler.list_tasks + ) + ): + raise LegacyCreativeProjectionError( + "Partially projected batches require inline sync_creatives " + "completion or SDK-managed PlatformHandler tasks; custom " + "asynchronous completion cannot retain projection failures" + ) + else: + params = normalize_legacy_creative_request( + params, + legacy_format_converter=getattr( + handler, "legacy_format_converter", None + ), + projection_sources=legacy_projection_sources, + ) # The normalized object is canonical handler input, not a # valid instance of the caller's legacy wire schema. post_adapter_validator_version = None @@ -3866,7 +3917,8 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None) params_model=params_model, ) - if request_mode is not None and request_mode != "off": + all_creatives_rejected = creative_batch is not None and not params.get("creatives") + if request_mode is not None and request_mode != "off" and not all_creatives_rejected: outcome = validate_request(method_name, params, version=post_adapter_validator_version) if not outcome.valid: summary = format_issues(outcome.issues) @@ -3889,7 +3941,7 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None) call_params = raw_params elif frozen_reporting_feed: call_params = params - elif params_model is not None and isinstance(params, dict): + elif params_model is not None and isinstance(params, dict) and not all_creatives_rejected: try: call_params = params_model.model_validate(params) except ValidationError as exc: @@ -3949,7 +4001,23 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None) ) ], ) from exc - result = await method(call_params, ctx) + result: Any + token = partial_creative_batch.set(creative_batch) + hash_token = _CREATIVE_BATCH_HASH_SOURCE.set( + {**raw_params, "idempotency_key": params.get("idempotency_key")} + if creative_batch is not None + else None + ) + try: + if all_creatives_rejected: + result = {"creatives": [], "dry_run": bool(params.get("dry_run"))} + else: + result = await method(call_params, ctx) + finally: + _CREATIVE_BATCH_HASH_SOURCE.reset(hash_token) + partial_creative_batch.reset(token) + if creative_batch is not None: + creative_batch.merge(result) if method_name == "get_adcp_capabilities": if isinstance(result, dict): from adcp.canonical_formats.dialect import canonical_creatives_capability diff --git a/tests/test_mixed_legacy_projection_regression.py b/tests/test_mixed_legacy_projection_regression.py new file mode 100644 index 0000000000..c7015e1748 --- /dev/null +++ b/tests/test_mixed_legacy_projection_regression.py @@ -0,0 +1,568 @@ +"""Integrator regression: one projection error must not reject valid siblings.""" + +from __future__ import annotations + +import asyncio +from concurrent.futures import ThreadPoolExecutor +from copy import deepcopy +from typing import Any + +import pytest + +from adcp.exceptions import ADCPTaskError +from adcp.server.base import ADCPHandler, ToolContext +from adcp.server.mcp_tools import create_tool_caller +from adcp.validation import ValidationHookConfig + + +class Seller(ADCPHandler[Any]): + adcp_capabilities = {"media_buy": {"features": {"canonical_creatives": False}}} + + def __init__(self) -> None: + self.received: list[dict[str, Any]] = [] + + async def sync_creatives(self, params: dict[str, Any], context: ToolContext) -> dict[str, Any]: + self.received.append(params) + return { + "creatives": [ + {"creative_id": creative["creative_id"], "action": "created", "status": "approved"} + for creative in params["creatives"] + ] + } + + +def batch() -> dict[str, Any]: + return { + "adcp_version": "3.1", + "idempotency_key": "mixed-creative-projection-0001", + "account": { + "brand": {"domain": "acmeoutdoor.example"}, + "operator": "pinnacle-agency.example", + "sandbox": True, + }, + "creatives": [ + { + "creative_id": "unsupported-native", + "name": "Native", + "format_id": { + "agent_url": "https://seller.example/mcp", + "id": "unsupported-native", + }, + "assets": {}, + }, + { + "creative_id": "display-one", + "name": "Display one", + "format_id": { + "agent_url": "https://seller.example/mcp", + "id": "display_300x250_image", + "width": 300, + "height": 250, + }, + "assets": {}, + }, + { + "creative_id": "display-two", + "name": "Display two", + "format_id": { + "agent_url": "https://seller.example/mcp", + "id": "display_300x250_image", + "width": 300, + "height": 250, + }, + "assets": {}, + }, + ], + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", [None, "strict"]) +async def test_strict_projection_rejects_the_batch_before_the_seller(mode: str | None) -> None: + seller = Seller() + request = batch() + if mode is not None: + request["validation_mode"] = mode + with pytest.raises(ADCPTaskError) as raised: + await create_tool_caller(seller, "sync_creatives")(request) + assert seller.received == [] + assert raised.value.errors[0].code == "INVALID_REQUEST" + assert ( + raised.value.errors[0].message + == "request.creatives[0].format_id cannot be projected (no_match)" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("strict_validation", [False, True]) +@pytest.mark.parametrize("version", ["3.0", "3.1"]) +async def test_lenient_mixed_batch_accepts_two_creatives_and_fails_one( + strict_validation: bool, + version: str, +) -> None: + seller = Seller() + response = await create_tool_caller( + seller, + "sync_creatives", + validation=( + ValidationHookConfig(requests="strict", responses="strict") + if strict_validation + else None + ), + )({**batch(), "adcp_version": version, "validation_mode": "lenient"}) + rows = {creative["creative_id"]: creative for creative in response["creatives"]} + assert rows["unsupported-native"]["action"] == "failed" + assert rows["unsupported-native"]["errors"] + assert rows["display-one"]["action"] == "created" + assert rows["display-two"]["action"] == "created" + assert [row["creative_id"] for row in response["creatives"]] == [ + "unsupported-native", + "display-one", + "display-two", + ] + assert [row["creative_id"] for row in seller.received[0]["creatives"]] == [ + "display-one", + "display-two", + ] + + +@pytest.mark.asyncio +async def test_configured_converter_receives_original_batch_positions() -> None: + seller = Seller() + paths: list[str] = [] + + def converter(context: Any) -> dict[str, Any] | None: + paths.append(context.field) + if context.field == "request.creatives[1].format_id": + return None + return {"format_kind": "image", "params": {"width": 300, "height": 250}} + + seller.legacy_format_converter = converter + request = {**batch(), "validation_mode": "lenient"} + for index, creative in enumerate(request["creatives"]): + creative["format_id"]["id"] = f"publisher-format-{index}" + response = await create_tool_caller(seller, "sync_creatives")(request) + assert paths == [f"request.creatives[{index}].format_id" for index in range(3)] + assert [row["action"] for row in response["creatives"]] == ["created", "failed", "created"] + assert response["creatives"][1]["errors"][0]["field"] == "creatives[1].format_id" + assert response["creatives"][1]["errors"][0]["code"] == "UNSUPPORTED_FEATURE" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mutation", ["account", "creative_id", "duplicate", "format_id"]) +async def test_malformed_lenient_request_never_reaches_seller(mutation: str) -> None: + seller = Seller() + request = {**batch(), "validation_mode": "lenient"} + if mutation == "account": + request["account"] = {"sandbox": True} + elif mutation == "creative_id": + request["creatives"][0].pop("creative_id") + elif mutation == "duplicate": + request["creatives"][0]["creative_id"] = "display-one" + else: + request["creatives"][0]["format_id"] = {"id": "missing-owner"} + with pytest.raises(ADCPTaskError) as caught: + await create_tool_caller(seller, "sync_creatives")(request) + assert caught.value.errors[0].code == "INVALID_REQUEST" + assert seller.received == [] + + +@pytest.mark.asyncio +async def test_all_unprojectable_rows_fail_without_empty_handler_dispatch() -> None: + seller = Seller() + request = { + **batch(), + "validation_mode": "lenient", + "dry_run": True, + "context": {"trace": "batch"}, + } + request["creatives"] = request["creatives"][:1] + response = await create_tool_caller( + seller, + "sync_creatives", + validation=ValidationHookConfig(requests="strict", responses="strict"), + )(request) + assert response["creatives"][0]["action"] == "failed" + assert response["dry_run"] is True + assert response["context"] == {"trace": "batch"} + assert seller.received == [] + + +@pytest.mark.asyncio +async def test_delete_missing_fails_before_any_library_reconciliation() -> None: + seller = Seller() + with pytest.raises(ADCPTaskError, match="delete_missing"): + await create_tool_caller(seller, "sync_creatives")( + {**batch(), "validation_mode": "lenient", "delete_missing": True} + ) + assert seller.received == [] + # A fully projectable replacement still receives every submitted ID and + # the original reconciliation flag. No failure silently disables a sweep. + request = {**batch(), "validation_mode": "lenient", "delete_missing": True} + request["creatives"][0]["format_id"] = deepcopy(request["creatives"][1]["format_id"]) + await create_tool_caller(seller, "sync_creatives")(request) + assert seller.received[0]["delete_missing"] is True + assert len(seller.received[0]["creatives"]) == 3 + + +@pytest.mark.asyncio +async def test_assignments_cannot_use_a_rejected_existing_creative() -> None: + seller = Seller() + request = { + **batch(), + "validation_mode": "lenient", + "assignments": [ + {"creative_id": "unsupported-native", "package_id": "package_bad"}, + {"creative_id": "display-one", "package_id": "package_good"}, + ], + } + response = await create_tool_caller(seller, "sync_creatives")(request) + assert seller.received[0]["assignments"] == [ + {"creative_id": "display-one", "package_id": "package_good"} + ] + assert "package_bad" in response["creatives"][0]["assignment_errors"] + + +@pytest.mark.asyncio +async def test_partial_creative_ids_filter_fails_before_mutation() -> None: + seller = Seller() + with pytest.raises(ADCPTaskError, match="creative_ids filtering"): + await create_tool_caller(seller, "sync_creatives")( + {**batch(), "validation_mode": "lenient", "creative_ids": ["display-one"]} + ) + assert seller.received == [] + + +@pytest.mark.asyncio +async def test_all_failed_uploads_do_not_silently_drop_valid_assignment_work() -> None: + seller = Seller() + request = { + **batch(), + "validation_mode": "lenient", + "assignments": [ + {"creative_id": "already-stored", "package_id": "package_existing"}, + ], + } + request["creatives"] = request["creatives"][:1] + with pytest.raises(ADCPTaskError, match="unrelated assignments"): + await create_tool_caller(seller, "sync_creatives")(request) + assert seller.received == [] + + +@pytest.mark.asyncio +async def test_explicit_assignment_operations_remain_strict_and_atomic() -> None: + seller = Seller() + with pytest.raises(ADCPTaskError, match="requires strict"): + await create_tool_caller(seller, "sync_creatives")( + { + **batch(), + "adcp_version": "3.2", + "validation_mode": "lenient", + "assignment_operations": [ + { + "operation": "assign", + "creative_id": "display-one", + "package_id": "package_one", + } + ], + } + ) + assert seller.received == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("change", ["creative_id", "name"]) +async def test_rejected_inputs_remain_in_original_idempotency_identity(change: str) -> None: + from adcp.exceptions import IdempotencyConflictError + from adcp.server import IdempotencyStore, MemoryBackend + + store = IdempotencyStore(MemoryBackend()) + + class IdempotentSeller(Seller): + @store.wrap + async def sync_creatives( + self, params: dict[str, Any], context: ToolContext + ) -> dict[str, Any]: + return await super().sync_creatives(params, context) + + seller = IdempotentSeller() + caller = create_tool_caller(seller, "sync_creatives") + request = {**batch(), "validation_mode": "lenient", "dry_run": True} + first = await caller(request, ToolContext(caller_identity="buyer_one")) + replay = await caller(deepcopy(request), ToolContext(caller_identity="buyer_one")) + assert replay["replayed"] is True + assert replay["creatives"] == first["creatives"] + assert len(seller.received) == 1 + assert seller.received[0]["dry_run"] is True + assert seller.received[0]["idempotency_key"] == request["idempotency_key"] + changed = deepcopy(request) + changed["creatives"][0][change] = "different-rejected-input" + with pytest.raises(IdempotencyConflictError): + await caller(changed, ToolContext(caller_identity="buyer_one")) + assert len(seller.received) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("surface", ["poll", "push"]) +async def test_custom_async_surfaces_are_refused_before_dispatch(surface: str) -> None: + class PollingSeller(Seller): + async def get_task_status(self, params: Any, context: Any = None) -> Any: + return {"task_id": "custom-task"} + + seller = PollingSeller() if surface == "poll" else Seller() + request = {**batch(), "validation_mode": "lenient"} + if surface == "push": + request["push_notification_config"] = { + "url": "https://buyer.example/webhook", + "operation_id": "mixed-batch-op", + } + with pytest.raises(ADCPTaskError, match="custom asynchronous"): + await create_tool_caller(seller, "sync_creatives")(request) + assert seller.received == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("same_key,wrap_outer", [(False, True), (True, True), (False, False)]) +async def test_nested_wrapped_sync_keeps_its_own_request_identity( + same_key: bool, wrap_outer: bool +) -> None: + from adcp.server import IdempotencyStore, MemoryBackend + from adcp.server.idempotency.canonicalize import canonical_json_sha256 + + inner_backend = MemoryBackend() + inner_store = IdempotencyStore(inner_backend) + outer_store = IdempotencyStore(MemoryBackend()) + request = {**batch(), "validation_mode": "lenient"} + child_key = request["idempotency_key"] if same_key else "child-operation-0001" + child_request = {"idempotency_key": child_key, "creatives": [], "child_operation": True} + + class Inner: + @inner_store.wrap + async def sync_creatives(self, params: Any, context: Any) -> Any: + return {"creatives": []} + + class Outer(Seller): + async def sync_creatives( + self, params: dict[str, Any], context: ToolContext + ) -> dict[str, Any]: + await Inner().sync_creatives(child_request, context) + return await super().sync_creatives(params, context) + + class WrappedOuter(Outer): + @outer_store.wrap + async def sync_creatives( + self, params: dict[str, Any], context: ToolContext + ) -> dict[str, Any]: + return await super().sync_creatives(params, context) + + seller = WrappedOuter() if wrap_outer else Outer() + await create_tool_caller(seller, "sync_creatives")( + request, ToolContext(caller_identity="buyer_one") + ) + cached = await inner_backend.get("buyer_one", child_key) + assert cached is not None + assert cached.payload_hash == canonical_json_sha256(child_request) + if not same_key: + assert await inner_backend.get("buyer_one", request["idempotency_key"]) is None + + +@pytest.mark.asyncio +async def test_pre_validation_hook_key_still_deduplicates_partial_batches() -> None: + from adcp.exceptions import IdempotencyConflictError + from adcp.server import IdempotencyStore, MemoryBackend + + store = IdempotencyStore(MemoryBackend()) + + class IdempotentSeller(Seller): + @store.wrap + async def sync_creatives( + self, params: dict[str, Any], context: ToolContext + ) -> dict[str, Any]: + return await super().sync_creatives(params, context) + + seller = IdempotentSeller() + request = {**batch(), "validation_mode": "lenient"} + request.pop("idempotency_key") + caller = create_tool_caller( + seller, + "sync_creatives", + pre_validation_hook=lambda tool, params: { + **params, + "idempotency_key": "hook-provided-key-0001", + }, + ) + await caller(request, ToolContext(caller_identity="buyer_one")) + replay = await caller(deepcopy(request), ToolContext(caller_identity="buyer_one")) + assert replay["replayed"] is True + assert len(seller.received) == 1 + changed = deepcopy(request) + changed["creatives"][0]["name"] = "different rejected content" + with pytest.raises(IdempotencyConflictError): + await caller(changed, ToolContext(caller_identity="buyer_one")) + assert len(seller.received) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("completion", ["inline", "task", "workflow"]) +@pytest.mark.parametrize("typed", [False, True]) +async def test_managed_completion_preserves_rejection_rows(completion: str, typed: bool) -> None: + from adcp.decisioning import ( + AdcpError, + DecisioningCapabilities, + DecisioningPlatform, + InMemoryTaskRegistry, + SingletonAccounts, + ) + from adcp.decisioning.handler import PlatformHandler + from adcp.types import SyncCreativesSuccessResponse + + class Platform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["sales-non-guaranteed"]) + accounts = SingletonAccounts(account_id="acct_one") + + def __init__(self) -> None: + self.uploaded: list[str] = [] + self.workflows: list[str] = [] + + def sync_creatives(self, req: Any, ctx: Any) -> Any: + def finish(task_ctx: Any = None) -> Any: + self.uploaded.extend(creative.creative_id for creative in req.creatives) + result = { + "creatives": [ + { + "creative_id": creative.creative_id, + "action": "created", + "status": "approved", + } + for creative in reversed(req.creatives) + ] + } + return SyncCreativesSuccessResponse(**result) if typed else result + + if completion == "task": + return ctx.handoff_to_task(finish) + if completion == "workflow": + return ctx.handoff_to_workflow(lambda task_ctx: self.workflows.append(task_ctx.id)) + return finish() + + platform = Platform() + registry = InMemoryTaskRegistry() + with ThreadPoolExecutor(max_workers=2) as executor: + handler = PlatformHandler(platform, executor=executor, registry=registry) + handler.adcp_capabilities = Seller.adcp_capabilities + caller = create_tool_caller(handler, "sync_creatives") + request = {**batch(), "account": {"account_id": "acct_one"}, "validation_mode": "lenient"} + if completion == "workflow": + with pytest.raises(AdcpError, match="cannot use WorkflowHandoff"): + await caller(request) + assert platform.workflows == [] + assert platform.uploaded == [] + assert registry._records == {} + return + response = await caller(request) + if completion == "task": + assert response["status"] == "submitted" + for _ in range(100): + record = await registry.get( + response["task_id"], expected_account_id="acct_one:anonymous" + ) + if record is not None and record["state"] in {"completed", "failed"}: + break + await asyncio.sleep(0.01) + assert record is not None + assert record["state"] == "completed", record + response = record["result"] + assert platform.uploaded == ["display-one", "display-two"] + assert [row["creative_id"] for row in response["creatives"]] == [ + "unsupported-native", + "display-one", + "display-two", + ] + assert response["creatives"][0]["action"] == "failed" + assert [row["action"] for row in response["creatives"][1:]] == ["created", "created"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("package_id", ["package.with.dot", ""]) +@pytest.mark.parametrize("handoff", [False, True], ids=["inline", "task"]) +async def test_typed_assignment_failures_keep_all_wire_valid_package_ids( + package_id: str, handoff: bool +) -> None: + from adcp.decisioning import ( + DecisioningCapabilities, + DecisioningPlatform, + InMemoryTaskRegistry, + SingletonAccounts, + ) + from adcp.decisioning.handler import PlatformHandler + from adcp.server import IdempotencyStore, MemoryBackend + from adcp.types import SyncCreativesSuccessResponse + from adcp.validation import validate_response + + store = IdempotencyStore(MemoryBackend()) + + class Platform(DecisioningPlatform): + capabilities = DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], supported_protocols=["media_buy"] + ) + accounts = SingletonAccounts(account_id="assignment_test") + + def __init__(self) -> None: + self.uploaded: list[str] = [] + self.response: Any = None + self.original_rows: list[Any] = [] + + @store.wrap + async def sync_creatives(self, req: Any, ctx: Any) -> Any: + def finish(task_ctx: Any = None) -> Any: + self.uploaded.extend(row.creative_id for row in req.creatives) + assert req.assignments is None + self.response = SyncCreativesSuccessResponse( + creatives=[ + {"creative_id": row.creative_id, "action": "created", "status": "approved"} + for row in req.creatives + ] + ) + self.original_rows = list(self.response.creatives) + return self.response + + return ctx.handoff_to_task(finish) if handoff else finish() + + platform = Platform() + registry = InMemoryTaskRegistry() + with ThreadPoolExecutor(max_workers=2) as executor: + handler = PlatformHandler(platform, executor=executor, registry=registry) + handler.adcp_capabilities = Seller.adcp_capabilities + request = { + **batch(), + "validation_mode": "lenient", + "account": {"account_id": "assignment_test"}, + "assignments": [{"creative_id": "unsupported-native", "package_id": package_id}], + } + caller = create_tool_caller( + handler, + "sync_creatives", + validation=ValidationHookConfig(requests="strict", responses="strict"), + ) + response = await caller(request) + if handoff: + for _ in range(100): + record = await registry.get( + response["task_id"], expected_account_id="assignment_test:anonymous" + ) + if record is not None and record["state"] in {"completed", "failed"}: + break + await asyncio.sleep(0.01) + assert record is not None and record["state"] == "completed", record + response = record["result"] + assert validate_response("sync_creatives", response, version="3.1").valid + assert platform.uploaded == ["display-one", "display-two"] + assert response["creatives"][0]["action"] == "failed" + assert package_id in response["creatives"][0]["assignment_errors"] + assert platform.response.creatives[1] is platform.original_rows[0] + assert platform.response.creatives[2] is platform.original_rows[1] + if not handoff: + replay = await caller(deepcopy(request)) + assert replay["replayed"] is True + assert replay["creatives"] == response["creatives"] + assert platform.uploaded == ["display-one", "display-two"] From 679af6e6374144c30effc430e019bea8a6413b70 Mon Sep 17 00:00:00 2001 From: "aao-ipr-bot[bot]" <279263549+aao-ipr-bot[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:19:02 -0500 Subject: [PATCH 24/28] chore(main): release 9.6.0 (#1833) Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 19 +++++++++++++++++++ pyproject.toml | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 96332ac50b..3e610f6d99 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { - ".": "9.5.1", + ".": "9.6.0", "packages/adcp-reporting-storage": "0.1.0-beta.4" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 12c1675cdc..f5cf176660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -192,6 +192,25 @@ change public consumer defaults. See [format-kind migration](docs/canonical-format-kinds-migration.md). +## [9.6.0](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.5.1...v9.6.0) (2026-11-03) + + +### Features + +* **reporting-storage:** accept 255 character row binding IDs and credential refs ([#1828](https://github.com/adcontextprotocol/adcp-client-python/issues/1828)) ([6bb34df](https://github.com/adcontextprotocol/adcp-client-python/commit/6bb34df00821e11f03aed2a018a0c5a6ccab3cdf)) +* **reporting-storage:** collect orphaned row objects by listing with a clone fence ([#1835](https://github.com/adcontextprotocol/adcp-client-python/issues/1835)) ([e004f1b](https://github.com/adcontextprotocol/adcp-client-python/commit/e004f1bc55eff3bc32065a9c785f451af3813f79)) +* **reporting-storage:** order the account change feed by xid and seq ([#1827](https://github.com/adcontextprotocol/adcp-client-python/issues/1827)) ([4449bc9](https://github.com/adcontextprotocol/adcp-client-python/commit/4449bc94cdd47900f39e15f5e99a35a9699d0c30)) + + +### Bug Fixes + +* **server:** preserve valid creatives in lenient legacy batches ([#1841](https://github.com/adcontextprotocol/adcp-client-python/issues/1841)) ([2bd7751](https://github.com/adcontextprotocol/adcp-client-python/commit/2bd7751cb1312efda5aefc4a11fecdfab0933c9e)) + + +### Performance Improvements + +* **reporting-storage:** drop the per-object advisory lock where nothing pairs on it ([#1836](https://github.com/adcontextprotocol/adcp-client-python/issues/1836)) ([536b8f8](https://github.com/adcontextprotocol/adcp-client-python/commit/536b8f870f518e7ceab3c7a1d64aa0b229b75e8e)) + ## [9.5.1](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.5.0...v9.5.1) (2026-11-03) diff --git a/pyproject.toml b/pyproject.toml index a4d5e7b021..9c196f5f82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "adcp" -version = "9.5.1" +version = "9.6.0" description = "Official Python client for the Ad Context Protocol (AdCP)" authors = [ {name = "AdCP Community", email = "maintainers@adcontextprotocol.org"} From 86302d09404067410a60d3f9d0f850790038a36f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 3 Nov 2026 14:17:57 -0500 Subject: [PATCH 25/28] fix(reporting): isolate handler registrations for each installation (#1847) * fix(reporting): allocate a unique handler name for every installation Give each newly installed reporting handler its own UUID-backed class name. Service addresses can be reused, and one service can install sellers with separate advertised tool sets. Preserve same-platform reinstall identity and strict conflicting handler registration checks. Fixes #1846 * test(reporting): isolate the installation fixture from global handler names --- src/adcp/reporting/service.py | 4 +- tests/test_reporting_install_identity.py | 64 ++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 tests/test_reporting_install_identity.py diff --git a/src/adcp/reporting/service.py b/src/adcp/reporting/service.py index ba973cc65f..1563f3abad 100644 --- a/src/adcp/reporting/service.py +++ b/src/adcp/reporting/service.py @@ -18,6 +18,7 @@ from datetime import datetime, timedelta, timezone from types import MappingProxyType from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable +from uuid import uuid4 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from pydantic import BaseModel @@ -1430,7 +1431,8 @@ async def delegated(instance: Any, *args: Any, **kwargs: Any) -> Any: attrs[name] = passthrough(name, method) - installed_name = f"ReliableReporting{original.__name__}_{id(self):x}" + # Registrations outlive services, and one service can install several sellers. + installed_name = f"ReliableReporting{original.__name__}_{uuid4().hex}" installed = type( installed_name, (ReportingInstallMixin, original), diff --git a/tests/test_reporting_install_identity.py b/tests/test_reporting_install_identity.py new file mode 100644 index 0000000000..073cbc61fc --- /dev/null +++ b/tests/test_reporting_install_identity.py @@ -0,0 +1,64 @@ +"""Reporting installations must retain each seller's own advertised tools.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from adcp.reporting.service import ReliableReportingService +from adcp.server import ADCPHandler +from adcp.server.mcp_tools import get_tools_for_handler, register_handler_tools + + +# Raw handler names share a process-wide registry with other tests. +class _InstallationIdentitySeller(ADCPHandler): + def __init__(self, tool: str) -> None: + super().__init__() + self.tool = tool + + def advertised_tools_for_instance(self) -> set[str]: + return {self.tool} + + async def get_products(self, params: Any, context: Any = None) -> Any: + return {"products": [{"product_id": "first-seller"}]} + + async def get_media_buys(self, params: Any, context: Any = None) -> Any: + return {"media_buys": [{"media_buy_id": "second-seller"}]} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("same_service", [True, False]) +async def test_install_preserves_distinct_seller_surfaces(same_service: bool) -> None: + first_service = ReliableReportingService.memory(account_context=lambda *_: None) + second_service = ( + first_service + if same_service + else ReliableReportingService.memory(account_context=lambda *_: None) + ) + first = first_service.install(_InstallationIdentitySeller("get_products")) + first_type = type(first) + assert first_service.install(first) is first + assert type(first) is first_type + + second = second_service.install(_InstallationIdentitySeller("get_media_buys")) + second_type = type(second) + assert second_type.__name__ != first_type.__name__ + assert second_service.install(second) is second + assert type(second) is second_type + + first_tools = {item["name"] for item in get_tools_for_handler(first, _include_schemas=False)} + second_tools = {item["name"] for item in get_tools_for_handler(second, _include_schemas=False)} + assert "get_products" in first_tools and "get_media_buys" not in first_tools + assert "get_media_buys" in second_tools and "get_products" not in second_tools + assert await first.get_products({}) == {"products": [{"product_id": "first-seller"}]} + assert await second.get_media_buys({}) == {"media_buys": [{"media_buy_id": "second-seller"}]} + + # New installation names must not weaken the public registry's conflict guard. + registered = first_type.advertised_tools + register_handler_tools(first_type.__name__, registered) + with pytest.raises(ValueError, match="called twice with different tool sets"): + register_handler_tools(first_type.__name__, registered | {"get_media_buys"}) + assert { + item["name"] for item in get_tools_for_handler(first, _include_schemas=False) + } == first_tools From 8da31ccfdc7f484cd19d78bd1a64ff5791eef153 Mon Sep 17 00:00:00 2001 From: "aao-ipr-bot[bot]" <279263549+aao-ipr-bot[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:57:52 -0500 Subject: [PATCH 26/28] chore(main): release 9.6.1 (#1848) Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 3e610f6d99..fc47ecb3da 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { - ".": "9.6.0", + ".": "9.6.1", "packages/adcp-reporting-storage": "0.1.0-beta.4" } diff --git a/CHANGELOG.md b/CHANGELOG.md index f5cf176660..a79fab232b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -192,6 +192,13 @@ change public consumer defaults. See [format-kind migration](docs/canonical-format-kinds-migration.md). +## [9.6.1](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.6.0...v9.6.1) (2026-11-03) + + +### Bug Fixes + +* **reporting:** isolate handler registrations for each installation ([#1847](https://github.com/adcontextprotocol/adcp-client-python/issues/1847)) ([86302d0](https://github.com/adcontextprotocol/adcp-client-python/commit/86302d09404067410a60d3f9d0f850790038a36f)) + ## [9.6.0](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.5.1...v9.6.0) (2026-11-03) diff --git a/pyproject.toml b/pyproject.toml index 9c196f5f82..2eb1716b31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "adcp" -version = "9.6.0" +version = "9.6.1" description = "Official Python client for the Ad Context Protocol (AdCP)" authors = [ {name = "AdCP Community", email = "maintainers@adcontextprotocol.org"} From 5beaaedf4285174e0e789208d95c227184d13310 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 7 Nov 2026 14:23:22 -0500 Subject: [PATCH 27/28] chore: add Traycer environment file (#1858) Adds .traycer/environment.json so new Traycer worktrees run the repo setup automatically: copy gitignored .env files from the main checkout, uv sync with all extras, and make bootstrap. Co-authored-by: Claude Sonnet 5.5 --- .traycer/environment.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .traycer/environment.json diff --git a/.traycer/environment.json b/.traycer/environment.json new file mode 100644 index 0000000000..c7e50a3c3f --- /dev/null +++ b/.traycer/environment.json @@ -0,0 +1,15 @@ +{ + "setup": { + "default": "ROOT=\"$(cd \"$(git rev-parse --git-common-dir)/..\" && pwd)\" || exit 1\nfor f in \"$ROOT\"/.env*; do n=\"${f##*/}\"; [ -f \"$f\" ] && [ ! -e \"$n\" ] && git check-ignore -q \"$n\" && cp \"$f\" \"$n\"; done; true || exit 1\nuv sync --all-extras || exit 1\nmake bootstrap || exit 1", + "macos": null, + "linux": null, + "windows": null + }, + "teardown": { + "default": "", + "macos": null, + "linux": null, + "windows": null + }, + "updatedAt": 1793934266991 +} From 5eebb5386fd09ac23e9727587cc9413ef24fa0af Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 7 Nov 2026 19:05:57 -0500 Subject: [PATCH 28/28] fix(reporting-storage): refuse a ledger upgraded to storage contract 2 with the real remedy (#1857) * fix(reporting-storage): refuse a ledger upgraded to storage contract 2 with the real remedy adcp-reporting-storage 0.2.0b1+ upgrades a 0.1.x ledger in place, widening the configuration, obligation-period and status-checkpoint keys with offering_id. A 0.1.x process that keeps running through a rolling deploy, or restarts after it, used to fail in ways that hid the cause: - create_schema() raised "Reporting caller ownership requires a stopped-worker maintenance migration", with a hint to run migrate_legacy_reporting. That migration doesn't apply here and would move reporting state into quarantine. - commit_obligation raised LedgerConflictError LEDGER_WRITE_FAILED ("integrity rule"), and put_configuration and the status checkpoint write raised a raw InvalidColumnReference (42P10): their ON CONFLICT targets no longer match a unique index. Refuse an upgraded ledger (reporting_configurations has offering_id) before any DDL with ReportingSchemaAheadError, an operator-action error that startup re-raises unwrapped. Map 42P10 on those writes to the same remedy: obligation writes raise LedgerConflictError code REPORTING_SCHEMA_AHEAD, and configuration and checkpoint writes raise ReportingSchemaAheadError. The remedy is to run adcp>=10.0.0b7 with adcp-reporting-storage>=0.2.0b1 everywhere; downgrading means restoring the pre-upgrade backup; do not run migrate_legacy_reporting. The packaged SQL's own check now raises the same message, for operators who apply the DDL themselves. Refs #1831 Co-Authored-By: Claude Opus 5.5 * test(public-api): record ReportingSchemaAheadError in the public API snapshot Co-Authored-By: Claude Opus 5.5 * test(reporting-storage): skip the contract-2 DDL test where psycopg is absent Import psycopg only after isolated_reporting_pool has checked it is installed, as the native suite runs without it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../src/adcp_reporting_storage/__init__.py | 4 + .../src/adcp_reporting_storage/ledger/pg.py | 78 ++++++----- .../reporting_ledger_account_generations.sql | 7 + .../src/adcp_reporting_storage/migration.py | 46 ++++++- .../outbox/status_pg.py | 22 ++- ..._reporting_storage_contract2_refusal_pg.py | 130 ++++++++++++++++++ tests/fixtures/public_api_snapshot.json | 1 + 7 files changed, 246 insertions(+), 42 deletions(-) create mode 100644 tests/conformance/reporting/test_reporting_storage_contract2_refusal_pg.py diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/__init__.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/__init__.py index 0eef67eb4c..59d4a48f9f 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/__init__.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/__init__.py @@ -168,6 +168,9 @@ from adcp_reporting_storage.migration import ( ReportingOwnershipMigrationError as ReportingOwnershipMigrationError, ) + from adcp_reporting_storage.migration import ( + ReportingSchemaAheadError as ReportingSchemaAheadError, + ) from adcp_reporting_storage.migration import ( backfill_legacy_reporting as backfill_legacy_reporting, ) @@ -252,6 +255,7 @@ # Caller-ownership migration "ReportingOwnershipBackfill": ("migration", "ReportingOwnershipBackfill"), "ReportingOwnershipMigrationError": ("migration", "ReportingOwnershipMigrationError"), + "ReportingSchemaAheadError": ("migration", "ReportingSchemaAheadError"), "backfill_legacy_reporting": ("migration", "backfill_legacy_reporting"), "legacy_generation_digest": ("migration", "legacy_generation_digest"), "migrate_legacy_reporting": ("migration", "migrate_legacy_reporting"), diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/pg.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/pg.py index c84109c437..f22c48ed0e 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/pg.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/pg.py @@ -159,6 +159,7 @@ ReportingRowStorageV1, ReportingRowStoreError, ) +from adcp_reporting_storage.migration import schema_ahead_error if TYPE_CHECKING: from psycopg_pool import AsyncConnectionPool @@ -1028,40 +1029,46 @@ async def put_configuration(self, configuration: ReportingConfiguration) -> None digest = _fingerprint(payload) async with self._connection() as connection, connection.transaction(): await self._lock_account(connection, key.account_id) - inserted_cursor = await connection.execute( - "INSERT INTO reporting_configurations" - " (delivery_config_id, delivery_config_version, account_id," - " report_definition_id, reporting_profile, feed_purpose, required_finality," - " account_timezone, schedule, media_buy_ids, activated_at, deactivated_at," - " automated_recovery_seconds, status_retention_days, definition," - " authoritative_party, content_sha256, consumer_id, quarantined)" - " VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s, %s, %s, %s," - " %s::jsonb, %s, %s, %s, %s)" - " ON CONFLICT (account_id, consumer_id, delivery_config_id, delive" - "ry_config_version) DO NOTHING" - " RETURNING delivery_config_id", - ( - key.delivery_config_id, - key.delivery_config_version, - key.account_id, - configuration.report_definition_id, - configuration.reporting_profile, - configuration.feed_purpose, - configuration.required_finality, - configuration.account_timezone, - _json(payload["schedule"]), - _json(sorted(configuration.media_buy_ids)), - configuration.activated_at, - configuration.deactivated_at, - configuration.automated_recovery_window.total_seconds(), - configuration.status_retention_days, - _json(payload["definition"]) if payload["definition"] else None, - configuration.authoritative_party, - digest, - configuration.consumer_id, - configuration.quarantined, - ), - ) + try: + inserted_cursor = await connection.execute( + "INSERT INTO reporting_configurations" + " (delivery_config_id, delivery_config_version, account_id," + " report_definition_id, reporting_profile, feed_purpose, required_finality," + " account_timezone, schedule, media_buy_ids, activated_at, deactivated_at," + " automated_recovery_seconds, status_retention_days, definition," + " authoritative_party, content_sha256, consumer_id, quarantined)" + " VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s, %s, %s, %s," + " %s::jsonb, %s, %s, %s, %s)" + " ON CONFLICT (account_id, consumer_id, delivery_config_id, delive" + "ry_config_version) DO NOTHING" + " RETURNING delivery_config_id", + ( + key.delivery_config_id, + key.delivery_config_version, + key.account_id, + configuration.report_definition_id, + configuration.reporting_profile, + configuration.feed_purpose, + configuration.required_finality, + configuration.account_timezone, + _json(payload["schedule"]), + _json(sorted(configuration.media_buy_ids)), + configuration.activated_at, + configuration.deactivated_at, + configuration.automated_recovery_window.total_seconds(), + configuration.status_retention_days, + _json(payload["definition"]) if payload["definition"] else None, + configuration.authoritative_party, + digest, + configuration.consumer_id, + configuration.quarantined, + ), + ) + except Exception as error: + ahead = schema_ahead_error(error) + if ahead is not None: + raise ahead from error + raise inserted = await inserted_cursor.fetchone() # Check *after* the insert. ON CONFLICT waits for a concurrent # winner; a fresh READ COMMITTED statement sees its retained @@ -3493,6 +3500,9 @@ def _translate_integrity_error(error: Exception) -> LedgerConflictError: Matching the index name rather than the driver's prose: the message text varies by Postgres version and locale, the index name does not. """ + ahead = schema_ahead_error(error) + if ahead is not None: + return LedgerConflictError("REPORTING_SCHEMA_AHEAD", str(ahead)) text = str(error) if "reporting_revisions_one_official" in text: return LedgerConflictError( diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger_account_generations.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger_account_generations.sql index 764c75163d..18296264a5 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger_account_generations.sql +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger_account_generations.sql @@ -29,6 +29,13 @@ BEGIN ] THEN RETURN; END IF; + -- adcp-reporting-storage 0.2+ widens the key with offering_id (storage + -- contract 2). This release cannot run on it, and the caller-ownership + -- remedy below would be wrong advice. + IF 'offering_id' = ANY(primary_key_columns) THEN + RAISE EXCEPTION 'adcp-reporting-storage 0.2 or later has upgraded this reporting ledger to storage contract 2; this release implements storage contract 1' + USING HINT = 'Run adcp>=10.0.0b7 with adcp-reporting-storage>=0.2.0b1 on every reporting process. Downgrading is unsupported: restore the backup taken before the upgrade. Do not run migrate_legacy_reporting.'; + END IF; RAISE EXCEPTION 'Reporting caller ownership requires a stopped-worker maintenance migration' USING HINT = 'Use migrate_legacy_reporting; retain legacy state in quarantine and backfill authoritative ownership.'; diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/migration.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/migration.py index f9753f3e63..3c7d0129f3 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/migration.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/migration.py @@ -26,6 +26,7 @@ __all__ = [ "ReportingOwnershipBackfill", "ReportingOwnershipMigrationError", + "ReportingSchemaAheadError", "backfill_legacy_reporting", "legacy_generation_digest", "migrate_legacy_reporting", @@ -43,6 +44,41 @@ class ReportingOwnershipMigrationError(ReportingOperatorActionError): """ +_CONTRACT_2_REMEDY = ( + "Run adcp>=10.0.0b7 with adcp-reporting-storage>=0.2.0b1 on every reporting process." + " Downgrading is unsupported: to go back, restore the backup taken before the upgrade." + " This is not a caller-ownership migration; do not run migrate_legacy_reporting." +) +# PostgreSQL: no unique index or constraint matches an ON CONFLICT target. +_NO_MATCHING_CONFLICT_KEY = "42P10" + + +class ReportingSchemaAheadError(ReportingOperatorActionError): + """adcp-reporting-storage 0.2 or later upgraded this ledger (storage contract 2). + + This release implements storage contract 1: its generation, obligation + and status keys name the columns before ``offering_id`` was added, so it + can neither start on the upgraded ledger nor write to it. Service startup + re-raises it unwrapped, so the operator sees the remedy. + """ + + +def schema_ahead_error(error: BaseException) -> ReportingSchemaAheadError | None: + """The contract-2 refusal for a write whose conflict key the upgrade widened. + + A running process of this release meets an upgraded ledger as PostgreSQL + 42P10 on its ``ON CONFLICT`` writes, since the unique keys they name now + include ``offering_id``. ``None`` for any other error. + """ + if getattr(error, "sqlstate", None) != _NO_MATCHING_CONFLICT_KEY: + return None + return ReportingSchemaAheadError( + "this reporting write names a key that a newer adcp-reporting-storage (storage contract" + " 2, with offering_id) has widened, so the ledger was upgraded while this process ran. " + + _CONTRACT_2_REMEDY + ) + + @dataclass(frozen=True) class ReportingOwnershipBackfill: """Authoritative mapping with an explicit retained-only recovery decision. @@ -83,9 +119,17 @@ async def require_owned_schema_or_empty(connection: Any) -> None: "SELECT EXISTS (SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace" " WHERE n.nspname=current_schema() AND c.relname='reporting_configurations')," " EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=current_schema()" - " AND table_name='reporting_configurations' AND column_name='consumer_id')" + " AND table_name='reporting_configurations' AND column_name='consumer_id')," + " EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=current_schema()" + " AND table_name='reporting_configurations' AND column_name='offering_id')" ) ).fetchone() + if row[2]: + raise ReportingSchemaAheadError( + "adcp-reporting-storage 0.2 or later has upgraded this reporting ledger to storage" + " contract 2 (reporting_configurations has offering_id). This release implements" + " storage contract 1 and cannot run against it. " + _CONTRACT_2_REMEDY + ) if row[0] and not row[1]: raise ReportingOwnershipMigrationError( "If all legacy tables are empty, call replace_empty_legacy_reporting. " diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/status_pg.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/status_pg.py index 63f4e91f31..4b1f36a8a2 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/status_pg.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/status_pg.py @@ -49,6 +49,7 @@ persist_replay_lifecycles_on, settle_snapshot_on, ) +from adcp_reporting_storage.migration import schema_ahead_error from adcp_reporting_storage.outbox.pg import PgReportingOutbox, database_now _KEY = ( @@ -288,13 +289,20 @@ async def _lock_scopes_on(self, connection: Any, snapshot: ReportingStatusSnapsh # Insert placeholders before allocating random IDs. New and existing # typed scopes have exactly the same row-lock/notification boundary. for scope in projection_scopes(snapshot): - await connection.execute( - f"INSERT INTO reporting_status_scope_checkpoints ({_KEY}, scope, fingerprint," # nosec B608 - " snapshot, source_sequence, baseline, publishable, selector_writer_floor)" - ' VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb,%s,\'{"health":"waiting"}\',0,FALSE,FALSE,2)' - f" ON CONFLICT ({_KEY}) DO NOTHING", # nosec B608 - (*scope.checkpoint_key, json.dumps(asdict(scope)), "0" * 64), - ) + try: + await connection.execute( + f"INSERT INTO reporting_status_scope_checkpoints ({_KEY}, scope, fingerprint," # nosec B608 + " snapshot, source_sequence, baseline, publishable, selector_writer_floor)" + " VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb,%s," + ' \'{"health":"waiting"}\',0,FALSE,FALSE,2)' + f" ON CONFLICT ({_KEY}) DO NOTHING", # nosec B608 + (*scope.checkpoint_key, json.dumps(asdict(scope)), "0" * 64), + ) + except Exception as error: + ahead = schema_ahead_error(error) + if ahead is not None: + raise ahead from error + raise await ( await connection.execute( "SELECT 1 FROM reporting_status_scope_checkpoints WHERE account_id=%s" diff --git a/tests/conformance/reporting/test_reporting_storage_contract2_refusal_pg.py b/tests/conformance/reporting/test_reporting_storage_contract2_refusal_pg.py new file mode 100644 index 0000000000..fb69f2e2e2 --- /dev/null +++ b/tests/conformance/reporting/test_reporting_storage_contract2_refusal_pg.py @@ -0,0 +1,130 @@ +"""A ledger upgraded to storage contract 2 is refused clearly by this release (#1831). + +adcp-reporting-storage 0.2.0b1+ upgrades a 0.1.x ledger in place: migration +0002 adds ``offering_id`` and widens the configuration key and the obligation +period key with it. If a process of this release keeps running during a +rolling deploy, or restarts afterwards, it must name the actual problem and +remedy rather than fail with a raw driver error or point at the unrelated +caller-ownership migration. + +``_widen`` applies the same key changes as 0002 on ``next`` +(``reporting_ledger_account_generations.sql``). +""" + +from __future__ import annotations + +import pytest + +from adcp.reporting.ledger import LedgerConflictError + +from ._generation_support import configuration, isolated_reporting_pool, obligation_for + +_WIDEN = """ +ALTER TABLE reporting_configurations ADD COLUMN offering_id TEXT COLLATE "C" NOT NULL DEFAULT ''; +ALTER TABLE reporting_configurations DROP CONSTRAINT reporting_configurations_pkey CASCADE; +ALTER TABLE reporting_configurations ADD CONSTRAINT reporting_configurations_pkey + PRIMARY KEY (account_id, consumer_id, delivery_config_id, delivery_config_version, offering_id); +ALTER TABLE reporting_obligations ADD COLUMN offering_id TEXT COLLATE "C" NOT NULL DEFAULT ''; +DROP INDEX reporting_obligations_period_key; +CREATE UNIQUE INDEX reporting_obligations_period_key + ON reporting_obligations + (account_id, consumer_id, delivery_config_id, delivery_config_version, offering_id, + period_start, period_end); +ALTER TABLE reporting_consumer_statuses ADD COLUMN offering_id TEXT COLLATE "C" NOT NULL DEFAULT ''; +""" + + +async def _upgraded_store(pool): + from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore + + store = PgReportingLedgerStore(pool=pool) + await store.create_schema() + await store.put_configuration(configuration("acct_a")) + async with pool.connection() as connection: + await connection.execute(_WIDEN) + return store + + +def _assert_remedy(message: str) -> None: + assert "storage contract 2" in message + assert "adcp>=10.0.0b7 with adcp-reporting-storage>=0.2.0b1" in message + assert "restore the backup" in message + assert "do not run migrate_legacy_reporting" in message + + +def test_only_a_missing_conflict_key_is_a_contract_2_refusal() -> None: + from adcp_reporting_storage.migration import schema_ahead_error + + class _Error(Exception): + def __init__(self, sqlstate: str) -> None: + super().__init__(sqlstate) + self.sqlstate = sqlstate + + assert schema_ahead_error(_Error("42P10")) is not None + assert schema_ahead_error(_Error("23505")) is None # unique violation + assert schema_ahead_error(ValueError("no sqlstate")) is None + + +@pytest.mark.asyncio +async def test_create_schema_refuses_an_upgraded_ledger_and_changes_nothing() -> None: + from adcp_reporting_storage import ReportingSchemaAheadError + + async with isolated_reporting_pool(autocommit=True) as pool: + store = await _upgraded_store(pool) + async with pool.connection() as connection: + before = await ( + await connection.execute( + "SELECT pg_get_constraintdef(oid) FROM pg_constraint" + " WHERE conname = 'reporting_configurations_pkey'" + " AND connamespace = current_schema()::regnamespace" + ) + ).fetchall() + with pytest.raises(ReportingSchemaAheadError) as refused: + await store.create_schema() + _assert_remedy(str(refused.value)) + async with pool.connection() as connection: + after = await ( + await connection.execute( + "SELECT pg_get_constraintdef(oid) FROM pg_constraint" + " WHERE conname = 'reporting_configurations_pkey'" + " AND connamespace = current_schema()::regnamespace" + ) + ).fetchall() + assert after == before and "offering_id" in before[0][0] + + +@pytest.mark.asyncio +async def test_the_ddl_alone_names_contract_2_not_the_ownership_migration() -> None: + """For operators who apply the packaged SQL themselves.""" + from importlib.resources import files + + ddl = ( + files("adcp_reporting_storage.ledger") + .joinpath("reporting_ledger_account_generations.sql") + .read_text() + ) + async with isolated_reporting_pool(autocommit=True) as pool: # skips without psycopg + from psycopg import errors + + await _upgraded_store(pool) + async with pool.connection() as connection: + with pytest.raises(errors.RaiseException) as raised: + await connection.execute(ddl) + diagnostic = raised.value.diag + assert "storage contract 2" in (diagnostic.message_primary or "") + assert "Do not run migrate_legacy_reporting" in (diagnostic.message_hint or "") + + +@pytest.mark.asyncio +async def test_a_running_process_names_the_upgrade_on_every_widened_write() -> None: + from adcp_reporting_storage import ReportingSchemaAheadError + + async with isolated_reporting_pool(autocommit=True) as pool: + store = await _upgraded_store(pool) + with pytest.raises(ReportingSchemaAheadError) as refused: + await store.put_configuration(configuration("acct_b")) + _assert_remedy(str(refused.value)) + with pytest.raises(LedgerConflictError) as conflict: + await store.commit_obligation(obligation_for(configuration("acct_a"))) + assert conflict.value.code == "REPORTING_SCHEMA_AHEAD" + _assert_remedy(str(conflict.value)) diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index cc4edb3fe2..48f3b62c85 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -8225,6 +8225,7 @@ "ReportingRowQuarantineReason": "union[value:'BINDING_MISMATCH',value:'ROWS_NOT_PORTABLE',value:'ROW_SEQUENCE_INVALID']", "ReportingRowStorageOptionsV1": "adcp_reporting_storage.ledger.row_storage", "ReportingRowStorageV1": "adcp_reporting_storage.ledger.row_storage", + "ReportingSchemaAheadError": "adcp_reporting_storage.migration", "assert_gcs_reporting_row_bucket_policy_v1": "adcp_reporting_storage.ledger.row_storage_gcs", "backfill_legacy_reporting": "adcp_reporting_storage.migration", "create_azure_blob_reporting_row_object_provider_v1": "adcp_reporting_storage.ledger.row_storage_azure",