diff --git a/SCHEMA_DELTAS.md b/SCHEMA_DELTAS.md index f0f4809e6..0f0db1c28 100644 --- a/SCHEMA_DELTAS.md +++ b/SCHEMA_DELTAS.md @@ -1,6 +1,3 @@ # Generated-types delta -## Field changes - -- `core/wholesale_feed_event.py` - - **classes added**: Range +_No field-shape changes detected._ diff --git a/docs/extending-types.md b/docs/extending-types.md index 072eded09..210d475e2 100644 --- a/docs/extending-types.md +++ b/docs/extending-types.md @@ -892,3 +892,9 @@ Version modules import lazily and construct only requested bases. Run `make validate-generated` checks both the runtime annotations and stubs for staleness. The generator does not read current generated-model annotations, so the scalar rewrite in #1286 cannot change these pinned nested types. + +## Migrating to 9.0 + +The 9.0 generator changes — scalar roots, composing roots, pointer refs, +strict scalars and root required groups — are collected with before/after +examples in [the 9.0 types migration guide](types-9-migration.md). diff --git a/docs/types-9-migration.md b/docs/types-9-migration.md new file mode 100644 index 000000000..8c17cd930 --- /dev/null +++ b/docs/types-9-migration.md @@ -0,0 +1,137 @@ +# Generated types in 9.0: migration guide + +Release 9.0 lands a batch of code-generator changes that make `adcp.types` +agree with the AdCP JSON Schemas it is generated from. Each one is a breaking +change on its own; this page collects them so a migration can be done in one +pass. The pull requests are linked for the full rationale and measurements. + +| Change | PR | What breaks | +| --- | --- | --- | +| Scalar schema roots are plain `str`/`int`/`float` subclasses | #1286 | `issubclass(X, RootModel)`, `X.model_validate`/`model_dump`/`model_json_schema` on 224 scalar types | +| A composing schema root is emitted as what it composes | #1353 | `.root` on 114 union/single-model roots; `TypeName.model_validate` on a union root | +| Structural pointer refs resolve to the type they select | #1371 | 47 per-position `RootModel` wrapper names under `adcp.types._generated` | +| Generated models validate `boolean`/`integer`/`number` strictly | #1375 | Payloads that relied on `"yes"`, `"1"`, `1` coercion | +| Root-level `anyOf`/`oneOf` required groups are enforced | #1368 | Documents that omit every required group of 42 request/response models, on generated and canonical names alike | + +Nothing is removed from `adcp` or `adcp.types`: every name importable before +is importable after, and the ten additions (`Issue`, `AdcpVersionEnvelope`, +seven `*Details` error models and `FormatReferenceStructuredObject` under +`adcp.types.legacy`) come with #1367. Identity changes on the public surface +are of exactly the kinds the table names and are pinned by +`tests/fixtures/public_api_snapshot.json`. + +## 1. Scalar roots are the scalar (#1286) + +A schema whose root is a scalar — `core/property-tag.json` is +`{"type": "string", "pattern": "^[a-z0-9_]+$"}` — generates a `str`, `int` or +`float` subclass instead of `RootModel[str]`. + +```python +# before +tag = PropertyTag(root="sports") +code = country.root +PropertyTag.model_validate("sports") + +# after +tag = PropertyTag("sports") # tag == "sports", hash(tag) == hash("sports") +code = country # it already is a str +TypeAdapter(PropertyTag).validate_python("sports") +``` + +`.root` and `X(root=...)` keep working behind a `DeprecationWarning`. +`issubclass(X, RootModel)` is `False` and the `model_*` classmethods are gone, +because the type is no longer a Pydantic model: use `TypeAdapter(X)`. + +Integer and number roots validate the way their fields do under #1375: +strictly, with a float carrying no fractional part narrowed to `int`. + +## 2. Composing roots are what they compose (#1353) + +A root that is a union of models becomes an `Annotated[A | B, Field(...)]` +alias carrying the schema's discriminator; a root that is a single model +becomes a subclass of that model. + +```python +# before +event = WholesaleFeedEvent.model_validate(payload) +inner = request.start_time.root + +# after +event = TypeAdapter(WholesaleFeedEvent).validate_python(payload) # honours the discriminator +inner = request.start_time +``` + +A single-model root such as `CheckGovernanceRequest` keeps +`model_validate(...)` and can now be subclassed with `extra="forbid"`. + +## 3. Pointer refs resolve to the selected type (#1371) + +A `$ref` into another schema's `properties`/`items` no longer mints a +single-arm `RootModel` wrapper per referencing site. Request-side overlays +read like response-side ones: + +```python +# before: TargetingOverlayInput.geo_countries was GeoCountries (a wrapper); len() raised +# after +for country in overlay.geo_countries: + code: str = country +``` + +The removed names were reachable only under `adcp.types._generated`, which is +documented as internal; the per-name replacement table is in #1371. A pointer +to an object or enum property still resolves to the one shared class +(`targeting.AgeRestriction`, `ProductResponseField`). + +## 4. Strict scalars (#1375) + +`type: boolean`, `integer` and `number` fields refuse what the bundled JSON +Schema refuses: + +```python +ListAccountsRequest.model_validate({"sandbox": "yes"}) # before: sandbox=True; after: ValidationError +Budget.model_validate({"amount": "100"}) # ValidationError; send 100 or 100.0 +Count.model_validate({"value": 1.0}) # still accepted, narrowed to 1 +``` + +The bundled schema validator additionally checks `format: uri` and +`format: hostname`, so a malformed URL in a response fails response validation. + +## 5. Root required groups (#1368) + +42 models enforce the root-level `anyOf`/`oneOf` their schema declares. +`CreateMediaBuyRequest` needs packages, a budget, or a proposal; a document +with none raises `ValidationError` naming the groups. The rule reaches the +canonical names in `adcp.types` too: `_canonical_clone` carries every validator +a generated class declares on itself, so `adcp.CreateMediaBuyRequest` and the +generated class agree. + +```python +CreateMediaBuyRequest.model_validate({**unconditional_fields}) +# ValidationError: CreateMediaBuyRequest requires at least one of these field groups: +# packages | total_budget+proposal_id | ... +``` + +Presence is what counts: an explicit `null` satisfies a group, a default the +caller never sent does not. + +## Also in this batch (not breaking) + +* `adcp.types.domains.[.]` and `adcp.types.error_details` + (#1367) give every generated class a public path, and the public API + snapshot records what each name resolves to. +* `scripts/generate_types.py --check` runs in CI, grades every post-generation + fix against `scripts/post_generation_manifest.json`, and the generator's + input order is total, so a regeneration is byte-identical on every + filesystem (#1374). +* `canonical_creative.pyi` is derived from the runtime models (#1366): every + field the canonical models carry is declared, and the synthesized + constructors demand exactly the fields the runtime demands. A program that + type-checked against the old stub while constructing `CreateMediaBuyRequest` + without `brand`, `start_time`, `end_time` or `idempotency_key` now fails + mypy, as it always failed at runtime. Enum-typed parameters keep accepting + the wire string. +* A format reference's `agent_url` is carried as the wire string, validated + as a URL (#1384). `ref.agent_url` is a `str`, not an `AnyUrl`, so + `migrated_…` option IDs derived from a model match those derived from the + wire mapping. +* `adcp.__all__` and `adcp.types.__all__` name each export once (#1380). diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index a3c9854ed..ecb063c1a 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -6342,11 +6342,10 @@ def fix_creative_manifest_standalone_asset_coercion() -> None: if "_coerce_standalone_assets" in source: target.write_text(source) return - source = source.replace( - "from pydantic import ConfigDict, Field, RootModel, StringConstraints", - "from pydantic import ConfigDict, Field, RootModel, StringConstraints, model_validator", - 1, - ) + # Idempotent: an earlier pass (``enforce_root_required_groups``) may already + # have put ``model_validator`` on this line, and a literal prefix replace + # appended it a second time on every regeneration. + source = ensure_pydantic_import(source, "model_validator") helper = """ def _normalize_asset_models(value: Any) -> Any: @@ -6879,6 +6878,285 @@ def _validate_change_term_set(self) -> CommercialTerms: print(" media_buy/commercial_terms.py: restored change-term set invariants") +_REQUIRED_GROUP_VALIDATOR = "_require_schema_required_group" + +# Bases that declare no schema fields of their own. Any other base the +# generated tree cannot resolve leaves the class's field set unknown, and an +# unknown field set means the required-group rule is reported, not guessed. +_FIELDLESS_GENERATED_BASES = {"AdCPBaseModel", "BaseModel", "RootModel"} + + +def _root_required_groups(schema: dict) -> tuple[tuple[str, ...], ...] | None: + """Return the required-field groups a schema root declares. + + A root ``anyOf``/``oneOf`` whose every arm carries ``required`` states which + combinations of fields a document may omit. Arms also carry discriminating + ``properties`` and ``not`` clauses; those narrow an arm further and are not + part of its group. + """ + for combinator in ("anyOf", "oneOf"): + arms = schema.get(combinator) + if not isinstance(arms, list) or not arms: + continue + if not all(isinstance(arm, dict) and isinstance(arm.get("required"), list) for arm in arms): + continue + groups: list[tuple[str, ...]] = [] + for arm in arms: + names = tuple(name for name in arm["required"] if isinstance(name, str)) + if names and names not in groups: + groups.append(names) + if groups: + return tuple(groups) + return None + + +def _generated_import_targets( + py_path: Path, tree: ast.Module +) -> dict[str, tuple[Path, str | None]]: + """Map each name a generated module imports to the file that defines it. + + The second element is the symbol imported from that file, or ``None`` when + the name is the module itself (``from ..core import account_ref``). + """ + targets: dict[str, tuple[Path, str | None]] = {} + for node in tree.body: + if not isinstance(node, ast.ImportFrom) or node.level < 1: + continue + package = py_path.parent + for _ in range(node.level - 1): + package = package.parent + module_parts = (node.module or "").split(".") + for alias in node.names: + as_module = package.joinpath(*module_parts, alias.name).with_suffix(".py") + if as_module.is_file(): + targets[alias.asname or alias.name] = (as_module, None) + continue + as_symbol = package.joinpath(*module_parts).with_suffix(".py") + if as_symbol.is_file(): + targets[alias.asname or alias.name] = (as_symbol, alias.name) + return targets + + +def _field_wire_name(statement: ast.AnnAssign, attribute: str) -> str: + """Return a generated field's wire name, honouring ``Field(alias=...)``.""" + for node in ast.walk(statement): + if not isinstance(node, ast.Call): + continue + func = node.func + if (func.id if isinstance(func, ast.Name) else getattr(func, "attr", None)) != "Field": + continue + for keyword in node.keywords: + if keyword.arg == "alias" and isinstance(keyword.value, ast.Constant): + if isinstance(keyword.value.value, str): + return keyword.value.value + return attribute + + +def _declared_fields(node: ast.ClassDef) -> dict[str, str]: + """Map wire name to attribute name for the fields a class declares.""" + fields: dict[str, str] = {} + for statement in node.body: + if not isinstance(statement, ast.AnnAssign) or not isinstance(statement.target, ast.Name): + continue + attribute = statement.target.id + if attribute.startswith("_") or attribute == "model_config": + continue + fields[_field_wire_name(statement, attribute)] = attribute + return fields + + +def _generated_fields( + py_path: Path, class_name: str, seen: frozenset[tuple[Path, str]] = frozenset() +) -> dict[str, str] | None: + """Map wire name to attribute name for a class and its bases. + + ``None`` means a base lies outside the generated tree, so the field set is + unknown. + """ + if (py_path, class_name) in seen: + return {} + seen = seen | {(py_path, class_name)} + try: + tree = ast.parse(py_path.read_text()) + except (OSError, SyntaxError): + return None + classes = {node.name: node for node in tree.body if isinstance(node, ast.ClassDef)} + node = classes.get(class_name) + if node is None: + return None + imports = _generated_import_targets(py_path, tree) + + fields: dict[str, str] = {} + for base in node.bases: + reference = base.value if isinstance(base, ast.Subscript) else base + if isinstance(reference, ast.Attribute) and isinstance(reference.value, ast.Name): + target = imports.get(reference.value.id) + if target is None or target[1] is not None: + return None + inherited = _generated_fields(target[0], reference.attr, seen) + elif not isinstance(reference, ast.Name): + return None + elif reference.id in classes: + inherited = _generated_fields(py_path, reference.id, seen) + elif imports.get(reference.id, (None, None))[1] is not None: + module_path, symbol = imports[reference.id] + inherited = _generated_fields(module_path, str(symbol), seen) + elif reference.id in _FIELDLESS_GENERATED_BASES: + inherited = {} + else: + return None + if inherited is None: + return None + fields.update(inherited) + + fields.update(_declared_fields(node)) + return fields + + +def _root_generated_class(content: str, schema: dict) -> str | None: + """Return the single class datamodel-code-generator emits for a schema root. + + The root class takes its name from the schema title, up to case: the + generator title-cases an acronym, so "SI Send Message Request" becomes + ``SiSendMessageRequest``. Match the title-derived name case-insensitively + and return the class as the module spells it. + + When no class carries that name the root did not generate one flat model — + the generator split it into per-arm classes behind a union alias — and a + group derived from one arm does not constrain another. When two classes + carry it, the name does not identify the root. + """ + title = schema.get("title") + if not isinstance(title, str) or not title.strip(): + return None + wanted = _schema_title_to_class_name(title).lower() + declared = { + match.group(1) + for match in re.finditer(r"^class (\w+)\b", content, re.MULTILINE) + if match.group(1).lower() == wanted + } + if len(declared) != 1: + return None + return declared.pop() + + +def _ensure_model_validator_import(content: str) -> str: + # ``ensure_pydantic_import`` keeps the statement in whichever form the + # formatter wrote it; strict scalar annotations push many modules past the + # line width, and a single-line rewrite of a parenthesized import leaves + # the module unparseable. + if _import_pattern("pydantic").search(content) is not None: + return ensure_pydantic_import(content, "model_validator") + return re.sub( + r"^class ", + "from pydantic import model_validator\n\n\nclass ", + content, + count=1, + flags=re.MULTILINE, + ) + + +def _append_to_class_block(content: str, class_name: str, block: str) -> str: + """Insert ``block`` at the end of ``class_name``'s body. + + Several generated modules close with module-level statements — compatibility + aliases, ``model_rebuild()`` calls — after the last class, so the end of the + class body is the end of its AST node, not the start of the next class. + """ + for node in ast.parse(content).body: + if not isinstance(node, ast.ClassDef) or node.name != class_name: + continue + lines = content.splitlines(keepends=True) + end = node.end_lineno or len(lines) + tail = "".join(lines[end:]) + separator = "" if not tail.strip() or tail.startswith("\n") else "\n\n" + return "".join(lines[:end]).rstrip("\n") + block + separator + tail + return content + + +def enforce_root_required_groups() -> None: + """Restore root-level ``anyOf``/``oneOf`` required-group constraints. + + datamodel-code-generator renders a schema root's ``properties`` and drops a + root-level ``anyOf``/``oneOf`` over required-field groups, so the generated + class accepts documents its own schema rejects — a ``create_media_buy`` + request with no packages, no budget and no proposal validates. + + Emit a ``model_validator`` requiring at least one group. That is the whole + constraint for ``anyOf``. For ``oneOf`` it is the part the schema states in + ``required``: cardinality is not derivable here, because those arms are + distinguished by ``properties`` discriminators and several schemas give two + arms the same ``required`` set, which no "exactly one required group" + reading satisfies. + """ + emitted = 0 + already = 0 + skipped: list[str] = [] + + for schema_file in sorted(SCHEMA_DIR.rglob("*.json")): + relative_path = schema_file.relative_to(SCHEMA_DIR) + try: + schema = json.loads(schema_file.read_text()) + except (json.JSONDecodeError, OSError): + continue + if not isinstance(schema, dict): + continue + groups = _root_required_groups(schema) + if groups is None: + continue + + module_path = relative_path.with_suffix(".py") + py_path = OUTPUT_DIR.joinpath(*(part.replace("-", "_") for part in module_path.parts)) + if not py_path.is_file(): + continue + + content = py_path.read_text() + class_name = _root_generated_class(content, schema) + if class_name is None: + skipped.append(f"{relative_path}: no single root model class") + continue + if f"def {_REQUIRED_GROUP_VALIDATOR}" in content: + already += 1 + continue + + fields = _generated_fields(py_path, class_name) + if fields is None: + skipped.append(f"{relative_path}: {class_name} inherits an unresolved base") + continue + if any(name not in fields for group in groups for name in group): + skipped.append(f"{relative_path}: {class_name} is not one flat model over the groups") + continue + attribute_groups = [tuple(fields[name] for name in group) for group in groups] + + rendered = ", ".join(repr(group) for group in attribute_groups) + readable = " | ".join("+".join(group) for group in attribute_groups) + validator = f""" + + @model_validator(mode='after') + def {_REQUIRED_GROUP_VALIDATOR}(self) -> {class_name}: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in ({rendered},): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + '{class_name} requires at least one of these field groups: {readable}' + ) +""" + content = _append_to_class_block(content, class_name, validator) + content = _ensure_model_validator_import(content) + py_path.write_text(content) + emitted += 1 + + print(f" Emitted {emitted} root required-group validator(s)") + if already: + print(f" {already} class(es) already carried one") + for note in skipped: + print(f" skipped {note}") + + def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -6941,6 +7219,7 @@ def main(argv: list[str] | None = None): fix_canceled_literal_defaults, fix_unchanged_literal_defaults, fix_optional_boolean_literal_defaults, + enforce_root_required_groups, fix_reporting_request_selectors, fix_reporting_capability_defaults, fix_protocol_envelope_status_default, diff --git a/src/adcp/types/canonical_creative.py b/src/adcp/types/canonical_creative.py index 30ac3c490..1716cd9ef 100644 --- a/src/adcp/types/canonical_creative.py +++ b/src/adcp/types/canonical_creative.py @@ -12,7 +12,7 @@ import copy import json import re -from collections.abc import Sequence +from collections.abc import Collection, Sequence from enum import Enum from typing import Annotated, Any, ClassVar, Literal, TypeVar @@ -324,6 +324,12 @@ class CanonicalBoundaryModel(AdCPBaseModel): model_config = ConfigDict(extra="allow", defer_build=True) __adcp_canonical_creative_model__: ClassVar[bool] = True + #: The generated class a canonical clone stands in for; ``None`` on a model + #: declared directly. Set by :func:`_canonical_clone`. + __adcp_canonical_source__: ClassVar[type[AdCPBaseModel] | None] = None + #: Source validators a clone left behind, each with the dropped field names + #: it touches. Empty on a model declared directly. + __adcp_canonical_validators_left_behind__: ClassVar[dict[str, set[str]]] = {} @model_validator(mode="before") @classmethod @@ -447,6 +453,88 @@ class CanonicalBoundaryModel``. return (*composed, CanonicalBoundaryModel) +def _validator_references(function: Any) -> set[str]: + """Names a validator's code touches: attributes read and string constants. + + A generated validator reaches a field as ``self.`` (``co_names``) or + names it in a literal — the root required-group validator carries its + groups as a tuple of field-name strings. Nested code objects (comprehensions) + are walked too. + """ + code = getattr(function, "__func__", function).__code__ + names: set[str] = set() + pending = [code] + while pending: + current = pending.pop() + names.update(current.co_names) + constants: list[Any] = list(current.co_consts) + while constants: + constant = constants.pop() + if isinstance(constant, str): + names.add(constant) + elif isinstance(constant, (tuple, frozenset)): + constants.extend(constant) + elif hasattr(constant, "co_code"): + pending.append(constant) + return names + + +def _own_validators( + source: type[AdCPBaseModel], fields: Collection[str] +) -> tuple[dict[str, Any], dict[str, set[str]]]: + """Return the validators ``source`` declares on itself, re-decorated for a clone. + + A canonical clone is built with ``create_model`` over ``source``'s *bases* + (:func:`_canonical_clone_bases`) and a copy of its *fields* + (:func:`_field_definitions`), so ``source`` itself is not in the clone's + MRO and nothing declared on its class body crosses over: the validators + the generator writes onto a request class — the root-level required + groups of ``create-media-buy-request.json`` (#1361), the uniqueness checks + on reporting selectors, the publisher-property coercion on ``Product`` — + would silently stop applying to the canonical name. Inherited validators do + cross, through the bases, so only the ones ``source`` declares on its own + body are carried here. + + A validator that touches a field the clone does not carry is left behind, + and the second mapping names which field for each: the clone drops the + legacy creative identity fields on purpose, so a rule written against + ``format_id`` — the ``format_id | format_kind`` root group on a manifest, + the format-reference XOR on a listed creative — has nothing to enforce on + the canonical boundary and would raise ``AttributeError`` on the read. + + The class namespace, not ``Decorator.func``, is what gets re-decorated: + pydantic stores a ``before`` validator's ``classmethod`` there, and the + unwrapped ``func`` has already lost its ``cls`` binding. + """ + own = vars(source) + decorators = source.__pydantic_decorators__ + dropped = set(source.model_fields) - set(fields) + carried: dict[str, Any] = {} + left_behind: dict[str, set[str]] = {} + for attribute, decorator in decorators.model_validators.items(): + if attribute not in own: + continue + touched = _validator_references(own[attribute]) & dropped + if touched: + left_behind[attribute] = touched + continue + carried[attribute] = model_validator(mode=decorator.info.mode)(own[attribute]) + for attribute, decorator in decorators.field_validators.items(): + if attribute not in own: + continue + info = decorator.info + touched = (set(info.fields) | _validator_references(own[attribute])) & dropped + if touched: + left_behind[attribute] = touched + continue + carried[attribute] = field_validator( + *info.fields, + mode=info.mode, + check_fields=info.check_fields, + )(own[attribute]) + return carried, left_behind + + def _canonical_clone( name: str, source: type[AdCPBaseModel], @@ -454,15 +542,23 @@ def _canonical_clone( exclude: frozenset[str] = frozenset(), overrides: dict[str, tuple[Any, Any]] | None = None, ) -> type[CanonicalBoundaryModel]: + fields = _field_definitions(source, exclude=exclude, overrides=overrides) + carried, left_behind = _own_validators(source, fields) model = create_model( # type: ignore[call-overload] name, __base__=_canonical_clone_bases(source), __module__=__name__, __validators__={ - "_serialize_canonical": model_serializer(mode="wrap")(_serialize_canonical_model) + **carried, + "_serialize_canonical": model_serializer(mode="wrap")(_serialize_canonical_model), }, - **_field_definitions(source, exclude=exclude, overrides=overrides), + **fields, ) + # The generated class a clone stands in for, and the validators it could + # not carry. ``tests/test_canonical_validator_parity.py`` reads both to + # assert the clone refuses every document its source refuses. + model.__adcp_canonical_source__ = source + model.__adcp_canonical_validators_left_behind__ = left_behind return model diff --git a/src/adcp/types/canonical_creative.pyi b/src/adcp/types/canonical_creative.pyi index 8879b862b..9acfececb 100644 --- a/src/adcp/types/canonical_creative.pyi +++ b/src/adcp/types/canonical_creative.pyi @@ -15,6 +15,8 @@ _T = TypeVar("_T", bound=AdCPBaseModel) class CanonicalBoundaryModel(AdCPBaseModel): __adcp_canonical_creative_model__: ClassVar[bool] + __adcp_canonical_source__: ClassVar[type[AdCPBaseModel] | None] + __adcp_canonical_validators_left_behind__: ClassVar[dict[str, set[str]]] class _CanonicalResponseEnvelope(AdcpVersionEnvelope, ProtocolEnvelope, CanonicalBoundaryModel): """Stub-only Liskov bridge for canonical responses; not a runtime class. diff --git a/src/adcp/types/generated_poc/account/list_account_changes_response.py b/src/adcp/types/generated_poc/account/list_account_changes_response.py index 0d93e8305..6c700af41 100644 --- a/src/adcp/types/generated_poc/account/list_account_changes_response.py +++ b/src/adcp/types/generated_poc/account/list_account_changes_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: account/list_account_changes_response.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types.base import AdCPBaseModel, SchemaInt from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, model_validator from ..core import account_change from ..core import context as context_1 @@ -125,3 +125,16 @@ class ListAccountChangesResponse(AdcpVersionEnvelope, ProtocolEnvelope): context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None status: Status22 + + @model_validator(mode='after') + def _require_schema_required_group(self) -> ListAccountChangesResponse: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('status', 'changes', 'cursor', 'has_more', 'available_since', 'generated_at'), ('status', 'adcp_error', 'errors'),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'ListAccountChangesResponse requires at least one of these field groups: status+changes+cursor+has_more+available_since+generated_at | status+adcp_error+errors' + ) diff --git a/src/adcp/types/generated_poc/brand/search_brands_response.py b/src/adcp/types/generated_poc/brand/search_brands_response.py index 1b6b3f45a..763918ffe 100644 --- a/src/adcp/types/generated_poc/brand/search_brands_response.py +++ b/src/adcp/types/generated_poc/brand/search_brands_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: brand/search_brands_response.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types.base import AdCPBaseModel from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, model_validator from ..core import context as context_1 from ..core import error @@ -189,3 +189,16 @@ class SearchBrandsResponse(AdcpVersionEnvelope, ProtocolEnvelope): context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None errors: Annotated[list[error.Error] | None, Field(min_length=1)] = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> SearchBrandsResponse: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('brands',), ('errors',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'SearchBrandsResponse requires at least one of these field groups: brands | errors' + ) diff --git a/src/adcp/types/generated_poc/brand/verify_brand_claim_request.py b/src/adcp/types/generated_poc/brand/verify_brand_claim_request.py index 516818f2b..1ec6e9604 100644 --- a/src/adcp/types/generated_poc/brand/verify_brand_claim_request.py +++ b/src/adcp/types/generated_poc/brand/verify_brand_claim_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: brand/verify_brand_claim_request.json -# timestamp: 2026-09-05T19:08:34+00:00 +# timestamp: 2026-10-02T23:28:30+00:00 from __future__ import annotations @@ -10,6 +10,9 @@ from ..core.version_envelope import AdcpVersionEnvelope +from pydantic import model_validator + + class ClaimType(StrEnum): subsidiary = 'subsidiary' parent = 'parent' @@ -20,3 +23,16 @@ class ClaimType(StrEnum): class VerifyBrandClaimRequest(AdcpVersionEnvelope): claim_type: ClaimType claim: dict[str, Any] + + @model_validator(mode='after') + def _require_schema_required_group(self) -> VerifyBrandClaimRequest: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('claim_type', 'claim'),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'VerifyBrandClaimRequest requires at least one of these field groups: claim_type+claim' + ) diff --git a/src/adcp/types/generated_poc/content_standards/create_content_standards_request.py b/src/adcp/types/generated_poc/content_standards/create_content_standards_request.py index 36eb8eb01..cb8fb330c 100644 --- a/src/adcp/types/generated_poc/content_standards/create_content_standards_request.py +++ b/src/adcp/types/generated_poc/content_standards/create_content_standards_request.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: content_standards/create_content_standards_request.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field +from pydantic import AnyUrl, ConfigDict, Field, model_validator from ..core import context as context_1 from ..core import ext as ext_1 @@ -115,3 +115,16 @@ class CreateContentStandardsRequest(AdcpVersionEnvelope): ] context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> CreateContentStandardsRequest: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('policies',), ('registry_policy_ids',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'CreateContentStandardsRequest requires at least one of these field groups: policies | registry_policy_ids' + ) diff --git a/src/adcp/types/generated_poc/core/assets/published_post_asset.py b/src/adcp/types/generated_poc/core/assets/published_post_asset.py index 86d173d77..204165e61 100644 --- a/src/adcp/types/generated_poc/core/assets/published_post_asset.py +++ b/src/adcp/types/generated_poc/core/assets/published_post_asset.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/assets/published_post_asset.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, model_validator from .. import provenance as provenance_1 @@ -115,3 +115,16 @@ class PublishedPostAsset(AdCPBaseModel): description='Provenance metadata for this reference asset, overrides manifest-level provenance.' ), ] = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> PublishedPostAsset: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('post_url',), ('platform_post_id',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'PublishedPostAsset requires at least one of these field groups: post_url | platform_post_id' + ) diff --git a/src/adcp/types/generated_poc/core/attestation_reference.py b/src/adcp/types/generated_poc/core/attestation_reference.py index 3a0c07fc4..e97dadf65 100644 --- a/src/adcp/types/generated_poc/core/attestation_reference.py +++ b/src/adcp/types/generated_poc/core/attestation_reference.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/attestation_reference.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated, Any, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, model_validator from . import attestation_issuer, attestation_subject from . import ext as ext_1 @@ -140,3 +140,16 @@ class AttestationReference(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> AttestationReference: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('locator',), ('embedded_credential',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'AttestationReference requires at least one of these field groups: locator | embedded_credential' + ) diff --git a/src/adcp/types/generated_poc/core/audience_characteristic.py b/src/adcp/types/generated_poc/core/audience_characteristic.py index df5d0b880..43bb30e53 100644 --- a/src/adcp/types/generated_poc/core/audience_characteristic.py +++ b/src/adcp/types/generated_poc/core/audience_characteristic.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/audience_characteristic.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Any, Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field, RootModel, StrictBool, StrictFloat +from pydantic import AnyUrl, ConfigDict, Field, RootModel, StrictBool, StrictFloat, model_validator class Dimension(RootModel[AnyUrl]): @@ -78,3 +78,16 @@ class AudienceCharacteristic(AdCPBaseModel): label: Annotated[ str | None, Field(description='Human-readable display label; never the comparison key.') ] = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> AudienceCharacteristic: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('value',), ('range',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'AudienceCharacteristic requires at least one of these field groups: value | range' + ) diff --git a/src/adcp/types/generated_poc/core/audience_member.py b/src/adcp/types/generated_poc/core/audience_member.py index 134cbfb1f..9265d1a96 100644 --- a/src/adcp/types/generated_poc/core/audience_member.py +++ b/src/adcp/types/generated_poc/core/audience_member.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/audience_member.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..enums import uid_type from . import ext as ext_1 @@ -53,3 +53,16 @@ class AudienceMember(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> AudienceMember: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('hashed_email',), ('hashed_phone',), ('uids',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'AudienceMember requires at least one of these field groups: hashed_email | hashed_phone | uids' + ) diff --git a/src/adcp/types/generated_poc/core/budget_range.py b/src/adcp/types/generated_poc/core/budget_range.py index bcf787e1d..986c3896a 100644 --- a/src/adcp/types/generated_poc/core/budget_range.py +++ b/src/adcp/types/generated_poc/core/budget_range.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/budget_range.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat, model_validator class BudgetRange(AdCPBaseModel): @@ -17,3 +17,16 @@ class BudgetRange(AdCPBaseModel): min: Annotated[StrictFloat | None, Field(ge=0.0)] = None max: Annotated[StrictFloat | None, Field(ge=0.0)] = None currency: Annotated[str, Field(pattern='^[A-Z]{3}$')] + + @model_validator(mode='after') + def _require_schema_required_group(self) -> BudgetRange: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('min',), ('max',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'BudgetRange requires at least one of these field groups: min | max' + ) diff --git a/src/adcp/types/generated_poc/core/catchment.py b/src/adcp/types/generated_poc/core/catchment.py index fa2dcebef..d89d3aa3b 100644 --- a/src/adcp/types/generated_poc/core/catchment.py +++ b/src/adcp/types/generated_poc/core/catchment.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/catchment.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat, model_validator from ..enums import distance_unit from ..enums import transport_mode as transport_mode_1 @@ -91,3 +91,16 @@ class Catchment(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> Catchment: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('travel_time', 'transport_mode'), ('radius',), ('geometry',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'Catchment requires at least one of these field groups: travel_time+transport_mode | radius | geometry' + ) diff --git a/src/adcp/types/generated_poc/core/collection_distribution.py b/src/adcp/types/generated_poc/core/collection_distribution.py index 82ad30276..8cb4f0c3b 100644 --- a/src/adcp/types/generated_poc/core/collection_distribution.py +++ b/src/adcp/types/generated_poc/core/collection_distribution.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/collection_distribution.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..enums import distribution_identifier_type from . import property_id @@ -48,3 +48,16 @@ class CollectionDistribution(AdCPBaseModel): min_length=1, ), ] = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> CollectionDistribution: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('property_ids',), ('identifiers',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'CollectionDistribution requires at least one of these field groups: property_ids | identifiers' + ) diff --git a/src/adcp/types/generated_poc/core/creative_asset.py b/src/adcp/types/generated_poc/core/creative_asset.py index cdf2c120a..82b03e922 100644 --- a/src/adcp/types/generated_poc/core/creative_asset.py +++ b/src/adcp/types/generated_poc/core/creative_asset.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/creative_asset.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, RootModel, StrictFloat, StringConstraints +from pydantic import ConfigDict, Field, RootModel, StrictFloat, StringConstraints, model_validator from ..enums import creative_status from . import canonical_format_kind, creative_assets @@ -143,3 +143,16 @@ class CreativeAsset(AdCPBaseModel): min_length=1, ), ] = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> CreativeAsset: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('format_id',), ('format_kind',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'CreativeAsset requires at least one of these field groups: format_id | format_kind' + ) diff --git a/src/adcp/types/generated_poc/core/creative_manifest.py b/src/adcp/types/generated_poc/core/creative_manifest.py index cf3b1eabf..6b273820c 100644 --- a/src/adcp/types/generated_poc/core/creative_manifest.py +++ b/src/adcp/types/generated_poc/core/creative_manifest.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/creative_manifest.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-04T06:16:29+00:00 from __future__ import annotations @@ -109,3 +109,16 @@ def _coerce_standalone_assets(cls, data: Any) -> Any: ), ] = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> CreativeManifest: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('format_id',), ('format_kind',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'CreativeManifest requires at least one of these field groups: format_id | format_kind' + ) diff --git a/src/adcp/types/generated_poc/core/demographic_age_range.py b/src/adcp/types/generated_poc/core/demographic_age_range.py index 217133fb4..a9ce5b885 100644 --- a/src/adcp/types/generated_poc/core/demographic_age_range.py +++ b/src/adcp/types/generated_poc/core/demographic_age_range.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/demographic_age_range.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator class DemographicAgeRange(AdCPBaseModel): @@ -36,3 +36,16 @@ class DemographicAgeRange(AdCPBaseModel): description='Whether delivery to people whose age is unavailable is part of this predicate. This field has no default and MUST be supplied.' ), ] + + @model_validator(mode='after') + def _require_schema_required_group(self) -> DemographicAgeRange: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('min',), ('max',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'DemographicAgeRange requires at least one of these field groups: min | max' + ) diff --git a/src/adcp/types/generated_poc/core/demographic_reporting_capability.py b/src/adcp/types/generated_poc/core/demographic_reporting_capability.py index e797b1560..746e36616 100644 --- a/src/adcp/types/generated_poc/core/demographic_reporting_capability.py +++ b/src/adcp/types/generated_poc/core/demographic_reporting_capability.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/demographic_reporting_capability.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from ..enums import demographic_system as demographic_system_1 from . import demographic_age_range @@ -103,3 +103,16 @@ class DemographicReportingCapability(AdCPBaseModel): description='Whether privacy, policy, or measurement thresholds may suppress otherwise reportable demographic rows. When true, buyers must inspect by_demographic_suppressed before testing whether rows reconcile to package totals.' ), ] + + @model_validator(mode='after') + def _require_schema_required_group(self) -> DemographicReportingCapability: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('age',), ('demographic_systems',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'DemographicReportingCapability requires at least one of these field groups: age | demographic_systems' + ) diff --git a/src/adcp/types/generated_poc/core/forecast_dimension_signal.py b/src/adcp/types/generated_poc/core/forecast_dimension_signal.py index 7940ba26d..8e25eb220 100644 --- a/src/adcp/types/generated_poc/core/forecast_dimension_signal.py +++ b/src/adcp/types/generated_poc/core/forecast_dimension_signal.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/forecast_dimension_signal.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, StrictBool, StrictFloat +from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator from . import signal_ref as signal_ref_1 @@ -57,3 +57,16 @@ class SignalForecastDimension(AdCPBaseModel): signal_value_name: Annotated[ str | None, Field(description='Human-readable label for the signal value bucket.') ] = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> SignalForecastDimension: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('signal_ref',), ('signal_id',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'SignalForecastDimension requires at least one of these field groups: signal_ref | signal_id' + ) diff --git a/src/adcp/types/generated_poc/core/forecast_range.py b/src/adcp/types/generated_poc/core/forecast_range.py index 3d3271f14..0873f3be7 100644 --- a/src/adcp/types/generated_poc/core/forecast_range.py +++ b/src/adcp/types/generated_poc/core/forecast_range.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/forecast_range.json -# timestamp: 2026-10-03T15:09:44+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat, model_validator class ForecastRange(AdCPBaseModel): @@ -23,3 +23,16 @@ class ForecastRange(AdCPBaseModel): high: Annotated[ StrictFloat | None, Field(description='Optimistic (high-end) forecast value', ge=0.0) ] = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> ForecastRange: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('mid',), ('low', 'high'),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'ForecastRange requires at least one of these field groups: mid | low+high' + ) diff --git a/src/adcp/types/generated_poc/core/frequency_cap.py b/src/adcp/types/generated_poc/core/frequency_cap.py index 36e5cb2db..06dbfdec0 100644 --- a/src/adcp/types/generated_poc/core/frequency_cap.py +++ b/src/adcp/types/generated_poc/core/frequency_cap.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/frequency_cap.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat, model_validator from ..enums import reach_unit from . import duration @@ -50,3 +50,16 @@ class FrequencyCap(AdCPBaseModel): description='Time window for the max_impressions cap (e.g. {"interval": 7, "unit": "days"} or {"interval": 1, "unit": "campaign"} for the full flight). Required when max_impressions is set.' ), ] = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> FrequencyCap: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('suppress',), ('suppress_minutes',), ('max_impressions',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'FrequencyCap requires at least one of these field groups: suppress | suppress_minutes | max_impressions' + ) diff --git a/src/adcp/types/generated_poc/core/frequency_cap_constraints.py b/src/adcp/types/generated_poc/core/frequency_cap_constraints.py index e6a470b57..b7e16aafd 100644 --- a/src/adcp/types/generated_poc/core/frequency_cap_constraints.py +++ b/src/adcp/types/generated_poc/core/frequency_cap_constraints.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/frequency_cap_constraints.json -# timestamp: 2026-09-14T14:16:02+00:00 +# timestamp: 2026-10-02T23:28:30+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..enums import frequency_cap_control_mode, frequency_cap_mutable_field, reach_unit from . import ext as ext_1 @@ -54,3 +54,16 @@ class FrequencyCapConstraints(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> FrequencyCapConstraints: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('mutable_fields',), ('supported_control_modes',), ('supported_per_units',), ('max_impressions_constraints',), ('window_constraints',), ('suppression_constraints',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'FrequencyCapConstraints requires at least one of these field groups: mutable_fields | supported_control_modes | supported_per_units | max_impressions_constraints | window_constraints | suppression_constraints' + ) diff --git a/src/adcp/types/generated_poc/core/frequency_cap_impression_constraints.py b/src/adcp/types/generated_poc/core/frequency_cap_impression_constraints.py index 0df8fdcef..e51a2b74d 100644 --- a/src/adcp/types/generated_poc/core/frequency_cap_impression_constraints.py +++ b/src/adcp/types/generated_poc/core/frequency_cap_impression_constraints.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/frequency_cap_impression_constraints.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from adcp.types.base import AdCPBaseModel, SchemaInt from adcp.types._scalar import ScalarInt -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from . import ext as ext_1 @@ -26,3 +26,16 @@ class FrequencyCapImpressionConstraints(AdCPBaseModel): maximum: Annotated[SchemaInt | None, Field(ge=1)] = None allowed_values: Annotated[list[AllowedValue] | None, Field(min_length=1)] = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> FrequencyCapImpressionConstraints: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('allowed_values',), ('minimum', 'maximum'),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'FrequencyCapImpressionConstraints requires at least one of these field groups: allowed_values | minimum+maximum' + ) diff --git a/src/adcp/types/generated_poc/core/frequency_cap_interval_constraints.py b/src/adcp/types/generated_poc/core/frequency_cap_interval_constraints.py index 3bbabf802..8c4b030ee 100644 --- a/src/adcp/types/generated_poc/core/frequency_cap_interval_constraints.py +++ b/src/adcp/types/generated_poc/core/frequency_cap_interval_constraints.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/frequency_cap_interval_constraints.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from adcp.types.base import AdCPBaseModel, SchemaInt from adcp.types._scalar import ScalarInt -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from . import ext as ext_1 from . import frequency_cap_duration_unit @@ -28,3 +28,16 @@ class FrequencyCapIntervalConstraints(AdCPBaseModel): maximum_interval: Annotated[SchemaInt | None, Field(ge=1)] = None allowed_intervals: Annotated[list[AllowedInterval] | None, Field(min_length=1)] = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> FrequencyCapIntervalConstraints: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('allowed_intervals',), ('minimum_interval', 'maximum_interval'),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'FrequencyCapIntervalConstraints requires at least one of these field groups: allowed_intervals | minimum_interval+maximum_interval' + ) diff --git a/src/adcp/types/generated_poc/core/get_geo_place_resolution_request.py b/src/adcp/types/generated_poc/core/get_geo_place_resolution_request.py index 1c711a286..b2cb4aa68 100644 --- a/src/adcp/types/generated_poc/core/get_geo_place_resolution_request.py +++ b/src/adcp/types/generated_poc/core/get_geo_place_resolution_request.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/get_geo_place_resolution_request.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from . import geo_place_type, locale_tag @@ -52,3 +52,16 @@ class GetGeographicPlaceResolutionRequest(AdCPBaseModel): ] = None cursor: Annotated[str | None, Field(min_length=1)] = None limit: Annotated[SchemaInt | None, Field(ge=1, le=100)] = 20 + + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetGeographicPlaceResolutionRequest: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('q',), ('value',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'GetGeographicPlaceResolutionRequest requires at least one of these field groups: q | value' + ) diff --git a/src/adcp/types/generated_poc/core/placement.py b/src/adcp/types/generated_poc/core/placement.py index 0664504de..c678776a6 100644 --- a/src/adcp/types/generated_poc/core/placement.py +++ b/src/adcp/types/generated_poc/core/placement.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/placement.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..enums import ( audio_distribution_type, @@ -181,3 +181,16 @@ class Placement(AdCPBaseModel): ), ] = None dooh_placement_attributes: ProductDoohPlacementAttributes | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> Placement: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('name',), ('publisher_domain',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'Placement requires at least one of these field groups: name | publisher_domain' + ) diff --git a/src/adcp/types/generated_poc/core/placement_definition.py b/src/adcp/types/generated_poc/core/placement_definition.py index fcfed8091..37d272494 100644 --- a/src/adcp/types/generated_poc/core/placement_definition.py +++ b/src/adcp/types/generated_poc/core/placement_definition.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/placement_definition.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..enums import audio_distribution_type from ..enums import channels as channels_1 @@ -194,3 +194,16 @@ class PlacementDefinition(AdCPBaseModel): ] = None dooh_placement_attributes: PublisherDoohPlacementAttributes | None = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> PlacementDefinition: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('property_ids',), ('property_tags',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'PlacementDefinition requires at least one of these field groups: property_ids | property_tags' + ) diff --git a/src/adcp/types/generated_poc/core/product.py b/src/adcp/types/generated_poc/core/product.py index f8cc3989e..6f99f0624 100644 --- a/src/adcp/types/generated_poc/core/product.py +++ b/src/adcp/types/generated_poc/core/product.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/product.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations @@ -878,3 +878,16 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: ), ] = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> Product: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('format_ids',), ('format_options',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'Product requires at least one of these field groups: format_ids | format_options' + ) diff --git a/src/adcp/types/generated_poc/core/signal_listing.py b/src/adcp/types/generated_poc/core/signal_listing.py index d24563445..30ca559d8 100644 --- a/src/adcp/types/generated_poc/core/signal_listing.py +++ b/src/adcp/types/generated_poc/core/signal_listing.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/signal_listing.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat, model_validator from ..enums import restricted_attribute, signal_value_type from . import demographic_predicate as demographic_predicate_1 @@ -94,3 +94,16 @@ class SignalListing(AdCPBaseModel): description='Machine-readable demographic meaning. For product-local signals this listing is authoritative; for data-provider and signal-source refs, any projected value MUST match the referenced authoritative definition. Signal names alone never establish demographic semantics.' ), ] = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> SignalListing: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('signal_ref',), ('signal_id',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'SignalListing requires at least one of these field groups: signal_ref | signal_id' + ) diff --git a/src/adcp/types/generated_poc/core/transformer.py b/src/adcp/types/generated_poc/core/transformer.py index 206cf9f80..f19273a11 100644 --- a/src/adcp/types/generated_poc/core/transformer.py +++ b/src/adcp/types/generated_poc/core/transformer.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/transformer.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations @@ -1859,3 +1859,16 @@ def _require_output_format_declaration(self) -> Transformer: 'one of output_capability_ids or deprecated output_format_ids is required' ) return self + + @model_validator(mode='after') + def _require_schema_required_group(self) -> Transformer: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('output_capability_ids',), ('output_format_ids',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'Transformer requires at least one of these field groups: output_capability_ids | output_format_ids' + ) diff --git a/src/adcp/types/generated_poc/core/user_match.py b/src/adcp/types/generated_poc/core/user_match.py index 054524fc3..7fc0a30b7 100644 --- a/src/adcp/types/generated_poc/core/user_match.py +++ b/src/adcp/types/generated_poc/core/user_match.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/user_match.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..enums import uid_type from . import ext as ext_1 @@ -57,3 +57,16 @@ class UserMatch(AdCPBaseModel): str | None, Field(description='Client user agent string for probabilistic matching') ] = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> UserMatch: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('uids',), ('hashed_email',), ('hashed_phone',), ('click_id',), ('client_ip', 'client_user_agent'),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'UserMatch requires at least one of these field groups: uids | hashed_email | hashed_phone | click_id | client_ip+client_user_agent' + ) diff --git a/src/adcp/types/generated_poc/core/webhook_challenge_response.py b/src/adcp/types/generated_poc/core/webhook_challenge_response.py index 7d401a27b..00e1dfba7 100644 --- a/src/adcp/types/generated_poc/core/webhook_challenge_response.py +++ b/src/adcp/types/generated_poc/core/webhook_challenge_response.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/webhook_challenge_response.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator class WebhookChallengeResponse(AdCPBaseModel): @@ -32,3 +32,16 @@ class WebhookChallengeResponse(AdCPBaseModel): pattern='^[A-Za-z0-9_.:-]{32,255}$', ), ] = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> WebhookChallengeResponse: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('challenge',), ('token',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'WebhookChallengeResponse requires at least one of these field groups: challenge | token' + ) diff --git a/src/adcp/types/generated_poc/creative/get_creative_delivery_request.py b/src/adcp/types/generated_poc/creative/get_creative_delivery_request.py index cc5a5ebbf..75aa17ad8 100644 --- a/src/adcp/types/generated_poc/creative/get_creative_delivery_request.py +++ b/src/adcp/types/generated_poc/creative/get_creative_delivery_request.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: creative/get_creative_delivery_request.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import SchemaInt -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..core import account_ref from ..core import context as context_1 @@ -69,3 +69,16 @@ class GetCreativeDeliveryRequest(AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetCreativeDeliveryRequest: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('media_buy_ids',), ('creative_ids',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'GetCreativeDeliveryRequest requires at least one of these field groups: media_buy_ids | creative_ids' + ) diff --git a/src/adcp/types/generated_poc/creative/sync_creatives_request.py b/src/adcp/types/generated_poc/creative/sync_creatives_request.py index 69948b1ce..ef53ce4ed 100644 --- a/src/adcp/types/generated_poc/creative/sync_creatives_request.py +++ b/src/adcp/types/generated_poc/creative/sync_creatives_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/sync_creatives_request.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from adcp.types.base import AdCPBaseModel from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field, StrictBool, StrictFloat +from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..core import account_ref from ..core import context as context_1 @@ -177,3 +177,16 @@ class SyncCreativesRequest(AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> SyncCreativesRequest: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('creatives',), ('assignments',), ('assignment_operations',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'SyncCreativesRequest requires at least one of these field groups: creatives | assignments | assignment_operations' + ) diff --git a/src/adcp/types/generated_poc/governance/get_plan_audit_logs_request.py b/src/adcp/types/generated_poc/governance/get_plan_audit_logs_request.py index 9f64f6651..caee40acd 100644 --- a/src/adcp/types/generated_poc/governance/get_plan_audit_logs_request.py +++ b/src/adcp/types/generated_poc/governance/get_plan_audit_logs_request.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: governance/get_plan_audit_logs_request.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from ..core import context as context_1 from ..core import ext as ext_1 @@ -51,3 +51,16 @@ class GetPlanAuditLogsRequest(AdcpVersionEnvelope): ] = False context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetPlanAuditLogsRequest: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('plan_ids',), ('portfolio_plan_ids',), ('governance_contexts',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'GetPlanAuditLogsRequest requires at least one of these field groups: plan_ids | portfolio_plan_ids | governance_contexts' + ) diff --git a/src/adcp/types/generated_poc/media_buy/acceptance_policy_catalog.py b/src/adcp/types/generated_poc/media_buy/acceptance_policy_catalog.py index 382c01fcf..faa7f51ea 100644 --- a/src/adcp/types/generated_poc/media_buy/acceptance_policy_catalog.py +++ b/src/adcp/types/generated_poc/media_buy/acceptance_policy_catalog.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/acceptance_policy_catalog.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from ..core import ext as ext_1 from . import acceptance_policy_profile, acceptance_policy_profile_ref @@ -30,3 +30,16 @@ class AcceptancePolicyCatalog(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> AcceptancePolicyCatalog: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('profiles',), ('registry_profiles',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'AcceptancePolicyCatalog requires at least one of these field groups: profiles | registry_profiles' + ) diff --git a/src/adcp/types/generated_poc/media_buy/control_media_buy_request.py b/src/adcp/types/generated_poc/media_buy/control_media_buy_request.py index e64202c5c..614d7325e 100644 --- a/src/adcp/types/generated_poc/media_buy/control_media_buy_request.py +++ b/src/adcp/types/generated_poc/media_buy/control_media_buy_request.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/control_media_buy_request.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field, StrictBool, StrictFloat +from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..core import bidding_policy, canonical_account_ref, canonical_budget_allocation from ..core import context as context_1 @@ -118,3 +118,16 @@ class ControlMediaBuyRequest(AdCPBaseModel): push_notification_config: push_notification_config_1.PushNotificationConfig | None = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> ControlMediaBuyRequest: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('name',), ('paused',), ('canceled',), ('total_budget',), ('daily_budget_cap',), ('frequency_cap',), ('budget_cap_timezone',), ('budget_allocation',), ('pacing',), ('bidding',), ('packages',), ('reporting_webhook',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'ControlMediaBuyRequest requires at least one of these field groups: name | paused | canceled | total_budget | daily_budget_cap | frequency_cap | budget_cap_timezone | budget_allocation | pacing | bidding | packages | reporting_webhook' + ) diff --git a/src/adcp/types/generated_poc/media_buy/create_media_buy_request.py b/src/adcp/types/generated_poc/media_buy/create_media_buy_request.py index c7c16b3d5..236bd73a0 100644 --- a/src/adcp/types/generated_poc/media_buy/create_media_buy_request.py +++ b/src/adcp/types/generated_poc/media_buy/create_media_buy_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/create_media_buy_request.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..core import account_ref, bidding_policy, brand_ref from ..core import budget_allocation as budget_allocation_1 @@ -315,3 +315,16 @@ class CreateMediaBuyRequest(AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> CreateMediaBuyRequest: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('packages',), ('packages', 'total_budget', 'budget_allocation'), ('proposal_id', 'total_budget'),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'CreateMediaBuyRequest requires at least one of these field groups: packages | packages+total_budget+budget_allocation | proposal_id+total_budget' + ) diff --git a/src/adcp/types/generated_poc/media_buy/get_reporting_status_response.py b/src/adcp/types/generated_poc/media_buy/get_reporting_status_response.py index 020c7305c..7b44321de 100644 --- a/src/adcp/types/generated_poc/media_buy/get_reporting_status_response.py +++ b/src/adcp/types/generated_poc/media_buy/get_reporting_status_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_reporting_status_response.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, model_validator from ..core import context as context_1 from ..core import error @@ -200,3 +200,16 @@ class GetReportingStatusResponse(AdcpVersionEnvelope, ProtocolEnvelope): context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None status: Status + + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetReportingStatusResponse: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('status',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'GetReportingStatusResponse requires at least one of these field groups: status' + ) diff --git a/src/adcp/types/generated_poc/media_buy/outcome_target.py b/src/adcp/types/generated_poc/media_buy/outcome_target.py index c123ee7b4..fdb0953f2 100644 --- a/src/adcp/types/generated_poc/media_buy/outcome_target.py +++ b/src/adcp/types/generated_poc/media_buy/outcome_target.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/outcome_target.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat, model_validator from ..core import outcome_target_cost_per from ..enums import event_type as event_type_1 @@ -66,3 +66,16 @@ class OutcomeTarget(AdCPBaseModel): ), ] = None cost_per: outcome_target_cost_per.OutcomeTargetCostPer | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> OutcomeTarget: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('volume',), ('cost_per',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'OutcomeTarget requires at least one of these field groups: volume | cost_per' + ) diff --git a/src/adcp/types/generated_poc/media_buy/package_control.py b/src/adcp/types/generated_poc/media_buy/package_control.py index e82c8df58..f33cbb6df 100644 --- a/src/adcp/types/generated_poc/media_buy/package_control.py +++ b/src/adcp/types/generated_poc/media_buy/package_control.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/package_control.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from adcp.types.base import AdCPBaseModel from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field, StrictBool, StrictFloat +from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..core import bidding_policy, canonical_optimization_goal, keyword_target, targeting_input from ..enums import pacing as pacing_1 @@ -89,3 +89,16 @@ class PackageControl(AdCPBaseModel): optimization_goals: Annotated[ list[canonical_optimization_goal.CanonicalOptimizationGoal] | None, Field(min_length=1) ] = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> PackageControl: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('budget',), ('daily_budget_cap',), ('min_spend_target',), ('impressions',), ('pacing',), ('bidding',), ('paused',), ('canceled',), ('targeting_overlay',), ('catalog_ids',), ('keyword_targets_add',), ('keyword_targets_remove',), ('negative_keywords_add',), ('negative_keywords_remove',), ('optimization_goals',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'PackageControl requires at least one of these field groups: budget | daily_budget_cap | min_spend_target | impressions | pacing | bidding | paused | canceled | targeting_overlay | catalog_ids | keyword_targets_add | keyword_targets_remove | negative_keywords_add | negative_keywords_remove | optimization_goals' + ) diff --git a/src/adcp/types/generated_poc/media_buy/proposal_budget_constraint.py b/src/adcp/types/generated_poc/media_buy/proposal_budget_constraint.py index fe5d6c511..436995315 100644 --- a/src/adcp/types/generated_poc/media_buy/proposal_budget_constraint.py +++ b/src/adcp/types/generated_poc/media_buy/proposal_budget_constraint.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/proposal_budget_constraint.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat, model_validator class ProposalBudgetConstraint(AdCPBaseModel): @@ -17,3 +17,16 @@ class ProposalBudgetConstraint(AdCPBaseModel): min: Annotated[StrictFloat | None, Field(ge=0.0)] = None max: Annotated[StrictFloat | None, Field(ge=0.0)] = None currency: Annotated[str, Field(pattern='^[A-Z]{3}$')] + + @model_validator(mode='after') + def _require_schema_required_group(self) -> ProposalBudgetConstraint: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('min',), ('max',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'ProposalBudgetConstraint requires at least one of these field groups: min | max' + ) diff --git a/src/adcp/types/generated_poc/media_buy/request_proposals_request.py b/src/adcp/types/generated_poc/media_buy/request_proposals_request.py index 869ec3f1b..218ba0e09 100644 --- a/src/adcp/types/generated_poc/media_buy/request_proposals_request.py +++ b/src/adcp/types/generated_poc/media_buy/request_proposals_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/request_proposals_request.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..core import brand_key, canonical_account_ref from ..core import context as context_1 @@ -97,3 +97,16 @@ class RequestProposalsRequest(AdCPBaseModel): description='Optional planning-cycle context that the seller associates with every proposal created by this request.' ), ] = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> RequestProposalsRequest: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('brand',), ('account',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'RequestProposalsRequest requires at least one of these field groups: brand | account' + ) diff --git a/src/adcp/types/generated_poc/media_buy/sync_reporting_receipts_request.py b/src/adcp/types/generated_poc/media_buy/sync_reporting_receipts_request.py index c6a060d28..ba34b6130 100644 --- a/src/adcp/types/generated_poc/media_buy/sync_reporting_receipts_request.py +++ b/src/adcp/types/generated_poc/media_buy/sync_reporting_receipts_request.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/sync_reporting_receipts_request.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import SchemaInt -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..core import canonical_account_ref from ..core import context as context_1 @@ -60,3 +60,16 @@ class SyncReportingReceiptsRequest(AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> SyncReportingReceiptsRequest: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('receipts',), ('adjustment_receipts',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'SyncReportingReceiptsRequest requires at least one of these field groups: receipts | adjustment_receipts' + ) diff --git a/src/adcp/types/generated_poc/sponsored_intelligence/si_send_message_request.py b/src/adcp/types/generated_poc/sponsored_intelligence/si_send_message_request.py index 57a61126f..f100cafd1 100644 --- a/src/adcp/types/generated_poc/sponsored_intelligence/si_send_message_request.py +++ b/src/adcp/types/generated_poc/sponsored_intelligence/si_send_message_request.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_send_message_request.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-04T01:14:11+00:00 from __future__ import annotations from typing import Annotated, Any from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..core import context as context_1 from ..core import ext as ext_1 @@ -52,3 +52,16 @@ class SiSendMessageRequest(AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _require_schema_required_group(self) -> SiSendMessageRequest: + # ``required`` asks whether the caller supplied the field, which is what + # model_fields_set answers. An explicit null is a supplied value — on a + # mutation input it is the command to clear — and a default the caller + # never sent is not. + for group in (('message',), ('action_response',),): + if all(name in self.model_fields_set for name in group): + return self + raise ValueError( + 'SiSendMessageRequest requires at least one of these field groups: message | action_response' + ) diff --git a/tests/test_canonical_creatives_rc3.py b/tests/test_canonical_creatives_rc3.py index 3b92cb5a6..5d2e7a9f4 100644 --- a/tests/test_canonical_creatives_rc3.py +++ b/tests/test_canonical_creatives_rc3.py @@ -373,6 +373,8 @@ def test_partial_product_is_retained_and_wholly_unmappable_product_is_omitted() { "kind": "seller_inline", "placement_id": "hero", + # core/placement.json requires ``name`` or ``publisher_domain``. + "name": "Hero", "mode": "included", "format_options": [{"format_kind": "image", "params": {"api_token": "unsafe"}}], } diff --git a/tests/test_canonical_validator_parity.py b/tests/test_canonical_validator_parity.py new file mode 100644 index 000000000..737cf1cc2 --- /dev/null +++ b/tests/test_canonical_validator_parity.py @@ -0,0 +1,187 @@ +"""A canonical clone enforces every validator its generated source declares. + +``canonical_creative.py`` builds the public canonical models with ``create_model`` +over the source's bases and a copy of its fields, so the generated class is not in +the clone's MRO. Validators declared on the generated class body — the root-level +required groups ``enforce_root_required_groups`` emits (#1361), the uniqueness and +pairing checks other post-generation fixes inject — reach the clone only because +``_canonical_clone`` carries them across. These tests hold that: a document the +source refuses, the public name refuses too. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from pydantic import BaseModel, ValidationError + +import adcp +from adcp.types import canonical_creative + +_REQUIRED_GROUP_VALIDATOR = "_require_schema_required_group" + + +def _canonical_clones() -> dict[str, type[BaseModel]]: + return { + name: obj + for name, obj in vars(canonical_creative).items() + if isinstance(obj, type) + and issubclass(obj, BaseModel) + and getattr(obj, "__adcp_canonical_source__", None) is not None + } + + +def _own_validator_names(model: type[BaseModel]) -> set[str]: + decorators = model.__pydantic_decorators__ + own = vars(model) + return { + name for name in (*decorators.model_validators, *decorators.field_validators) if name in own + } + + +def test_every_canonical_clone_carries_its_source_model_validators() -> None: + clones = _canonical_clones() + assert "CreateMediaBuyRequest" in clones + missing: dict[str, set[str]] = {} + for name, clone in clones.items(): + source = clone.__adcp_canonical_source__ + assert source is not None + carried = set(clone.__pydantic_decorators__.model_validators) + left_behind = clone.__adcp_canonical_validators_left_behind__ + wanted = { + validator + for validator in source.__pydantic_decorators__.model_validators + if validator in vars(source) and validator not in left_behind + } + if wanted - carried: + missing[name] = wanted - carried + assert not missing, f"canonical clones dropped source model validators: {missing}" + + +def test_every_canonical_clone_carries_its_source_field_validators() -> None: + missing: dict[str, set[str]] = {} + for name, clone in _canonical_clones().items(): + source = clone.__adcp_canonical_source__ + assert source is not None + carried = set(clone.__pydantic_decorators__.field_validators) + left_behind = clone.__adcp_canonical_validators_left_behind__ + wanted = { + validator + for validator in source.__pydantic_decorators__.field_validators + if validator in vars(source) and validator not in left_behind + } + if wanted - carried: + missing[name] = wanted - carried + assert not missing, f"canonical clones dropped source field validators: {missing}" + + +def test_a_validator_is_left_behind_only_for_a_field_the_clone_drops() -> None: + """The clone drops legacy creative identity on purpose; nothing else is skipped.""" + for name, clone in _canonical_clones().items(): + source = clone.__adcp_canonical_source__ + assert source is not None + for validator, touched in clone.__adcp_canonical_validators_left_behind__.items(): + assert touched, (name, validator) + assert touched <= set(source.model_fields) - set(clone.model_fields), ( + name, + validator, + touched, + ) + + +def test_canonical_manifest_keeps_its_optional_kind_without_legacy_identity() -> None: + """``format_id | format_kind`` names a legacy field; the canonical boundary has no + ``format_id`` to satisfy it, so the group stays on the generated class only.""" + manifest = canonical_creative.CreativeManifest + assert _REQUIRED_GROUP_VALIDATOR in manifest.__adcp_canonical_validators_left_behind__ + assert manifest.model_validate({"assets": {}}).format_kind is None + + +def test_before_validators_carry_their_classmethod_binding() -> None: + """A ``before`` validator is stored as a classmethod; re-decorating the bare + function hands it ``ValidationInfo`` as ``data`` and Product refused every payload.""" + from adcp.types import Product + from adcp.types.generated_poc.core.product import Product as Generated + + assert "_coerce_publisher_property_models" in Product.__pydantic_decorators__.model_validators + assert "_coerce_publisher_property_models" in Generated.__pydantic_decorators__.model_validators + + +def test_required_group_validators_reach_the_canonical_names() -> None: + """The gap #1368 documented: the source rejected, the clone accepted.""" + sources_with_groups = { + name: clone + for name, clone in _canonical_clones().items() + if _REQUIRED_GROUP_VALIDATOR + in clone.__adcp_canonical_source__.__pydantic_decorators__.model_validators + } + assert "CreateMediaBuyRequest" in sources_with_groups + for name, clone in sources_with_groups.items(): + if _REQUIRED_GROUP_VALIDATOR in clone.__adcp_canonical_validators_left_behind__: + # The group names a legacy identity field the clone drops; see + # test_a_validator_is_left_behind_only_for_a_field_the_clone_drops. + continue + assert _REQUIRED_GROUP_VALIDATOR in clone.__pydantic_decorators__.model_validators, name + + +_MEDIA_BUY_UNCONDITIONAL_FIELDS: dict[str, Any] = { + "idempotency_key": "idem-key-0123456789", + "account": {"account_id": "acct_1"}, + "brand": {"brand_id": "brand_1", "domain": "example.com"}, + "start_time": "2026-11-01T00:00:00Z", + "end_time": "2026-11-30T00:00:00Z", +} + + +@pytest.mark.parametrize( + "model", + [adcp.CreateMediaBuyRequest, canonical_creative.CreateMediaBuyRequest], + ids=["adcp", "canonical_creative"], +) +def test_canonical_create_media_buy_request_requires_one_root_anyof_group( + model: type[BaseModel], +) -> None: + """A media buy with no packages, no budget and no proposal is refused on every path. + + ``create-media-buy-request.json`` declares the three ways to be a media buy as + a root ``anyOf``. The generated class gained the rule in #1368; this is the + controlled probe that showed the public canonical clone still accepted the + document, held green. + """ + source = model.__adcp_canonical_source__ # type: ignore[attr-defined] + with pytest.raises(ValidationError, match="at least one of these field groups"): + source.model_validate(dict(_MEDIA_BUY_UNCONDITIONAL_FIELDS)) + with pytest.raises(ValidationError, match="at least one of these field groups"): + model.model_validate(dict(_MEDIA_BUY_UNCONDITIONAL_FIELDS)) + + committed = model.model_validate( + { + **_MEDIA_BUY_UNCONDITIONAL_FIELDS, + "proposal_id": "prop_1", + "total_budget": {"amount": 1000.0, "currency": "USD"}, + } + ) + assert committed.proposal_id == "prop_1" # type: ignore[attr-defined] + assert committed.packages is None # type: ignore[attr-defined] + + +def test_canonical_clone_keeps_the_boundary_validator_first() -> None: + """Carrying source validators must not displace the boundary's own.""" + clone = canonical_creative.CreateMediaBuyRequest + validators = clone.__pydantic_decorators__.model_validators + assert "_reject_legacy_creative_identity" in validators + assert _REQUIRED_GROUP_VALIDATOR in validators + with pytest.raises(ValidationError, match="legacy creative identity"): + clone.model_validate( + { + **_MEDIA_BUY_UNCONDITIONAL_FIELDS, + "packages": [ + { + "buyer_ref": "pkg_1", + "product_id": "prod_1", + "format_ids": [{"agent_url": "https://x.example", "id": "f"}], + } + ], + } + ) diff --git a/tests/test_code_generation.py b/tests/test_code_generation.py index 1c511d61b..c08758c75 100644 --- a/tests/test_code_generation.py +++ b/tests/test_code_generation.py @@ -2581,3 +2581,223 @@ def test_collapse_nullable_unions_leaves_an_items_position_alone(): collapse_nullable_unions(schema) assert schema == expected + + +_REQUIRED_GROUP_SCHEMA = { + "title": "Budget Plan", + "type": "object", + "properties": { + "account": {"type": "string"}, + "packages": {"type": "array"}, + "total_budget": {"type": "object"}, + "proposal_id": {"type": "string"}, + }, + "required": ["account"], + "anyOf": [ + { + "title": "Explicit packages", + "required": ["packages"], + "not": {"required": ["proposal_id"]}, + }, + {"title": "Committed proposal", "required": ["proposal_id", "total_budget"]}, + ], +} + +_REQUIRED_GROUP_MODULE = ( + "from __future__ import annotations\n\n" + "from pydantic import BaseModel\n\n\n" + "class BudgetPlan(BaseModel):\n" + " account: str\n" + " packages: list[str] | None = None\n" + " total_budget: dict[str, int] | None = None\n" + " proposal_id: str | None = None\n\n\n" + "LegacyBudgetPlan = BudgetPlan\n" +) + + +def _write_required_group_fixture(tmp_path, monkeypatch, schema, module_source): + from scripts import post_generate_fixes + + schema_dir = tmp_path / "schemas" + generated_dir = tmp_path / "generated_poc" + (schema_dir / "media-buy").mkdir(parents=True) + (schema_dir / "media-buy" / "budget-plan.json").write_text(json.dumps(schema)) + (generated_dir / "media_buy").mkdir(parents=True) + target = generated_dir / "media_buy" / "budget_plan.py" + target.write_text(module_source) + + monkeypatch.setattr(post_generate_fixes, "SCHEMA_DIR", schema_dir) + monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", generated_dir) + post_generate_fixes.enforce_root_required_groups() + return target + + +def test_root_required_group_validator_enforces_every_declared_group(tmp_path, monkeypatch): + """A root ``anyOf`` over required groups becomes a runtime check. + + The groups come from ``required`` alone: an arm's ``not`` and discriminating + ``properties`` narrow that arm further, so "at least one group is present" + is the part of the constraint the generator derives. + """ + from pydantic import ValidationError + + target = _write_required_group_fixture( + tmp_path, monkeypatch, _REQUIRED_GROUP_SCHEMA, _REQUIRED_GROUP_MODULE + ) + fixed = target.read_text() + + assert "def _require_schema_required_group(self) -> BudgetPlan:" in fixed + assert "(('packages',), ('proposal_id', 'total_budget'),)" in fixed + # The module tail survives: several generated modules close with + # compatibility aliases after the root class. + assert fixed.rstrip().endswith("LegacyBudgetPlan = BudgetPlan") + + namespace: dict[str, object] = {} + exec(compile(fixed, str(target), "exec"), namespace) # noqa: S102 + model = namespace["BudgetPlan"] + + assert model.model_validate({"account": "acct_1", "packages": ["pkg_1"]}).packages == ["pkg_1"] + assert ( + model.model_validate( + {"account": "acct_1", "proposal_id": "prop_1", "total_budget": {"total": 1}} + ).proposal_id + == "prop_1" + ) + # Every unconditionally required field, and no group. + with pytest.raises(ValidationError, match="at least one of these field groups"): + model.model_validate({"account": "acct_1"}) + # A partial group is not a group. + with pytest.raises(ValidationError, match="at least one of these field groups"): + model.model_validate({"account": "acct_1", "proposal_id": "prop_1"}) + + +def test_root_required_group_leaves_per_arm_models_alone(tmp_path, monkeypatch): + """A root the generator splits into arm classes keeps no root-wide check. + + One arm's required group says nothing about another arm, and the union alias + is not a class, so there is nothing to attach a validator to. + """ + schema = { + "title": "Budget Plan", + "type": "object", + "oneOf": [ + {"properties": {"packages": {"type": "array"}}, "required": ["packages"]}, + {"properties": {"proposal_id": {"type": "string"}}, "required": ["proposal_id"]}, + ], + } + module_source = ( + "from __future__ import annotations\n\n" + "from pydantic import BaseModel\n\n\n" + "class BudgetPlan1(BaseModel):\n" + " packages: list[str]\n\n\n" + "class BudgetPlan2(BaseModel):\n" + " proposal_id: str\n\n\n" + "BudgetPlan = BudgetPlan1 | BudgetPlan2\n" + ) + + target = _write_required_group_fixture(tmp_path, monkeypatch, schema, module_source) + + assert target.read_text() == module_source + + +def test_root_required_group_resolves_the_generator_acronym_casing(tmp_path, monkeypatch): + """The root class name matches the schema title up to case. + + datamodel-code-generator title-cases an acronym, so the title + "SI Send Message Request" generates ``SiSendMessageRequest``. Resolving the + title-derived name exactly would skip the site. + """ + schema = { + "title": "SI Send Message Request", + "type": "object", + "properties": {"message": {"type": "string"}, "action_response": {"type": "object"}}, + "anyOf": [{"required": ["message"]}, {"required": ["action_response"]}], + } + module_source = ( + "from __future__ import annotations\n\n" + "from pydantic import BaseModel\n\n\n" + "class SiSendMessageRequest(BaseModel):\n" + " message: str | None = None\n" + " action_response: dict[str, str] | None = None\n" + ) + + schema_dir = tmp_path / "schemas" + generated_dir = tmp_path / "generated_poc" + (schema_dir / "sponsored-intelligence").mkdir(parents=True) + (schema_dir / "sponsored-intelligence" / "si-send-message-request.json").write_text( + json.dumps(schema) + ) + (generated_dir / "sponsored_intelligence").mkdir(parents=True) + target = generated_dir / "sponsored_intelligence" / "si_send_message_request.py" + target.write_text(module_source) + + from scripts import post_generate_fixes + + monkeypatch.setattr(post_generate_fixes, "SCHEMA_DIR", schema_dir) + monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", generated_dir) + post_generate_fixes.enforce_root_required_groups() + + assert "def _require_schema_required_group(self) -> SiSendMessageRequest:" in target.read_text() + + +def test_root_required_group_skips_a_name_two_classes_share(tmp_path, monkeypatch): + """A name that resolves to two classes does not identify the root.""" + schema = { + "title": "Budget Plan", + "type": "object", + "properties": {"packages": {"type": "array"}, "proposal_id": {"type": "string"}}, + "anyOf": [{"required": ["packages"]}, {"required": ["proposal_id"]}], + } + module_source = ( + "from __future__ import annotations\n\n" + "from pydantic import BaseModel\n\n\n" + "class BudgetPlan(BaseModel):\n" + " packages: list[str] | None = None\n" + " proposal_id: str | None = None\n\n\n" + "class BUDGETPLAN(BaseModel):\n" + " packages: list[str] | None = None\n" + " proposal_id: str | None = None\n" + ) + + target = _write_required_group_fixture(tmp_path, monkeypatch, schema, module_source) + + assert target.read_text() == module_source + + +def test_root_required_group_asks_whether_the_caller_supplied_the_field(tmp_path, monkeypatch): + """A default is not a supplied value, and an explicit null is. + + ``required`` asks whether the property is present. AdCP 3.2 mutation inputs + use an explicit null to clear a stored value, so a request whose only group + is ``daily_budget_cap: null`` is a request to remove the cap. + """ + from pydantic import ValidationError + + schema = { + "title": "Budget Control", + "type": "object", + "properties": { + "daily_budget_cap": {"type": ["number", "null"]}, + "replayed": {"type": "boolean"}, + }, + "anyOf": [{"required": ["daily_budget_cap"]}, {"required": ["replayed"]}], + } + module_source = ( + "from __future__ import annotations\n\n" + "from pydantic import BaseModel\n\n\n" + "class BudgetControl(BaseModel):\n" + " daily_budget_cap: float | None = None\n" + " replayed: bool = True\n" + ) + + target = _write_required_group_fixture(tmp_path, monkeypatch, schema, module_source) + namespace: dict[str, object] = {} + exec(compile(target.read_text(), str(target), "exec"), namespace) # noqa: S102 + model = namespace["BudgetControl"] + + cleared = model.model_validate({"daily_budget_cap": None}) + assert cleared.daily_budget_cap is None + assert model.model_validate({"replayed": True}).replayed is True + # `replayed` carries a default the caller never sent, so its group is unsatisfied. + with pytest.raises(ValidationError, match="at least one of these field groups"): + model.model_validate({}) diff --git a/tests/test_codegen_contract_compatibility.py b/tests/test_codegen_contract_compatibility.py index d4794bf74..599ed47f0 100644 --- a/tests/test_codegen_contract_compatibility.py +++ b/tests/test_codegen_contract_compatibility.py @@ -256,3 +256,62 @@ def test_public_response_bases_remain_constructible_and_arms_remain_specific() - response_base.model_validate(invalid_base) with pytest.raises(ValueError): parse_json_or_text(invalid_base, response_base) + + +_MEDIA_BUY_UNCONDITIONAL_FIELDS = { + "idempotency_key": "idem-key-0123456789", + "account": {"account_id": "acct_1"}, + "brand": {"brand_id": "brand_1", "domain": "example.com"}, + "start_time": "2026-11-01T00:00:00Z", + "end_time": "2026-11-30T00:00:00Z", +} + + +def test_create_media_buy_request_requires_one_root_anyof_group() -> None: + """``create-media-buy-request.json`` declares three ways to be a media buy. + + Root ``required`` names the five fields every arm shares; the root ``anyOf`` + names the three groups, one of which a request has to carry. A document with + the five and none of the three is a media buy with no packages, no budget and + no proposal. + """ + from adcp.types.generated_poc.media_buy.create_media_buy_request import CreateMediaBuyRequest + + with pytest.raises(ValidationError, match="at least one of these field groups"): + CreateMediaBuyRequest.model_validate(dict(_MEDIA_BUY_UNCONDITIONAL_FIELDS)) + + committed = CreateMediaBuyRequest.model_validate( + { + **_MEDIA_BUY_UNCONDITIONAL_FIELDS, + "proposal_id": "prop_1", + "total_budget": {"amount": 1000.0, "currency": "USD"}, + } + ) + assert committed.proposal_id == "prop_1" + assert committed.packages is None + + +def test_sync_creatives_request_takes_assignment_operations_without_creatives() -> None: + """``creatives`` is one arm of a root ``anyOf``, not an unconditional field. + + AdCP 3.2 moved ``creatives`` out of root ``required`` into a group alongside + ``assignments`` and ``assignment_operations``, so a request that traffics + existing creative IDs carries no ``creatives`` at all. + """ + from adcp.types.generated_poc.creative.sync_creatives_request import SyncCreativesRequest + + base = {"idempotency_key": "idem-key-0123456789", "account": {"account_id": "acct_1"}} + + with pytest.raises(ValidationError, match="at least one of these field groups"): + SyncCreativesRequest.model_validate(dict(base)) + + assignments_only = SyncCreativesRequest.model_validate( + { + **base, + "assignment_operations": [ + {"operation": "assign", "creative_id": "creative_1", "package_id": "package_1"} + ], + } + ) + assert assignments_only.creatives is None + assert assignments_only.assignment_operations is not None diff --git a/tests/test_decisioning_async_discovery.py b/tests/test_decisioning_async_discovery.py index 9dd7018c6..a64ef442c 100644 --- a/tests/test_decisioning_async_discovery.py +++ b/tests/test_decisioning_async_discovery.py @@ -1045,6 +1045,7 @@ def create_media_buy(self, req, ctx): idempotency_key="idem_async_create_1", start_time="2026-05-01T00:00:00Z", end_time="2026-05-31T23:59:59Z", + packages=[{"buyer_ref": "pkg_1", "product_id": "prod_1", "pricing_option_id": "cpm"}], push_notification_config={ "url": "https://buyer.example.com/wh", "operation_id": "op-cmb-789", diff --git a/tests/test_decisioning_handler.py b/tests/test_decisioning_handler.py index 6c6fea61f..d6d9517ea 100644 --- a/tests/test_decisioning_handler.py +++ b/tests/test_decisioning_handler.py @@ -203,6 +203,7 @@ async def create_media_buy(self, req, ctx): idempotency_key="idem_aaaa1234567890", start_time="2026-05-01T00:00:00Z", end_time="2026-05-31T23:59:59Z", + packages=[{"buyer_ref": "pkg_1", "product_id": "prod_1", "pricing_option_id": "cpm"}], ), ToolContext(), ) @@ -236,6 +237,7 @@ async def create_media_buy(self, req, ctx): idempotency_key="idem_aaaa1234567890", start_time="2026-05-01T00:00:00Z", end_time="2026-05-31T23:59:59Z", + packages=[{"buyer_ref": "pkg_1", "product_id": "prod_1", "pricing_option_id": "cpm"}], ), ToolContext(), ) diff --git a/tests/test_decisioning_webhook_emit.py b/tests/test_decisioning_webhook_emit.py index 9cf7941f2..bfbaa1ba3 100644 --- a/tests/test_decisioning_webhook_emit.py +++ b/tests/test_decisioning_webhook_emit.py @@ -494,6 +494,8 @@ def _make_request(*, with_url: bool = True, idem_suffix: str = "x") -> CreateMed "idempotency_key": f"idem_aaaa12345678{idem_suffix}", "start_time": "2026-05-01T00:00:00Z", "end_time": "2026-05-31T23:59:59Z", + # create-media-buy-request.json requires packages, a budget, or a proposal. + "packages": [{"buyer_ref": "pkg_1", "product_id": "prod_1", "pricing_option_id": "cpm"}], } if with_url: payload["push_notification_config"] = { diff --git a/tests/test_feed_mirror.py b/tests/test_feed_mirror.py index 62aa8203e..380ae65ee 100644 --- a/tests/test_feed_mirror.py +++ b/tests/test_feed_mirror.py @@ -70,6 +70,8 @@ def make_product_dict( def make_signal_dict(segment_id: str, *, cpm: float = 2.5) -> dict[str, Any]: return { + # core/signal-listing.json requires signal_ref or signal_id. + "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}", diff --git a/tests/test_media_buy_store_integration.py b/tests/test_media_buy_store_integration.py index 9a3d318a8..2c19d9c57 100644 --- a/tests/test_media_buy_store_integration.py +++ b/tests/test_media_buy_store_integration.py @@ -225,6 +225,7 @@ async def create_media_buy(self, req, ctx): idempotency_key="idem_aaaa1234567890", start_time="2026-05-01T00:00:00Z", end_time="2026-05-31T23:59:59Z", + packages=[{"buyer_ref": "pkg_1", "product_id": "prod_1", "pricing_option_id": "cpm"}], ), ToolContext(), )