diff --git a/CHANGELOG.md b/CHANGELOG.md index 9370c0b6d..6e2890ef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased +### ⚠ BREAKING CHANGES + +* **types:** `ProductFormatDeclaration` is a class again — a `Format` subclass + that reads the root rules of `core/product-format-declaration.json` on every + validation — rather than an `Annotated` union. Construction, + `model_validate`, `isinstance` and the `adcp.canonical_formats` projection + helpers all work on it, and `legacy_format_refs` / `params_as` are reachable; + `validate_union` and `TypeAdapter` keep working and return the class. + `get_args()` no longer yields the 16 generated branches and `params` is the + open bag `Format` declares — read typed parameters with + `params_as(CanonicalFormatImage)`, or use `LegacyProductFormatDeclaration` + for the branch union. Enforcement is wider: the root `oneOf` is evaluated as + well as the `allOf`, so each branch's own schema is graded — a declaration + such as `{"format_kind": "image", "params": {"width": 300}}` is now refused + by `image.json`'s size-mode mutex. Refs #1401. + ### Features * **auth:** `BearerTokenAuth.resolve_principal` supports sync/async non-bearer diff --git a/MIGRATION_v8_to_v9.md b/MIGRATION_v8_to_v9.md index a6b39e7ad..f1313713a 100644 --- a/MIGRATION_v8_to_v9.md +++ b/MIGRATION_v8_to_v9.md @@ -12,7 +12,7 @@ changes below before upgrading each client, server and reporting worker. | Generated required-field groups are enforced | Supply the fields required by a schema arm: for example, a frequency-cap field group, or packages/proposal for a media-buy request. Empty models that previously constructed can now raise `ValidationError`. See [generated type migration](docs/types-9-migration.md). | | Scalar schema roots are scalar subclasses | Replace scalar `.model_validate()` with `TypeAdapter` and remove `.root` access. See [scalar roots](docs/types-9-migration.md#1-scalar-roots-are-the-scalar-1286). | | Union roots are typing aliases; single-model roots are models | Validate union payloads with `validate_union` or a reused `TypeAdapter`. List roots still use `.root`. See [composing roots](docs/types-9-migration.md#2-composing-roots-are-what-they-compose-1353). | -| `ProductFormatDeclaration` names the schema's authoring union | Use `validate_union(ProductFormatDeclaration, payload)` for its typed branches. Use `Format` for open consumer/projection code and `params_as`. See [product declarations](docs/types-9-migration.md#product-declarations-use-the-authoring-union). | +| `ProductFormatDeclaration` enforces its schema's root rules | It is a `Format` subclass, so construction, `model_validate`, `isinstance` and the projection helpers all work on it, and it refuses a `format_kind` outside the schema's closed set. Read typed parameters with `params_as`. Use `Format` for open consumer parsing of unknown kinds, and `LegacyProductFormatDeclaration` for the generated branch union. A validated declaration is published on `Product.format_options` as-is. See [product declarations](docs/types-9-migration.md#product-declarations-are-graded-against-their-root-schema) and [publishing a declaration](docs/types-9-migration.md#validating-a-declaration-and-publishing-it-as-a-format). | | Structural pointer references share the selected type | Remove imports of obsolete internal per-position wrappers. Use public `adcp.types` names or `adcp.types.domains` modules. See [pointer references](docs/types-9-migration.md#3-pointer-refs-resolve-to-the-selected-type-1371). | | Format-kind fields preserve open strings | Replace enum identity comparisons with string equality. Opt into `CanonicalFormatKindStr` when your application needs a closed vocabulary. See [format-kind validation](docs/canonical-format-kinds-migration.md). | | Reporting generations require authenticated caller ownership | Include `consumer_id` in custom store/binding implementations and typed constructors. Migrate populated ledgers with stopped workers; an entirely empty ledger can use the explicit replacement API. See [reporting ownership migration](docs/reporting-caller-ownership-migration.md). | diff --git a/docs/releasing.md b/docs/releasing.md index 6bb2f7f78..f3a8ce8d1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -24,15 +24,17 @@ before confirming; the PR title and description alone are not parsed. After re-enabling a disabled Release Please workflow, run it once manually on `main` to process the current head; subsequent main pushes start it automatically. -Before SDK 9 GA, resolve the public declaration naming in -[#1401](https://github.com/adcontextprotocol/adcp-client-python/issues/1401). -`ProductFormatDeclaration` currently aliases the canonical `Format` helper, -while the schema's discriminated union is exposed as -`LegacyProductFormatDeclaration`. Simply rebinding the public name to that -union loses the mutual-exclusion validator for `canonical_formats_only` and -`v1_format_ref`. The fix must preserve that validation, credential-key -screening, typed parameter access, and the explicit legacy projection -boundary. Make any public-name changes during beta and document the migration. +The public declaration naming in +[#1401](https://github.com/adcontextprotocol/adcp-client-python/issues/1401) is +resolved: `ProductFormatDeclaration` is a `Format` subclass that reads the root +rules of `core/product-format-declaration.json` on every validation. That keeps +the four things a public-name change had to preserve — the +`canonical_formats_only`/`v1_format_ref` mutual exclusion, credential-key +screening, the explicit legacy projection boundary, and a constructible, +`isinstance`-able type whose `params_as` reaches typed parameters. Typed +`params` attribute access lives on `LegacyProductFormatDeclaration`, the +generated branch union. Make any further public-name changes during beta and +document the migration. At the reviewed GA exit, remove the three prerelease configuration keys, update the release-channel contract test, and select `Release-As: 9.0.0`. diff --git a/docs/types-9-migration.md b/docs/types-9-migration.md index 6ac650d0e..751aeeabd 100644 --- a/docs/types-9-migration.md +++ b/docs/types-9-migration.md @@ -12,7 +12,7 @@ pass. The pull requests are linked for the full rationale and measurements. | 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 | -| Consumer `format_kind` fields preserve open strings | [adcp#7929](https://github.com/adcontextprotocol/adcp/issues/7929) | `x.format_kind is CanonicalFormatKind.y` identity comparisons; the authoring union still requires a known discriminator | +| Consumer `format_kind` fields preserve open strings | [adcp#7929](https://github.com/adcontextprotocol/adcp/issues/7929) | `x.format_kind is CanonicalFormatKind.y` identity comparisons; `ProductFormatDeclaration` still refuses a kind outside its schema's closed set | Nothing is removed from `adcp` or `adcp.types`: every name importable before is importable after. The additions are `Issue`, `AdcpVersionEnvelope`, seven @@ -137,27 +137,68 @@ when schemas are regenerated. A single-model root such as `CheckGovernanceRequest` keeps `model_validate(...)` and can now be subclassed with `extra="forbid"`. -### Product declarations use the authoring union +### Product declarations are graded against their root schema -`ProductFormatDeclaration` now names the 16 generated, discriminated authoring -branches from `core/product-format-declaration.json`, with its normative -cross-field rules enforced. It is no longer an alias for the open `Format` -model. Validate an authored declaration with the cached helper: +`ProductFormatDeclaration` is a `Format` subclass that enforces the root rules +of `core/product-format-declaration.json`: the six cross-field `allOf` clauses +and the sixteen-branch `format_kind`/`params` `oneOf`, including each branch's +own schema. It is no longer an alias for the open `Format` model, which +enforces none of them. ```python -from adcp.types import ProductFormatDeclaration, validate_union +from adcp.types import ProductFormatDeclaration -declaration = validate_union(ProductFormatDeclaration, { - "format_kind": "image", "params": {"width": 300, "height": 250}, -}) +declaration = ProductFormatDeclaration( + format_kind="image", params={"width": 300, "height": 250} +) +``` + +Because it is a class, `ProductFormatDeclaration(...)`, +`ProductFormatDeclaration.model_validate(...)` and +`isinstance(x, ProductFormatDeclaration)` all work, and `validate_union` and +`TypeAdapter` return it. Because it is a `Format`, every projection helper in +`adcp.canonical_formats` accepts one, and `legacy_format_refs` and `params_as` +are reachable on it. + +`params` is the open bag `Format` declares; read it as a typed model with +`declaration.params_as(CanonicalFormatImage)`. Use `Format(...)` for open +consumer parsing of kinds this SDK's pin does not know — +`ProductFormatDeclaration` refuses a `format_kind` outside the schema's closed +set, which is the producer-side rule. `LegacyProductFormatDeclaration` names the +raw generated branch union for code that wants per-branch typed parameters. + +#### Validating a declaration and publishing it as a `Format` + +`Product.format_options` is `list[Format]`, so a seller converting stored +declarations into products validates each option strictly and then publishes the +validated object itself. There is no conversion step: a +`ProductFormatDeclaration` *is* a `Format`. + +```python +from adcp.types import Product, ProductFormatDeclaration + +options = [ProductFormatDeclaration.model_validate(raw) for raw in stored_options] +product = Product(product_id="p1", format_options=options, ...) ``` -The selected branch exposes typed parameters and requires a known discriminator. -Use `Format(...)` for open consumer parsing, projection helpers and the existing -`params_as` convenience method. `Format` continues to preserve unknown kind -strings and parameter fields. `LegacyProductFormatDeclaration` remains available -as a compatibility spelling for the raw generated union; new authoring code -should use the current name with its cross-field validation. +The published element keeps its `ProductFormatDeclaration` type, and the wire +document carries only the fields the stored option set — no branch defaults are +added, under `model_dump()`, `exclude_unset=True` or `exclude_none=True` alike: + +```python +product.model_dump(mode="json")["format_options"] +# [{'format_kind': 'image', 'params': {'width': 300, 'height': 250}}] +``` + +Round-tripping holds: `ProductFormatDeclaration.model_validate(...)` on a +published element re-grades it against the root schema. + +One field does not survive, by design. `v1_format_ref` is legacy identity, which +every canonical boundary model strips from its output — `Format` and +`ProductFormatDeclaration` both capture it on input and expose it as +`declaration.legacy_format_refs`, and neither serializes it. Project it +explicitly with `adcp.canonical_formats.project_declaration_to_v1(declaration)` +when a legacy peer needs `format_ids`. ## 3. Pointer refs resolve to the selected type (#1371) @@ -283,8 +324,10 @@ For application models that should reject unknown kinds during construction, use the opt-in `CanonicalFormatKindStr` annotation or an `AfterValidator(require_canonical_format_kind(vocabulary))`. Nullable and list annotations compose normally. See [format-kind validation](canonical-format-kinds-migration.md). -The product authoring union described above has a fixed set of discriminator -branches; use open `Format` when consuming a declaration for a future kind. +`ProductFormatDeclaration`, described above, is the producer-side exception: +it refuses a `format_kind` outside the closed set its root schema declares, +and the refusal carries the `oneOf` keyword. Use open `Format` when consuming +a declaration for a kind this pin does not know. **What this replaced.** Five pieces of scaffolding existed only to reconcile the closed enum with the open requirement, and all five are gone: the diff --git a/src/adcp/types/_product_format_declaration.py b/src/adcp/types/_product_format_declaration.py index d678d4e7b..6c2bf7b01 100644 --- a/src/adcp/types/_product_format_declaration.py +++ b/src/adcp/types/_product_format_declaration.py @@ -1,19 +1,46 @@ -"""Schema-derived cross-field rules for the generated authoring union.""" +"""Schema-derived root rules for the canonical product format declaration. + +``core/product-format-declaration.json`` carries two kinds of root constraint +that a generated class cannot express on its own: + +* six ``allOf`` clauses — ``if``/``then``/``not`` cross-field rules over + ``required`` and ``const`` (for example ``publisher_domain`` is addressable + only alongside ``format_option_id``); +* a sixteen-branch ``oneOf`` pairing each ``format_kind`` const with the + canonical ``params`` schema for that kind. + +:func:`check_declaration_rules` evaluates both against the bundled schema, so +the rules stay derived from the signed bundle rather than restated by hand. +:class:`adcp.types.canonical_creative.ProductFormatDeclaration` runs it as a +``model_validator``, which keeps one class — constructible, ``isinstance``-able +and ``model_validate``-able — as the public authoring type. +""" from __future__ import annotations -from typing import Annotated, Any +from typing import Any -from pydantic import BaseModel, BeforeValidator +from pydantic import BaseModel from pydantic_core import PydanticCustomError -from adcp.types.domains.core.product_format_declaration import ( - ProductFormatDeclaration as _GeneratedProductFormatDeclaration, -) from adcp.validation.schema_loader import get_named_validator +PRODUCT_FORMAT_DECLARATION_SCHEMA = "core/product-format-declaration.json" + + +def check_declaration_rules(data: Any, *, schema_name: str) -> Any: + """Return the document in ``data``, refusing it if a root rule fails. + + Raises :class:`pydantic_core.PydanticCustomError` keyed on the failing + JSON Schema keyword, so the refusal carries the schema's own vocabulary + (``required``, ``not``, ``oneOf``) rather than a hand-written sentence. + + A model arrives when a sibling generated branch or another ``Format`` is + adopted. It is returned as its wire document so the declaration is built + from the same keys the rules were graded against, and so an unset default + stays absent rather than becoming an explicit null. + """ -def _check_declaration_rules(data: Any) -> Any: if isinstance(data, BaseModel): # Defaults that were never supplied are absent on the wire. Explicit # nulls retain their presence: root rules test required, not truthiness. @@ -23,35 +50,51 @@ def _check_declaration_rules(data: Any) -> Any: else: return data - validator = get_named_validator("core/product-format-declaration.json") + validator = get_named_validator(schema_name) if validator is None: - raise RuntimeError("Bundled product-format-declaration schema is unavailable") + raise RuntimeError(f"Bundled {schema_name} schema is unavailable") # Each lookup supplies an independent resolver over cached schema data. # Evolving it preserves reference resolution and format checking, while - # selecting only the normative root rules codegen cannot express. - rules = validator.evolve(schema={"allOf": validator.schema.get("allOf", [])}) - error = next(rules.iter_errors(document), None) + # selecting only the normative root rules the declared fields cannot carry: + # the cross-field allOf clauses and the format_kind/params oneOf pairing. + # ``properties``, ``required`` and ``type`` are deliberately left out — + # the model's own fields already enforce those, and including them would + # report every field error twice. + schema = validator.schema + rules = {"allOf": schema.get("allOf", [])} + branch = _select_branch(schema, document) + if branch is None: + # No branch claims this discriminator value. Reporting the whole oneOf + # names the closed set of accepted kinds, which is the useful message. + rules["oneOf"] = schema.get("oneOf", []) + else: + # Grading the one branch the discriminator selects — rather than the + # whole oneOf — keeps the failing keyword and its instance path, so a + # bad parameter reports as ``params.width`` instead of a root ``oneOf``. + rules["allOf"] = [*rules["allOf"], branch] + + error = next(validator.evolve(schema=rules).iter_errors(document), None) if error is not None: - raise PydanticCustomError(str(error.validator), error.message) - - # Preserve the public declaration's credential screening without imposing - # it on generated classes or changing their definition site. - from adcp.types.canonical_creative import _walk_for_credential_keys - - known_fields = set(validator.schema.get("properties", {})) | {"format_kind", "params"} - for bag_name, bag in ( - ("params", document.get("params")), - ("extras", {key: value for key, value in document.items() if key not in known_fields}), - ): - found = _walk_for_credential_keys(bag, path=bag_name) - if found is not None: - raise ValueError( - f"{found!r} matches a credential-shaped key suffix and cannot " - "be stored in a product format declaration" - ) - return data - - -ProductFormatDeclaration = Annotated[ - _GeneratedProductFormatDeclaration, BeforeValidator(_check_declaration_rules) -] + raise PydanticCustomError(str(error.validator), _message_with_path(error)) + return document + + +def _select_branch(schema: dict[str, Any], document: Any) -> dict[str, Any] | None: + """Return the ``oneOf`` branch the schema's discriminator selects.""" + + property_name = schema.get("discriminator", {}).get("propertyName") + if not property_name or not isinstance(document, dict): + return None + value = document.get(property_name) + for branch in schema.get("oneOf", []): + if branch.get("properties", {}).get(property_name, {}).get("const") == value: + selected: dict[str, Any] = branch + return selected + return None + + +def _message_with_path(error: Any) -> str: + """Prefix ``error``'s message with the instance path it failed at.""" + + path = ".".join(str(part) for part in error.absolute_path) + return f"{path}: {error.message}" if path else error.message diff --git a/src/adcp/types/aliases.py b/src/adcp/types/aliases.py index 5bed70f8c..276f99e93 100644 --- a/src/adcp/types/aliases.py +++ b/src/adcp/types/aliases.py @@ -403,8 +403,9 @@ def _generated_alias(name: str, fallback_name: str) -> Any: "daast_tracker", ] -# Use the generated authoring union with its schema-derived cross-field rules. -from adcp.types._product_format_declaration import ProductFormatDeclaration +# The public authoring type is a class whose root rules are read from the +# bundled schema — see canonical_creative.ProductFormatDeclaration. +from adcp.types.canonical_creative import ProductFormatDeclaration from adcp.types.domains.core.assets.pixel_tracker_asset import ( Method as PixelTrackerMethod, ) diff --git a/src/adcp/types/canonical_creative.py b/src/adcp/types/canonical_creative.py index e5c0d23f4..049bb7f66 100644 --- a/src/adcp/types/canonical_creative.py +++ b/src/adcp/types/canonical_creative.py @@ -38,10 +38,13 @@ it the vocabulary and :func:`is_canonical_format_kind` to meet it, and leaves the decision where the knowledge is. -The explicit ``ProductFormatDeclaration`` authoring union selects one of the -sixteen generated branches and enforces the schema's root cross-field rules. -Use ``Format`` to parse consumer declarations with future kinds, and the -opt-in ``CanonicalFormatKindStr`` annotation to restrict an adopter boundary. +The ``ProductFormatDeclaration`` authoring class is a ``Format`` narrowed by +the root rules of ``core/product-format-declaration.json`` — the six +cross-field ``allOf`` clauses and the sixteen-branch ``format_kind``/``params`` +``oneOf``, both read from the bundled schema. Use ``Format`` to parse consumer +declarations with future kinds, ``LegacyProductFormatDeclaration`` for the +generated branch union, and the opt-in ``CanonicalFormatKindStr`` annotation to +restrict an adopter boundary. So the vocabulary is not discarded, it is **relocated**: :class:`CanonicalFormatKind` stays a first-class export, used for comparison @@ -90,7 +93,10 @@ from pydantic.json_schema import GenerateJsonSchema from pydantic_core import CoreSchema -from adcp.types._product_format_declaration import ProductFormatDeclaration +from adcp.types._product_format_declaration import ( + PRODUCT_FORMAT_DECLARATION_SCHEMA, + check_declaration_rules, +) from adcp.types.base import AdCPBaseModel from adcp.types.domains.core.canonical_format_kind import CanonicalFormatKind from adcp.types.domains.core.creative_asset import CreativeAsset as _CanonicalCreativeWire @@ -482,9 +488,13 @@ def _serialize_canonical_model( ) -> Any: """Enforce the boundary for nested and TypeAdapter serialization too.""" + # ``self`` is whatever pydantic is serializing at this position, which is not + # always a model: a field annotated with a canonical model can hold a plain + # dict (a mismatched value pydantic serializes with a warning). Read the + # capability off the type defensively, because ``dict`` does not carry it. return strip_legacy_creative_identity( handler(self), - _format_scope=self.__class__.__name__ == "Format", + _format_scope=getattr(type(self), "__adcp_format_declaration_scope__", False), ) @@ -516,6 +526,13 @@ class CanonicalBoundaryModel(AdCPBaseModel): model_config = ConfigDict(extra="allow", defer_build=True) __adcp_canonical_creative_model__: ClassVar[bool] = True + # Whether this model is a format declaration, and so may carry a root + # ``v1_format_ref`` and owns the format-scoped legacy-identity strip. It is + # a declared, INHERITED capability rather than a ``cls.__name__ == + # "Format"`` comparison, which no subclass of ``Format`` could satisfy — + # a subclass would silently lose both behaviours. + __adcp_format_declaration_scope__: ClassVar[bool] = False + _serialize_canonical = model_serializer(mode="wrap")(_serialize_canonical_model) @classmethod @@ -552,8 +569,8 @@ def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: def _reject_legacy_creative_identity(cls, value: Any) -> Any: found = _legacy_creative_identity_path( value, - allow_root_v1_ref=cls.__name__ == "Format", - format_scope=cls.__name__ == "Format", + allow_root_v1_ref=cls.__adcp_format_declaration_scope__, + format_scope=cls.__adcp_format_declaration_scope__, ) if found is not None: raise ValueError( @@ -565,7 +582,7 @@ def model_dump(self, **kwargs: Any) -> dict[str, Any]: kwargs.setdefault("serialize_as_any", False) stripped: dict[str, Any] = strip_legacy_creative_identity( super().model_dump(**kwargs), - _format_scope=self.__class__.__name__ == "Format", + _format_scope=type(self).__adcp_format_declaration_scope__, ) return stripped @@ -574,7 +591,7 @@ def model_dump_json(self, **kwargs: Any) -> str: raw = super().model_dump_json(**kwargs) clean = strip_legacy_creative_identity( json.loads(raw), - _format_scope=self.__class__.__name__ == "Format", + _format_scope=type(self).__adcp_format_declaration_scope__, ) indent = kwargs.get("indent") return json.dumps( @@ -636,6 +653,14 @@ def _inherit(source: type[AdCPBaseModel], name: str) -> Any: class Format(CanonicalBoundaryModel): """Canonical format declaration exposed as ``adcp.Format``.""" + __adcp_format_declaration_scope__: ClassVar[bool] = True + + # Set by a subclass that enforces a bundled root schema. ``None`` keeps the + # permissive reader behaviour every other ``Format`` consumer relies on; + # naming a schema makes the root rules authoritative, which includes the + # ``allOf`` clause forbidding ``capability_id`` outright. + _root_schema_name: ClassVar[str | None] = None + format_option_id: str | None = Field( default=None, description="Stable option identifier within the product or publisher namespace.", @@ -686,7 +711,11 @@ def _reject_legacy_conflicts_and_credentials(cls, data: Any) -> Any: def __init__(self, **data: Any) -> None: refs = data.get("v1_format_ref") - if "capability_id" in data and "format_option_id" not in data: + if ( + "capability_id" in data + and "format_option_id" not in data + and type(self)._root_schema_name is None + ): data["format_option_id"] = data.pop("capability_id") super().__init__(**data) if self.__pydantic_extra__ is not None: @@ -719,6 +748,36 @@ def _validate_custom_shape(self) -> Format: return self +class ProductFormatDeclaration(Format): + """A product-bound canonical declaration, graded against its root schema. + + The AdCP 3.2 authoring type. It is a :class:`Format` — so every projection + entry point in :mod:`adcp.canonical_formats` accepts it, and + ``legacy_format_refs`` / ``params_as`` stay reachable — narrowed by the root + rules of ``core/product-format-declaration.json``: the six cross-field + ``allOf`` clauses and the sixteen-branch ``format_kind``/``params`` + ``oneOf``. Those are read from the bundled schema on every validation + rather than restated here, so the schema stays the only statement of them. + """ + + # ``revalidate_instances`` stays at its ``"never"`` default deliberately. + # Setting ``"always"`` hands this validator a dict of every declared field, + # unset ones included as ``None`` — which destroys the presence the root + # rules test. ``format_shape: None`` would then read as present and + # allOf[2]'s ``else`` branch would refuse every non-custom declaration. + # Presence therefore comes from the buyer's own document, or from + # ``exclude_unset`` when a model is revalidated across classes. + _root_schema_name: ClassVar[str | None] = PRODUCT_FORMAT_DECLARATION_SCHEMA + + @model_validator(mode="before") + @classmethod + def _enforce_root_schema_rules(cls, data: Any) -> Any: + schema_name = cls._root_schema_name + if schema_name is None: # pragma: no cover - set on this class + return data + return check_declaration_rules(data, schema_name=schema_name) + + class Placement(_LegacyPlacement, CanonicalBoundaryModel): """Canonical placement; ``format_options`` are canonical declarations.""" diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index 9b4ffb2d6..3970ce9ae 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -453,7 +453,7 @@ "Product": "adcp.types.canonical_creative", "ProductAllowedAction": "core.product_allowed_action", "ProductFilters": "adcp.types.canonical_creative", - "ProductFormatDeclaration": "union[instance:pydantic.fields:FieldInfo,instance:pydantic.functional_validators:BeforeValidator,union[core.product_format_declaration:ProductFormatDeclaration1,core.product_format_declaration:ProductFormatDeclaration10,core.product_format_declaration:ProductFormatDeclaration11,core.product_format_declaration:ProductFormatDeclaration12,core.product_format_declaration:ProductFormatDeclaration13,core.product_format_declaration:ProductFormatDeclaration14,core.product_format_declaration:ProductFormatDeclaration15,core.product_format_declaration:ProductFormatDeclaration16,core.product_format_declaration:ProductFormatDeclaration2,core.product_format_declaration:ProductFormatDeclaration3,core.product_format_declaration:ProductFormatDeclaration4,core.product_format_declaration:ProductFormatDeclaration5,core.product_format_declaration:ProductFormatDeclaration6,core.product_format_declaration:ProductFormatDeclaration7,core.product_format_declaration:ProductFormatDeclaration8,core.product_format_declaration:ProductFormatDeclaration9]]", + "ProductFormatDeclaration": "adcp.types.canonical_creative", "ProductSignalTargetingOption": "core.product_signal_targeting_option", "ProjectedMediaBuyAction": "adcp.media_buy_actions", "Property": "core.property", @@ -1327,7 +1327,7 @@ "ProductDiscoveryProductId": "media_buy.product_discovery_criteria:ProductId", "ProductFilterCountry": "core.product_filters:Country", "ProductFilters": "adcp.types.canonical_creative", - "ProductFormatDeclaration": "union[instance:pydantic.fields:FieldInfo,instance:pydantic.functional_validators:BeforeValidator,union[core.product_format_declaration:ProductFormatDeclaration1,core.product_format_declaration:ProductFormatDeclaration10,core.product_format_declaration:ProductFormatDeclaration11,core.product_format_declaration:ProductFormatDeclaration12,core.product_format_declaration:ProductFormatDeclaration13,core.product_format_declaration:ProductFormatDeclaration14,core.product_format_declaration:ProductFormatDeclaration15,core.product_format_declaration:ProductFormatDeclaration16,core.product_format_declaration:ProductFormatDeclaration2,core.product_format_declaration:ProductFormatDeclaration3,core.product_format_declaration:ProductFormatDeclaration4,core.product_format_declaration:ProductFormatDeclaration5,core.product_format_declaration:ProductFormatDeclaration6,core.product_format_declaration:ProductFormatDeclaration7,core.product_format_declaration:ProductFormatDeclaration8,core.product_format_declaration:ProductFormatDeclaration9]]", + "ProductFormatDeclaration": "adcp.types.canonical_creative", "ProductFormatSellerPreference": "core.product_format_declaration:SellerPreference", "ProductSignalTargetingOption": "core.product_signal_targeting_option", "Property": "core.property", diff --git a/tests/test_product_format_declaration.py b/tests/test_product_format_declaration.py new file mode 100644 index 000000000..1f6117eee --- /dev/null +++ b/tests/test_product_format_declaration.py @@ -0,0 +1,297 @@ +"""The authoring declaration is a class graded against its bundled root schema.""" + +from __future__ import annotations + +import copy +import warnings +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +import pytest +from pydantic import BaseModel, TypeAdapter, ValidationError + +import adcp +from adcp.canonical_formats import ( + find_declaration_by_kind, + group_declarations_by_product, + project_declaration_to_v1, +) +from adcp.types import ( + CanonicalFormatImage, + CanonicalFormatKind, + Format, + LegacyProductFormatDeclaration, + Package, + ProductFormatDeclaration, + validate_union, +) +from adcp.types.domains.core.product_format_declaration import ( + ProductFormatDeclaration as GeneratedProductFormatDeclaration, +) +from adcp.validation.schema_loader import get_named_validator + +_REF = {"agent_url": "https://creative.example.com", "id": "display_300x250"} +_SCHEMA = {"uri": "https://formats.example.com/custom.json", "digest": "sha256:" + "a" * 64} + + +def _payload(kind: str = "image") -> dict[str, Any]: + data: dict[str, Any] = {"format_kind": kind, "params": {}} + if kind == "custom": + data.update(format_shape="roadblock", format_schema=_SCHEMA, canonical_formats_only=True) + elif kind == "seller_rendered_stateful_display": + data["params"] = { + "states": [ + { + "state_id": "initial", + "anchoring": "inline", + "breakpoints": [{"breakpoint_id": "desktop", "width": 300, "height": 250}], + "close_affordance": False, + } + ], + "initial_state_id": "initial", + "user_controls": {"dismissible": False, "user_collapsible": False}, + } + elif kind == "coordinated_placements": + data["params"] = { + "components": [ + { + "component_id": identifier, + "placement_ref": {"placement_id": identifier}, + "required": True, + "format_option_ref": { + "scope": "product", + "format_option_id": f"{identifier}_image", + }, + } + for identifier in ("left", "right") + ] + } + return data + + +# --- the public name is a class ------------------------------------------------ + + +def test_public_name_is_a_constructible_class() -> None: + assert adcp.ProductFormatDeclaration is ProductFormatDeclaration + assert isinstance(ProductFormatDeclaration, type) + declaration = ProductFormatDeclaration( + format_kind="image", params={"width": 300, "height": 250} + ) + assert declaration.format_kind == "image" + + +def test_public_name_supports_isinstance_and_model_validate() -> None: + declaration = ProductFormatDeclaration.model_validate(_payload()) + assert isinstance(declaration, ProductFormatDeclaration) + assert not isinstance(Format(format_kind="image", params={}), ProductFormatDeclaration) + + +def test_declaration_is_a_format_so_the_projection_surface_accepts_it() -> None: + declaration = ProductFormatDeclaration.model_validate( + {"format_kind": "image", "params": {"width": 300, "height": 250}, "v1_format_ref": [_REF]} + ) + assert isinstance(declaration, Format) + assert project_declaration_to_v1(declaration) is not None + assert find_declaration_by_kind("image", [declaration]) is declaration + assert group_declarations_by_product([declaration], {"product_1": ["image"]}) is not None + + +def test_consumer_accessors_stay_reachable_on_the_public_name() -> None: + declaration = ProductFormatDeclaration.model_validate( + {"format_kind": "image", "params": {"width": 300, "height": 250}, "v1_format_ref": [_REF]} + ) + assert [ref.id for ref in declaration.legacy_format_refs] == ["display_300x250"] + assert declaration.params_as(CanonicalFormatImage).width == 300 + + +def test_the_generated_union_remains_exported_under_its_own_name() -> None: + assert LegacyProductFormatDeclaration is GeneratedProductFormatDeclaration + assert ProductFormatDeclaration is not LegacyProductFormatDeclaration + assert ProductFormatDeclaration is not Format + + +# --- the schema's root rules --------------------------------------------------- + + +@pytest.mark.parametrize("kind", [kind.value for kind in CanonicalFormatKind]) +def test_every_canonical_kind_validates_through_the_public_class(kind: str) -> None: + declaration = ProductFormatDeclaration.model_validate(_payload(kind)) + assert declaration.format_kind == kind + assert isinstance(declaration, ProductFormatDeclaration) + + +def test_a_format_kind_outside_the_closed_set_is_refused() -> None: + with pytest.raises(ValidationError) as exc: + ProductFormatDeclaration.model_validate({"format_kind": "not_a_kind", "params": {}}) + assert exc.value.errors()[0]["type"] == "oneOf" + # The open consumer type still reads a kind it does not know. + assert Format(format_kind="not_a_kind", params={}).format_kind == "not_a_kind" + + +def test_per_kind_parameters_are_graded_against_the_branch_schema() -> None: + with pytest.raises(ValidationError) as exc: + ProductFormatDeclaration.model_validate( + {"format_kind": "image", "params": {"width": "not-an-int", "height": 250}} + ) + error = exc.value.errors()[0] + assert error["type"] == "type" + # The discriminator selects one branch, so the refusal names the field. + assert "params.width" in error["msg"] + + +@pytest.mark.parametrize( + "changes", + [ + {"canonical_formats_only": True, "v1_format_ref": [_REF]}, + {"canonical_formats_only": True, "v1_format_ref": None}, + {"capability_id": "creative-agent-only"}, + {"publisher_domain": "publisher.example.com"}, + {"tracker_execution_contract": None}, + {"format_shape": None}, + {"format_schema": _SCHEMA}, + {"locale_policy": {"accepted_language_ranges": ["en"]}}, + ], +) +def test_every_root_rule_rejects_with_its_schema_keyword(changes: dict[str, Any]) -> None: + payload = {**_payload(), **changes} + validator = get_named_validator("core/product-format-declaration.json") + assert validator is not None + expected = next( + validator.evolve(schema={"allOf": validator.schema["allOf"]}).iter_errors(payload) + ) + with pytest.raises(ValidationError) as exc: + ProductFormatDeclaration.model_validate(payload) + assert exc.value.errors()[0]["type"] == expected.validator + + +def test_capability_id_is_refused_rather_than_migrated() -> None: + # ``Format`` keeps its reader tolerance and renames the key. + assert Format(format_kind="image", params={}, capability_id="c1").format_option_id == "c1" + # The declaration enforces the schema's allOf, which forbids the key. + for build in ( + lambda: ProductFormatDeclaration(format_kind="image", params={}, capability_id="c1"), + lambda: ProductFormatDeclaration.model_validate( + {"format_kind": "image", "params": {}, "capability_id": "c1"} + ), + ): + with pytest.raises(ValidationError) as exc: + build() + assert exc.value.errors()[0]["type"] == "not" + + +@pytest.mark.parametrize( + "changes", + [ + {"v1_format_ref": [_REF]}, + {"canonical_formats_only": True}, + {"publisher_domain": "publisher.example.com", "format_option_id": "image_1"}, + {"locale_policy": {"accepted_language_ranges": ["en"]}, "canonical_formats_only": True}, + ], +) +def test_conforming_root_rule_combinations_still_validate(changes: dict[str, Any]) -> None: + payload = {**_payload(), **changes} + assert ProductFormatDeclaration.model_validate(payload).format_kind == "image" + + +def test_custom_declarations_need_the_source_schemas_complete_contract() -> None: + assert ProductFormatDeclaration.model_validate(_payload("custom")).format_kind == "custom" + with pytest.raises(ValidationError): + ProductFormatDeclaration.model_validate({"format_kind": "custom", "params": {}}) + without_projection = _payload("custom") + without_projection.pop("canonical_formats_only") + with pytest.raises(ValidationError): + ProductFormatDeclaration.model_validate(without_projection) + + +def test_explicit_presence_is_distinguished_from_an_unset_default() -> None: + # An unset default is absent from the wire document, so a rule keyed on + # ``required`` does not fire for it; an explicitly supplied null does. + assert ProductFormatDeclaration.model_validate(_payload()).format_kind == "image" + with pytest.raises(ValidationError) as exc: + ProductFormatDeclaration.model_validate({**_payload(), "tracker_execution_contract": None}) + assert exc.value.errors()[0]["type"] == "required" + + +def test_an_unset_default_is_not_read_as_present_when_a_model_is_revalidated() -> None: + # Revalidating across classes dumps with ``exclude_unset``, so the nine + # declared-but-unset fields must not arrive as explicit nulls — otherwise + # allOf[2]'s ``else`` branch would refuse every non-custom declaration. + generated = TypeAdapter(GeneratedProductFormatDeclaration).validate_python(_payload()) + assert ProductFormatDeclaration.model_validate(generated).format_kind == "image" + + +# --- behaviour the rebinding must not lose ------------------------------------ + + +@pytest.mark.parametrize( + "changes", + [{"params": {"nested": {"api_token": "credential"}}}, {"upstream_secret": "credential"}], +) +def test_credential_screening_survives_the_public_rebinding(changes: dict[str, Any]) -> None: + with pytest.raises(ValidationError, match="credential-shaped key"): + ProductFormatDeclaration.model_validate({**_payload(), **changes}) + + +def test_validation_does_not_mutate_the_buyers_document() -> None: + payload = {**_payload(), "v1_format_ref": [_REF]} + original = copy.deepcopy(payload) + ProductFormatDeclaration.model_validate(payload) + assert payload == original + + +def test_the_declaration_can_be_used_in_adopter_models() -> None: + class ProductCatalog(BaseModel): + format_options: list[ProductFormatDeclaration] + + result = ProductCatalog.model_validate({"format_options": [_payload()]}) + assert isinstance(result.format_options[0], ProductFormatDeclaration) + with pytest.raises(ValidationError): + ProductCatalog.model_validate( + { + "format_options": [ + {**_payload(), "canonical_formats_only": True, "v1_format_ref": [_REF]} + ] + } + ) + + +def test_validate_union_and_type_adapter_both_return_the_class() -> None: + for result in ( + validate_union(ProductFormatDeclaration, _payload()), + TypeAdapter(ProductFormatDeclaration).validate_python(_payload()), + ): + assert isinstance(result, ProductFormatDeclaration) + + +def test_the_wrap_serializer_tolerates_a_dict_at_a_canonical_position() -> None: + # A field annotated with a canonical model can hold a plain dict, and the + # wrap serializer then receives that dict rather than a model. Reading the + # format-declaration capability off it must not raise. + class Response(BaseModel): + affected_packages: list[Package] | None = None + + response = Response.model_construct(affected_packages=[{"package_id": "pkg_1"}]) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + assert response.model_dump() == {"affected_packages": [{"package_id": "pkg_1"}]} + + +def test_open_consumer_format_and_params_helper_remain_available() -> None: + future = Format(format_kind="future_kind", params={}) + assert future.model_dump()["format_kind"] == "future_kind" + image = Format(format_kind="image", params={"width": 300}) + assert image.params_as(CanonicalFormatImage).width == 300 + + +def test_concurrent_authoring_validation_uses_independent_resolvers() -> None: + def validate_batch(_: int) -> None: + for _ in range(20): + assert ProductFormatDeclaration.model_validate(_payload()).format_kind == "image" + with pytest.raises(ValidationError): + ProductFormatDeclaration.model_validate( + {**_payload(), "canonical_formats_only": True, "v1_format_ref": [_REF]} + ) + + with ThreadPoolExecutor(max_workers=4) as executor: + list(executor.map(validate_batch, range(4))) diff --git a/tests/test_product_format_declaration_union.py b/tests/test_product_format_declaration_union.py deleted file mode 100644 index 857e1cc2b..000000000 --- a/tests/test_product_format_declaration_union.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Authoring declarations use generated branches and the schema's root rules.""" - -from __future__ import annotations - -import copy -from concurrent.futures import ThreadPoolExecutor -from typing import Any, get_args - -import pytest -from pydantic import BaseModel, TypeAdapter, ValidationError - -import adcp -from adcp.types import ( - CanonicalFormatImage, - CanonicalFormatKind, - Format, - LegacyProductFormatDeclaration, - ProductFormatDeclaration, - validate_union, -) -from adcp.types.domains.core.product_format_declaration import ( - ProductFormatDeclaration as GeneratedProductFormatDeclaration, -) -from adcp.validation.schema_loader import get_named_validator - -_REF = {"agent_url": "https://creative.example.com", "id": "display_300x250"} -_SCHEMA = {"uri": "https://formats.example.com/custom.json", "digest": "sha256:" + "a" * 64} - - -def _payload(kind: str = "image") -> dict[str, Any]: - data: dict[str, Any] = {"format_kind": kind, "params": {}} - if kind == "custom": - data.update(format_shape="roadblock", format_schema=_SCHEMA, canonical_formats_only=True) - elif kind == "seller_rendered_stateful_display": - data["params"] = { - "states": [ - { - "state_id": "initial", - "anchoring": "inline", - "breakpoints": [{"breakpoint_id": "desktop", "width": 300, "height": 250}], - "close_affordance": False, - } - ], - "initial_state_id": "initial", - "user_controls": {"dismissible": False, "user_collapsible": False}, - } - elif kind == "coordinated_placements": - data["params"] = { - "components": [ - { - "component_id": identifier, - "placement_ref": {"placement_id": identifier}, - "required": True, - "format_option_ref": { - "scope": "product", - "format_option_id": f"{identifier}_image", - }, - } - for identifier in ("left", "right") - ] - } - return data - - -def test_public_name_is_the_generated_union_with_validation_metadata() -> None: - assert adcp.ProductFormatDeclaration is ProductFormatDeclaration - assert ProductFormatDeclaration is not Format - assert get_args(ProductFormatDeclaration)[0] is get_args(GeneratedProductFormatDeclaration)[0] - assert LegacyProductFormatDeclaration is GeneratedProductFormatDeclaration - - -@pytest.mark.parametrize("kind", [kind.value for kind in CanonicalFormatKind]) -def test_all_sixteen_generated_branches_are_reachable(kind: str) -> None: - result = validate_union(ProductFormatDeclaration, _payload(kind)) - assert result.format_kind == kind - assert type(result).__module__ == "adcp.types.domains.core.product_format_declaration" - assert type(result) is type( - TypeAdapter(GeneratedProductFormatDeclaration).validate_python(_payload(kind)) - ) - - -def test_params_are_typed_and_round_trip_with_the_discriminator() -> None: - result = validate_union( - ProductFormatDeclaration, {"format_kind": "image", "params": {"width": 300, "height": 250}} - ) - assert isinstance(result.params, CanonicalFormatImage) - assert result.params.width == 300 - assert result.model_dump(mode="json", exclude_unset=True) == { - "format_kind": "image", - "params": {"width": 300.0, "height": 250.0}, - } - - -@pytest.mark.parametrize( - "changes", - [ - {"canonical_formats_only": True, "v1_format_ref": [_REF]}, - {"canonical_formats_only": True, "v1_format_ref": None}, - {"capability_id": "creative-agent-only"}, - {"publisher_domain": "publisher.example.com"}, - {"tracker_execution_contract": None}, - {"format_shape": None}, - {"format_schema": _SCHEMA}, - {"locale_policy": {"accepted_language_ranges": ["en"]}}, - ], -) -def test_every_root_rule_rejects_with_its_schema_keyword(changes: dict[str, Any]) -> None: - payload = {**_payload(), **changes} - validator = get_named_validator("core/product-format-declaration.json") - assert validator is not None - expected = next( - validator.evolve(schema={"allOf": validator.schema["allOf"]}).iter_errors(payload) - ) - with pytest.raises(ValidationError) as exc: - validate_union(ProductFormatDeclaration, payload) - assert exc.value.errors()[0]["type"] == expected.validator - - -@pytest.mark.parametrize( - "changes", - [ - {"v1_format_ref": [_REF]}, - {"canonical_formats_only": True}, - {"publisher_domain": "publisher.example.com", "format_option_id": "image_1"}, - {"locale_policy": {"accepted_language_ranges": ["en"]}, "canonical_formats_only": True}, - ], -) -def test_conforming_root_rule_combinations_still_validate(changes: dict[str, Any]) -> None: - payload = {**_payload(), **changes} - assert validate_union(ProductFormatDeclaration, payload).format_kind == "image" - - -def test_custom_declarations_need_the_source_schemas_complete_contract() -> None: - assert validate_union(ProductFormatDeclaration, _payload("custom")).format_kind == "custom" - with pytest.raises(ValidationError): - validate_union(ProductFormatDeclaration, {"format_kind": "custom", "params": {}}) - without_projection = _payload("custom") - without_projection.pop("canonical_formats_only") - with pytest.raises(ValidationError): - validate_union(ProductFormatDeclaration, without_projection) - - -def test_existing_generated_instances_preserve_explicit_presence_and_omit_defaults() -> None: - original = TypeAdapter(GeneratedProductFormatDeclaration).validate_python(_payload()) - assert "v1_format_ref" not in original.model_fields_set - assert validate_union(ProductFormatDeclaration, original) is original - invalid = original.model_copy(update={"canonical_formats_only": True, "v1_format_ref": None}) - with pytest.raises(ValidationError) as exc: - validate_union(ProductFormatDeclaration, invalid) - assert exc.value.errors()[0]["type"] == "not" - - -@pytest.mark.parametrize( - "changes", - [{"params": {"nested": {"api_token": "credential"}}}, {"upstream_secret": "credential"}], -) -def test_credential_screening_survives_the_public_rebinding(changes: dict[str, Any]) -> None: - with pytest.raises(ValidationError, match="credential-shaped key"): - validate_union(ProductFormatDeclaration, {**_payload(), **changes}) - - -def test_validation_does_not_mutate_the_buyers_document() -> None: - payload = {**_payload(), "v1_format_ref": [_REF]} - original = copy.deepcopy(payload) - validate_union(ProductFormatDeclaration, payload) - assert payload == original - - -def test_authoring_union_can_be_used_in_adopter_models() -> None: - class ProductCatalog(BaseModel): - format_options: list[ProductFormatDeclaration] - - result = ProductCatalog.model_validate({"format_options": [_payload()]}) - assert result.format_options[0].format_kind == "image" - with pytest.raises(ValidationError): - ProductCatalog.model_validate( - { - "format_options": [ - {**_payload(), "canonical_formats_only": True, "v1_format_ref": [_REF]} - ] - } - ) - - -def test_open_consumer_format_and_params_helper_remain_available() -> None: - future = Format(format_kind="future_kind", params={}) - assert future.model_dump()["format_kind"] == "future_kind" - image = Format(format_kind="image", params={"width": 300}) - assert image.params_as(CanonicalFormatImage).width == 300 - - -def test_concurrent_authoring_validation_uses_independent_resolvers() -> None: - def validate_batch(_: int) -> None: - for _ in range(20): - assert validate_union(ProductFormatDeclaration, _payload()).format_kind == "image" - with pytest.raises(ValidationError): - validate_union( - ProductFormatDeclaration, - {**_payload(), "canonical_formats_only": True, "v1_format_ref": [_REF]}, - ) - - with ThreadPoolExecutor(max_workers=4) as executor: - list(executor.map(validate_batch, range(4))) diff --git a/tests/type_checks/product_format_declaration.py b/tests/type_checks/product_format_declaration.py new file mode 100644 index 000000000..195982a4a --- /dev/null +++ b/tests/type_checks/product_format_declaration.py @@ -0,0 +1,65 @@ +"""The authoring declaration is a class; consumer formats stay open.""" + +from typing import Any + +from pydantic import BaseModel, TypeAdapter +from typing_extensions import assert_type + +from adcp.canonical_formats import ( + V2ToV1Projection, + find_declaration_by_kind, + group_declarations_by_product, + project_declaration_to_v1, + project_v1_format_to_declaration, +) +from adcp.types import Format, ProductFormatDeclaration + + +class ProductCatalog(BaseModel): + format_options: list[ProductFormatDeclaration] + + +def read_kind(declaration: ProductFormatDeclaration) -> str: + kind: str = declaration.format_kind + return kind + + +def read_parameter_bag(declaration: ProductFormatDeclaration) -> dict[str, Any]: + return declaration.params + + +adapter: TypeAdapter[ProductFormatDeclaration] = TypeAdapter(ProductFormatDeclaration) +catalog = ProductCatalog.model_validate( + {"format_options": [{"format_kind": "image", "params": {"width": 300, "height": 250}}]} +) +assert_type(read_kind(catalog.format_options[0]), str) + +# The public name is a class, so an adopter can construct it, validate through +# it, and narrow with it. Each of these is a type error against a union alias. +authored = ProductFormatDeclaration(format_kind="image", params={"width": 300, "height": 250}) +validated = ProductFormatDeclaration.model_validate( + {"format_kind": "image", "params": {"width": 300, "height": 250}} +) +assert_type(authored, ProductFormatDeclaration) +assert_type(validated, ProductFormatDeclaration) +assert_type(adapter.validate_python({}), ProductFormatDeclaration) +assert_type(authored.params_as(Format), Format) + + +def narrow(value: object) -> str | None: + if isinstance(value, ProductFormatDeclaration): + return value.format_kind + return None + + +# A declaration IS a Format, so every projection entry point accepts one +# without the adopter restating the type. +assert_type(project_declaration_to_v1(authored), V2ToV1Projection) +assert_type(find_declaration_by_kind("image", [authored]), Format | None) +assert_type(group_declarations_by_product([authored], {}), dict[str, list[Format]]) + +consumer = Format(format_kind="future_kind", params={}) +assert_type(consumer.format_kind, str) +projection = project_v1_format_to_declaration({}) +assert_type(projection.declaration, Format | None) +project_declaration_to_v1(consumer) diff --git a/tests/type_checks/product_format_declaration_union.py b/tests/type_checks/product_format_declaration_union.py deleted file mode 100644 index 67ee40f3b..000000000 --- a/tests/type_checks/product_format_declaration_union.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Authoring declarations bind the generated union; consumer formats stay open.""" - -from typing import Any - -from pydantic import BaseModel, TypeAdapter -from typing_extensions import assert_type - -from adcp.canonical_formats import project_declaration_to_v1, project_v1_format_to_declaration -from adcp.types import Format, ProductFormatDeclaration - - -class ProductCatalog(BaseModel): - format_options: list[ProductFormatDeclaration] - - -def read_kind(declaration: ProductFormatDeclaration) -> str: - kind: str = declaration.format_kind - return kind - - -def read_parameter_model(declaration: ProductFormatDeclaration) -> object: - return declaration.params - - -adapter: TypeAdapter[Any] = TypeAdapter(ProductFormatDeclaration) -catalog = ProductCatalog.model_validate( - {"format_options": [{"format_kind": "image", "params": {"width": 300}}]} -) -assert_type(read_kind(catalog.format_options[0]), str) - -consumer = Format(format_kind="future_kind", params={}) -assert_type(consumer.format_kind, str) -projection = project_v1_format_to_declaration({}) -assert_type(projection.declaration, Format | None) -project_declaration_to_v1(consumer)