From 69f84d0e5fd4ca158bac37f655ad8b0ea3c81e78 Mon Sep 17 00:00:00 2001 From: Greg Smith Date: Wed, 28 Oct 2026 15:22:16 +0000 Subject: [PATCH] feat(models): remove postprocess_models.py and emit pure OOTB Pydantic DTOs --- .github/workflows/tests.yml | 21 +- README.md | 39 +- generate_models.sh | 50 +- postprocess_models.py | 2941 ---------------------- pyproject.toml | 10 - src/ucp_sdk/models/schemas/__init__.py | 14 +- src/ucp_sdk/models/schemas/_generated.py | 926 +------ tests/test_codegen_pipeline.py | 2821 --------------------- tests/test_models.py | 162 +- 9 files changed, 324 insertions(+), 6660 deletions(-) delete mode 100644 postprocess_models.py delete mode 100644 tests/test_codegen_pipeline.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e3a0691..c0fed0e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,7 +25,7 @@ permissions: jobs: schema-preprocessing: - name: Schema preprocessing (Python ${{ matrix.python-version }}) + name: Unit tests (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest strategy: fail-fast: false @@ -42,7 +42,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install package (generated-model tests import it) run: pip install -e . - - name: Run preprocessing tests + - name: Run unit tests run: python -m unittest discover -s tests -p "test_*.py" model-drift: @@ -58,10 +58,19 @@ jobs: with: enable-cache: true - name: Install ucp-schema CLI - # Pinned to an immutable commit on main + Cargo.lock (includes - # ucp-schema#81-#89); switch to `cargo install ucp-schema@ - # --locked` once a crates.io release > 1.4.1 is published. - run: cargo install --git https://github.com/Universal-Commerce-Protocol/ucp-schema.git --rev 2ce04ccdcd529416808160b8419e97a91483a1f7 --locked ucp-schema + env: + UCP_SCHEMA_VERSION: "2.0.0" + run: | + set -euo pipefail + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + base_url="https://github.com/Universal-Commerce-Protocol/ucp-schema/releases/download/v${UCP_SCHEMA_VERSION}" + curl -fsSL --retry 3 -o "$tmp_dir/ucp-schema-linux-x86_64" "${base_url}/ucp-schema-linux-x86_64" + curl -fsSL --retry 3 -o "$tmp_dir/SHA256SUMS" "${base_url}/SHA256SUMS" + (cd "$tmp_dir" && sha256sum -c --ignore-missing SHA256SUMS) + mkdir -p "$HOME/.local/bin" + install -m 0755 "$tmp_dir/ucp-schema-linux-x86_64" "$HOME/.local/bin/ucp-schema" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Regenerate models from the pinned spec version # SDK 0.6.x targets UCP 2026-08-25 (see the README compatibility # table). Bump this pin together with the SDK version line. diff --git a/README.md b/README.md index 1d142d3..c25010b 100644 --- a/README.md +++ b/README.md @@ -92,16 +92,45 @@ both `ucp_sdk.models` and `ucp_sdk.models.schemas`: ### Validation -All models support Pydantic validation and serialization: +Pydantic models in `ucp_sdk.models` provide Tier 1 wire DTO structural typing +and tolerant-reader deserialization (preserving unknown extension fields for +forward compatibility) rather than full UCP protocol conformance. + +To validate inbound messages against normative UCP constraints (capability +profile composition, directional `ucp_request` / `ucp_response` visibility, +`dependentRequired`, `if`/`then`, `uniqueItems`, `contains`, `propertyNames`, +`not.enum`, and closed-schema rules), verify received JSON payloads with +[`ucp-schema validate`](https://crates.io/crates/ucp-schema) prior to +deserializing into SDK DTOs: + +```bash +# Self-describing response or JSON-RPC envelope +ucp-schema validate payload.json --op read + +# REST request against a discovery profile (or --schema checkout.json --request --op create) +ucp-schema validate payload.json --profile profile.json --op create +``` ```python +from pathlib import Path +import subprocess + from pydantic import ValidationError from ucp_sdk.models import Checkout -# Validate data against UCP schemas +payload_path = Path("payload.json") + +# Step 1: Verify normative UCP protocol constraints with ucp-schema validate +subprocess.run( + ["ucp-schema", "validate", str(payload_path), "--op", "read"], + check=True, +) + +# Step 2: Deserialize verified JSON into typed Pydantic DTOs try: - checkout = Checkout.model_validate(checkout_data) - # Serialize to JSON-compatible dict + checkout = Checkout.model_validate_json( + payload_path.read_text(encoding="utf-8") + ) checkout_dict = checkout.model_dump(exclude_none=True) except ValidationError as e: print(e.errors()) @@ -145,7 +174,7 @@ a branch or commit SHA, or a path to a local UCP repository checkout. If no argument is specified, the `main` branch of the [UCP repo](https://github.com/Universal-Commerce-Protocol/ucp) will be used. -The generated code is automatically post-processed and formatted using `ruff`. +The generated code is automatically formatted using `ruff`. ### Running Tests diff --git a/generate_models.sh b/generate_models.sh index 7d8bb35..946d794 100755 --- a/generate_models.sh +++ b/generate_models.sh @@ -137,8 +137,7 @@ echo "Compiling type bundle with ucp-schema generate-types..." "$UCP_SCHEMA_BIN" generate-types --schema-dir "$SCOPED_SCHEMA_DIR" --output "$TMP_TYPES_JSON" echo "Generating Pydantic models from compiled type bundle..." -rm -rf "$OUTPUT_DIR" -mkdir -p "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" && rm -f "$OUTPUT_DIR/_generated.py" uv run \ --link-mode=copy \ @@ -161,8 +160,51 @@ uv run \ --use-title-as-name \ --additional-imports pydantic.ConfigDict -echo "Post-processing generated models (constraints the generator ignores)..." -uv run python postprocess_models.py "$TMP_TYPES_JSON" src/ucp_sdk/models "$SCOPED_SCHEMA_DIR" +echo "Generating lazy package index in $OUTPUT_DIR/__init__.py..." +python3 - <<'PY' +import ast +from pathlib import Path + +_SYNTHETIC_WRAPPERS = {"UCPSchemaTypes", "UcpSchemaTypes"} +schemas_dir = Path("src/ucp_sdk/models/schemas") +tree = ast.parse((schemas_dir / "_generated.py").read_text(encoding="utf-8")) +type_alias_cls = getattr(ast, "TypeAlias", ()) +names: set[str] = set() +for node in tree.body: + if isinstance(node, ast.ClassDef): + names.add(node.name) + elif isinstance(node, ast.Assign): + names.update(t.id for t in node.targets if isinstance(t, ast.Name)) + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + names.add(node.target.id) + elif isinstance(node, type_alias_cls) and isinstance(getattr(node, "name", None), ast.Name): + names.add(node.name.id) +exports = sorted(n for n in names if not n.startswith("_") and n not in _SYNTHETIC_WRAPPERS) +imports = ", ".join(exports) +all_items = ", ".join(f'"{n}"' for n in exports) +(schemas_dir / "__init__.py").write_text( + '# generated by generate_models.sh\n\n' + '"""Generated UCP schema models."""\n\n' + 'from __future__ import annotations\n\n' + 'import importlib\n' + 'from typing import TYPE_CHECKING, Any\n\n' + f'if TYPE_CHECKING:\n from ._generated import ({imports},)\n\n' + f'__all__ = [{all_items},]\n\n\n' + 'def __getattr__(name: str) -> Any:\n' + ' """Import public schema models and type aliases on first access."""\n' + ' if name not in __all__:\n' + ' msg = f"module {__name__!r} has no attribute {name!r}"\n' + ' raise AttributeError(msg)\n' + ' module = importlib.import_module("._generated", __name__)\n' + ' value = getattr(module, name)\n' + ' globals()[name] = value\n' + ' return value\n\n\n' + 'def __dir__() -> list[str]:\n' + ' """Return module attributes including lazily re-exported schema names."""\n' + ' return sorted({*globals(), *__all__})\n', + encoding="utf-8", +) +PY echo "Formatting generated models..." uv run ruff check --fix src/ucp_sdk/models diff --git a/postprocess_models.py b/postprocess_models.py deleted file mode 100644 index 95229ac..0000000 --- a/postprocess_models.py +++ /dev/null @@ -1,2941 +0,0 @@ -# Copyright 2026 UCP Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Post-generation fixes for constraints datamodel-code-generator ignores. - -Eleven constraint families are handled: - -* ``minProperties`` / ``maxProperties`` on an object schema WITH declared - properties are dropped by the generator: every field is optional, so an - empty instance (or, for ``maxProperties``, an over-full one) passes - validation in violation of the schema. ``minProperties`` support (issue - #49, PR #55) never grew a ``maxProperties`` counterpart, so - ``location_serves.json``'s ``maxProperties: 1`` ("the Platform MUST - supply exactly one target form") went unenforced even though its sibling - ``minProperties: 1`` on the same schema was already caught. (Either bound - on a free-form object property is already handled natively — the - generator maps it to ``Field(min_length=..., max_length=...)`` on the - dict field.) The script scans the preprocessed schemas for root-level - ``minProperties``/``maxProperties`` constraints and injects a - ``model_validator(mode="after")`` into the matching generated classes, - one validator per bound so both can coexist on the same class. JSON - Schema counts the keys present on the object, so the validator counts - provided fields (``model_fields_set``) unioned with extra keys - (``model_extra``) — an explicit null is a present key, and unknown keys on - ``extra="allow"`` models count too. - -* ``contains`` / ``minContains`` / ``maxContains`` on an array schema is likewise - dropped by the generator: ``totals.json`` requires *exactly one* ``subtotal`` - *and exactly one* ``total`` entry, but the generated ``Totals`` is a bare - ``list[Total]`` alias, so an empty array (or one missing either required entry, - or with duplicates) validates in violation of the schema. An array root is - emitted as a ``TypeAliasType`` wrapping ``Annotated[list[...], ...]`` rather - than a ``BaseModel`` subclass, so ``model_validator`` cannot apply; this script - instead injects a module-level counting function, threaded into the alias - metadata as a ``pydantic.AfterValidator``. Every predicate is derived from - ``contains.properties..const`` — nothing is hard-coded — and one function - enforces *all* of a schema's contains bounds. - - The source schemas are read directly for this: ``totals.json`` carries its two - containment rules as two ``allOf`` branches, whereas ``ucp-schema generate-types`` - strips validation-only ``contains`` ``allOf`` branches when synthesizing the - flat ``$defs`` bundle. The bound is applied to the base model and to its - generated request variants, and travels wherever the alias is reused as a field - type. - -* ``propertyNames`` on an object WITH named ``properties`` is not enforced. Such - a schema is emitted as a ``BaseModel(extra="allow")`` with the named fields, so - unknown (extra) keys are accepted without being checked against the declared - key pattern (``signals.json`` requires reverse-domain keys, yet a malformed - extra key validates). The script scans for objects that declare - ``propertyNames`` AND carry named ``properties`` and injects a - ``model_validator(mode="after")`` that matches every ``model_extra`` key against - the pattern. The pattern is read from the source schema (inline or via ``$ref`` - to e.g. ``reverse_domain_name.json``), never duplicated here. An object with - ``propertyNames`` but *no* named properties is emitted as a ``dict[KeyType, V]`` - whose key type already carries the pattern, so it is out of scope. - -* ``uniqueItems`` on an array is dropped entirely by the generator, so a list - field accepts duplicate entries in violation of the schema. The script - collects the names of array properties declared with ``uniqueItems`` and - injects a ``field_validator(mode="after")`` into each generated class that - declares a matching list field. - -* Simple conditional ``required`` constraints are dropped: pagination requires - ``cursor`` when ``has_next_page`` is true, but the generated response model - always treats it as optional. The script accepts only an unambiguous single - required discriminator using ``const``/``enum`` and a ``then.required`` list, - then injects a ``model_validator(mode="after")``. More complex conditions are - skipped rather than approximated. These rules are commonly carried as - ``allOf`` branches with no sibling ``properties`` of their own (every - conditional rule in ``profile.json``'s ``jwk_public_key`` is this shape), so - the scan validates them against the enclosing object's property set, the - same threading ``find_conditional_bounds`` (below) already used. A branch's - own documentation ``title`` (some carry one purely as rule prose, e.g. "EC - keys carry crv, x, y") is never adopted as the enclosing class name; see - ``_is_bare_conditional_branch``. - -* Conditional numeric bounds, and conditional exact-value (``const``) pins, - are dropped for the same reason: ``total.json`` requires a ``discount`` - amount to be negative and a ``tax`` amount to be non-negative via if/then - branches, and ``unit.json`` pins ``scale`` to exactly 0 when ``unit`` is - ``C62``, but the generated ``Total``/``Unit`` carry no validator, so a - positive discount or a nonzero C62 scale both validate. These rules are - carried as ``allOf`` branches, which have no sibling ``properties`` of - their own, so the scan validates them against the enclosing object's - property set. A rule whose fields were stripped by request-variant - projection is inapplicable rather than malformed and is skipped silently. - As with conditional required rules above, a branch's own documentation - ``title`` is never adopted as the enclosing class name. - -* A discriminator retyping an array PROPERTY's items to a schema file - different from the property's own base ``$ref`` is dropped entirely, a - third if/then shape distinct from the required-fields and numeric-bounds - families above. ``fulfillment_method.json``'s ``destinations`` stays typed - to the base ``FulfillmentDestination`` regardless of ``type``, even though - a `shipping` method's destinations are really ``ShippingDestination`` - (postal address fields, `type` const `shipping_address`) and a `pickup` - method's are really ``LocationDestination`` (`type` const - `business_location`) — so a `shipping` method can currently list a - destination typed `business_location` and it validates. Pydantic has no - clean way to retype a field's item type from a source-text splice, so - this is enforced with a runtime check instead of a static type change: - each item is checked against the referenced schema's own (root-level, - post-merge) required keys and const-pinned properties — an approximation, - not a full re-derivation of the retyped type (a schema the retyped file - itself ``allOf``-references, e.g. ``postal_address.json``, is not - inspected). - -* ``dependentRequired`` on an object is dropped entirely by the generator. For - example, ``time_interval.json`` allows an empty fragment but requires ``opens`` - and ``closes`` to appear together; the generated ``TimeInterval`` instead - accepts either field alone. The script scans root object schemas for valid - dependent-field maps and injects a ``model_validator(mode="after")`` that uses - property presence (``model_fields_set`` plus extra keys), not value truthiness. - A rule naming a field absent from a projected generated class is skipped rather - than approximated. - -* ``additionalProperties: false`` on an object schema with named properties is - normally overridden by the generator's ``--extra-fields=allow`` flag. The - script detects schemas with ``additionalProperties: false`` and flips their - generated ``model_config`` to ``extra="forbid"`` while preserving - ``extra="allow"`` on sibling models in the same module. - -* ``pattern`` on a ``format: date-time`` string is not dropped but emitted as - ``Field(pattern=...)`` on the ``AwareDatetime`` field, where pydantic - applies it to the parsed datetime rather than the string: validating - ``location_filter.json``'s ``hours.open_at`` raised a raw ``TypeError`` - ("Unable to apply constraint 'pattern'") instead of a ``ValidationError``. - ``AwareDatetime`` alone is not equivalent to the pattern (it also accepts - ``+0530`` offsets and epoch-second strings), so the script removes the - ``pattern`` argument and injects a ``field_validator(mode="before")`` that - matches the raw string against the same pattern. The fields are found in - the generated source, not the schemas: dropping the pattern before - generation would let ``--reuse-model`` merge the class with a pattern-less - twin, and the validator would then reach both. A pattern inside an - ``Annotated`` type alias (e.g. date-time array items) is not rewritten; it - is reported and fails the run. - -Runs from generate_models.sh between generation and formatting; idempotent. -""" - -import ast -import json -import re -import sys -from pathlib import Path - -SCHEMA_DIR = Path("ucp/source/schemas") -# Pristine schemas snapshotted by generate_models.sh before preprocessing. -# Array contains bounds are read from here, not SCHEMA_DIR, because -# preprocessing merges allOf and can drop a second contains keyword. -RAW_SCHEMA_DIR = Path("ucp/raw_schemas") -OUTPUT_DIR = Path("src/ucp_sdk/models/schemas") - -_MARKER = "_enforce_min_properties" - -_VALIDATOR_TEMPLATE = ''' - @model_validator(mode="after") - def {marker}(self) -> Any: - """JSON Schema minProperties: require at least {minimum} - provided {properties_noun}.""" - provided = self.model_fields_set | set(self.model_extra or {{}}) - if len(provided) < {minimum}: - raise ValueError( - "At least {minimum} {properties_noun} must be provided " - "(schema minProperties={minimum})" - ) - return self -''' - -_MAX_MARKER = "_enforce_max_properties" - -_MAX_VALIDATOR_TEMPLATE = ''' - @model_validator(mode="after") - def {marker}(self) -> Any: - """JSON Schema maxProperties: allow at most {maximum} - provided {properties_noun}.""" - provided = self.model_fields_set | set(self.model_extra or {{}}) - if len(provided) > {maximum}: - raise ValueError( - "At most {maximum} {properties_noun} may be provided " - "(schema maxProperties={maximum})" - ) - return self -''' - -_PROPNAMES_MARKER = "_enforce_property_names" - -_PROPNAMES_VALIDATOR_TEMPLATE = ''' - @model_validator(mode="after") - def {marker}(self) -> Any: - """JSON Schema propertyNames: every extra key must match the - declared reverse-domain pattern (schema propertyNames).""" - pattern = {pattern!r} - for key in self.model_extra or {{}}: - if re.fullmatch(pattern, key) is None: - raise ValueError( - f"Property name {{key!r}} does not match the schema " - f"propertyNames pattern {{pattern}}" - ) - return self -''' - -_DEPENDENT_REQUIRED_MARKER = "_enforce_dependent_required" - -_DEPENDENT_REQUIRED_TEMPLATE = ''' - @model_validator(mode="after") - def {marker}(self) -> Any: - """JSON Schema dependentRequired: enforce dependent fields.""" - rules = {rules!r} - provided = self.model_fields_set | set(self.model_extra or {{}}) - for field, required_fields in rules.items(): - if field not in provided: - continue - for required in required_fields: - if required not in provided: - raise ValueError( - f"Field {{required!r}} is required when {{field!r}} " - "is provided (schema dependentRequired)" - ) - return self -''' - -_UNIQUE_MARKER = "_enforce_unique_items" - -_CONDITIONAL_REQUIRED_MARKER = "_enforce_conditional_required" - -_CONDITIONAL_REQUIRED_TEMPLATE = ''' - @model_validator(mode="after") - def {marker}(self) -> Any: - """JSON Schema if/then: enforce conditionally required fields.""" - rules: list[dict[str, Any]] = {rules!r} - for rule in rules: - if getattr(self, rule["discriminator"], None) not in rule["values"]: - continue - for field in rule["required"]: - if field not in self.model_fields_set: - raise ValueError( - f"Field {{field!r}} is required by a schema condition" - ) - return self -''' - -_CONDITIONAL_BOUNDS_MARKER = "_enforce_conditional_bounds" - -# Returned when a rule is well-formed but names fields absent from the class it -# would apply to — distinct from None, which means the shape is unsupported and -# warrants a warning. -_RULE_NOT_APPLICABLE = object() - -# Keyword -> (comparison rendered in the message, python operator name). The -# operator is applied as "value limit" and a true result is a violation. -# "const" pins the field to an exact value (e.g. unit.json: scale must be -# exactly 0 when unit is C62) rather than bounding a range; it reuses the -# same "value limit -> violation" shape with not-equal as the operator. -_BOUND_KEYWORDS = { - "minimum": (">=", "lt"), - "maximum": ("<=", "gt"), - "exclusiveMinimum": (">", "le"), - "exclusiveMaximum": ("<", "ge"), - "const": ("==", "ne"), -} - -_CONDITIONAL_BOUNDS_TEMPLATE = ''' - @model_validator(mode="after") - def {marker}(self) -> Any: - """JSON Schema if/then: enforce conditional numeric bounds.""" - rules: list[dict[str, Any]] = {rules!r} - checks = {checks!r} - for rule in rules: - actual = getattr(self, rule["discriminator"], None) - if actual not in rule["values"]: - continue - for field, bounds in rule["bounds"].items(): - value = getattr(self, field, None) - if value is None: - continue - for keyword, limit in bounds.items(): - symbol, op_name = checks[keyword] - if getattr(operator, op_name)(value, limit): - raise ValueError( - f"Field {{field!r}} must be {{symbol}} {{limit}} " - f"when {{rule['discriminator']}} is {{actual!r}}" - ) - return self -''' - -_RETYPE_MARKER = "_enforce_conditional_item_retyping" - -_RETYPE_TEMPLATE = ''' - @model_validator(mode="after") - def {marker}(self) -> Any: - """JSON Schema if/then: approximate a discriminator's array-item - retyping to a different referenced schema, via that schema's own - required keys and const-pinned fields.""" - rules: list[dict[str, Any]] = {rules!r} - for rule in rules: - actual = getattr(self, rule["discriminator"], None) - if actual not in rule["values"]: - continue - for _item in getattr(self, rule["field"], None) or []: - _provided = ( - set(_item.keys()) - if isinstance(_item, dict) - else _item.model_fields_set | set(_item.model_extra or {{}}) - ) - for _required in rule["required"]: - if _required not in _provided: - raise ValueError( - f"Field {{_required!r}} is required for " - f"{{rule['field']}} items when " - f"{{rule['discriminator']}} is {{actual!r}}" - ) - for _const_field, _const_value in rule["consts"].items(): - _actual_value = ( - _item.get(_const_field) - if isinstance(_item, dict) - else getattr(_item, _const_field, None) - ) - if _actual_value != _const_value: - raise ValueError( - f"Field {{_const_field!r}} must equal " - f"{{_const_value!r}} for {{rule['field']}} items " - f"when {{rule['discriminator']}} is {{actual!r}}" - ) - return self -''' - -_UNIQUE_VALIDATOR_TEMPLATE = ''' - @field_validator("{field}", mode="after") - def {marker}_{field}(cls, value: Any) -> Any: # noqa: N805 - """JSON Schema uniqueItems: reject duplicate entries.""" - if value is None: - return value - seen: list[Any] = [] - for item in value: - if item in seen: - raise ValueError( - "Items must be unique (schema uniqueItems=true)" - ) - seen.append(item) - return value -''' - -_DATETIME_PATTERN_MARKER = "_enforce_datetime_pattern" - -# Annotations the generator emits for ``format: date-time`` strings. -_DATETIME_TYPES = {"AwareDatetime", "NaiveDatetime", "datetime"} - -# re.search, not fullmatch: JSON Schema patterns are unanchored, and the -# date-time pattern UCP uses is anchored only at the end. -_DATETIME_PATTERN_TEMPLATE = ''' - @field_validator("{field}", mode="before") - def {marker}_{field}(cls, value: Any) -> Any: # noqa: N805 - """JSON Schema pattern: match the raw date-time string, since - pydantic cannot apply a regex to the parsed datetime.""" - pattern = {pattern!r} - if isinstance(value, str) and re.search(pattern, value) is None: - raise ValueError( - f"{{value!r}} does not match the schema pattern {{pattern}}" - ) - return value -''' - - -def _iter_schema_files(schema_dir): - """Return a list of JSON schema paths from a file or directory.""" - path = Path(schema_dir) - if path.is_file(): - return [path] - return sorted(path.rglob("*.json")) - - -def _iter_root_and_def_objects(schema): - """Yield root and top-level $defs schema dicts.""" - if not isinstance(schema, dict): - return - yield schema - defs = schema.get("$defs") - if isinstance(defs, dict): - for def_node in defs.values(): - if isinstance(def_node, dict): - yield def_node - - -def find_root_min_properties(schema_dir): - """Map schema title -> minProperties for root-level object constraints.""" - found = {} - for path in _iter_schema_files(schema_dir): - try: - schema = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - continue - for obj in _iter_root_and_def_objects(schema): - minimum = obj.get("minProperties") - if not minimum or not obj.get("properties"): - continue - title = obj.get("title") - if not title: - sys.stderr.write( - f" ! {path}: root minProperties but no title; " - "cannot map to a class\n" - ) - continue - found[_alias_name(title)] = minimum - return found - - -def find_root_max_properties(schema_dir): - """Map schema title -> maxProperties for root-level object constraints. - - Symmetric twin of find_root_min_properties (see #49/#55, which added - minProperties support but never a maxProperties counterpart): - maxProperties on an object schema WITH declared properties is dropped by - the generator the same way minProperties is, so - location_serves.json's maxProperties: 1 ("the Platform MUST supply - exactly one target form") was silently unenforced. As with the min - side, maxProperties on a free-form object property (no named - properties) is already handled natively by the generator - (Field(max_length=...) on the dict field), so it is out of scope here. - """ - found = {} - for path in _iter_schema_files(schema_dir): - try: - schema = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - continue - for obj in _iter_root_and_def_objects(schema): - maximum = obj.get("maxProperties") - if not isinstance(maximum, int) or not obj.get("properties"): - continue - title = obj.get("title") - if not title: - sys.stderr.write( - f" ! {path}: root maxProperties but no title; " - "cannot map to a class\n" - ) - continue - found[_alias_name(title)] = maximum - return found - - -def _ensure_pydantic_import(source, symbol): - """Add ``symbol`` to the ``from pydantic import`` line if absent.""" - if re.search( - rf"^from pydantic import .*\b{re.escape(symbol)}\b", source, re.M - ): - return source - return re.sub( - r"^(from pydantic import [^\n]+)$", - lambda m: f"{m.group(1)}, {symbol}", - source, - count=1, - flags=re.M, - ) - - -def _ensure_stdlib_import(source, statement): - """Add a top-level ``import`` statement if absent. - - Inserted right after ``from __future__ import annotations`` so ruff's - isort pass (run later in the pipeline) settles it into the stdlib group. - """ - if re.search(rf"^{re.escape(statement)}$", source, re.M): - return source - return re.sub( - r"^(from __future__ import annotations\n)", - lambda m: f"{m.group(1)}\n{statement}\n", - source, - count=1, - flags=re.M, - ) - - -def _resolve_property_names_pattern(prop_names, schema_path, root_schema=None): - """Return the key pattern a ``propertyNames`` node enforces, or ``None``. - - Reads an inline ``pattern`` directly, or follows a ``$ref`` to an internal - ``#/$defs/`` entry or external schema file's root ``pattern`` (e.g. - ``reverse_domain_name.json``) so the pattern is never duplicated here. - """ - if not isinstance(prop_names, dict): - return None - inline = prop_names.get("pattern") - if isinstance(inline, str): - return inline - ref = prop_names.get("$ref") - if not isinstance(ref, str): - return None - if ref.startswith("#/$defs/") and isinstance(root_schema, dict): - def_key = ref.removeprefix("#/$defs/") - referenced = (root_schema.get("$defs") or {}).get(def_key) - if isinstance(referenced, dict) and isinstance( - referenced.get("pattern"), str - ): - return referenced["pattern"] - if ref.startswith("#"): - sys.stderr.write( - f" ! {schema_path}: propertyNames $ref '{ref}' is a local " - "pointer; pattern not resolved\n" - ) - return None - file_part = ref.split("#", 1)[0] - target = (Path(schema_path).parent / file_part).resolve() - try: - referenced = json.loads(target.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - sys.stderr.write( - f" ! {schema_path}: propertyNames $ref '{ref}' could not be " - "loaded; pattern not resolved\n" - ) - return None - pattern = ( - referenced.get("pattern") if isinstance(referenced, dict) else None - ) - if not isinstance(pattern, str): - sys.stderr.write( - f" ! {schema_path}: propertyNames $ref '{ref}' target has no " - "root pattern; not resolved\n" - ) - return None - return pattern - - -def find_property_names_patterns(schema_dir): - """Map generated class name -> propertyNames pattern for extra-allow models. - - The gap this targets: an object schema that declares ``propertyNames`` AND - carries named ``properties`` is emitted by the generator as a - ``BaseModel(extra="allow")`` with those named fields, so unknown (extra) - keys are never pattern-checked. An object with ``propertyNames`` but *no* - named ``properties`` is emitted as a ``dict[KeyType, V]`` map whose key type - already carries the pattern (pydantic validates the keys), so it is out of - scope. The class is defined mechanically: has ``propertyNames`` (resolvable - to a pattern) AND non-empty ``properties`` AND a ``title`` to map to a class. - Nested titled objects are walked too, so the rule is general, not per-file. - """ - found = {} - - def walk(node, path_str, root_schema): - if not isinstance(node, dict): - if isinstance(node, list): - for item in node: - walk(item, path_str, root_schema) - return - props = node.get("properties") - if "propertyNames" in node and isinstance(props, dict) and props: - pattern = _resolve_property_names_pattern( - node["propertyNames"], path_str, root_schema - ) - title = node.get("title") - if pattern is None: - pass - elif not title: - sys.stderr.write( - f" ! {path_str}: propertyNames on an extra-allow object " - "but no title; cannot map to a class\n" - ) - else: - if not (pattern.startswith("^") and pattern.endswith("$")): - sys.stderr.write( - f" ! {path_str}: propertyNames pattern {pattern!r} is " - "not ^/$-anchored; fullmatch enforcement may be " - "stricter than JSON Schema search semantics\n" - ) - found[_alias_name(title)] = pattern - for value in node.values(): - walk(value, path_str, root_schema) - - for path in _iter_schema_files(schema_dir): - try: - schema = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - continue - walk(schema, str(path), schema) - return found - - -def inject_property_names(source, class_name, pattern): - """Inject the propertyNames key validator at the end of ``class_name``.""" - class_re = re.compile(rf"^class {re.escape(class_name)}\(", re.M) - match = class_re.search(source) - if not match: - return source - # The class body ends at the next top-level statement or EOF. - tail = re.compile(r"^\S", re.M) - end_match = tail.search(source, match.end()) - end = end_match.start() if end_match else len(source) - # Scope the idempotency guard to this class's own body, so a second - # target class in the same module is still patched. - if f"def {_PROPNAMES_MARKER}(" in source[match.start() : end]: - return source - method = _PROPNAMES_VALIDATOR_TEMPLATE.format( - marker=_PROPNAMES_MARKER, pattern=pattern - ) - body = source[:end].rstrip("\n") - rest = source[end:] - out = body + "\n" + method + ("\n" + rest if rest else "") - out = _ensure_pydantic_import(out, "model_validator") - return _ensure_stdlib_import(out, "import re") - - -def inject_min_properties(source, class_name, minimum): - """Inject the minProperties validator at the end of ``class_name``.""" - class_re = re.compile(rf"^class {re.escape(class_name)}\(", re.M) - match = class_re.search(source) - if not match: - return source - # The class body ends at the next top-level statement or EOF. - tail = re.compile(r"^\S", re.M) - end_match = tail.search(source, match.end()) - end = end_match.start() if end_match else len(source) - if f"def {_MARKER}(" in source[match.start() : end]: - return source - method = _VALIDATOR_TEMPLATE.format( - marker=_MARKER, - minimum=minimum, - properties_noun="property" if minimum == 1 else "properties", - ) - body = source[:end].rstrip("\n") - rest = source[end:] - out = body + "\n" + method + ("\n" + rest if rest else "") - return _ensure_pydantic_import(out, "model_validator") - - -def inject_max_properties(source, class_name, maximum): - """Inject the maxProperties validator at the end of ``class_name``. - - Symmetric twin of inject_min_properties; both validators can be - injected into the same class (location_serves.json declares both - minProperties: 1 and maxProperties: 1), each guarded by its own marker - so neither injection clobbers the other or re-runs on a second pass. - """ - class_re = re.compile(rf"^class {re.escape(class_name)}\(", re.M) - match = class_re.search(source) - if not match: - return source - # The class body ends at the next top-level statement or EOF. - tail = re.compile(r"^\S", re.M) - end_match = tail.search(source, match.end()) - end = end_match.start() if end_match else len(source) - if f"def {_MAX_MARKER}(" in source[match.start() : end]: - return source - method = _MAX_VALIDATOR_TEMPLATE.format( - marker=_MAX_MARKER, - maximum=maximum, - properties_noun="property" if maximum == 1 else "properties", - ) - body = source[:end].rstrip("\n") - rest = source[end:] - out = body + "\n" + method + ("\n" + rest if rest else "") - return _ensure_pydantic_import(out, "model_validator") - - -def _extract_contains_groups(schema, path=None): - """Collect every array ``contains`` group from a schema's root + allOf. - - Each group is ``{"pairs": [(field, const), ...], "min": int, - "max": int | None}``, derived from ``contains.properties..const`` - with its ``minContains`` / ``maxContains`` bounds. A ``contains`` keyword - may sit at the schema root or inside any ``allOf`` branch; each contributes - a group, so "exactly one subtotal and one total" yields two. The predicate - is read from the schema, never hard-coded. - """ - nodes = [schema] - if isinstance(schema.get("allOf"), list): - nodes.extend(n for n in schema["allOf"] if isinstance(n, dict)) - groups = [] - for node in nodes: - contains = node.get("contains") - if not isinstance(contains, dict): - continue - props = contains.get("properties") - pairs = [] - if isinstance(props, dict): - for field, spec in props.items(): - if isinstance(spec, dict) and "const" in spec: - pairs.append((field, spec["const"])) - if not pairs: - if path is not None: - sys.stderr.write( - f" ! {path}: contains predicate has no " - "properties.*.const; cannot derive a check\n" - ) - continue - # JSON Schema: minContains defaults to 1 when contains is present. - groups.append( - { - "pairs": pairs, - "min": node.get("minContains", 1), - "max": node.get("maxContains"), - } - ) - return groups - - -def find_array_contains_constraints(schema_dir): - """Map file stem -> ``{"title": str, "groups": [...]}`` for array schemas. - - Keyed by file stem (not title) so a base schema can be linked to its - generated request variants, whose stems extend it (``totals`` -> - ``totals_create_request``). Scanned against the *pristine* schemas - (``RAW_SCHEMA_DIR``); see the module docstring for why the preprocessed - output must not be used here. - """ - found = {} - for path in _iter_schema_files(schema_dir): - try: - schema = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - continue - if not isinstance(schema, dict): - continue - # ``contains`` only constrains arrays; skip anything else. - if schema.get("type") != "array" and "items" not in schema: - continue - groups = _extract_contains_groups(schema, path) - if not groups: - continue - item_condition = _extract_item_required_condition(schema) - title = schema.get("title") - if not title: - sys.stderr.write( - f" ! {path}: array contains constraint but no title; " - "cannot map to a model\n" - ) - continue - found[path.stem] = { - "title": title, - "groups": groups, - "item_condition": item_condition, - } - return found - - -def _extract_item_required_condition(schema): - """Read a simple array-item ``not.enum`` + ``then.required`` rule.""" - items = schema.get("items") - if not isinstance(items, dict): - return None - nodes = [items] - nodes.extend( - node for node in items.get("allOf", []) if isinstance(node, dict) - ) - for node in nodes: - condition = node.get("if") - consequence = node.get("then") - if not isinstance(condition, dict) or not isinstance(consequence, dict): - continue - props = condition.get("properties") - required = consequence.get("required") - if not isinstance(props, dict) or len(props) != 1: - continue - field, predicate = next(iter(props.items())) - if ( - not isinstance(predicate, dict) - or set(node) != {"if", "then"} - or set(condition) != {"properties", "required"} - or set(consequence) != {"required"} - ): - continue - excluded = predicate.get("not") - values = excluded.get("enum") if isinstance(excluded, dict) else None - if ( - condition.get("required") == [field] - and set(predicate) == {"not"} - and isinstance(excluded, dict) - and set(excluded) == {"enum"} - and isinstance(values, list) - and values - and all(isinstance(value, str) for value in values) - and isinstance(required, list) - and required - and all(isinstance(name, str) for name in required) - ): - return {"field": field, "excluded": values, "required": required} - return None - - -def _alias_name(title): - """Derive the generated alias name from a schema title (drop spaces).""" - return "".join(title.split()) - - -def _is_bare_conditional_branch(node): - """True when ``node`` is an if/then rule wrapper with no type of its own. - - UCP schemas sometimes give an allOf if/then branch its own human-readable - ``title`` purely as rule documentation (e.g. profile.json's jwk_public_key: - "EC keys carry crv, x, y", "P-256 pairs with ES256"). Such a branch has no - ``properties`` of its own -- it constrains the *enclosing* object -- so its - title never names a generated class. Adopting it as ``current_class_name`` - (the same code path real class-defining titles use) misattributes the - rule to a nonexistent class instead of the enclosing one. A node that - carries both an if/then rule AND its own ``properties`` is a genuine - titled type that happens to declare an inline conditional, and keeps - adopting its title as before. - """ - return ( - isinstance(node.get("if"), dict) - and isinstance(node.get("then"), dict) - and not isinstance(node.get("properties"), dict) - ) - - -def _to_camel_case(string): - """Convert a string (snake, kebab, space-separated, or PascalCase) to CamelCase.""" - parts = re.split(r"[^a-zA-Z0-9]", string) - return "".join(p[0].upper() + p[1:] for p in parts if p) - - -def _snake_name(name): - """CamelCase alias -> snake_case suffix for a unique function name.""" - return re.sub(r"(? list[Any]:", - ' """JSON Schema contains/minContains/maxContains (see #49)."""', - ] - for index, group in enumerate(groups): - count = "_matched" if len(groups) == 1 else f"_matched_{index}" - desc = ", ".join(f"{f}=={c!r}" for f, c in group["pairs"]) - lines += [ - f" {count} = sum(", - " 1", - " for _item in value", - f" if {_predicate_expr(group['pairs'])}", - " )", - ] - minimum = group["min"] - noun = "entry" if minimum == 1 else "entries" - lines += [ - f" if {count} < {minimum}:", - " raise ValueError(", - f' "Array must contain at least {minimum} {noun} "', - f' "matching {desc} (schema minContains={minimum})"', - " )", - ] - maximum = group["max"] - if maximum is not None: - noun = "entry" if maximum == 1 else "entries" - lines += [ - f" if {count} > {maximum}:", - " raise ValueError(", - f' "Array must contain at most {maximum} {noun} "', - f' "matching {desc} (schema maxContains={maximum})"', - " )", - ] - if item_condition: - field = item_condition["field"] - lines += [ - f" _excluded = {item_condition['excluded']!r}", - " for _item in value:", - f" _actual = (_item.get({field!r}) if isinstance(_item, dict)", - f" else getattr(_item, {field!r}, None))", - " if _actual in _excluded:", - " continue", - ] - for required in item_condition["required"]: - lines += [ - f" if isinstance(_item, dict) and {required!r} not in _item:", - f' raise ValueError("Field {required!r} is required for custom {field}")', - f" if not isinstance(_item, dict) and {required!r} not in (_item.model_fields_set | set(_item.model_extra or {{}})):", - f' raise ValueError("Field {required!r} is required for custom {field}")', - ] - lines.append(" return value") - return "\n".join(lines) + "\n" - - -def inject_array_contains(source, alias_name, groups, item_condition=None): - """Thread an ``AfterValidator`` into ``alias_name``'s alias metadata. - - Array roots are emitted as ``NAME = TypeAliasType("NAME", Annotated[...])``, - not a ``BaseModel`` subclass, so the constraint is enforced by inserting - ``AfterValidator()`` into the ``Annotated[...]`` metadata and defining - ```` just above the assignment. Idempotent via the function name. - """ - func_name = f"_enforce_contains_{_snake_name(alias_name)}" - if f"def {func_name}(" in source: - return source - assign_re = re.compile(rf"^{re.escape(alias_name)} = TypeAliasType\(", re.M) - match = assign_re.search(source) - if not match: - return source - ann_start = source.find("Annotated[", match.end()) - if ann_start == -1: - return source - # Bracket-match to the ``]`` that closes ``Annotated[``. - depth = 0 - close = None - for pos in range(ann_start + len("Annotated"), len(source)): - char = source[pos] - if char == "[": - depth += 1 - elif char == "]": - depth -= 1 - if depth == 0: - close = pos - break - if close is None: - return source - # Insert right after the last real token inside Annotated[...], not - # blindly right before the closing bracket. When the annotation is - # line-wrapped -- which ruff/black do once the item type reference is - # long enough to push the line past the wrap width, e.g. a - # request-variant $ref such as total_create_request.TotalCreateRequest - # replacing the shorter total.Total -- there is already a trailing - # comma just before the whitespace that precedes "]". Splicing before - # that whitespace would leave the existing trailing comma and our own - # leading comma separated by nothing but whitespace: two commas with no - # expression between them, a SyntaxError (see #34/#35). - scan = close - 1 - while scan >= 0 and source[scan] in " \t\n": - scan -= 1 - if scan >= 0 and source[scan] == ",": - insert_at = scan + 1 - addition = f" AfterValidator({func_name})," - else: - insert_at = scan + 1 - addition = f", AfterValidator({func_name})" - out = source[:insert_at] + addition + source[insert_at:] - func_src = _build_contains_function(func_name, groups, item_condition) - assign_match = assign_re.search(out) - assert assign_match is not None - insert_at = assign_match.start() - out = out[:insert_at] + func_src + "\n\n" + out[insert_at:] - if item_condition: - out = _inject_item_condition_on_item_class( - out, f"{alias_name}Item", item_condition - ) - return _ensure_pydantic_import(out, "AfterValidator") - - -_ITEM_COND_MARKER = "_enforce_item_required_condition" - -_ITEM_COND_TEMPLATE = ''' - @model_validator(mode="after") - def {marker}(self) -> Any: - """JSON Schema if/then: require {required!r} when {field!r} is custom.""" - if getattr(self, {field!r}, None) not in {excluded!r}: - _present = self.model_fields_set | set(self.model_extra or {{}}) - for _req in {required!r}: - if _req not in _present: - raise ValueError( - f"Field {{_req!r}} is required for custom {field}" - ) - return self -''' - - -def _inject_item_condition_on_item_class(source, item_class_name, cond): - """Inject item_condition model_validator onto ``item_class_name`` if present.""" - span = _class_body_span(source, item_class_name) - if span is None: - return source - if f"def {_ITEM_COND_MARKER}(" in source[span[0] : span[1]]: - return source - method = _ITEM_COND_TEMPLATE.format( - marker=_ITEM_COND_MARKER, - field=cond["field"], - excluded=cond["excluded"], - required=cond["required"], - ) - body = source[: span[1]].rstrip("\n") - rest = source[span[1] :] - out = body + "\n" + method + ("\n" + rest if rest else "") - return _ensure_pydantic_import(out, "model_validator") - - -def find_conditional_required(schema_dir): - """Map generated class names to simple if/then required rules.""" - rules_by_class = {} - - def describe(node, properties): - if not isinstance(node, dict) or set(node) != {"if", "then"}: - return None - condition = node["if"] - consequence = node["then"] - if ( - not isinstance(condition, dict) - or set(condition) != {"properties", "required"} - or not isinstance(consequence, dict) - or set(consequence) != {"required"} - ): - return None - condition_props = condition["properties"] - condition_required = condition["required"] - consequence_required = consequence["required"] - if ( - not isinstance(condition_props, dict) - or len(condition_props) != 1 - or not isinstance(condition_required, list) - or len(condition_required) != 1 - or not isinstance(consequence_required, list) - or not consequence_required - ): - return None - discriminator, predicate = next(iter(condition_props.items())) - if condition_required != [discriminator] or not isinstance( - predicate, dict - ): - return None - if set(predicate) == {"const"}: - values = [predicate["const"]] - elif ( - set(predicate) == {"enum"} - and isinstance(predicate["enum"], list) - and predicate["enum"] - ): - values = predicate["enum"] - else: - return None - if ( - discriminator not in properties - or any( - not isinstance(name, str) or name not in properties - for name in consequence_required - ) - or any( - not isinstance(value, (str, int, float, bool)) - for value in values - ) - ): - return None - return { - "discriminator": discriminator, - "values": values, - "required": sorted(consequence_required), - } - - def walk(node, current_class_name, path_str, enclosing_properties=None): - if not isinstance(node, dict): - return - if isinstance( - node.get("title"), str - ) and not _is_bare_conditional_branch(node): - current_class_name = _alias_name(node["title"]) - properties = node.get("properties") - # An if/then pair carried as an allOf branch has no sibling - # properties of its own (every JWK required-field rule is exactly - # this shape): the object it constrains is the enclosing schema, so - # its property set is what the rule must be validated against. This - # mirrors find_conditional_bounds's existing enclosing_properties - # threading. - scope = ( - properties if isinstance(properties, dict) else enclosing_properties - ) - then = node.get("then") - is_required_rule = ( - isinstance(then, dict) - and "required" in then - and "properties" not in then - ) - if isinstance(scope, dict) and is_required_rule: - if "else" in node: - rule = None - else: - rule = describe( - {key: node[key] for key in ("if", "then") if key in node}, - scope, - ) - if rule is None: - sys.stderr.write( - f" ! {path_str}: unsupported conditional required rule; skipped\n" - ) - elif current_class_name is not None: - rules_by_class.setdefault(current_class_name, []).append(rule) - if isinstance(properties, dict): - for name, prop in properties.items(): - walk(prop, _to_camel_case(name), path_str) - defs = node.get("$defs") - if isinstance(defs, dict): - for def_name, def_node in defs.items(): - walk(def_node, _to_camel_case(def_name), path_str) - for key in ("allOf", "anyOf", "oneOf"): - if isinstance(node.get(key), list): - for item in node[key]: - walk(item, current_class_name, path_str, scope) - - for path in _iter_schema_files(schema_dir): - try: - schema = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - continue - if not isinstance(schema, dict): - continue - root_title = schema.get("title") - initial_class = ( - _alias_name(root_title) if root_title else _to_camel_case(path.stem) - ) - walk(schema, initial_class, str(path)) - return rules_by_class - - -def inject_conditional_required(source, class_name, rules): - """Inject simple conditional-required checks into one generated class.""" - class_re = re.compile(rf"^class {re.escape(class_name)}\(", re.M) - match = class_re.search(source) - if not match: - return source - tail = re.compile(r"^\S", re.M) - end_match = tail.search(source, match.end()) - end = end_match.start() if end_match else len(source) - if f"def {_CONDITIONAL_REQUIRED_MARKER}(" in source[match.start() : end]: - return source - method = _CONDITIONAL_REQUIRED_TEMPLATE.format( - marker=_CONDITIONAL_REQUIRED_MARKER, - rules=rules, - ) - body = source[:end].rstrip("\n") - rest = source[end:] - out = body + "\n" + method + ("\n" + rest if rest else "") - return _ensure_pydantic_import(out, "model_validator") - - -def find_conditional_bounds(schema_dir): - """Map generated class names to if/then numeric-bound rules. - - Complements find_conditional_required, which only handles a ``then`` that - adds required fields. A ``then`` that instead narrows a numeric range is - dropped by datamodel-code-generator, so the constraint would otherwise be - absent from the generated model entirely. - """ - rules_by_class = {} - - def describe(node, properties): - if not isinstance(node, dict) or set(node) != {"if", "then"}: - return None - condition = node["if"] - consequence = node["then"] - if ( - not isinstance(condition, dict) - or set(condition) != {"properties", "required"} - or not isinstance(consequence, dict) - or set(consequence) != {"properties"} - ): - return None - condition_props = condition["properties"] - condition_required = condition["required"] - consequence_props = consequence["properties"] - if ( - not isinstance(condition_props, dict) - or len(condition_props) != 1 - or not isinstance(condition_required, list) - or len(condition_required) != 1 - or not isinstance(consequence_props, dict) - or not consequence_props - ): - return None - discriminator, predicate = next(iter(condition_props.items())) - if condition_required != [discriminator] or not isinstance( - predicate, dict - ): - return None - if set(predicate) == {"const"}: - values = [predicate["const"]] - elif ( - set(predicate) == {"enum"} - and isinstance(predicate["enum"], list) - and predicate["enum"] - ): - values = predicate["enum"] - else: - return None - if any( - not isinstance(value, (str, int, float, bool)) for value in values - ): - return None - bounds = {} - for name, constraint in consequence_props.items(): - if ( - not isinstance(name, str) - or not isinstance(constraint, dict) - or not constraint - or set(constraint) - set(_BOUND_KEYWORDS) - ): - return None - # "const" pins an exact value and may legitimately be a string - # (unit.json's C62 pin is an int; JWK's algorithm pins are - # strings), so it is checked against the same scalar types - # already accepted for the discriminator's own const/enum - # values above. The numeric bound keywords keep their existing, - # narrower int/float (non-bool) requirement. - for keyword, limit in constraint.items(): - if keyword == "const": - if not isinstance(limit, (str, int, float, bool)): - return None - elif not isinstance(limit, (int, float)) or isinstance( - limit, bool - ): - return None - bounds[name] = dict(constraint) - # A request variant strips the fields a platform must not send, so a - # rule naming one is inapplicable to that class rather than malformed. - if discriminator not in properties or any( - name not in properties for name in bounds - ): - return _RULE_NOT_APPLICABLE - return { - "discriminator": discriminator, - "values": values, - "bounds": bounds, - } - - def walk(node, current_class_name, path_str, enclosing_properties=None): - if not isinstance(node, dict): - return - if isinstance( - node.get("title"), str - ) and not _is_bare_conditional_branch(node): - current_class_name = _alias_name(node["title"]) - properties = node.get("properties") - # An if/then pair carried as an allOf branch has no sibling properties: - # the object it constrains is the enclosing schema, so its property set - # is what the rule must be validated against. - scope = ( - properties if isinstance(properties, dict) else enclosing_properties - ) - then = node.get("then") - then_props = then.get("properties") if isinstance(then, dict) else None - is_retyping_only = ( - isinstance(then_props, dict) - and bool(then_props) - and all(_is_ref_array(v) for v in then_props.values()) - ) - is_bounds_rule = ( - isinstance(then, dict) - and "properties" in then - and "required" not in then - and not is_retyping_only - ) - if isinstance(scope, dict) and is_bounds_rule: - rule = ( - None - if "else" in node - else describe( - {key: node[key] for key in ("if", "then") if key in node}, - scope, - ) - ) - if rule is None: - sys.stderr.write( - f" ! {path_str}: unsupported conditional bounds rule; skipped\n" - ) - elif rule is not _RULE_NOT_APPLICABLE and current_class_name: - rules_by_class.setdefault(current_class_name, []).append(rule) - if isinstance(properties, dict): - for name, prop in properties.items(): - walk(prop, _to_camel_case(name), path_str) - if isinstance(node.get("items"), dict): - walk(node["items"], current_class_name, path_str) - defs = node.get("$defs") - if isinstance(defs, dict): - for def_name, def_node in defs.items(): - walk(def_node, _to_camel_case(def_name), path_str) - for key in ("allOf", "anyOf", "oneOf"): - if isinstance(node.get(key), list): - for item in node[key]: - walk(item, current_class_name, path_str, scope) - - for path in _iter_schema_files(schema_dir): - try: - schema = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - continue - if not isinstance(schema, dict): - continue - root_title = schema.get("title") - initial_class = ( - _alias_name(root_title) if root_title else _to_camel_case(path.stem) - ) - walk(schema, initial_class, str(path)) - return rules_by_class - - -def inject_conditional_bounds(source, class_name, rules): - """Inject conditional numeric-bound checks into one generated class.""" - class_re = re.compile(rf"^class {re.escape(class_name)}\(", re.M) - match = class_re.search(source) - if not match: - return source - tail = re.compile(r"^\S", re.M) - end_match = tail.search(source, match.end()) - end = end_match.start() if end_match else len(source) - if f"def {_CONDITIONAL_BOUNDS_MARKER}(" in source[match.start() : end]: - return source - method = _CONDITIONAL_BOUNDS_TEMPLATE.format( - marker=_CONDITIONAL_BOUNDS_MARKER, - rules=rules, - checks=_BOUND_KEYWORDS, - ) - body = source[:end].rstrip("\n") - rest = source[end:] - out = body + "\n" + method + ("\n" + rest if rest else "") - out = _ensure_stdlib_import(out, "import operator") - return _ensure_pydantic_import(out, "model_validator") - - -def _resolve_referenced_shape(ref, schema_path): - """Load ``ref`` (relative to ``schema_path``) and return its own - (root-level, post-merge) required keys and const-pinned properties. - - Returns ``None`` if the file cannot be loaded. Deliberately shallow: it - reads only the referenced schema's own ``required``/``properties``, not - anything it in turn ``allOf``-references (e.g. shipping_destination.json - ``allOf``-refs postal_address.json, whose fields are not inspected) -- - an approximation, not a full re-derivation of the retyped shape. - """ - file_part = ref.split("#", 1)[0] - if not file_part: - return None - target_path = (Path(schema_path).parent / file_part).resolve() - try: - referenced = json.loads(target_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - sys.stderr.write( - f" ! {schema_path}: retyped $ref {ref!r} could not be loaded; " - "rule skipped\n" - ) - return None - if not isinstance(referenced, dict): - return None - required = sorted( - name for name in referenced.get("required", []) if isinstance(name, str) - ) - consts = { - name: prop["const"] - for name, prop in (referenced.get("properties") or {}).items() - if isinstance(prop, dict) and "const" in prop - } - return {"required": required, "consts": consts} - - -def _is_ref_array(node): - """True when ``node`` is an array property typed via ``items.$ref``.""" - return ( - isinstance(node, dict) - and node.get("type") == "array" - and isinstance(node.get("items"), dict) - and isinstance(node["items"].get("$ref"), str) - ) - - -def _describe_retyping_branch(branch, properties, schema_path): - """Describe one allOf if/then branch that retypes an array property's - items to a schema file different from the property's own base ``$ref``. - - Mechanical and narrow by design, mirroring the other conditional - scanners in this module: a single-key ``const``/``enum`` discriminator - naming a property present on the enclosing object, and a ``then`` that - narrows exactly one array property (also present on the enclosing - object) to a different ``items.$ref``. Anything else -- multiple - discriminators, a non-array or non-$ref field, a `then` naming a field - absent from the enclosing object (a request variant that omits it, as - fulfillment_method_create_request.json does for ``destinations``) -- - returns ``None`` silently: those are either a different rule shape - (left to find_conditional_required/find_conditional_bounds, which scan - the same branches) or legitimately inapplicable, not malformed. - """ - if not isinstance(branch, dict) or set(branch) != {"if", "then"}: - return None - condition = branch["if"] - consequence = branch["then"] - if ( - not isinstance(condition, dict) - or set(condition) != {"properties", "required"} - or not isinstance(consequence, dict) - or set(consequence) != {"properties"} - ): - return None - condition_props = condition["properties"] - condition_required = condition["required"] - if ( - not isinstance(condition_props, dict) - or len(condition_props) != 1 - or not isinstance(condition_required, list) - or len(condition_required) != 1 - ): - return None - discriminator, predicate = next(iter(condition_props.items())) - if condition_required != [discriminator] or not isinstance(predicate, dict): - return None - if set(predicate) == {"const"}: - values = [predicate["const"]] - elif ( - set(predicate) == {"enum"} - and isinstance(predicate["enum"], list) - and predicate["enum"] - ): - values = predicate["enum"] - else: - return None - if discriminator not in properties or any( - not isinstance(value, (str, int, float, bool)) for value in values - ): - return None - consequence_props = consequence["properties"] - if not isinstance(consequence_props, dict) or len(consequence_props) != 1: - return None - field, field_schema = next(iter(consequence_props.items())) - if field not in properties or not _is_ref_array(field_schema): - return None - base_field_schema = properties[field] - if not _is_ref_array(base_field_schema): - return None - base_ref = base_field_schema["items"]["$ref"] - new_ref = field_schema["items"]["$ref"] - if new_ref == base_ref: - return None - target = _resolve_referenced_shape(new_ref, schema_path) - if target is None: - return None - return { - "discriminator": discriminator, - "values": values, - "field": field, - "required": target["required"], - "consts": target["consts"], - } - - -def find_conditional_array_retyping(schema_dir): - """Map generated class names to array-item retyping rules. - - Complements find_conditional_required/find_conditional_bounds, which - only handle a ``then`` that adds required fields or narrows a numeric - range. A ``then`` that instead retypes an array PROPERTY's items to a - schema file different from the property's own base ``$ref`` is a third - shape the generator drops entirely: fulfillment_method.json's - ``destinations`` stays typed to the base FulfillmentDestination - regardless of ``type``, even though a `shipping` method's destinations - are really ShippingDestination (postal address fields, `type` const - `shipping_address`) and a `pickup` method's are really - LocationDestination (`type` const `business_location`). Pydantic has no - clean way to retype a field's item type from a source-text splice, so - this is enforced with a runtime check instead of a static type change: - each item is checked against the referenced schema's own required keys - and const-pinned fields (see _resolve_referenced_shape), an - approximation rather than a full re-derivation of the retyped type. - """ - rules_by_class = {} - - def walk(node, current_class_name, schema_path): - if not isinstance(node, dict): - return - if isinstance(node.get("title"), str): - current_class_name = _alias_name(node["title"]) - properties = node.get("properties") - allof = node.get("allOf") - if isinstance(properties, dict) and isinstance(allof, list): - for branch in allof: - rule = _describe_retyping_branch( - branch, properties, schema_path - ) - if rule is not None and current_class_name is not None: - rules_by_class.setdefault(current_class_name, []).append( - rule - ) - if isinstance(properties, dict): - for name, prop in properties.items(): - walk(prop, _to_camel_case(name), schema_path) - defs = node.get("$defs") - if isinstance(defs, dict): - for def_name, def_node in defs.items(): - walk(def_node, _to_camel_case(def_name), schema_path) - - for path in _iter_schema_files(schema_dir): - try: - schema = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - continue - if not isinstance(schema, dict): - continue - root_title = schema.get("title") - initial_class = ( - _alias_name(root_title) if root_title else _to_camel_case(path.stem) - ) - walk(schema, initial_class, path) - return rules_by_class - - -def inject_conditional_array_retyping(source, class_name, rules): - """Inject array-item retyping checks into one generated class.""" - class_re = re.compile(rf"^class {re.escape(class_name)}\(", re.M) - match = class_re.search(source) - if not match: - return source - tail = re.compile(r"^\S", re.M) - end_match = tail.search(source, match.end()) - end = end_match.start() if end_match else len(source) - if f"def {_RETYPE_MARKER}(" in source[match.start() : end]: - return source - method = _RETYPE_TEMPLATE.format(marker=_RETYPE_MARKER, rules=rules) - body = source[:end].rstrip("\n") - rest = source[end:] - out = body + "\n" + method + ("\n" + rest if rest else "") - return _ensure_pydantic_import(out, "model_validator") - - -def find_root_dependent_required(schema_dir): - """Map generated class names to root-level dependentRequired rules. - - Only complete rules over declared properties are returned. Request variants - can project either side out; those rules are inapplicable to that generated - class and are skipped by ``inject_dependent_required``. - """ - found = {} - for path in _iter_schema_files(schema_dir): - try: - schema = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - continue - if not isinstance(schema, dict): - continue - for obj in _iter_root_and_def_objects(schema): - properties = obj.get("properties") - rules = obj.get("dependentRequired") - title = obj.get("title") - if not isinstance(properties, dict) or not isinstance(rules, dict): - continue - normalized = {} - malformed = False - for field, required in rules.items(): - if ( - not isinstance(field, str) - or not isinstance(required, list) - or not required - or not all(isinstance(name, str) for name in required) - ): - malformed = True - break - # Request variants may project either side out. Such a rule no - # longer applies to that generated class and is skipped silently. - if field in properties and all( - name in properties for name in required - ): - normalized[field] = required - if malformed: - sys.stderr.write( - f" ! {path}: unsupported dependentRequired rule; skipped\n" - ) - continue - if not normalized: - continue - if not isinstance(title, str) or not title: - sys.stderr.write( - f" ! {path}: root dependentRequired but no title; " - "cannot map to a class\n" - ) - continue - found[_alias_name(title)] = normalized - return found - - -def inject_dependent_required(source, class_name, rules): - """Inject root dependentRequired checks into one generated class.""" - class_re = re.compile(rf"^class {re.escape(class_name)}\(", re.M) - match = class_re.search(source) - if not match: - return source - tail = re.compile(r"^\S", re.M) - end_match = tail.search(source, match.end()) - end = end_match.start() if end_match else len(source) - class_body = source[match.start() : end] - if f"def {_DEPENDENT_REQUIRED_MARKER}(" in class_body: - return source - declared = { - field.group(1) - for field in re.finditer(r"^ (\w+): [^\n]+", class_body, re.M) - } - allow_extra_trigger = ( - class_name.endswith("Base") and 'extra="allow"' in class_body - ) - applicable = { - field: required - for field, required in rules.items() - if (field in declared or allow_extra_trigger) - and all(name in declared for name in required) - } - if not applicable: - return source - method = _DEPENDENT_REQUIRED_TEMPLATE.format( - marker=_DEPENDENT_REQUIRED_MARKER, - rules=applicable, - ) - body = source[:end].rstrip("\n") - rest = source[end:] - out = body + "\n" + method + ("\n" + rest if rest else "") - return _ensure_pydantic_import(out, "model_validator") - - -def find_unique_items_fields(schema_dir): - """Map generated class names to fields carrying ``uniqueItems``. - - A schema node needs a title so its constraint can be associated with a - generated class. Untitled nodes are resolved using their property path. - """ - fields_by_class = {} - - def walk(node, current_class_name, path_str, is_def=False): - if not isinstance(node, dict): - return - - if not is_def and isinstance(node.get("title"), str): - current_class_name = _alias_name(node["title"]) - - props = node.get("properties") - if isinstance(props, dict): - for name, prop in props.items(): - if not isinstance(prop, dict): - continue - - if prop.get("uniqueItems") is True and ( - prop.get("type") == "array" or "items" in prop - ): - if current_class_name is None: - sys.stderr.write( - f" ! {path_str}: uniqueItems field '{name}' " - "belongs to an untitled object; cannot map to a class\n" - ) - continue - fields_by_class.setdefault(current_class_name, set()).add( - name - ) - - # Recurse into properties - next_class_name = ( - _to_camel_case(name) if current_class_name else None - ) - walk(prop, next_class_name, path_str) - - # Recurse into $defs - defs = node.get("$defs") - if isinstance(defs, dict): - for def_name, def_node in defs.items(): - walk(def_node, _to_camel_case(def_name), path_str, is_def=True) - - # Recurse into combinators (allOf, anyOf, oneOf) - for key in ("allOf", "anyOf", "oneOf"): - if isinstance(node.get(key), list): - for item in node[key]: - walk(item, current_class_name, path_str) - - # Recurse into conditional then branches - if isinstance(node.get("then"), dict): - walk(node["then"], current_class_name, path_str) - - for path in _iter_schema_files(schema_dir): - try: - schema = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - continue - if not isinstance(schema, dict): - continue - - root_title = schema.get("title") - initial_class = ( - _alias_name(root_title) if root_title else _to_camel_case(path.stem) - ) - walk(schema, initial_class, str(path)) - - return fields_by_class - - -def _expand_unique_fields_across_type_aliases(source, unique_fields_by_class): - """Propagate uniqueItems field sets from TypeAliasType unions to their member classes.""" - expanded = {k: set(v) for k, v in unique_fields_by_class.items()} - alias_re = re.compile( - r'^(\w+) = TypeAliasType\(\s*"\w+",\s*(?:Annotated\[\s*)?([^,\])]+)', - re.M | re.S, - ) - for match in alias_re.finditer(source): - alias_name = match.group(1) - fields = expanded.get(alias_name) - if not fields: - continue - for member in match.group(2).split("|"): - member_name = member.strip() - if member_name: - expanded.setdefault(member_name, set()).update(fields) - return expanded - - -def inject_unique_items(source, unique_fields_by_class): - """Inject uniqueness validators for list fields declared ``uniqueItems``. - - A validator is added only when both the generated class name and list - field name match the scoped schema constraints. - """ - if not unique_fields_by_class: - return source - expanded_fields = _expand_unique_fields_across_type_aliases( - source, unique_fields_by_class - ) - class_re = re.compile(r"^class (\w+)\(", re.M) - matches = list(class_re.finditer(source)) - if not matches: - return source - new_source = source - patched = False - # Process from the last class to the first so earlier insert offsets - # (computed against the original source) stay valid as text is appended. - for match in reversed(matches): - unique_fields = expanded_fields.get(match.group(1), set()) - if not unique_fields: - continue - body_start = match.end() - tail = re.compile(r"^\S", re.M) - end_match = tail.search(source, body_start) - body_end = end_match.start() if end_match else len(source) - body = source[body_start:body_end] - targets = [] - for field_match in re.finditer( - r"^ (\w+): [^\n]*\blist\[", body, re.M - ): - field = field_match.group(1) - marker = f"def {_UNIQUE_MARKER}_{field}(" - if field in unique_fields and marker not in body: - targets.append(field) - if not targets: - continue - methods = "".join( - _UNIQUE_VALIDATOR_TEMPLATE.format( - marker=_UNIQUE_MARKER, field=field - ) - for field in targets - ) - prefix = new_source[:body_end].rstrip("\n") - suffix = new_source[body_end:] - new_source = prefix + methods + ("\n" + suffix if suffix else "") - patched = True - if patched: - new_source = _ensure_pydantic_import(new_source, "field_validator") - return new_source - - -def _patch_min_properties(): - """Inject minProperties validators; return (patched_count, exit_code).""" - constraints = find_root_min_properties(SCHEMA_DIR) - if not constraints: - sys.stdout.write( - "postprocess: no root-level minProperties constraints found\n" - ) - return 0, 0 - patched = 0 - for title, minimum in sorted(constraints.items()): - hits = [] - for path in sorted(OUTPUT_DIR.rglob("*.py")): - source = path.read_text(encoding="utf-8") - if not re.search(rf"^class {re.escape(title)}\(", source, re.M): - continue - updated = inject_min_properties(source, title, minimum) - if updated != source: - path.write_text(updated, encoding="utf-8") - patched += 1 - hits.append(path) - label = ", ".join(str(h) for h in hits) or "NO GENERATED CLASS FOUND" - sys.stdout.write(f" minProperties={minimum} on '{title}' -> {label}\n") - if not hits: - sys.stderr.write( - f" ! '{title}' has no generated class; " - "constraint not enforced\n" - ) - return patched, 1 - return patched, 0 - - -def _patch_max_properties(): - """Inject maxProperties validators; return (patched_count, exit_code).""" - constraints = find_root_max_properties(SCHEMA_DIR) - if not constraints: - sys.stdout.write( - "postprocess: no root-level maxProperties constraints found\n" - ) - return 0, 0 - patched = 0 - for title, maximum in sorted(constraints.items()): - hits = [] - for path in sorted(OUTPUT_DIR.rglob("*.py")): - source = path.read_text(encoding="utf-8") - if not re.search(rf"^class {re.escape(title)}\(", source, re.M): - continue - updated = inject_max_properties(source, title, maximum) - if updated != source: - path.write_text(updated, encoding="utf-8") - patched += 1 - hits.append(path) - label = ", ".join(str(h) for h in hits) or "NO GENERATED CLASS FOUND" - sys.stdout.write(f" maxProperties={maximum} on '{title}' -> {label}\n") - if not hits: - sys.stderr.write( - f" ! '{title}' has no generated class; " - "constraint not enforced\n" - ) - return patched, 1 - return patched, 0 - - -_NOT_ENUM_MARKER = "_enforce_not_enum" - -_NOT_ENUM_TEMPLATE = ''' - @model_validator(mode="after") - def {marker}(self) -> Any: - """JSON Schema not.enum: reject known variant discriminator values on open-union fallback.""" - if getattr(self, {field!r}, None) in {excluded!r}: - raise ValueError( - f"Field {field!r} must not be one of {excluded!r} " - "(schema not.enum)" - ) - return self -''' - - -def _class_body_span(source, class_name): - """Return (start, end) of ``class_name`` in ``source``, or ``None``.""" - match = re.search(rf"^class {re.escape(class_name)}\(", source, re.M) - if match is None: - return None - tail = re.compile(r"^\S", re.M) - end_match = tail.search(source, match.end()) - end = end_match.start() if end_match else len(source) - return match.start(), end - - -def _declared_fields(class_body): - """Return the set of field names declared on a class body.""" - return { - m.group(1) for m in re.finditer(r"^ (\w+): [^\n]+", class_body, re.M) - } - - -def _array_contains_targets(): - """Resolve ``title -> groups`` for every model needing a contains bound. - - The authoritative (complete) groups come from the pristine schemas. - """ - raw = ( - find_array_contains_constraints(RAW_SCHEMA_DIR) - if RAW_SCHEMA_DIR.exists() - else {} - ) - if not raw: - raw = find_array_contains_constraints(SCHEMA_DIR) - if not raw: - return {} - raw_stems = sorted(raw, key=len, reverse=True) - targets = {} - for stem, info in find_array_contains_constraints(SCHEMA_DIR).items(): - origin = next( - (s for s in raw_stems if stem == s or stem.startswith(s + "_")), - None, - ) - if origin is not None: - targets[info["title"]] = { - "groups": raw[origin]["groups"], - "item_condition": raw[origin]["item_condition"], - } - py_sources = [ - path.read_text(encoding="utf-8") - for path in sorted(OUTPUT_DIR.rglob("*.py")) - ] - for info in raw.values(): - base_title = info["title"] - base_alias = _alias_name(base_title) - targets.setdefault( - base_title, - { - "groups": info["groups"], - "item_condition": info["item_condition"], - }, - ) - for suffix in ("CreateRequest", "UpdateRequest", "CompleteRequest"): - variant_alias = f"{base_alias}{suffix}" - pattern = rf"^{re.escape(variant_alias)} = TypeAliasType\(" - if any(re.search(pattern, src, re.M) for src in py_sources): - targets.setdefault( - variant_alias, - { - "groups": info["groups"], - "item_condition": info["item_condition"], - }, - ) - return targets - - -def _patch_property_names(): - """Inject propertyNames validators; return (patched_count, exit_code).""" - patterns = find_property_names_patterns(SCHEMA_DIR) - if not patterns: - sys.stdout.write( - "postprocess: no propertyNames constraints on extra-allow " - "models found\n" - ) - return 0, 0 - py_sources = [ - path.read_text(encoding="utf-8") - for path in sorted(OUTPUT_DIR.rglob("*.py")) - ] - for class_name, pattern in list(patterns.items()): - for suffix in ("CreateRequest", "UpdateRequest", "CompleteRequest"): - variant = f"{class_name}{suffix}" - if any( - re.search(rf"^class {re.escape(variant)}\(", src, re.M) - for src in py_sources - ): - patterns.setdefault(variant, pattern) - patched = 0 - for class_name, pattern in sorted(patterns.items()): - hits = [] - for path in sorted(OUTPUT_DIR.rglob("*.py")): - source = path.read_text(encoding="utf-8") - if not re.search( - rf"^class {re.escape(class_name)}\(", source, re.M - ): - continue - updated = inject_property_names(source, class_name, pattern) - if updated != source: - path.write_text(updated, encoding="utf-8") - patched += 1 - hits.append(path) - label = ", ".join(str(h) for h in hits) or "NO GENERATED CLASS FOUND" - sys.stdout.write( - f" propertyNames {pattern!r} on '{class_name}' -> {label}\n" - ) - if not hits: - sys.stderr.write( - f" ! '{class_name}' has no generated class; " - "constraint not enforced\n" - ) - return patched, 1 - return patched, 0 - - -def _patch_array_contains(): - """Inject array-contains validators; return (patched_count, exit_code).""" - targets = _array_contains_targets() - if not targets: - sys.stdout.write("postprocess: no array contains constraints found\n") - return 0, 0 - patched = 0 - for title, constraint in sorted(targets.items()): - groups = constraint["groups"] - alias = _alias_name(title) - hits = [] - for path in sorted(OUTPUT_DIR.rglob("*.py")): - source = path.read_text(encoding="utf-8") - if not re.search( - rf"^{re.escape(alias)} = TypeAliasType\(", source, re.M - ): - continue - updated = inject_array_contains( - source, alias, groups, constraint["item_condition"] - ) - if updated != source: - path.write_text(updated, encoding="utf-8") - patched += 1 - hits.append(path) - preds = "; ".join( - " & ".join(f"{f}=={c!r}" for f, c in g["pairs"]) for g in groups - ) - label = ", ".join(str(h) for h in hits) or "NO GENERATED ALIAS FOUND" - sys.stdout.write(f" contains [{preds}] on '{title}' -> {label}\n") - if not hits: - sys.stderr.write( - f" ! '{title}' has no generated alias; " - "constraint not enforced\n" - ) - return patched, 1 - return patched, 0 - - -def _resolve_conditional_required_targets(source, class_name, rules): - """Return matching class names in ``source`` for ``class_name``.""" - if re.search(rf"^class {re.escape(class_name)}\(", source, re.M): - return [class_name] - needed = {rule["discriminator"] for rule in rules} | { - field for rule in rules for field in rule["required"] - } - candidates = [] - for match in re.finditer( - rf"^class (\w+{re.escape(class_name)})\(", source, re.M - ): - candidate = match.group(1) - span = _class_body_span(source, candidate) - if span is None: - continue - if needed <= _declared_fields(source[span[0] : span[1]]): - candidates.append(candidate) - return candidates - - -def _patch_conditional_required(): - """Inject conditional-required validators; return counts and status.""" - rules_by_class = find_conditional_required(SCHEMA_DIR) - if not rules_by_class: - sys.stdout.write( - "postprocess: no simple conditional required rules found\n" - ) - return 0, 0 - patched = 0 - for class_name, rules in sorted(rules_by_class.items()): - hits = [] - for path in sorted(OUTPUT_DIR.rglob("*.py")): - source = path.read_text(encoding="utf-8") - targets = _resolve_conditional_required_targets( - source, class_name, rules - ) - if not targets: - continue - updated = source - for target in targets: - updated = inject_conditional_required(updated, target, rules) - if updated != source: - path.write_text(updated, encoding="utf-8") - patched += 1 - hits.append(path) - label = ( - ", ".join(str(path) for path in hits) or "NO GENERATED CLASS FOUND" - ) - sys.stdout.write( - f" conditional required on '{class_name}' -> {label}\n" - ) - if not hits: - return patched, 1 - return patched, 0 - - -def _resolve_conditional_bounds_targets(source, class_name, rules): - """Return matching class names (including numeric suffixes and request slices).""" - pattern = rf"^class ({re.escape(class_name)}(?:\d+|CreateRequest|UpdateRequest|CompleteRequest)?)\(" - candidate_names = [m.group(1) for m in re.finditer(pattern, source, re.M)] - targets = [] - for candidate in candidate_names: - span = _class_body_span(source, candidate) - if span is None: - continue - declared = _declared_fields(source[span[0] : span[1]]) - applicable = [ - rule - for rule in rules - if rule["discriminator"] in declared - and all(field in declared for field in rule["bounds"]) - ] - if applicable: - targets.append((candidate, applicable)) - return targets - - -def _patch_conditional_bounds(): - """Inject conditional numeric-bound validators; return counts and status.""" - rules_by_class = find_conditional_bounds(SCHEMA_DIR) - if not rules_by_class: - sys.stdout.write("postprocess: no conditional bounds rules found\n") - return 0, 0 - patched = 0 - for class_name, rules in sorted(rules_by_class.items()): - hits = [] - for path in sorted(OUTPUT_DIR.rglob("*.py")): - source = path.read_text(encoding="utf-8") - targets = _resolve_conditional_bounds_targets( - source, class_name, rules - ) - if not targets: - continue - updated = source - for target, applicable_rules in targets: - updated = inject_conditional_bounds( - updated, target, applicable_rules - ) - if updated != source: - path.write_text(updated, encoding="utf-8") - patched += 1 - hits.append(path) - label = ( - ", ".join(str(path) for path in hits) or "NO GENERATED CLASS FOUND" - ) - sys.stdout.write(f" conditional bounds on '{class_name}' -> {label}\n") - if not hits: - return patched, 1 - return patched, 0 - - -def _patch_conditional_array_retyping(): - """Inject array-item retyping checks; return counts and status.""" - rules_by_class = find_conditional_array_retyping(SCHEMA_DIR) - if not rules_by_class: - sys.stdout.write( - "postprocess: no conditional array-item retyping rules found\n" - ) - return 0, 0 - patched = 0 - for class_name, rules in sorted(rules_by_class.items()): - hits = [] - for path in sorted(OUTPUT_DIR.rglob("*.py")): - source = path.read_text(encoding="utf-8") - if re.search( - rf"^{re.escape(class_name)} = TypeAliasType\(", source, re.M - ): - hits.append(path) - continue - if not re.search( - rf"^class {re.escape(class_name)}\(", source, re.M - ): - continue - updated = inject_conditional_array_retyping( - source, class_name, rules - ) - if updated != source: - path.write_text(updated, encoding="utf-8") - patched += 1 - hits.append(path) - label = ( - ", ".join(str(path) for path in hits) or "NO GENERATED CLASS FOUND" - ) - sys.stdout.write( - f" conditional array-item retyping on '{class_name}' -> {label}\n" - ) - if not hits: - return patched, 1 - return patched, 0 - - -def _dependent_required_target_classes(source, class_name): - """Return all generated class variants of ``class_name`` in ``source``.""" - base_name = class_name[:-4] if class_name.endswith("Base") else class_name - suffixes = ( - "", - "Base", - "CreateRequest", - "UpdateRequest", - "CompleteRequest", - "CreateRequestBase", - "UpdateRequestBase", - "CompleteRequestBase", - ) - candidates = [f"{base_name}{s}" for s in suffixes] - return [ - name - for name in candidates - if _class_body_span(source, name) is not None - ] - - -def _patch_dependent_required(): - """Inject dependentRequired validators; return counts and status.""" - rules_by_class = find_root_dependent_required(SCHEMA_DIR) - if not rules_by_class: - sys.stdout.write( - "postprocess: no dependentRequired constraints found\n" - ) - return 0, 0 - patched = 0 - for class_name, rules in sorted(rules_by_class.items()): - hits = [] - for path in sorted(OUTPUT_DIR.rglob("*.py")): - source = path.read_text(encoding="utf-8") - targets = _dependent_required_target_classes(source, class_name) - if not targets: - if re.search( - rf"^{re.escape(class_name)} = TypeAliasType\(", source, re.M - ): - hits.append(path) - continue - updated = source - any_already = False - for target in targets: - span = _class_body_span(updated, target) - if ( - span is not None - and f"def {_DEPENDENT_REQUIRED_MARKER}(" - in updated[span[0] : span[1]] - ): - any_already = True - continue - updated = inject_dependent_required(updated, target, rules) - if updated != source: - path.write_text(updated, encoding="utf-8") - patched += 1 - hits.append(path) - elif any_already: - hits.append(path) - label = ( - ", ".join(str(path) for path in hits) or "NO APPLICABLE CLASS FOUND" - ) - sys.stdout.write(f" dependentRequired on '{class_name}' -> {label}\n") - if not hits: - return patched, 1 - return patched, 0 - - -def _patch_unique_items(): - """Inject uniqueItems validators; return (patched_count, exit_code).""" - unique_fields_by_class = find_unique_items_fields(SCHEMA_DIR) - if not unique_fields_by_class: - sys.stdout.write("postprocess: no uniqueItems constraints found\n") - return 0, 0 - for class_name, fields in list(unique_fields_by_class.items()): - for suffix in ("CreateRequest", "UpdateRequest", "CompleteRequest"): - unique_fields_by_class.setdefault( - f"{class_name}{suffix}", set(fields) - ) - unique_patched = 0 - touched = [] - for path in sorted(OUTPUT_DIR.rglob("*.py")): - source = path.read_text(encoding="utf-8") - updated = inject_unique_items(source, unique_fields_by_class) - if updated != source: - path.write_text(updated, encoding="utf-8") - unique_patched += 1 - touched.append(path) - labels = sorted( - f"{class_name}.{field}" - for class_name, fields in unique_fields_by_class.items() - for field in fields - ) - sys.stdout.write( - f" uniqueItems fields {labels} -> " - f"{unique_patched} module(s) patched" - f" ({', '.join(str(t) for t in touched) or 'none'})\n" - ) - return unique_patched, 0 - - -def _extract_variant_discriminator_tags(var_body, base_fields): - """Extract (disc_field, tags) from Literal annotations in a variant body.""" - disc_field = None - tags = set() - for lit_match in re.finditer( - r"^ (\w+): [^\n]*\bLiteral\[([^\]]+)\]", var_body, re.M - ): - field_name = lit_match.group(1) - if field_name not in base_fields: - continue - disc_field = field_name - for q1, q2 in re.findall(r'"([^"]+)"|\'([^\']+)\'', lit_match.group(2)): - tags.add(q1 or q2) - return disc_field, tags - - -def _find_open_union_base_exclusions(source): - """Discover ``Base`` discriminator ``not.enum`` rules in ``source``.""" - exclusions = {} - alias_re = re.compile( - r"^(\w+) = TypeAliasType\(\s*\"\w+\",\s*Annotated\[\s*([^,\]]+),", - re.M | re.S, - ) - for match in alias_re.finditer(source): - members = [m.strip() for m in match.group(2).split("|") if m.strip()] - if len(members) < 2 or not members[-1].endswith("Base"): - continue - base_class = members[-1] - base_span = _class_body_span(source, base_class) - if base_span is None: - continue - base_fields = _declared_fields(source[base_span[0] : base_span[1]]) - disc_field = None - tags = set() - for variant in members[:-1]: - var_span = _class_body_span(source, variant) - if var_span is None: - continue - field_name, var_tags = _extract_variant_discriminator_tags( - source[var_span[0] : var_span[1]], base_fields - ) - if field_name: - disc_field = field_name - tags.update(var_tags) - if disc_field and tags: - exclusions[base_class] = { - "field": disc_field, - "excluded": sorted(tags), - } - return exclusions - - -def inject_open_union_not_enum(source, class_name, field, excluded): - """Inject ``not.enum`` discriminator guard onto ``class_name``.""" - span = _class_body_span(source, class_name) - if span is None: - return source - if f"def {_NOT_ENUM_MARKER}(" in source[span[0] : span[1]]: - return source - method = _NOT_ENUM_TEMPLATE.format( - marker=_NOT_ENUM_MARKER, - field=field, - excluded=excluded, - ) - body = source[: span[1]].rstrip("\n") - rest = source[span[1] :] - out = body + "\n" + method + ("\n" + rest if rest else "") - return _ensure_pydantic_import(out, "model_validator") - - -def _patch_open_union_not_enum(): - """Inject ``not.enum`` discriminator checks on ``Base`` classes. - - Also performs single-file bundle hygiene: - 1. ``datamodel-code-generator`` emits ``typing.Dict[...]`` inside - ``__pydantic_extra__`` annotations on ``extra="allow"`` models with typed - ``additionalProperties`` (e.g. ``Actions``) even when - ``--use-standard-collections`` is enabled; normalizing ``Dict[`` to - built-in ``dict[`` keeps Ruff ``UP006``/``UP035`` clean. - 2. Strips the synthetic root bundle wrapper ``UCPSchemaTypes`` / - ``UcpSchemaTypes = TypeAliasType(...)`` emitted from the bundle's root - ``"title": "UCP Schema Types"``. - """ - patched = 0 - for path in sorted(OUTPUT_DIR.rglob("*.py")): - source = path.read_text(encoding="utf-8") - updated = re.sub(r"\bDict\[", "dict[", source) - updated = re.sub( - r"^(from typing import [^\n]*?),\s*Dict\b", - r"\1", - updated, - flags=re.M, - ) - updated = re.sub( - r"^(?:Ucp|UCP)SchemaTypes = TypeAliasType\((?:[^\n]*\)|.*?\n\))\n*", - "", - updated, - flags=re.M | re.S, - ) - exclusions = _find_open_union_base_exclusions(updated) - for base_class, rule in sorted(exclusions.items()): - updated = inject_open_union_not_enum( - updated, base_class, rule["field"], rule["excluded"] - ) - sys.stdout.write( - f" not.enum {rule['field']}!={rule['excluded']} on '{base_class}' -> {path}\n" - ) - if updated != source: - path.write_text(updated, encoding="utf-8") - patched += 1 - return patched, 0 - - -_FORBIDDEN_KEYS_MARKER = "_enforce_forbidden_keys" - -_FORBIDDEN_KEYS_TEMPLATE = ''' - @model_validator(mode="after") - def {marker}(self) -> Any: - """JSON Schema not: reject forbidden property keys.""" - _present = self.model_fields_set | set(self.model_extra or {{}}) - for _key in {forbidden!r}: - if _key in _present: - raise ValueError( - f"Field {{_key!r}} is forbidden by schema 'not' constraint" - ) - return self -''' - - -def _extract_forbidden_keys(not_node): - """Extract forbidden key names from a ``not`` schema dict, or return [].""" - if not isinstance(not_node, dict): - return [] - if set(not_node) == {"required"} and isinstance( - not_node.get("required"), list - ): - if len(not_node["required"]) == 1 and isinstance( - not_node["required"][0], str - ): - return [not_node["required"][0]] - return [] - if set(not_node) == {"anyOf"} and isinstance(not_node.get("anyOf"), list): - keys = [] - for branch in not_node["anyOf"]: - if ( - isinstance(branch, dict) - and set(branch) == {"required"} - and isinstance(branch.get("required"), list) - and len(branch["required"]) == 1 - and isinstance(branch["required"][0], str) - ): - keys.append(branch["required"][0]) - else: - return [] - return sorted(set(keys)) - return [] - - -def find_forbidden_keys(schema_dir): - """Map generated class names to forbidden property keys from ``not`` rules.""" - found = {} - - def walk(node, current_class_name, is_def=False): - if not isinstance(node, dict): - return - if not is_def and isinstance(node.get("title"), str): - current_class_name = _alias_name(node["title"]) - forbidden = _extract_forbidden_keys(node.get("not")) - if forbidden and current_class_name: - found.setdefault(current_class_name, set()).update(forbidden) - for name, prop in (node.get("properties") or {}).items(): - if isinstance(prop, dict): - walk(prop, _to_camel_case(name)) - for def_name, def_node in (node.get("$defs") or {}).items(): - if isinstance(def_node, dict): - walk(def_node, _to_camel_case(def_name), is_def=True) - for key in ("allOf", "anyOf", "oneOf"): - if isinstance(node.get(key), list): - for item in node[key]: - walk(item, current_class_name) - - for path in _iter_schema_files(schema_dir): - try: - schema = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - continue - if not isinstance(schema, dict): - continue - root_title = schema.get("title") - initial_class = ( - _alias_name(root_title) if root_title else _to_camel_case(path.stem) - ) - walk(schema, initial_class) - - return {cls: sorted(keys) for cls, keys in found.items()} - - -def inject_forbidden_keys(source, class_name, forbidden): - """Inject forbidden-key validator onto ``class_name``.""" - span = _class_body_span(source, class_name) - if span is None: - return source - if f"def {_FORBIDDEN_KEYS_MARKER}(" in source[span[0] : span[1]]: - return source - method = _FORBIDDEN_KEYS_TEMPLATE.format( - marker=_FORBIDDEN_KEYS_MARKER, - forbidden=sorted(forbidden), - ) - body = source[: span[1]].rstrip("\n") - rest = source[span[1] :] - out = body + "\n" + method + ("\n" + rest if rest else "") - return _ensure_pydantic_import(out, "model_validator") - - -def _patch_forbidden_keys(): - """Inject forbidden-key validators for ``not`` required rules.""" - rules_by_class = find_forbidden_keys(SCHEMA_DIR) - if not rules_by_class: - sys.stdout.write("postprocess: no forbidden-key constraints found\n") - return 0, 0 - patched = 0 - for class_name, forbidden in sorted(rules_by_class.items()): - hits = [] - for path in sorted(OUTPUT_DIR.rglob("*.py")): - source = path.read_text(encoding="utf-8") - if not re.search( - rf"^class {re.escape(class_name)}\(", source, re.M - ): - continue - updated = inject_forbidden_keys(source, class_name, forbidden) - if updated != source: - path.write_text(updated, encoding="utf-8") - patched += 1 - hits.append(path) - label = ", ".join(str(h) for h in hits) or "NO GENERATED CLASS FOUND" - sys.stdout.write( - f" forbidden keys {forbidden} on '{class_name}' -> {label}\n" - ) - if not hits: - return patched, 1 - return patched, 0 - - -def find_extra_forbid_class_names(schema_dir): - """Map generated class names for objects that forbid unknown keys. - - The gap this targets: an object schema that declares - ``additionalProperties: false`` AND carries named ``properties`` is still - emitted by the generator as ``BaseModel(extra="allow")`` (generation runs - with ``--extra-fields=allow``), so unknown keys are silently retained in - ``model_extra`` instead of being rejected. The rule is mechanical: an - object node with ``additionalProperties is False`` and non-empty named - ``properties`` maps to its generated class name via its ``title`` (root - objects) or its property path (untitled nested objects, e.g. - ``allows_multi_destination`` -> ``AllowsMultiDestination``). - """ - found = set() - - def visit(node, class_name): - if not isinstance(node, dict): - if isinstance(node, list): - for item in node: - visit(item, class_name) - return - effective = ( - _alias_name(node["title"]) if node.get("title") else class_name - ) - if ( - node.get("additionalProperties") is False - and isinstance(node.get("properties"), dict) - and node["properties"] - ): - found.add(effective) - for name, child in (node.get("properties") or {}).items(): - visit(child, _to_camel_case(name)) - for def_name, child in (node.get("$defs") or {}).items(): - visit(child, _to_camel_case(def_name)) - - for path in _iter_schema_files(schema_dir): - try: - schema = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - continue - if not isinstance(schema, dict): - continue - root_name = ( - _alias_name(schema["title"]) - if schema.get("title") - else _to_camel_case(path.stem) - ) - visit(schema, root_name) - return found - - -def inject_extra_forbid(source, class_name): - """Flip the target class's ``extra="allow"`` config to ``extra="forbid"``. - - Only the named class's own ``model_config`` is changed (its body, from the - ``class`` statement to the next top-level ``class``/``def``), so sibling - classes in the same module keep ``extra="allow"``. The source is returned - unchanged when the class is absent or already ``extra="forbid"``. - """ - head = re.search( - rf"^class {re.escape(class_name)}\(BaseModel\):", source, re.M - ) - if not head: - return source - rest = source[head.end() :] - next_top = re.search(r"^(?=class |def )", rest, re.M) - body_end = len(rest) if next_top is None else next_top.start() - body = rest[:body_end] - if 'extra="allow"' not in body: - return source - new_body = body.replace('extra="allow"', 'extra="forbid"', 1) - return source[: head.end()] + new_body + rest[body_end:] - - -def _resolve_extra_forbid_targets(source, class_name): - """Return matching class names in ``source`` for ``class_name`` (including parent-qualified names).""" - if re.search(rf"^class {re.escape(class_name)}\(", source, re.M): - return [class_name] - return [ - m.group(1) - for m in re.finditer( - rf"^class (\w+{re.escape(class_name)})\(", source, re.M - ) - ] - - -def _patch_extra_forbid(): - """Inject extra="forbid" on models whose schema forbids unknown keys.""" - class_names = find_extra_forbid_class_names(SCHEMA_DIR) - if not class_names: - sys.stdout.write( - "postprocess: no additionalProperties:false models found\n" - ) - return 0, 0 - patched = 0 - for class_name in sorted(class_names): - hits = [] - for path in sorted(OUTPUT_DIR.rglob("*.py")): - source = path.read_text(encoding="utf-8") - targets = _resolve_extra_forbid_targets(source, class_name) - if not targets: - continue - updated = source - for target in targets: - updated = inject_extra_forbid(updated, target) - if updated != source: - path.write_text(updated, encoding="utf-8") - patched += 1 - hits.append(path) - label = ", ".join(str(h) for h in hits) or "NO GENERATED CLASS FOUND" - sys.stdout.write(f" extra=forbid on '{class_name}' -> {label}\n") - if not hits: - sys.stderr.write( - f" ! '{class_name}' has no generated class; " - "constraint not enforced\n" - ) - return patched, 1 - return patched, 0 - - -def _mentions_datetime(annotation): - """True if an annotation expression names a generated datetime type.""" - return any( - isinstance(node, ast.Name) and node.id in _DATETIME_TYPES - for node in ast.walk(annotation) - ) - - -def _pattern_keyword(node): - """Return the string ``pattern=`` keyword of a ``Field(...)`` call.""" - if not ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == "Field" - ): - return None - for keyword in node.keywords: - if ( - keyword.arg == "pattern" - and isinstance(keyword.value, ast.Constant) - and isinstance(keyword.value.value, str) - ): - return keyword - return None - - -def _datetime_pattern_fields(tree): - """Yield ``(class_node, statement)`` for patterned datetime fields.""" - for node in tree.body: - if not isinstance(node, ast.ClassDef): - continue - for stmt in node.body: - if ( - isinstance(stmt, ast.AnnAssign) - and isinstance(stmt.target, ast.Name) - and _mentions_datetime(stmt.annotation) - and _pattern_keyword(stmt.value) is not None - ): - yield node, stmt - - -def find_datetime_pattern_fields(source): - """Find datetime fields the generator gave a regex ``pattern``. - - Returns ``(fields, unsupported)``. ``fields`` lists ``(class_name, field, - pattern)`` for class attributes written as ``name: = - Field(..., pattern=...)``, which ``inject_datetime_patterns`` rewrites. - ``unsupported`` lists the line of every other datetime pattern (e.g. an - ``Annotated[AwareDatetime, Field(pattern=...)]`` type alias for array - items), which is left as generated and must be reported. - """ - tree = ast.parse(source) - fields = [] - for cls, stmt in _datetime_pattern_fields(tree): - if not isinstance(stmt.target, ast.Name): - continue - keyword = _pattern_keyword(stmt.value) - if ( - keyword is None - or not isinstance(keyword.value, ast.Constant) - or not isinstance(keyword.value.value, str) - ): - continue - fields.append((cls.name, stmt.target.id, keyword.value.value)) - unsupported = [ - node.lineno - for node in ast.walk(tree) - if isinstance(node, ast.Subscript) - and isinstance(node.value, ast.Name) - and node.value.id == "Annotated" - and isinstance(node.slice, ast.Tuple) - and _mentions_datetime(node.slice.elts[0]) - and any(_pattern_keyword(meta) for meta in node.slice.elts[1:]) - ] - return fields, unsupported - - -def _source_offset(source, lineno, col_offset): - """Map an ast position (1-based line, UTF-8 byte column) to an index.""" - start = 0 - for _ in range(lineno - 1): - start = source.index("\n", start) + 1 - line = source[start:].split("\n", 1)[0] - return start + len(line.encode("utf-8")[:col_offset].decode("utf-8")) - - -def inject_datetime_patterns(source): - """Move each datetime field's regex ``pattern`` into a before-validator. - - The ``pattern`` argument is removed from the field's ``Field(...)`` call, - and the whole call when only the default remains, matching how the - generator writes an unconstrained field. A ``field_validator(mode="before")`` - carrying the same pattern is appended to the class. Fields are rewritten - one at a time, re-parsing in between so ast offsets stay valid. - """ - while True: - cls, stmt = next( - _datetime_pattern_fields(ast.parse(source)), (None, None) - ) - if cls is None or stmt is None: - return source - assert isinstance(stmt.target, ast.Name) - call = stmt.value - assert isinstance(call, ast.Call) - keyword = _pattern_keyword(call) - assert keyword is not None and isinstance(keyword.value, ast.Constant) - call.keywords = [kw for kw in call.keywords if kw is not keyword] - default = call.args[0] if len(call.args) == 1 else None - if ( - not call.keywords - and isinstance(default, ast.Constant) - and default.value is Ellipsis - ): - value = "" - elif ( - not call.keywords - and isinstance(default, ast.Constant) - and default.value is None - ): - value = " = None" - else: - value = f" = {ast.unparse(call)}" - assert ( - stmt.annotation.end_lineno is not None - and stmt.annotation.end_col_offset is not None - ) - assert call.end_lineno is not None and call.end_col_offset is not None - start = _source_offset( - source, stmt.annotation.end_lineno, stmt.annotation.end_col_offset - ) - end = _source_offset(source, call.end_lineno, call.end_col_offset) - source = source[:start] + value + source[end:] - - match = re.search(rf"^class {re.escape(cls.name)}\(", source, re.M) - assert match is not None - end_match = re.compile(r"^\S", re.M).search(source, match.end()) - body_end = end_match.start() if end_match else len(source) - method = _DATETIME_PATTERN_TEMPLATE.format( - marker=_DATETIME_PATTERN_MARKER, - field=stmt.target.id, - pattern=keyword.value.value, - ) - prefix = source[:body_end].rstrip("\n") - suffix = source[body_end:] - source = prefix + "\n" + method + ("\n" + suffix if suffix else "") - source = _ensure_pydantic_import(source, "field_validator") - source = _ensure_stdlib_import(source, "import re") - - -def _patch_datetime_patterns(): - """Move datetime regex patterns into validators; return counts, status.""" - patched = 0 - status = 0 - for path in sorted(OUTPUT_DIR.rglob("*.py")): - source = path.read_text(encoding="utf-8") - fields, unsupported = find_datetime_pattern_fields(source) - for line in unsupported: - sys.stderr.write( - f" ! {path}:{line}: regex pattern on a datetime type alias " - "is not rewritten; validating it raises TypeError\n" - ) - status = 1 - if not fields: - continue - path.write_text(inject_datetime_patterns(source), encoding="utf-8") - patched += 1 - names = ", ".join(f"{cls}.{field}" for cls, field, _ in fields) - sys.stdout.write(f" date-time pattern on {names} -> {path}\n") - if not patched and not status: - sys.stdout.write( - "postprocess: no regex patterns on datetime fields found\n" - ) - return patched, status - - -def _write_schemas_reexport_init(): - """Write schemas/__init__.py lazily re-exporting public symbols from _generated.py.""" - candidates = ( - OUTPUT_DIR / "schemas" / "_generated.py", - OUTPUT_DIR / "_generated.py", - ) - generated_path = next((p for p in candidates if p.is_file()), None) - if generated_path is None: - return 0 - tree = ast.parse(generated_path.read_text(encoding="utf-8")) - exports = [] - type_alias_cls = getattr(ast, "TypeAlias", ()) - for node in tree.body: - if isinstance(node, ast.ClassDef) and not node.name.startswith("_"): - exports.append(node.name) - continue - if isinstance(node, ast.Assign): - for target in node.targets: - if isinstance(target, ast.Name) and not target.id.startswith( - "_" - ): - exports.append(target.id) - continue - if ( - isinstance(node, ast.AnnAssign) - and isinstance(node.target, ast.Name) - and not node.target.id.startswith("_") - ): - exports.append(node.target.id) - continue - if isinstance(node, type_alias_cls): - alias_name = getattr(node, "name", None) - if isinstance( - alias_name, ast.Name - ) and not alias_name.id.startswith("_"): - exports.append(alias_name.id) - exports = sorted(dict.fromkeys(exports)) - init_path = generated_path.parent / "__init__.py" - if exports: - typing_imports = "from typing import TYPE_CHECKING, Any\n\n" - imports_block = ",\n ".join(exports) - all_block = ",\n ".join(f'"{name}"' for name in exports) - exports_section = ( - "if TYPE_CHECKING:\n" - f" from ._generated import (\n {imports_block},\n )\n\n" - f"__all__ = [\n {all_block},\n]\n" - ) - else: - typing_imports = "from typing import Any\n\n" - exports_section = "__all__: list[str] = []\n" - content = ( - "# Copyright 2026 UCP Authors\n" - "#\n" - '# Licensed under the Apache License, Version 2.0 (the "License");\n' - "# you may not use this file except in compliance with the License.\n" - "# You may obtain a copy of the License at\n" - "#\n" - "# http://www.apache.org/licenses/LICENSE-2.0\n" - "#\n" - "# Unless required by applicable law or agreed to in writing, software\n" - '# distributed under the License is distributed on an "AS IS" BASIS,\n' - "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n" - "# See the License for the specific language governing permissions and\n" - "# limitations under the License.\n\n" - '"""Generated UCP schema models."""\n\n' - "from __future__ import annotations\n\n" - "import importlib\n" - f"{typing_imports}" - f"{exports_section}\n\n" - "def __getattr__(name: str) -> Any:\n" - ' """Import public schema models and type aliases on first access."""\n' - " if name not in __all__:\n" - ' msg = f"module {__name__!r} has no attribute {name!r}"\n' - " raise AttributeError(msg)\n" - ' module = importlib.import_module("._generated", __name__)\n' - " value = getattr(module, name)\n" - " globals()[name] = value\n" - " return value\n\n\n" - "def __dir__() -> list[str]:\n" - ' """Return module attributes including lazily re-exported schema names."""\n' - " return sorted({*globals(), *__all__})\n" - ) - if init_path.is_file() and init_path.read_text(encoding="utf-8") == content: - return 0 - init_path.write_text(content, encoding="utf-8") - sys.stdout.write( - f" re-export index ({len(exports)} symbols) -> {init_path}\n" - ) - return 1 - - -def main(argv=None): - """Main entry point to scan schemas and patch generated models.""" - global SCHEMA_DIR, OUTPUT_DIR, RAW_SCHEMA_DIR - args = sys.argv[1:] if argv is None else list(argv) - if len(args) >= 1: - SCHEMA_DIR = Path(args[0]) - if len(args) >= 2: - OUTPUT_DIR = Path(args[1]) - if len(args) >= 3: - RAW_SCHEMA_DIR = Path(args[2]) - patched_mp, rc_mp = _patch_min_properties() - patched_xp, rc_xp = _patch_max_properties() - patched_pn, rc_pn = _patch_property_names() - patched_ac, rc_ac = _patch_array_contains() - patched_cr, rc_cr = _patch_conditional_required() - patched_cb, rc_cb = _patch_conditional_bounds() - patched_rt, rc_rt = _patch_conditional_array_retyping() - patched_dr, rc_dr = _patch_dependent_required() - patched_ui, rc_ui = _patch_unique_items() - patched_ef, rc_ef = _patch_extra_forbid() - patched_ne, rc_ne = _patch_open_union_not_enum() - patched_fk, rc_fk = _patch_forbidden_keys() - patched_dp, rc_dp = _patch_datetime_patterns() - patched_re = _write_schemas_reexport_init() - total = ( - patched_mp - + patched_xp - + patched_pn - + patched_ac - + patched_cr - + patched_cb - + patched_rt - + patched_dr - + patched_ui - + patched_ef - + patched_ne - + patched_fk - + patched_dp - + patched_re - ) - sys.stdout.write(f"postprocess: {total} module(s) patched\n") - return ( - rc_mp - or rc_xp - or rc_pn - or rc_ac - or rc_cr - or rc_cb - or rc_rt - or rc_dr - or rc_ui - or rc_ef - or rc_ne - or rc_fk - or rc_dp - ) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 0a4affe..49ab086 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,6 @@ line-ending = "auto" [tool.ruff.lint.per-file-ignores] "src/ucp_sdk/models/schemas/**/*.py" = ["E501", "D", "N801", "N815"] -"postprocess_models.py" = ["E501", "D"] "tests/**/*.py" = ["E501", "D"] [tool.ruff.lint] @@ -101,15 +100,6 @@ strict = true files = ["src/ucp_sdk", "tests/test_models.py"] cache_dir = "/tmp/ucp_sdk_mypy_cache" -[[tool.mypy.overrides]] -module = ["postprocess_models", "tests.test_codegen_pipeline", "test_codegen_pipeline"] -disallow_untyped_defs = false -disallow_incomplete_defs = false -disallow_untyped_calls = false -check_untyped_defs = false -warn_return_any = false -disable_error_code = ["var-annotated", "type-arg", "arg-type", "call-arg", "attr-defined", "union-attr", "index", "operator", "annotation-unchecked"] - [tool.pytest.ini_options] pythonpath = ["."] cache_dir = "/tmp/ucp_sdk_pytest_cache" diff --git a/src/ucp_sdk/models/schemas/__init__.py b/src/ucp_sdk/models/schemas/__init__.py index 79cfc96..a3dcd20 100644 --- a/src/ucp_sdk/models/schemas/__init__.py +++ b/src/ucp_sdk/models/schemas/__init__.py @@ -1,16 +1,4 @@ -# Copyright 2026 UCP Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# generated by generate_models.sh """Generated UCP schema models.""" diff --git a/src/ucp_sdk/models/schemas/_generated.py b/src/ucp_sdk/models/schemas/_generated.py index 7ba1ed0..1dca63a 100644 --- a/src/ucp_sdk/models/schemas/_generated.py +++ b/src/ucp_sdk/models/schemas/_generated.py @@ -20,25 +20,16 @@ from __future__ import annotations -import operator - -import re - from datetime import date from typing import Annotated, Any, Literal -from pydantic import ( - AnyUrl, - AwareDatetime, - BaseModel, - ConfigDict, - Field, - model_validator, - AfterValidator, - field_validator, -) +from pydantic import AnyUrl, AwareDatetime, BaseModel, ConfigDict, Field from typing_extensions import TypeAliasType +UCPSchemaTypes = TypeAliasType( + "UCPSchemaTypes", Annotated[Any, Field(..., title="UCP Schema Types")] +) + class ActionsDeviceDataCollectionConfig(BaseModel): model_config = ConfigDict( @@ -334,22 +325,6 @@ class DailyHour(BaseModel): A stable UCP day-of-week identifier for the day on which this recurring local civil-time interval begins in the containing Location's `timezone`. It is not localized display text. """ - @model_validator(mode="after") - def _enforce_dependent_required(self) -> Any: - """JSON Schema dependentRequired: enforce dependent fields.""" - rules = {"closes": ["opens"], "opens": ["closes"]} - provided = self.model_fields_set | set(self.model_extra or {}) - for field, required_fields in rules.items(): - if field not in provided: - continue - for required in required_fields: - if required not in provided: - raise ValueError( - f"Field {required!r} is required when {field!r} " - "is provided (schema dependentRequired)" - ) - return self - class Description(BaseModel): """ @@ -372,17 +347,6 @@ class Description(BaseModel): Markdown-formatted content. """ - @model_validator(mode="after") - def _enforce_min_properties(self) -> Any: - """JSON Schema minProperties: require at least 1 - provided property.""" - provided = self.model_fields_set | set(self.model_extra or {}) - if len(provided) < 1: - raise ValueError( - "At least 1 property must be provided (schema minProperties=1)" - ) - return self - class DetailOptionValue(BaseModel): """ @@ -518,22 +482,6 @@ class ExceptionHour(BaseModel): The last local civil date to which this exception applies, interpreted in the containing Location's `timezone`. """ - @model_validator(mode="after") - def _enforce_dependent_required(self) -> Any: - """JSON Schema dependentRequired: enforce dependent fields.""" - rules = {"closes": ["opens"], "opens": ["closes"]} - provided = self.model_fields_set | set(self.model_extra or {}) - for field, required_fields in rules.items(): - if field not in provided: - continue - for required in required_fields: - if required not in provided: - raise ValueError( - f"Field {required!r} is required when {field!r} " - "is provided (schema dependentRequired)" - ) - return self - class ExpectationLineItem(BaseModel): model_config = ConfigDict( @@ -611,19 +559,6 @@ class FulfillmentDestinationBase(BaseModel): Fulfillment destination identifier. """ - @model_validator(mode="after") - def _enforce_not_enum(self) -> Any: - """JSON Schema not.enum: reject known variant discriminator values on open-union fallback.""" - if getattr(self, "type", None) in [ - "business_location", - "shipping_address", - ]: - raise ValueError( - "Field 'type' must not be one of ['business_location', 'shipping_address'] " - "(schema not.enum)" - ) - return self - class FulfillmentDestinationFilter(BaseModel): """ @@ -768,32 +703,6 @@ class FulfillmentMethodCreateRequestBase(BaseModel): Fulfillment groups for selecting options. Agent sets selected_option_id on groups to choose shipping method. """ - @model_validator(mode="after") - def _enforce_dependent_required(self) -> Any: - """JSON Schema dependentRequired: enforce dependent fields.""" - rules = {"destinations": ["type"]} - provided = self.model_fields_set | set(self.model_extra or {}) - for field, required_fields in rules.items(): - if field not in provided: - continue - for required in required_fields: - if required not in provided: - raise ValueError( - f"Field {required!r} is required when {field!r} " - "is provided (schema dependentRequired)" - ) - return self - - @model_validator(mode="after") - def _enforce_not_enum(self) -> Any: - """JSON Schema not.enum: reject known variant discriminator values on open-union fallback.""" - if getattr(self, "type", None) in ["pickup", "shipping"]: - raise ValueError( - "Field 'type' must not be one of ['pickup', 'shipping'] " - "(schema not.enum)" - ) - return self - class FulfillmentMethodUpdateRequestBase(BaseModel): """ @@ -824,32 +733,6 @@ class FulfillmentMethodUpdateRequestBase(BaseModel): Fulfillment groups for selecting options. Agent sets selected_option_id on groups to choose shipping method. """ - @model_validator(mode="after") - def _enforce_dependent_required(self) -> Any: - """JSON Schema dependentRequired: enforce dependent fields.""" - rules = {"destinations": ["type"]} - provided = self.model_fields_set | set(self.model_extra or {}) - for field, required_fields in rules.items(): - if field not in provided: - continue - for required in required_fields: - if required not in provided: - raise ValueError( - f"Field {required!r} is required when {field!r} " - "is provided (schema dependentRequired)" - ) - return self - - @model_validator(mode="after") - def _enforce_not_enum(self) -> Any: - """JSON Schema not.enum: reject known variant discriminator values on open-union fallback.""" - if getattr(self, "type", None) in ["pickup", "shipping"]: - raise ValueError( - "Field 'type' must not be one of ['pickup', 'shipping'] " - "(schema not.enum)" - ) - return self - class FulfillmentOptionBase(BaseModel): """ @@ -1008,7 +891,7 @@ class JsonrpcErrorResponse(BaseModel): """ model_config = ConfigDict( - extra="forbid", + extra="allow", ) jsonrpc: Literal["2.0"] """ @@ -1024,7 +907,7 @@ class JsonrpcRequest(BaseModel): """ model_config = ConfigDict( - extra="forbid", + extra="allow", ) jsonrpc: Literal["2.0"] """ @@ -1047,7 +930,7 @@ class JsonrpcSuccessResponse(BaseModel): """ model_config = ConfigDict( - extra="forbid", + extra="allow", ) jsonrpc: Literal["2.0"] """ @@ -1097,86 +980,6 @@ class JwkPublicKey(BaseModel): JWK public key use. UCP examples use sig for signatures. """ - @model_validator(mode="after") - def _enforce_conditional_required(self) -> Any: - """JSON Schema if/then: enforce conditionally required fields.""" - rules: list[dict[str, Any]] = [ - { - "discriminator": "kty", - "values": ["EC"], - "required": ["crv", "x", "y"], - }, - { - "discriminator": "kty", - "values": ["OKP"], - "required": ["crv", "x"], - }, - ] - for rule in rules: - if getattr(self, rule["discriminator"], None) not in rule["values"]: - continue - for field in rule["required"]: - if field not in self.model_fields_set: - raise ValueError( - f"Field {field!r} is required by a schema condition" - ) - return self - - @model_validator(mode="after") - def _enforce_conditional_bounds(self) -> Any: - """JSON Schema if/then: enforce conditional numeric bounds.""" - rules: list[dict[str, Any]] = [ - { - "discriminator": "crv", - "values": ["P-256"], - "bounds": {"alg": {"const": "ES256"}}, - }, - { - "discriminator": "crv", - "values": ["P-384"], - "bounds": {"alg": {"const": "ES384"}}, - }, - { - "discriminator": "crv", - "values": ["Ed25519"], - "bounds": {"alg": {"const": "EdDSA"}}, - }, - ] - checks = { - "minimum": (">=", "lt"), - "maximum": ("<=", "gt"), - "exclusiveMinimum": (">", "le"), - "exclusiveMaximum": ("<", "ge"), - "const": ("==", "ne"), - } - for rule in rules: - actual = getattr(self, rule["discriminator"], None) - if actual not in rule["values"]: - continue - for field, bounds in rule["bounds"].items(): - value = getattr(self, field, None) - if value is None: - continue - for keyword, limit in bounds.items(): - symbol, op_name = checks[keyword] - if getattr(operator, op_name)(value, limit): - raise ValueError( - f"Field {field!r} must be {symbol} {limit} " - f"when {rule['discriminator']} is {actual!r}" - ) - return self - - @model_validator(mode="after") - def _enforce_forbidden_keys(self) -> Any: - """JSON Schema not: reject forbidden property keys.""" - _present = self.model_fields_set | set(self.model_extra or {}) - for _key in ["d", "dp", "dq", "k", "oth", "p", "q", "qi"]: - if _key in _present: - raise ValueError( - f"Field {_key!r} is forbidden by schema 'not' constraint" - ) - return self - class Link(BaseModel): model_config = ConfigDict( @@ -1249,17 +1052,6 @@ class LocationFilterHours(BaseModel): The RFC 3339 instant at which matching Locations must be open, expressed with `Z` or a numeric offset. The Platform selects the instant that represents the Buyer's intent. The Business evaluates it exactly as supplied using each Location's authoritative `timezone`; the supplied offset does not identify that timezone. """ - @field_validator("open_at", mode="before") - def _enforce_datetime_pattern_open_at(cls, value: Any) -> Any: # noqa: N805 - """JSON Schema pattern: match the raw date-time string, since - pydantic cannot apply a regex to the parsed datetime.""" - pattern = "(?:[Zz]|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$" - if isinstance(value, str) and re.search(pattern, value) is None: - raise ValueError( - f"{value!r} does not match the schema pattern {pattern}" - ) - return value - LocationFilterItem = TypeAliasType( "LocationFilterItem", @@ -1358,28 +1150,6 @@ class LocationServes(BaseModel): Coarse locality of the service target. """ - @model_validator(mode="after") - def _enforce_min_properties(self) -> Any: - """JSON Schema minProperties: require at least 1 - provided property.""" - provided = self.model_fields_set | set(self.model_extra or {}) - if len(provided) < 1: - raise ValueError( - "At least 1 property must be provided (schema minProperties=1)" - ) - return self - - @model_validator(mode="after") - def _enforce_max_properties(self) -> Any: - """JSON Schema maxProperties: allow at most 1 - provided property.""" - provided = self.model_fields_set | set(self.model_extra or {}) - if len(provided) > 1: - raise ValueError( - "At most 1 property may be provided (schema maxProperties=1)" - ) - return self - class LookupLocationInput(BaseModel): model_config = ConfigDict( @@ -1420,6 +1190,10 @@ class LookupVariantSeller(BaseModel): class McpToolCallResponseResult(BaseModel): + """ + Successful transport result. UCP bindings define the nested result payload. + """ + model_config = ConfigDict( extra="allow", ) @@ -1430,7 +1204,7 @@ class McpToolCallResponseResult(BaseModel): content: list[ContentPart] | None = None -class McpToolCallResponse(JsonrpcSuccessResponse): +class McpToolCallResponse(BaseModel): """ MCP tools/call response envelope. UCP payloads are carried in result.structuredContent; content is compatibility output. """ @@ -1438,9 +1212,17 @@ class McpToolCallResponse(JsonrpcSuccessResponse): model_config = ConfigDict( extra="allow", ) - result: McpToolCallResponseResult | None = Field( - None, title="McpToolCallResponseResult" + jsonrpc: Literal["2.0"] + """ + JSON-RPC protocol version. + """ + id: Id + result: McpToolCallResponseResult = Field( + ..., title="McpToolCallResponseResult" ) + """ + Successful transport result. UCP bindings define the nested result payload. + """ class Measure(BaseModel): @@ -1468,40 +1250,6 @@ class Measure(BaseModel): Integer count of `10^-scale` units of `unit`. """ - @model_validator(mode="after") - def _enforce_conditional_bounds(self) -> Any: - """JSON Schema if/then: enforce conditional numeric bounds.""" - rules: list[dict[str, Any]] = [ - { - "discriminator": "unit", - "values": ["C62"], - "bounds": {"scale": {"const": 0}}, - } - ] - checks = { - "minimum": (">=", "lt"), - "maximum": ("<=", "gt"), - "exclusiveMinimum": (">", "le"), - "exclusiveMaximum": ("<", "ge"), - "const": ("==", "ne"), - } - for rule in rules: - actual = getattr(self, rule["discriminator"], None) - if actual not in rule["values"]: - continue - for field, bounds in rule["bounds"].items(): - value = getattr(self, field, None) - if value is None: - continue - for keyword, limit in bounds.items(): - symbol, op_name = checks[keyword] - if getattr(operator, op_name)(value, limit): - raise ValueError( - f"Field {field!r} must be {symbol} {limit} " - f"when {rule['discriminator']} is {actual!r}" - ) - return self - class MediaBasePreview(BaseModel): """ @@ -1797,20 +1545,6 @@ class Oauth2Provider(BaseModel): OIDC Core §5.1 claim names the business requires in the JWT authorization grant from this provider (e.g. 'email'). Optional pre-filter hint: platforms SHOULD skip this provider during selection when their upstream identity lacks any of the listed claim names. Reactive enforcement at the token endpoint remains mandatory regardless; this field expresses claim presence only, not value constraints. """ - @field_validator("required_claims", mode="after") - def _enforce_unique_items_required_claims(cls, value: Any) -> Any: # noqa: N805 - """JSON Schema uniqueItems: reject duplicate entries.""" - if value is None: - return value - seen: list[Any] = [] - for item in value: - if item in seen: - raise ValueError( - "Items must be unique (schema uniqueItems=true)" - ) - seen.append(item) - return value - class OptionValue(BaseModel): """ @@ -1927,26 +1661,6 @@ class PaginationResponse(BaseModel): Total number of matching items, if available. """ - @model_validator(mode="after") - def _enforce_conditional_required(self) -> Any: - """JSON Schema if/then: enforce conditionally required fields.""" - rules: list[dict[str, Any]] = [ - { - "discriminator": "has_next_page", - "values": [True], - "required": ["cursor"], - } - ] - for rule in rules: - if getattr(self, rule["discriminator"], None) not in rule["values"]: - continue - for field in rule["required"]: - if field not in self.model_fields_set: - raise ValueError( - f"Field {field!r} is required by a schema condition" - ) - return self - class PanCredential(BaseModel): """ @@ -2305,15 +2019,6 @@ class ProviderBase(BaseModel): Provider mechanism discriminator. 'oauth2' for OAuth 2.0 / OIDC authorization servers. Additional values MAY be defined by future versions; the value is an open string, not a closed enum, so unrecognized types remain valid and are filtered at runtime. """ - @model_validator(mode="after") - def _enforce_not_enum(self) -> Any: - """JSON Schema not.enum: reject known variant discriminator values on open-union fallback.""" - if getattr(self, "type", None) in ["oauth2"]: - raise ValueError( - "Field 'type' must not be one of ['oauth2'] (schema not.enum)" - ) - return self - class QuantityUnit(BaseModel): """ @@ -2340,40 +2045,6 @@ class QuantityUnit(BaseModel): Ordering granularity, denominated in steps: the Business sells this item in integer multiples of `increment` steps. Its effective value is the provided value or 1. Advisory merchandising policy, not a representational bound: Platform-authored quantities SHOULD be integer multiples of the effective increment; the Business MAY accept, revise, or reject an off-increment request with a recoverable business outcome and MUST NOT silently reinterpret it. Business-authored quantities (checkout revisions, fulfillment events, adjustments) are bounded only by `scale`. """ - @model_validator(mode="after") - def _enforce_conditional_bounds(self) -> Any: - """JSON Schema if/then: enforce conditional numeric bounds.""" - rules: list[dict[str, Any]] = [ - { - "discriminator": "unit", - "values": ["C62"], - "bounds": {"scale": {"const": 0}}, - } - ] - checks = { - "minimum": (">=", "lt"), - "maximum": ("<=", "gt"), - "exclusiveMinimum": (">", "le"), - "exclusiveMaximum": ("<", "ge"), - "const": ("==", "ne"), - } - for rule in rules: - actual = getattr(self, rule["discriminator"], None) - if actual not in rule["values"]: - continue - for field, bounds in rule["bounds"].items(): - value = getattr(self, field, None) - if value is None: - continue - for keyword, limit in bounds.items(): - symbol, op_name = checks[keyword] - if getattr(operator, op_name)(value, limit): - raise ValueError( - f"Field {field!r} must be {symbol} {limit} " - f"when {rule['discriminator']} is {actual!r}" - ) - return self - class Rating(BaseModel): """ @@ -2801,19 +2472,6 @@ class Signals(BaseModel): Client's HTTP User-Agent header or equivalent. """ - @model_validator(mode="after") - def _enforce_property_names(self) -> Any: - """JSON Schema propertyNames: every extra key must match the - declared reverse-domain pattern (schema propertyNames).""" - pattern = "^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$" - for key in self.model_extra or {}: - if re.fullmatch(pattern, key) is None: - raise ValueError( - f"Property name {key!r} does not match the schema " - f"propertyNames pattern {pattern}" - ) - return self - SignedAmount = TypeAliasType( "SignedAmount", @@ -2846,22 +2504,6 @@ class TimeInterval(BaseModel): Closing time in 24-hour HH:MM format. """ - @model_validator(mode="after") - def _enforce_dependent_required(self) -> Any: - """JSON Schema dependentRequired: enforce dependent fields.""" - rules = {"opens": ["closes"], "closes": ["opens"]} - provided = self.model_fields_set | set(self.model_extra or {}) - for field, required_fields in rules.items(): - if field not in provided: - continue - for required in required_fields: - if required not in provided: - raise ValueError( - f"Field {required!r} is required when {field!r} " - "is provided (schema dependentRequired)" - ) - return self - class TokenCredential(BaseModel): """ @@ -2895,45 +2537,6 @@ class Total(BaseModel): """ amount: SignedAmount - @model_validator(mode="after") - def _enforce_conditional_bounds(self) -> Any: - """JSON Schema if/then: enforce conditional numeric bounds.""" - rules: list[dict[str, Any]] = [ - { - "discriminator": "type", - "values": ["discount", "items_discount"], - "bounds": {"amount": {"exclusiveMaximum": 0}}, - }, - { - "discriminator": "type", - "values": ["subtotal", "fulfillment", "tax", "fee"], - "bounds": {"amount": {"minimum": 0}}, - }, - ] - checks = { - "minimum": (">=", "lt"), - "maximum": ("<=", "gt"), - "exclusiveMinimum": (">", "le"), - "exclusiveMaximum": ("<", "ge"), - "const": ("==", "ne"), - } - for rule in rules: - actual = getattr(self, rule["discriminator"], None) - if actual not in rule["values"]: - continue - for field, bounds in rule["bounds"].items(): - value = getattr(self, field, None) - if value is None: - continue - for keyword, limit in bounds.items(): - symbol, op_name = checks[keyword] - if getattr(operator, op_name)(value, limit): - raise ValueError( - f"Field {field!r} must be {symbol} {limit} " - f"when {rule['discriminator']} is {actual!r}" - ) - return self - class TotalsItemLine(BaseModel): """ @@ -2968,141 +2571,9 @@ class TotalsItem(BaseModel): Optional itemized breakdown. The parent entry is always rendered; lines are supplementary. Sum of line amounts MUST equal the parent entry amount. """ - @model_validator(mode="after") - def _enforce_item_required_condition(self) -> Any: - """JSON Schema if/then: require ['display_text'] when 'type' is custom.""" - if getattr(self, "type", None) not in [ - "subtotal", - "items_discount", - "discount", - "fulfillment", - "tax", - "fee", - "total", - ]: - _present = self.model_fields_set | set(self.model_extra or {}) - for _req in ["display_text"]: - if _req not in _present: - raise ValueError( - f"Field {_req!r} is required for custom type" - ) - return self - - @model_validator(mode="after") - def _enforce_conditional_bounds(self) -> Any: - """JSON Schema if/then: enforce conditional numeric bounds.""" - rules: list[dict[str, Any]] = [ - { - "discriminator": "type", - "values": ["discount", "items_discount"], - "bounds": {"amount": {"exclusiveMaximum": 0}}, - }, - { - "discriminator": "type", - "values": ["subtotal", "fulfillment", "tax", "fee"], - "bounds": {"amount": {"minimum": 0}}, - }, - ] - checks = { - "minimum": (">=", "lt"), - "maximum": ("<=", "gt"), - "exclusiveMinimum": (">", "le"), - "exclusiveMaximum": ("<", "ge"), - "const": ("==", "ne"), - } - for rule in rules: - actual = getattr(self, rule["discriminator"], None) - if actual not in rule["values"]: - continue - for field, bounds in rule["bounds"].items(): - value = getattr(self, field, None) - if value is None: - continue - for keyword, limit in bounds.items(): - symbol, op_name = checks[keyword] - if getattr(operator, op_name)(value, limit): - raise ValueError( - f"Field {field!r} must be {symbol} {limit} " - f"when {rule['discriminator']} is {actual!r}" - ) - return self - - -def _enforce_contains_totals(value: list[Any]) -> list[Any]: - """JSON Schema contains/minContains/maxContains (see #49).""" - _matched_0 = sum( - 1 - for _item in value - if ( - _item.get("type") - if isinstance(_item, dict) - else getattr(_item, "type", None) - ) - == "subtotal" - ) - if _matched_0 < 1: - raise ValueError( - "Array must contain at least 1 entry " - "matching type=='subtotal' (schema minContains=1)" - ) - if _matched_0 > 1: - raise ValueError( - "Array must contain at most 1 entry " - "matching type=='subtotal' (schema maxContains=1)" - ) - _matched_1 = sum( - 1 - for _item in value - if ( - _item.get("type") - if isinstance(_item, dict) - else getattr(_item, "type", None) - ) - == "total" - ) - if _matched_1 < 1: - raise ValueError( - "Array must contain at least 1 entry " - "matching type=='total' (schema minContains=1)" - ) - if _matched_1 > 1: - raise ValueError( - "Array must contain at most 1 entry " - "matching type=='total' (schema maxContains=1)" - ) - _excluded = [ - "subtotal", - "items_discount", - "discount", - "fulfillment", - "tax", - "fee", - "total", - ] - for _item in value: - _actual = ( - _item.get("type") - if isinstance(_item, dict) - else getattr(_item, "type", None) - ) - if _actual in _excluded: - continue - if isinstance(_item, dict) and "display_text" not in _item: - raise ValueError("Field 'display_text' is required for custom type") - if not isinstance(_item, dict) and "display_text" not in ( - _item.model_fields_set | set(_item.model_extra or {}) - ): - raise ValueError("Field 'display_text' is required for custom type") - return value - Totals = TypeAliasType( - "Totals", - Annotated[ - list[TotalsItem], - Field(..., title="Totals"), - AfterValidator(_enforce_contains_totals), - ], + "Totals", Annotated[list[TotalsItem], Field(..., title="Totals")] ) """ Pricing breakdown provided by the business. MUST contain exactly one subtotal and one total entry. Detail types (tax, fee, discount, fulfillment) may appear multiple times for itemization. Platforms MUST render all entries in order using display_text and amount. @@ -3144,40 +2615,6 @@ class Unit(BaseModel): Required printable unit label provided by the Business. The Platform MUST use it when it does not recognize `unit`; for a recognized UN/CEFACT Rec 20 Common Code, the Platform MAY substitute its own localized label. It does not participate in unit identity or mismatch comparison. """ - @model_validator(mode="after") - def _enforce_conditional_bounds(self) -> Any: - """JSON Schema if/then: enforce conditional numeric bounds.""" - rules: list[dict[str, Any]] = [ - { - "discriminator": "unit", - "values": ["C62"], - "bounds": {"scale": {"const": 0}}, - } - ] - checks = { - "minimum": (">=", "lt"), - "maximum": ("<=", "gt"), - "exclusiveMinimum": (">", "le"), - "exclusiveMaximum": ("<", "ge"), - "const": ("==", "ne"), - } - for rule in rules: - actual = getattr(self, rule["discriminator"], None) - if actual not in rule["values"]: - continue - for field, bounds in rule["bounds"].items(): - value = getattr(self, field, None) - if value is None: - continue - for keyword, limit in bounds.items(): - symbol, op_name = checks[keyword] - if getattr(operator, op_name)(value, limit): - raise ValueError( - f"Field {field!r} must be {symbol} {limit} " - f"when {rule['discriminator']} is {actual!r}" - ) - return self - class UnitPriceMeasure(BaseModel): """ @@ -3204,40 +2641,6 @@ class UnitPriceMeasure(BaseModel): Integer count of `10^-scale` units of `unit`. """ - @model_validator(mode="after") - def _enforce_conditional_bounds(self) -> Any: - """JSON Schema if/then: enforce conditional numeric bounds.""" - rules: list[dict[str, Any]] = [ - { - "discriminator": "unit", - "values": ["C62"], - "bounds": {"scale": {"const": 0}}, - } - ] - checks = { - "minimum": (">=", "lt"), - "maximum": ("<=", "gt"), - "exclusiveMinimum": (">", "le"), - "exclusiveMaximum": ("<", "ge"), - "const": ("==", "ne"), - } - for rule in rules: - actual = getattr(self, rule["discriminator"], None) - if actual not in rule["values"]: - continue - for field, bounds in rule["bounds"].items(): - value = getattr(self, field, None) - if value is None: - continue - for keyword, limit in bounds.items(): - symbol, op_name = checks[keyword] - if getattr(operator, op_name)(value, limit): - raise ValueError( - f"Field {field!r} must be {symbol} {limit} " - f"when {rule['discriminator']} is {actual!r}" - ) - return self - class UnitPriceReference(BaseModel): """ @@ -3264,40 +2667,6 @@ class UnitPriceReference(BaseModel): Integer count of `10^-scale` units of `unit`. """ - @model_validator(mode="after") - def _enforce_conditional_bounds(self) -> Any: - """JSON Schema if/then: enforce conditional numeric bounds.""" - rules: list[dict[str, Any]] = [ - { - "discriminator": "unit", - "values": ["C62"], - "bounds": {"scale": {"const": 0}}, - } - ] - checks = { - "minimum": (">=", "lt"), - "maximum": ("<=", "gt"), - "exclusiveMinimum": (">", "le"), - "exclusiveMaximum": ("<", "ge"), - "const": ("==", "ne"), - } - for rule in rules: - actual = getattr(self, rule["discriminator"], None) - if actual not in rule["values"]: - continue - for field, bounds in rule["bounds"].items(): - value = getattr(self, field, None) - if value is None: - continue - for keyword, limit in bounds.items(): - symbol, op_name = checks[keyword] - if getattr(operator, op_name)(value, limit): - raise ValueError( - f"Field {field!r} must be {symbol} {limit} " - f"when {rule['discriminator']} is {actual!r}" - ) - return self - class UnitPrice(BaseModel): """ @@ -3339,20 +2708,6 @@ class ValueConstraintEnum(BaseModel): """ const: Any | None = None - @field_validator("enum", mode="after") - def _enforce_unique_items_enum(cls, value: Any) -> Any: # noqa: N805 - """JSON Schema uniqueItems: reject duplicate entries.""" - if value is None: - return value - seen: list[Any] = [] - for item in value: - if item in seen: - raise ValueError( - "Items must be unique (schema uniqueItems=true)" - ) - seen.append(item) - return value - class ValueConstraintConst(BaseModel): """ @@ -3368,20 +2723,6 @@ class ValueConstraintConst(BaseModel): """ const: Any - @field_validator("enum", mode="after") - def _enforce_unique_items_enum(cls, value: Any) -> Any: # noqa: N805 - """JSON Schema uniqueItems: reject duplicate entries.""" - if value is None: - return value - seen: list[Any] = [] - for item in value: - if item in seen: - raise ValueError( - "Items must be unique (schema uniqueItems=true)" - ) - seen.append(item) - return value - ValueConstraint = TypeAliasType( "ValueConstraint", @@ -3475,13 +2816,17 @@ class A2aMessageMessage(BaseModel): class A2aMessageRequestParams(BaseModel): + """ + Method parameters. Binding-specific schemas define the object shape. + """ + model_config = ConfigDict( extra="allow", ) message: A2aMessageMessage -class A2aMessageRequest(JsonrpcRequest): +class A2aMessageRequest(BaseModel): """ A2A message/send JSON-RPC request whose params carry a UCP-bearing Message from the platform to the business agent. """ @@ -3489,13 +2834,24 @@ class A2aMessageRequest(JsonrpcRequest): model_config = ConfigDict( extra="allow", ) - method: Literal["message/send"] = "message/send" + jsonrpc: Literal["2.0"] + """ + JSON-RPC protocol version. + """ + id: Id | None = None + method: Literal["message/send"] = Field(..., min_length=1) + """ + Transport method name. Binding-specific schemas constrain the method namespace. + """ params: A2aMessageRequestParams = Field( ..., title="A2aMessageRequestParams" ) + """ + Method parameters. Binding-specific schemas define the object shape. + """ -class A2aMessageResponse(JsonrpcSuccessResponse): +class A2aMessageResponse(BaseModel): """ JSON-RPC success response whose result is an A2A Message from the business agent. """ @@ -3503,7 +2859,15 @@ class A2aMessageResponse(JsonrpcSuccessResponse): model_config = ConfigDict( extra="allow", ) - result: A2aMessageMessage | None = None + jsonrpc: Literal["2.0"] + """ + JSON-RPC protocol version. + """ + id: Id + result: A2aMessageMessage + """ + Successful transport result. UCP bindings define the nested result payload. + """ class Actions(BaseModel): @@ -3530,19 +2894,6 @@ class Actions(BaseModel): A 3DS challenge Action. """ - @model_validator(mode="after") - def _enforce_property_names(self) -> Any: - """JSON Schema propertyNames: every extra key must match the - declared reverse-domain pattern (schema propertyNames).""" - pattern = "^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$" - for key in self.model_extra or {}: - if re.fullmatch(pattern, key) is None: - raise ValueError( - f"Property name {key!r} does not match the schema " - f"propertyNames pattern {pattern}" - ) - return self - class AdjustmentLineItem(BaseModel): model_config = ConfigDict( @@ -4028,7 +3379,7 @@ class ConstraintExpression(BaseModel): """ model_config = ConfigDict( - extra="forbid", + extra="allow", ) required: list[str] | None = Field(None, min_length=1) """ @@ -4043,20 +3394,6 @@ class ConstraintExpression(BaseModel): Alternative Object Constraints. The constrained object must satisfy at least one. A branch must be non-empty: an empty branch is satisfied by every object and neutralizes the alternation. """ - @field_validator("required", mode="after") - def _enforce_unique_items_required(cls, value: Any) -> Any: # noqa: N805 - """JSON Schema uniqueItems: reject duplicate entries.""" - if value is None: - return value - seen: list[Any] = [] - for item in value: - if item in seen: - raise ValueError( - "Items must be unique (schema uniqueItems=true)" - ) - seen.append(item) - return value - class ContextPayment(BaseModel): model_config = ConfigDict( @@ -4117,20 +3454,6 @@ class Context(BaseModel): Buyer-preferred payment handlers in priority order (most preferred first). Each entry names a handler advertised in the Business profile's `ucp.payment_handlers`, optionally narrowed to preferred instrument types. The Business SHOULD use it to preselect or prioritize the handler (and type, when given) and MAY ignore unavailable or ineligible entries; unrecognized values MUST be ignored without error. """ - @field_validator("eligibility", mode="after") - def _enforce_unique_items_eligibility(cls, value: Any) -> Any: # noqa: N805 - """JSON Schema uniqueItems: reject duplicate entries.""" - if value is None: - return value - seen: list[Any] = [] - for item in value: - if item in seen: - raise ValueError( - "Items must be unique (schema uniqueItems=true)" - ) - seen.append(item) - return value - class DiscountsObject(BaseModel): """ @@ -4203,7 +3526,7 @@ class EarningForecast(BaseModel): """ -class EmbeddedMessageRequest(JsonrpcRequest): +class EmbeddedMessageRequest(BaseModel): """ Embedded Protocol request or notification envelope. Messages with id expect a response; messages without id are notifications. """ @@ -4211,14 +3534,22 @@ class EmbeddedMessageRequest(JsonrpcRequest): model_config = ConfigDict( extra="allow", ) - method: Method | None = None + jsonrpc: Literal["2.0"] + """ + JSON-RPC protocol version. + """ + id: Id | None = None + method: Method + """ + Transport method name. Binding-specific schemas constrain the method namespace. + """ params: dict[str, Any] """ Capability-specific EP parameters. """ -class EmbeddedMessageResponse(JsonrpcSuccessResponse): +class EmbeddedMessageResponse(BaseModel): """ Embedded Protocol success response envelope. UCP application-level success and error outcomes are both carried in result.ucp.status. """ @@ -4226,7 +3557,12 @@ class EmbeddedMessageResponse(JsonrpcSuccessResponse): model_config = ConfigDict( extra="allow", ) - result: dict[str, Any] | None = None + jsonrpc: Literal["2.0"] + """ + JSON-RPC protocol version. + """ + id: Id + result: dict[str, Any] """ Capability-specific EP result. Application-level status is defined by capability schemas; see Embedded Protocol response handling. """ @@ -4661,22 +3997,6 @@ class Location(BaseModel): The Business-owned IANA Time Zone Database identifier (e.g., 'America/New_York') defining this Location's canonical local civil-time frame for all returned schedule day, time, and date fields. The Business does not vary this canonical framing by the requesting Platform's or Buyer's timezone. Required when hours or exception_hours is present. """ - @model_validator(mode="after") - def _enforce_dependent_required(self) -> Any: - """JSON Schema dependentRequired: enforce dependent fields.""" - rules = {"exception_hours": ["timezone"], "hours": ["timezone"]} - provided = self.model_fields_set | set(self.model_extra or {}) - for field, required_fields in rules.items(): - if field not in provided: - continue - for required in required_fields: - if required not in provided: - raise ValueError( - f"Field {required!r} is required when {field!r} " - "is provided (schema dependentRequired)" - ) - return self - class LocationDestination(BaseModel): """ @@ -4725,20 +4045,6 @@ class LocationFilter(BaseModel): Current item-availability filter. A candidate Location matches only when the Business can currently provide every referenced item at that Location; all references combine with AND. """ - @field_validator("items", mode="after") - def _enforce_unique_items_items(cls, value: Any) -> Any: # noqa: N805 - """JSON Schema uniqueItems: reject duplicate entries.""" - if value is None: - return value - seen: list[Any] = [] - for item in value: - if item in seen: - raise ValueError( - "Items must be unique (schema uniqueItems=true)" - ) - seen.append(item) - return value - class LocationLookupRequest(BaseModel): """ @@ -4858,22 +4164,6 @@ class LookupLocation(BaseModel): Which request identifiers resolved to this Location. Each entry preserves one identifier exactly as supplied in the request. """ - @model_validator(mode="after") - def _enforce_dependent_required(self) -> Any: - """JSON Schema dependentRequired: enforce dependent fields.""" - rules = {"exception_hours": ["timezone"], "hours": ["timezone"]} - provided = self.model_fields_set | set(self.model_extra or {}) - for field, required_fields in rules.items(): - if field not in provided: - continue - for required in required_fields: - if required not in provided: - raise ValueError( - f"Field {required!r} is required when {field!r} " - "is provided (schema dependentRequired)" - ) - return self - class MediaBase(BaseModel): """ @@ -4920,21 +4210,6 @@ class MediaBase(BaseModel): Alternate renditions of the resource at `url`. The Business SHOULD include the rendition at `url`. """ - @model_validator(mode="after") - def _enforce_not_enum(self) -> Any: - """JSON Schema not.enum: reject known variant discriminator values on open-union fallback.""" - if getattr(self, "type", None) in [ - "external_video", - "image", - "model_3d", - "video", - ]: - raise ValueError( - "Field 'type' must not be one of ['external_video', 'image', 'model_3d', 'video'] " - "(schema not.enum)" - ) - return self - class MediaExternalVideo(BaseModel): """ @@ -5487,7 +4762,7 @@ class RequestConstraints(BaseModel): """ model_config = ConfigDict( - extra="forbid", + extra="allow", ) path: str | None = None """ @@ -5508,20 +4783,6 @@ class RequestConstraints(BaseModel): Alternative Object Constraints. The constrained object must satisfy at least one. A branch must be non-empty: an empty branch is satisfied by every object and neutralizes the alternation. """ - @field_validator("required", mode="after") - def _enforce_unique_items_required(cls, value: Any) -> Any: # noqa: N805 - """JSON Schema uniqueItems: reject duplicate entries.""" - if value is None: - return value - seen: list[Any] = [] - for item in value: - if item in seen: - raise ValueError( - "Items must be unique (schema uniqueItems=true)" - ) - seen.append(item) - return value - class Requires(BaseModel): """ @@ -6355,32 +5616,6 @@ class FulfillmentMethodBase(BaseModel): Fulfillment groups for selecting options. Agent sets selected_option_id on groups to choose shipping method. """ - @model_validator(mode="after") - def _enforce_dependent_required(self) -> Any: - """JSON Schema dependentRequired: enforce dependent fields.""" - rules = {"destinations": ["type"]} - provided = self.model_fields_set | set(self.model_extra or {}) - for field, required_fields in rules.items(): - if field not in provided: - continue - for required in required_fields: - if required not in provided: - raise ValueError( - f"Field {required!r} is required when {field!r} " - "is provided (schema dependentRequired)" - ) - return self - - @model_validator(mode="after") - def _enforce_not_enum(self) -> Any: - """JSON Schema not.enum: reject known variant discriminator values on open-union fallback.""" - if getattr(self, "type", None) in ["pickup", "shipping"]: - raise ValueError( - "Field 'type' must not be one of ['pickup', 'shipping'] " - "(schema not.enum)" - ) - return self - class IdentityLinkingBusinessSchemaConfig(BaseModel): """ @@ -6471,6 +5706,10 @@ class LoyaltyMembership(BaseModel): class McpToolCallRequestParams(BaseModel): + """ + Method parameters. Binding-specific schemas define the object shape. + """ + model_config = ConfigDict( extra="allow", ) @@ -6481,7 +5720,7 @@ class McpToolCallRequestParams(BaseModel): arguments: Arguments -class McpToolCallRequest(JsonrpcRequest): +class McpToolCallRequest(BaseModel): """ MCP tools/call request envelope for invoking a UCP operation. """ @@ -6489,10 +5728,21 @@ class McpToolCallRequest(JsonrpcRequest): model_config = ConfigDict( extra="allow", ) - method: Literal["tools/call"] = "tools/call" + jsonrpc: Literal["2.0"] + """ + JSON-RPC protocol version. + """ + id: Id | None = None + method: Literal["tools/call"] = Field(..., min_length=1) + """ + Transport method name. Binding-specific schemas constrain the method namespace. + """ params: McpToolCallRequestParams = Field( ..., title="McpToolCallRequestParams" ) + """ + Method parameters. Binding-specific schemas define the object shape. + """ Media = TypeAliasType( @@ -7293,7 +6543,7 @@ class ErrorResponse(BaseModel): """ model_config = ConfigDict( - extra="forbid", + extra="allow", ) ucp: UcpError """ diff --git a/tests/test_codegen_pipeline.py b/tests/test_codegen_pipeline.py deleted file mode 100644 index 7a47b4f..0000000 --- a/tests/test_codegen_pipeline.py +++ /dev/null @@ -1,2821 +0,0 @@ -# Copyright 2026 UCP Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# pyright: reportCallIssue=false, reportArgumentType=false, reportOptionalMemberAccess=false - -"""Tests for the schema codegen and postprocessing pipeline.""" - -import ast -import contextlib -import copy -import io -import json -import tempfile -import unittest -from pathlib import Path -from unittest import mock - -import postprocess_models - -from pydantic import TypeAdapter, ValidationError -from ucp_sdk.models import ( - Description, - Totals, -) - - -class DescriptionMinPropertiesTest(unittest.TestCase): - """description.json declares minProperties: 1 at the schema root.""" - - def test_empty_instance_rejected(self): - with self.assertRaisesRegex(ValidationError, "[Aa]t least 1"): - Description() - - def test_empty_mapping_rejected(self): - with self.assertRaisesRegex(ValidationError, "[Aa]t least 1"): - Description.model_validate({}) - - def test_single_declared_field_accepted(self): - self.assertEqual(Description(plain="hello").plain, "hello") - - def test_explicit_null_key_counts_as_present(self): - # {"html": null} has one property per JSON Schema's key counting. - Description.model_validate({"html": None}) - - def test_extra_field_counts_as_present(self): - # extra="allow": an unknown key is a present property. - Description.model_validate({"x-vendor-note": "hi"}) - - def test_all_fields_accepted(self): - Description(plain="p", html="

p

", markdown="p") - - -class SignalsPropertyNamesTest(unittest.TestCase): - """signals.json declares propertyNames (reverse-domain keys). - - Signals has named ``properties`` AND ``additionalProperties: true``, so the - generator emits ``class Signals(BaseModel)`` with ``extra="allow"`` and named - fields; extra keys bypass the ``propertyNames`` pattern. The post-generation - injector restores the check on every ``model_extra`` key while preserving - well-formed reverse-domain extras (extra="allow" keeps them). - """ - - def _signals(self): - from ucp_sdk.models import Signals - - return Signals - - def test_malformed_extra_key_rejected(self): - with self.assertRaisesRegex(ValidationError, "propertyNames"): - self._signals().model_validate( - {"dev.ucp.buyer_ip": "1.2.3.4", "bogus KEY!": "x"} - ) - - def test_trailing_newline_key_rejected(self): - # A $-anchored pattern with re.match would let a trailing newline - # slip through; the enforcement uses re.fullmatch to agree with - # pydantic-core's key validation on the sibling dict-map path. - with self.assertRaisesRegex(ValidationError, "propertyNames"): - self._signals().model_validate({"com.example.k\n": "x"}) - - def test_valid_reverse_domain_extra_accepted_and_preserved(self): - signals = self._signals().model_validate( - {"com.example.device_id": "abc123"} - ) - # extra="allow" must still keep a well-formed extra key. - self.assertEqual( - signals.model_extra, {"com.example.device_id": "abc123"} - ) - - def test_known_named_fields_still_work(self): - signals = self._signals().model_validate( - { - "dev.ucp.buyer_ip": "1.2.3.4", - "dev.ucp.user_agent": "curl/8", - } - ) - self.assertEqual(signals.dev_ucp_buyer_ip, "1.2.3.4") - self.assertEqual(signals.dev_ucp_user_agent, "curl/8") - self.assertEqual(signals.model_extra, {}) - - def test_request_variants_enforce_property_names(self): - # Request models share the Signals type and enforce propertyNames on signals. - from ucp_sdk.models import ( - CatalogLookupRequest, - CatalogSearchRequest, - ) - - with self.assertRaisesRegex(ValidationError, "propertyNames"): - CatalogLookupRequest.model_validate( - {"ids": ["prod_1"], "signals": {"bogus KEY!": "x"}} - ) - with self.assertRaisesRegex(ValidationError, "propertyNames"): - CatalogSearchRequest.model_validate( - {"signals": {"bogus KEY!": "x"}} - ) - req = CatalogSearchRequest.model_validate( - {"signals": {"com.example.k": "v"}} - ) - self.assertEqual(req.signals.model_extra, {"com.example.k": "v"}) - - -class IdentityLinkingRoleSchemaTest(unittest.TestCase): - """identity_linking.json keeps its role schemas instead of Any aliases. - - The dotted 'dev.ucp.common.identity_linking' def is a capability role - container. Flattening must split it into two generatable defs so the - business role keeps the upstream contract: 'config.scopes' required with - OAuth scope-token keys. - """ - - def _business(self): - from ucp_sdk.models import IdentityLinkingBusinessSchema - - return IdentityLinkingBusinessSchema - - def _base(self): - return { - "version": "2026-08-25", - "schema": "https://ucp.dev/2026-08-25/schemas/common/identity_linking", - } - - def test_platform_role_schema_exists(self): - from ucp_sdk.models import IdentityLinkingPlatformSchema - - IdentityLinkingPlatformSchema( - version="2026-08-25", - **{ - "schema": "https://ucp.dev/2026-08-25/schemas/common/identity_linking" - }, - spec="https://ucp.dev/specification/common/identity-linking", - ) - - def test_business_config_with_scopes_accepted(self): - obj = self._business().model_validate( - { - **self._base(), - "config": {"scopes": {"dev.ucp.shopping.order:read": {}}}, - } - ) - self.assertEqual( - list(obj.config.scopes), ["dev.ucp.shopping.order:read"] - ) - self.assertIsNone( - obj.config.scopes["dev.ucp.shopping.order:read"].description - ) - - def test_missing_config_rejected(self): - with self.assertRaisesRegex(ValidationError, "config"): - self._business().model_validate(self._base()) - - def test_config_without_scopes_rejected(self): - with self.assertRaisesRegex(ValidationError, "scopes"): - self._business().model_validate({**self._base(), "config": {}}) - - def test_malformed_scope_key_rejected(self): - with self.assertRaisesRegex(ValidationError, "pattern"): - self._business().model_validate( - {**self._base(), "config": {"scopes": {"BAD": {}}}} - ) - - -class PropertyNamesInjectorTest(unittest.TestCase): - """The propertyNames post-generation injector's own behavior.""" - - PATTERN = "^[a-z][a-z0-9]*(?:\\.[a-z][a-z0-9_]*)+$" - - SCHEMA = { - "title": "Signals", - "type": "object", - "propertyNames": {"pattern": PATTERN}, - "properties": {"dev.ucp.buyer_ip": {"type": "string"}}, - "additionalProperties": True, - } - - # An object with propertyNames but no named properties is a dict-map - # (key type already carries the pattern) — out of scope. - DICT_MAP_SCHEMA = { - "title": "Requires", - "type": "object", - "propertyNames": {"pattern": PATTERN}, - "additionalProperties": {"type": "string"}, - } - - MODULE = ( - "from __future__ import annotations\n" - "\n" - "from pydantic import BaseModel, ConfigDict, Field\n" - "\n" - "\n" - "class Signals(BaseModel):\n" - ' """Signals."""\n' - "\n" - " model_config = ConfigDict(\n" - ' extra="allow",\n' - " )\n" - ' dev_ucp_buyer_ip: str | None = Field(None, alias="dev.ucp.buyer_ip")\n' - ) - - def test_scan_finds_only_extra_allow_object(self): - with tempfile.TemporaryDirectory() as tmp: - (Path(tmp) / "signals.json").write_text(json.dumps(self.SCHEMA)) - (Path(tmp) / "requires.json").write_text( - json.dumps(self.DICT_MAP_SCHEMA) - ) - found = postprocess_models.find_property_names_patterns(Path(tmp)) - self.assertEqual(found, {"Signals": self.PATTERN}) - - def test_scan_resolves_ref_pattern(self): - with tempfile.TemporaryDirectory() as tmp: - (Path(tmp) / "reverse_domain_name.json").write_text( - json.dumps({"type": "string", "pattern": self.PATTERN}) - ) - (Path(tmp) / "thing.json").write_text( - json.dumps( - { - "title": "Thing", - "type": "object", - "propertyNames": {"$ref": "reverse_domain_name.json"}, - "properties": {"a": {"type": "string"}}, - } - ) - ) - found = postprocess_models.find_property_names_patterns(Path(tmp)) - self.assertEqual(found, {"Thing": self.PATTERN}) - - def test_injects_validator_and_imports(self): - out = postprocess_models.inject_property_names( - self.MODULE, "Signals", self.PATTERN - ) - self.assertIn("model_validator", out) - self.assertIn("import re", out) - self.assertIn("propertyNames", out) - - def test_injection_is_idempotent(self): - once = postprocess_models.inject_property_names( - self.MODULE, "Signals", self.PATTERN - ) - twice = postprocess_models.inject_property_names( - once, "Signals", self.PATTERN - ) - self.assertEqual(once, twice) - - def test_injected_validator_enforces_pattern(self): - out = postprocess_models.inject_property_names( - self.MODULE, "Signals", self.PATTERN - ) - namespace: dict = {} - exec(compile(out, "", "exec"), namespace) # noqa: S102 - signals_cls = namespace["Signals"] - with self.assertRaises(ValidationError): - signals_cls.model_validate({"bogus KEY!": "x"}) - # fullmatch (not match) — a trailing newline must not slip through. - with self.assertRaises(ValidationError): - signals_cls.model_validate({"com.example.ok\n": "v"}) - signals_cls.model_validate({"com.example.ok": "v"}) - - -class ConditionalRequiredInjectorTest(unittest.TestCase): - """Simple JSON Schema if/then required constraints are restored.""" - - MODULE = ( - "from __future__ import annotations\n" - "\n" - "from pydantic import BaseModel, ConfigDict\n" - "\n" - "\n" - "class Response(BaseModel):\n" - ' model_config = ConfigDict(extra="allow")\n' - " cursor: str | None = None\n" - " has_next_page: bool\n" - ) - RULES = [ - { - "discriminator": "has_next_page", - "values": [True], - "required": ["cursor"], - } - ] - - def test_schema_scan_maps_nested_definition_to_generated_class(self): - schema = { - "title": "Pagination", - "type": "object", - "$defs": { - "response": { - "type": "object", - "properties": { - "cursor": {"type": "string"}, - "has_next_page": {"type": "boolean"}, - }, - "if": { - "properties": {"has_next_page": {"const": True}}, - "required": ["has_next_page"], - }, - "then": {"required": ["cursor"]}, - } - }, - } - with tempfile.TemporaryDirectory() as tmp: - Path(tmp, "pagination.json").write_text(json.dumps(schema)) - found = postprocess_models.find_conditional_required(Path(tmp)) - self.assertEqual(found, {"Response": self.RULES}) - - def test_schema_scan_skips_else_branches(self): - schema = { - "title": "Response", - "type": "object", - "properties": { - "cursor": {"type": "string"}, - "has_next_page": {"type": "boolean"}, - }, - "if": { - "properties": {"has_next_page": {"const": True}}, - "required": ["has_next_page"], - }, - "then": {"required": ["cursor"]}, - "else": {"required": ["other"]}, - } - with tempfile.TemporaryDirectory() as tmp: - Path(tmp, "response.json").write_text(json.dumps(schema)) - found = postprocess_models.find_conditional_required(Path(tmp)) - self.assertEqual(found, {}) - - def test_schema_scan_threads_enclosing_scope_into_titled_allof_branch( - self, - ): - """An allOf branch with neither its own `properties` nor a type - title of its own is invisible today: the scan only looks at - `node.get("properties")` on the branch itself, so a branch that - relies on the enclosing object's properties (JWK's five if/then - rules, none of which repeat `properties`) is silently dropped with - no warning. And when the branch DOES carry a human-readable - documentation `title` (JWK's branches are each titled, e.g. "EC - keys carry crv, x, y"), the current code adopts that title as the - class name via `_alias_name`, misattributing the rule to a - nonexistent class instead of the enclosing `JwkPublicKey`. This - mirrors profile.json's jwk_public_key def exactly. - """ - schema = { - "$defs": { - "jwk_public_key": { - "type": "object", - "required": ["kid", "kty"], - "properties": { - "kid": {"type": "string"}, - "kty": {"type": "string"}, - "crv": {"type": "string"}, - "x": {"type": "string"}, - "y": {"type": "string"}, - }, - "allOf": [ - { - "title": "EC keys carry crv, x, y", - "if": { - "properties": {"kty": {"const": "EC"}}, - "required": ["kty"], - }, - "then": {"required": ["crv", "x", "y"]}, - }, - { - "title": "OKP keys carry crv, x", - "if": { - "properties": {"kty": {"const": "OKP"}}, - "required": ["kty"], - }, - "then": {"required": ["crv", "x"]}, - }, - ], - } - } - } - with tempfile.TemporaryDirectory() as tmp: - Path(tmp, "profile.json").write_text(json.dumps(schema)) - found = postprocess_models.find_conditional_required(Path(tmp)) - self.assertEqual( - found, - { - "JwkPublicKey": [ - { - "discriminator": "kty", - "values": ["EC"], - "required": ["crv", "x", "y"], - }, - { - "discriminator": "kty", - "values": ["OKP"], - "required": ["crv", "x"], - }, - ] - }, - ) - - def test_injection_is_idempotent(self): - once = postprocess_models.inject_conditional_required( - self.MODULE, "Response", self.RULES - ) - twice = postprocess_models.inject_conditional_required( - once, "Response", self.RULES - ) - self.assertEqual(once, twice) - - def test_injected_validator_enforces_conditional_required(self): - out = postprocess_models.inject_conditional_required( - self.MODULE, "Response", self.RULES - ) - namespace: dict = {} - exec(compile(out, "", "exec"), namespace) # noqa: S102 - response = namespace["Response"] - with self.assertRaises(ValidationError): - response(has_next_page=True) - response(has_next_page=True, cursor="next-page") - response(has_next_page=False) - - -class ConditionalBoundsInjectorTest(unittest.TestCase): - """JSON Schema if/then numeric bounds are restored.""" - - MODULE = ( - "from __future__ import annotations\n" - "\n" - "from pydantic import BaseModel, ConfigDict\n" - "\n" - "\n" - "class Total(BaseModel):\n" - ' model_config = ConfigDict(extra="allow")\n' - " type: str\n" - " amount: int\n" - ) - RULES = [ - { - "discriminator": "type", - "values": ["discount"], - "bounds": {"amount": {"exclusiveMaximum": 0}}, - }, - { - "discriminator": "type", - "values": ["tax"], - "bounds": {"amount": {"minimum": 0}}, - }, - ] - - def _schema(self): - return { - "title": "Total", - "type": "object", - "properties": { - "type": {"type": "string"}, - "amount": {"type": "integer"}, - }, - "allOf": [ - { - "if": { - "properties": {"type": {"enum": ["discount"]}}, - "required": ["type"], - }, - "then": {"properties": {"amount": {"exclusiveMaximum": 0}}}, - }, - { - "if": { - "properties": {"type": {"enum": ["tax"]}}, - "required": ["type"], - }, - "then": {"properties": {"amount": {"minimum": 0}}}, - }, - ], - } - - def test_schema_scan_reads_rules_carried_as_allof_branches(self): - """An if/then branch constrains the enclosing object's properties.""" - with tempfile.TemporaryDirectory() as tmp: - Path(tmp, "total.json").write_text(json.dumps(self._schema())) - found = postprocess_models.find_conditional_bounds(Path(tmp)) - self.assertEqual(found, {"Total": self.RULES}) - - def test_schema_scan_skips_rules_whose_fields_were_stripped(self): - """A request variant drops the fields, so the rule cannot apply.""" - schema = self._schema() - schema["properties"] = {} - with tempfile.TemporaryDirectory() as tmp: - Path(tmp, "total_create_request.json").write_text( - json.dumps(schema) - ) - stderr = io.StringIO() - with contextlib.redirect_stderr(stderr): - found = postprocess_models.find_conditional_bounds(Path(tmp)) - self.assertEqual(found, {}) - self.assertNotIn("unsupported", stderr.getvalue()) - - def test_schema_scan_warns_on_unsupported_shape(self): - schema = self._schema() - schema["allOf"][0]["then"]["properties"]["amount"] = {"pattern": "^x$"} - with tempfile.TemporaryDirectory() as tmp: - Path(tmp, "total.json").write_text(json.dumps(schema)) - stderr = io.StringIO() - with contextlib.redirect_stderr(stderr): - found = postprocess_models.find_conditional_bounds(Path(tmp)) - # The malformed branch is dropped; the well-formed sibling survives. - self.assertEqual(found, {"Total": [self.RULES[1]]}) - self.assertIn("unsupported", stderr.getvalue()) - - def test_schema_scan_recognizes_const_pinning(self): - """A `then.properties..const` pin is dropped today: describe() - only accepts the four numeric bound keywords in _BOUND_KEYWORDS, so - `set(constraint) - set(_BOUND_KEYWORDS)` is non-empty for a bare - `{"const": ...}` constraint and the whole rule returns None. This - mirrors unit.json exactly: when unit is C62, scale must be exactly - 0. The branch carries no title of its own (unlike the JWK case - below), isolating bug (b) from bug (c). - """ - schema = { - "title": "Unit", - "type": "object", - "properties": { - "unit": {"type": "string"}, - "scale": {"type": "integer"}, - }, - "allOf": [ - { - "if": { - "properties": {"unit": {"const": "C62"}}, - "required": ["unit"], - }, - "then": {"properties": {"scale": {"const": 0}}}, - } - ], - } - with tempfile.TemporaryDirectory() as tmp: - Path(tmp, "unit.json").write_text(json.dumps(schema)) - found = postprocess_models.find_conditional_bounds(Path(tmp)) - self.assertEqual( - found, - { - "Unit": [ - { - "discriminator": "unit", - "values": ["C62"], - "bounds": {"scale": {"const": 0}}, - } - ] - }, - ) - - def test_schema_scan_recognizes_const_pinning_in_titled_allof_branch( - self, - ): - """profile.json's JWK curve/algorithm pairing rules combine both - gaps at once: `then.properties.alg.const` (bug b, see above) inside - a branch that carries its own documentation `title` (bug c, see - ConditionalRequiredInjectorTest) which must not overwrite the - enclosing `JwkPublicKey` class name. - """ - schema = { - "$defs": { - "jwk_public_key": { - "type": "object", - "required": ["kid", "kty"], - "properties": { - "kid": {"type": "string"}, - "kty": {"type": "string"}, - "crv": {"type": "string"}, - "alg": {"type": "string"}, - }, - "allOf": [ - { - "title": "P-256 pairs with ES256", - "if": { - "properties": {"crv": {"const": "P-256"}}, - "required": ["crv"], - }, - "then": {"properties": {"alg": {"const": "ES256"}}}, - } - ], - } - } - } - with tempfile.TemporaryDirectory() as tmp: - Path(tmp, "profile.json").write_text(json.dumps(schema)) - found = postprocess_models.find_conditional_bounds(Path(tmp)) - self.assertEqual( - found, - { - "JwkPublicKey": [ - { - "discriminator": "crv", - "values": ["P-256"], - "bounds": {"alg": {"const": "ES256"}}, - } - ] - }, - ) - - def test_injection_is_idempotent(self): - once = postprocess_models.inject_conditional_bounds( - self.MODULE, "Total", self.RULES - ) - twice = postprocess_models.inject_conditional_bounds( - once, "Total", self.RULES - ) - self.assertEqual(once, twice) - - def test_injected_validator_enforces_const_pinning(self): - module = ( - "from __future__ import annotations\n" - "\n" - "from pydantic import BaseModel, ConfigDict\n" - "\n" - "\n" - "class Unit(BaseModel):\n" - ' model_config = ConfigDict(extra="allow")\n' - " unit: str\n" - " scale: int | None = 0\n" - ) - rules = [ - { - "discriminator": "unit", - "values": ["C62"], - "bounds": {"scale": {"const": 0}}, - } - ] - out = postprocess_models.inject_conditional_bounds( - module, "Unit", rules - ) - namespace: dict = {} - exec(compile(out, "", "exec"), namespace) # noqa: S102 - unit = namespace["Unit"] - with self.assertRaises(ValidationError): - unit(unit="C62", scale=5) - unit(unit="C62", scale=0) - unit(unit="C62") - # A unit outside the pinned vocabulary is unconstrained. - unit(unit="KGM", scale=3) - - def test_injected_validator_enforces_conditional_bounds(self): - out = postprocess_models.inject_conditional_bounds( - self.MODULE, "Total", self.RULES - ) - namespace: dict = {} - exec(compile(out, "", "exec"), namespace) # noqa: S102 - total = namespace["Total"] - with self.assertRaises(ValidationError): - total(type="discount", amount=500) - with self.assertRaises(ValidationError): - total(type="tax", amount=-1) - total(type="discount", amount=-500) - total(type="tax", amount=0) - # A type carrying no rule is unconstrained (the vocabulary is open). - total(type="total", amount=-5) - - -class ConditionalArrayRetypingInjectorTest(unittest.TestCase): - """A discriminator retyping an array property's items to a different - referenced schema file is a third if/then shape, distinct from - conditional-required and conditional-bounds above. This mirrors - fulfillment_method.json: the base `destinations` property is typed via - `items.$ref` to fulfillment_destination.json, but a `shipping` method's - destinations should really be shipping_destination.json items (postal - address fields, `type` const `shipping_address`) and a `pickup` - method's should really be location_destination.json items (`type` - const `business_location`). The generator drops both retyping branches - entirely -- no scanner in this module ever looked for this shape. - """ - - MODULE = ( - "from __future__ import annotations\n" - "\n" - "from pydantic import BaseModel, ConfigDict\n" - "\n" - "from . import destination\n" - "\n" - "\n" - "class Method(BaseModel):\n" - ' model_config = ConfigDict(extra="allow")\n' - " type: str\n" - " destinations: list[destination.Destination] | None = None\n" - ) - RULES = [ - { - "discriminator": "type", - "values": ["shipping"], - "field": "destinations", - "required": ["id", "type"], - "consts": {"type": "shipping_address"}, - }, - { - "discriminator": "type", - "values": ["pickup"], - "field": "destinations", - "required": ["type"], - "consts": {"type": "business_location"}, - }, - ] - - def _schema(self): - return { - "title": "Method", - "type": "object", - "properties": { - "type": {"type": "string"}, - "destinations": { - "type": "array", - "items": {"$ref": "destination.json"}, - }, - }, - "allOf": [ - { - "if": { - "properties": {"type": {"const": "shipping"}}, - "required": ["type"], - }, - "then": { - "properties": { - "destinations": { - "type": "array", - "items": {"$ref": "shipping_destination.json"}, - } - } - }, - }, - { - "if": { - "properties": {"type": {"const": "pickup"}}, - "required": ["type"], - }, - "then": { - "properties": { - "destinations": { - "type": "array", - "items": {"$ref": "location_destination.json"}, - } - } - }, - }, - ], - } - - def _write_schema_tree(self, tmp): - Path(tmp, "method.json").write_text(json.dumps(self._schema())) - Path(tmp, "destination.json").write_text( - json.dumps( - { - "title": "Destination", - "type": "object", - "required": ["id", "type"], - "properties": { - "id": {"type": "string"}, - "type": {"type": "string"}, - }, - } - ) - ) - Path(tmp, "shipping_destination.json").write_text( - json.dumps( - { - "title": "Shipping Destination", - "type": "object", - "required": ["id", "type"], - "properties": { - "id": {"type": "string"}, - "type": {"type": "string", "const": "shipping_address"}, - }, - "allOf": [{"$ref": "postal_address.json"}], - } - ) - ) - Path(tmp, "location_destination.json").write_text( - json.dumps( - { - "title": "Business Location Destination", - "type": "object", - "required": ["type"], - "properties": { - "type": {"type": "string", "const": "business_location"} - }, - "allOf": [{"$ref": "location_summary.json"}], - } - ) - ) - - def test_schema_scan_reads_both_retyping_branches(self): - with tempfile.TemporaryDirectory() as tmp: - self._write_schema_tree(tmp) - found = postprocess_models.find_conditional_array_retyping( - Path(tmp) - ) - self.assertEqual(found, {"Method": self.RULES}) - - def test_schema_scan_ignores_branch_matching_the_base_ref(self): - # A then.properties..items.$ref identical to the base ref is - # not a retype -- nothing to approximate. The sibling pickup branch - # (still a genuine retype) is unaffected. - schema = self._schema() - schema["allOf"][0]["then"]["properties"]["destinations"]["items"][ - "$ref" - ] = "destination.json" - with tempfile.TemporaryDirectory() as tmp: - self._write_schema_tree(tmp) - Path(tmp, "method.json").write_text(json.dumps(schema)) - found = postprocess_models.find_conditional_array_retyping( - Path(tmp) - ) - self.assertEqual(found, {"Method": [self.RULES[1]]}) - - def test_schema_scan_skips_rule_whose_field_was_stripped(self): - # A request variant that omits `destinations` entirely (as - # fulfillment_method_create_request.json does) makes the rule - # inapplicable, not malformed -- no warning, and no rule recorded - # for the variant's own class. - schema = self._schema() - schema["title"] = "Method Create Request" - del schema["properties"]["destinations"] - with tempfile.TemporaryDirectory() as tmp: - self._write_schema_tree(tmp) - Path(tmp, "method_create_request.json").write_text( - json.dumps(schema) - ) - stderr = io.StringIO() - with contextlib.redirect_stderr(stderr): - found = postprocess_models.find_conditional_array_retyping( - Path(tmp) - ) - self.assertNotIn("MethodCreateRequest", found) - self.assertEqual(found, {"Method": self.RULES}) - self.assertNotIn("unsupported", stderr.getvalue()) - - def test_schema_scan_warns_when_retyped_ref_cannot_be_loaded(self): - schema = self._schema() - with tempfile.TemporaryDirectory() as tmp: - Path(tmp, "method.json").write_text(json.dumps(schema)) - Path(tmp, "destination.json").write_text( - json.dumps({"title": "Destination", "type": "object"}) - ) - # shipping_destination.json / location_destination.json are - # deliberately absent. - stderr = io.StringIO() - with contextlib.redirect_stderr(stderr): - found = postprocess_models.find_conditional_array_retyping( - Path(tmp) - ) - self.assertEqual(found, {}) - self.assertIn("could not be loaded", stderr.getvalue()) - - def test_injection_is_idempotent(self): - once = postprocess_models.inject_conditional_array_retyping( - self.MODULE, "Method", self.RULES - ) - twice = postprocess_models.inject_conditional_array_retyping( - once, "Method", self.RULES - ) - self.assertEqual(once, twice) - - def test_injected_validator_enforces_retyping(self): - module = ( - "from __future__ import annotations\n" - "\n" - "from pydantic import BaseModel, ConfigDict\n" - "\n" - "\n" - "class Destination(BaseModel):\n" - ' model_config = ConfigDict(extra="allow")\n' - " type: str\n" - " id: str\n" - "\n" - "\n" - "class Method(BaseModel):\n" - ' model_config = ConfigDict(extra="allow")\n' - " type: str\n" - " destinations: list[Destination] | None = None\n" - ) - out = postprocess_models.inject_conditional_array_retyping( - module, "Method", self.RULES - ) - namespace: dict = {} - exec(compile(out, "", "exec"), namespace) # noqa: S102 - # Forward reference (from __future__ import annotations): Method's - # "destinations: list[Destination]" annotation resolves once both - # classes exist in the exec'd namespace. - namespace["Method"].model_rebuild(_types_namespace=namespace) - method_cls = namespace["Method"] - destination_cls = namespace["Destination"] - with self.assertRaises(ValidationError): - method_cls( - type="shipping", - destinations=[ - destination_cls(type="business_location", id="d1") - ], - ) - with self.assertRaises(ValidationError): - method_cls( - type="pickup", - destinations=[ - destination_cls(type="shipping_address", id="d1") - ], - ) - method_cls( - type="shipping", - destinations=[destination_cls(type="shipping_address", id="d1")], - ) - method_cls( - type="pickup", - destinations=[destination_cls(type="business_location", id="d1")], - ) - # A type carrying no rule is unconstrained (open vocabulary). - method_cls( - type="courier", - destinations=[destination_cls(type="anything", id="d1")], - ) - # No destinations at all is unconstrained regardless of type. - method_cls(type="shipping") - - def test_patch_skips_reified_type_alias_union(self): - union_source = ( - "from __future__ import annotations\n\n" - "from typing import Annotated\n" - "from typing_extensions import TypeAliasType\n\n" - 'Method = TypeAliasType("Method", Annotated[str, None])\n' - ) - with tempfile.TemporaryDirectory() as tmp: - schema_dir = Path(tmp) / "schemas" - out_dir = Path(tmp) / "out" - schema_dir.mkdir() - out_dir.mkdir() - self._write_schema_tree(schema_dir) - out_file = out_dir / "__init__.py" - out_file.write_text(union_source, encoding="utf-8") - old_schema_dir = postprocess_models.SCHEMA_DIR - old_out_dir = postprocess_models.OUTPUT_DIR - try: - postprocess_models.SCHEMA_DIR = schema_dir - postprocess_models.OUTPUT_DIR = out_dir - with contextlib.redirect_stdout(io.StringIO()): - patched, rc = ( - postprocess_models._patch_conditional_array_retyping() - ) - finally: - postprocess_models.SCHEMA_DIR = old_schema_dir - postprocess_models.OUTPUT_DIR = old_out_dir - self.assertEqual(patched, 0) - self.assertEqual(rc, 0) - self.assertEqual(out_file.read_text(encoding="utf-8"), union_source) - - -class OpenUnionNotEnumInjectorTest(unittest.TestCase): - """Tests for open-union Base not.enum discriminator enforcement.""" - - SOURCE = ( - "from __future__ import annotations\n\n" - "from typing import Annotated, Literal\n" - "from pydantic import BaseModel, ConfigDict, Field\n" - "from typing_extensions import TypeAliasType\n\n\n" - "class ShippingDestination(BaseModel):\n" - ' model_config = ConfigDict(extra="allow")\n' - ' type: Literal["shipping_address"]\n' - " id: str\n\n\n" - "class LocationDestination(BaseModel):\n" - ' model_config = ConfigDict(extra="allow")\n' - ' type: Literal["business_location"]\n' - " id: str\n\n\n" - "class FulfillmentDestinationBase(BaseModel):\n" - ' model_config = ConfigDict(extra="allow")\n' - " type: str\n" - " id: str\n\n\n" - "FulfillmentDestination = TypeAliasType(\n" - ' "FulfillmentDestination",\n' - " Annotated[\n" - " LocationDestination | ShippingDestination | FulfillmentDestinationBase,\n" - ' Field(..., title="FulfillmentDestination"),\n' - " ],\n" - ")\n" - ) - - def test_discovers_open_union_base_exclusions(self): - exclusions = postprocess_models._find_open_union_base_exclusions( - self.SOURCE - ) - self.assertEqual( - exclusions, - { - "FulfillmentDestinationBase": { - "field": "type", - "excluded": ["business_location", "shipping_address"], - } - }, - ) - - def test_injection_is_idempotent(self): - once = postprocess_models.inject_open_union_not_enum( - self.SOURCE, - "FulfillmentDestinationBase", - "type", - ["business_location", "shipping_address"], - ) - twice = postprocess_models.inject_open_union_not_enum( - once, - "FulfillmentDestinationBase", - "type", - ["business_location", "shipping_address"], - ) - self.assertEqual(once, twice) - self.assertIn("def _enforce_not_enum(", once) - - -class DependentRequiredInjectorTest(unittest.TestCase): - """The dependentRequired post-generation injector's behavior.""" - - SCHEMA = { - "title": "Time Interval", - "type": "object", - "properties": { - "opens": {"type": "string"}, - "closes": {"type": "string"}, - }, - "dependentRequired": { - "opens": ["closes"], - "closes": ["opens"], - }, - } - - MODULE = ( - "from __future__ import annotations\n" - "\n" - "from pydantic import BaseModel, ConfigDict\n" - "\n" - "\n" - "class TimeInterval(BaseModel):\n" - ' model_config = ConfigDict(extra="allow")\n' - " opens: str | None = None\n" - " closes: str | None = None\n" - ) - - def test_schema_scan_finds_root_rules(self): - with tempfile.TemporaryDirectory() as tmp: - Path(tmp, "time_interval.json").write_text( - json.dumps(self.SCHEMA), encoding="utf-8" - ) - found = postprocess_models.find_root_dependent_required(Path(tmp)) - self.assertEqual( - found, - { - "TimeInterval": { - "opens": ["closes"], - "closes": ["opens"], - } - }, - ) - - def test_schema_scan_filters_rules_outside_declared_properties(self): - schema = copy.deepcopy(self.SCHEMA) - schema["dependentRequired"]["opens"] = ["timezone"] - with tempfile.TemporaryDirectory() as tmp: - Path(tmp, "time_interval.json").write_text( - json.dumps(schema), encoding="utf-8" - ) - found = postprocess_models.find_root_dependent_required(Path(tmp)) - self.assertEqual(found, {"TimeInterval": {"closes": ["opens"]}}) - - def test_injection_is_idempotent(self): - rules = {"opens": ["closes"], "closes": ["opens"]} - once = postprocess_models.inject_dependent_required( - self.MODULE, "TimeInterval", rules - ) - twice = postprocess_models.inject_dependent_required( - once, "TimeInterval", rules - ) - self.assertEqual(once, twice) - - def test_injection_skips_rules_for_projected_out_fields(self): - projected = self.MODULE.replace(" closes: str | None = None\n", "") - out = postprocess_models.inject_dependent_required( - projected, - "TimeInterval", - {"opens": ["closes"], "closes": ["opens"]}, - ) - self.assertEqual(out, projected) - - def test_injected_validator_uses_property_presence(self): - rules = {"opens": ["closes"], "closes": ["opens"]} - out = postprocess_models.inject_dependent_required( - self.MODULE, "TimeInterval", rules - ) - namespace: dict = {} - exec(compile(out, "", "exec"), namespace) # noqa: S102 - interval = namespace["TimeInterval"] - - interval() - interval(opens="09:00", closes="17:00") - interval(opens=None, closes=None) - with self.assertRaisesRegex(ValidationError, "dependentRequired"): - interval(opens="09:00") - with self.assertRaisesRegex(ValidationError, "dependentRequired"): - interval(closes="17:00") - with self.assertRaisesRegex(ValidationError, "dependentRequired"): - interval(opens=None) - - -class InjectorTest(unittest.TestCase): - """The post-generation injector's own behavior.""" - - SCHEMA = { - "title": "Sample", - "type": "object", - "minProperties": 2, - "properties": {"a": {"type": "string"}, "b": {"type": "string"}}, - } - - MODULE = ( - "from __future__ import annotations\n" - "\n" - "from pydantic import BaseModel, ConfigDict\n" - "\n" - "\n" - "class Sample(BaseModel):\n" - ' """A sample."""\n' - "\n" - " model_config = ConfigDict(\n" - ' extra="allow",\n' - " )\n" - " a: str | None = None\n" - " b: str | None = None\n" - ) - - def test_injects_validator_with_declared_minimum(self): - out = postprocess_models.inject_min_properties(self.MODULE, "Sample", 2) - self.assertIn("model_validator", out) - self.assertIn("at least 2", out.lower()) - - def test_injected_validator_enforces_count(self): - out = postprocess_models.inject_min_properties(self.MODULE, "Sample", 2) - namespace: dict = {} - exec(compile(out, "", "exec"), namespace) # noqa: S102 - sample_cls = namespace["Sample"] - with self.assertRaises(ValidationError): - sample_cls(a="only-one") - sample_cls(a="one", b="two") - - def test_injection_is_idempotent(self): - once = postprocess_models.inject_min_properties( - self.MODULE, "Sample", 2 - ) - twice = postprocess_models.inject_min_properties(once, "Sample", 2) - self.assertEqual(once, twice) - - def test_schema_scan_finds_root_constraints(self): - with tempfile.TemporaryDirectory() as tmp: - sub = Path(tmp) / "sub" - sub.mkdir() - (sub / "sample.json").write_text(json.dumps(self.SCHEMA)) - (sub / "plain.json").write_text( - json.dumps( - {"title": "Plain", "type": "object", "properties": {}} - ) - ) - found = postprocess_models.find_root_min_properties(Path(tmp)) - self.assertEqual(found, {"Sample": 2}) - - -class MaxPropertiesInjectorTest(unittest.TestCase): - """maxProperties is the symmetric twin of minProperties (see #49/#55), - but only minProperties was ever scanned: find_root_min_properties reads - schema.get("minProperties") and there is no find_root_max_properties at - all, so location_serves.json's maxProperties: 1 -- "the Platform MUST - supply exactly one target form" -- is silently dropped. This mirrors - InjectorTest above one for one, for the max side. - """ - - SCHEMA = { - "title": "Sample", - "type": "object", - "maxProperties": 1, - "properties": {"a": {"type": "string"}, "b": {"type": "string"}}, - } - - MODULE = ( - "from __future__ import annotations\n" - "\n" - "from pydantic import BaseModel, ConfigDict\n" - "\n" - "\n" - "class Sample(BaseModel):\n" - ' """A sample."""\n' - "\n" - " model_config = ConfigDict(\n" - ' extra="allow",\n' - " )\n" - " a: str | None = None\n" - " b: str | None = None\n" - ) - - def test_injects_validator_with_declared_maximum(self): - out = postprocess_models.inject_max_properties(self.MODULE, "Sample", 1) - self.assertIn("model_validator", out) - self.assertIn("at most 1", out.lower()) - - def test_injected_validator_enforces_count(self): - out = postprocess_models.inject_max_properties(self.MODULE, "Sample", 1) - namespace: dict = {} - exec(compile(out, "", "exec"), namespace) # noqa: S102 - sample_cls = namespace["Sample"] - with self.assertRaises(ValidationError): - sample_cls(a="one", b="two") - sample_cls(a="only-one") - sample_cls() - - def test_injection_is_idempotent(self): - once = postprocess_models.inject_max_properties( - self.MODULE, "Sample", 1 - ) - twice = postprocess_models.inject_max_properties(once, "Sample", 1) - self.assertEqual(once, twice) - - def test_schema_scan_finds_root_constraints(self): - with tempfile.TemporaryDirectory() as tmp: - sub = Path(tmp) / "sub" - sub.mkdir() - (sub / "sample.json").write_text(json.dumps(self.SCHEMA)) - (sub / "plain.json").write_text( - json.dumps( - {"title": "Plain", "type": "object", "properties": {}} - ) - ) - found = postprocess_models.find_root_max_properties(Path(tmp)) - self.assertEqual(found, {"Sample": 1}) - - def test_schema_scan_ignores_object_without_declared_properties(self): - # Mirrors find_root_min_properties: maxProperties on a free-form - # object property (no named properties) is already handled natively - # by the generator (Field(max_length=...) on the dict field), so a - # bare maxProperties with no properties is out of scope here. - schema = { - "title": "OpenMap", - "type": "object", - "maxProperties": 3, - } - with tempfile.TemporaryDirectory() as tmp: - Path(tmp, "open_map.json").write_text(json.dumps(schema)) - found = postprocess_models.find_root_max_properties(Path(tmp)) - self.assertEqual(found, {}) - - def test_both_bounds_coexist_on_the_same_class(self): - """location_serves.json declares both minProperties: 1 AND - maxProperties: 1 on the same object; both validators must be - injectable into the same class without clobbering each other.""" - module = postprocess_models.inject_min_properties( - self.MODULE, "Sample", 1 - ) - module = postprocess_models.inject_max_properties(module, "Sample", 1) - self.assertIn("_enforce_min_properties", module) - self.assertIn("_enforce_max_properties", module) - namespace: dict = {} - exec(compile(module, "", "exec"), namespace) # noqa: S102 - sample_cls = namespace["Sample"] - with self.assertRaises(ValidationError): - sample_cls() - with self.assertRaises(ValidationError): - sample_cls(a="one", b="two") - sample_cls(a="only-one") - - -class LocationServesMaxPropertiesSemanticTest(unittest.TestCase): - """location_serves.json: "The Platform MUST supply exactly one target - form" -- minProperties: 1 AND maxProperties: 1 together. Only the - minimum was ever enforced (see MaxPropertiesInjectorTest above), so a - map naming both point and address currently validates in violation of - the schema. - """ - - def _location_serves(self): - from ucp_sdk.models import LocationServes - - return LocationServes - - def _geo(self): - from ucp_sdk.models import Geo - - return Geo - - def _address(self): - from ucp_sdk.models import LocationServesAddressCountry - - return LocationServesAddressCountry - - def test_both_point_and_address_rejected(self): - with self.assertRaises(ValidationError): - self._location_serves()( - point=self._geo()(latitude=1.0, longitude=2.0), - address=self._address()(address_country="US"), - ) - - def test_point_only_accepted(self): - location = self._location_serves()( - point=self._geo()(latitude=1.0, longitude=2.0) - ) - self.assertIsNotNone(location.point) - - def test_address_only_accepted(self): - location = self._location_serves()( - address=self._address()(address_country="US") - ) - self.assertIsNotNone(location.address) - - def test_empty_still_rejected_by_the_existing_minimum(self): - # Unaffected by this fix; confirms minProperties: 1 still holds. - with self.assertRaises(ValidationError): - self._location_serves()() - - def test_extension_key_alongside_point_rejected(self): - # extra="allow": an extension form key still counts toward the - # maxProperties=1 total per JSON Schema's key-counting semantics. - with self.assertRaises(ValidationError): - self._location_serves().model_validate( - { - "point": {"latitude": 1.0, "longitude": 2.0}, - "dev.example.custom_target": {"foo": "bar"}, - } - ) - - -class TotalsContainsTest(unittest.TestCase): - """totals.json requires exactly one ``subtotal`` AND one ``total`` entry. - - Both rules live as two ``allOf`` ``contains`` branches; the generator drops - them, leaving ``Totals`` a bare ``list[TotalsItem]``. The post-generation - injector reads the pristine schema and restores BOTH bounds as an - ``AfterValidator`` on the alias. - """ - - SUBTOTAL = {"type": "subtotal", "amount": 100, "display_text": "Subtotal"} - TOTAL = {"type": "total", "amount": 100, "display_text": "Total"} - - #: (name, array, expected-valid?) exercised against every totals model. - def _cases(self): - return [ - ("empty", [], False), - ("two_total_no_subtotal", [self.TOTAL, self.TOTAL], False), - ("two_subtotal_no_total", [self.SUBTOTAL, self.SUBTOTAL], False), - ("subtotal_only", [self.SUBTOTAL], False), - ("total_only", [self.TOTAL], False), - ("valid_subtotal_and_total", [self.SUBTOTAL, self.TOTAL], True), - ] - - def _assert_matrix(self, alias): - adapter = TypeAdapter(alias) - for name, array, valid in self._cases(): - with self.subTest(model=alias.__name__, case=name): - if valid: - self.assertEqual(len(adapter.validate_python(array)), 2) - else: - with self.assertRaises(ValidationError): - adapter.validate_python(array) - - def test_base_totals_enforces_both_bounds(self): - self._assert_matrix(Totals) - - def test_phantom_request_slices_not_emitted(self): - import ucp_sdk.models as models_mod - - for name in ( - "TotalCreateRequest", - "TotalUpdateRequest", - "TotalsCreateRequest", - "TotalsUpdateRequest", - "OrderCreateRequest", - "OrderUpdateRequest", - ): - self.assertFalse( - hasattr(models_mod, name), - f"{name} should not be emitted for response-only/unannotated schemas", - ) - - def test_totals_item_enforces_conditional_bounds(self): - base = [self.SUBTOTAL, self.TOTAL] - adapter = TypeAdapter(Totals) - with self.assertRaises(ValidationError): - adapter.validate_python(base + [{"type": "discount", "amount": 50}]) - with self.assertRaises(ValidationError): - adapter.validate_python(base + [{"type": "tax", "amount": -10}]) - adapter.validate_python(base + [{"type": "discount", "amount": -50}]) - adapter.validate_python(base + [{"type": "tax", "amount": 10}]) - - def test_custom_type_requires_display_text(self): - base = [self.SUBTOTAL, self.TOTAL] - adapter = TypeAdapter(Totals) - with self.assertRaisesRegex(ValidationError, "display_text"): - adapter.validate_python(base + [{"type": "surcharge", "amount": 5}]) - adapter.validate_python(base + [{"type": "tax", "amount": 5}]) - adapter.validate_python( - base - + [ - { - "type": "surcharge", - "amount": 5, - "display_text": "Surcharge", - } - ] - ) - - def test_missing_total_names_the_total_rule(self): - # A subtotal-only array must fail specifically on the total rule. - with self.assertRaisesRegex(ValidationError, "total"): - TypeAdapter(Totals).validate_python([self.SUBTOTAL]) - - -class ArrayContainsInjectorTest(unittest.TestCase): - """The array-contains injector's own behavior.""" - - MODULE = ( - "from __future__ import annotations\n" - "\n" - "from typing import Annotated\n" - "\n" - "from pydantic import BaseModel, ConfigDict, Field\n" - "from typing_extensions import TypeAliasType\n" - "\n" - "\n" - "class Total(BaseModel):\n" - ' model_config = ConfigDict(extra="allow")\n' - " type: str\n" - " amount: int\n" - "\n" - "\n" - "Totals = TypeAliasType(\n" - ' "Totals", Annotated[list[Total], Field(..., title="Totals")]\n' - ")\n" - ) - - #: The same alias, but formatted the way ruff/black renders it once the - #: item type name is long enough to force line-wrapping (e.g. once a - #: request-variant $ref like ``total_create_request.TotalCreateRequest`` - #: replaces the short ``Total`` reference). The trailing comma after - #: ``Field(...)`` before the closing ``]`` is the shape that matters here. - MODULE_LINE_WRAPPED = ( - "from __future__ import annotations\n" - "\n" - "from typing import Annotated\n" - "\n" - "from pydantic import Field\n" - "from typing_extensions import TypeAliasType\n" - "\n" - "from . import total_create_request\n" - "\n" - "\n" - "TotalsCreateRequest = TypeAliasType(\n" - ' "TotalsCreateRequest",\n' - " Annotated[\n" - " list[total_create_request.TotalCreateRequest],\n" - ' Field(..., title="Totals Create Request"),\n' - " ],\n" - ")\n" - ) - - #: subtotal AND total, mirroring the real totals.json. - GROUPS = [ - {"pairs": [("type", "subtotal")], "min": 1, "max": 1}, - {"pairs": [("type", "total")], "min": 1, "max": 1}, - ] - - ITEM_CONDITION = { - "field": "type", - "excluded": ["subtotal", "total"], - "required": ["display_text"], - } - - def test_scan_reads_both_contains_from_allof_branches(self): - # The pristine totals.json shape: two allOf contains branches. - schema = { - "title": "Totals", - "type": "array", - "items": { - "allOf": [ - { - "if": { - "properties": { - "type": {"not": {"enum": ["subtotal", "total"]}} - }, - "required": ["type"], - }, - "then": {"required": ["display_text"]}, - } - ] - }, - "allOf": [ - { - "contains": {"properties": {"type": {"const": "subtotal"}}}, - "minContains": 1, - "maxContains": 1, - }, - { - "contains": {"properties": {"type": {"const": "total"}}}, - "minContains": 1, - "maxContains": 1, - }, - ], - } - with tempfile.TemporaryDirectory() as tmp: - (Path(tmp) / "totals.json").write_text(json.dumps(schema)) - found = postprocess_models.find_array_contains_constraints( - Path(tmp) - ) - self.assertEqual(set(found), {"totals"}) - self.assertEqual(found["totals"]["title"], "Totals") - self.assertEqual( - [g["pairs"] for g in found["totals"]["groups"]], - [[("type", "subtotal")], [("type", "total")]], - ) - self.assertEqual( - found["totals"]["item_condition"], - self.ITEM_CONDITION, - ) - - def test_scan_reads_root_level_single_contains(self): - # A root-level (non-allOf) contains still yields one group. - schema = { - "title": "Totals", - "type": "array", - "items": {"type": "object"}, - "contains": {"properties": {"type": {"const": "subtotal"}}}, - "minContains": 1, - "maxContains": 1, - } - with tempfile.TemporaryDirectory() as tmp: - (Path(tmp) / "totals.json").write_text(json.dumps(schema)) - found = postprocess_models.find_array_contains_constraints( - Path(tmp) - ) - self.assertEqual( - found["totals"]["groups"], - [{"pairs": [("type", "subtotal")], "min": 1, "max": 1}], - ) - - def test_scan_ignores_non_array_and_predicateless_contains(self): - with tempfile.TemporaryDirectory() as tmp: - # An object schema (not an array) is out of scope. - (Path(tmp) / "obj.json").write_text( - json.dumps({"title": "Obj", "type": "object"}) - ) - # A contains with no derivable const predicate is skipped. - (Path(tmp) / "arr.json").write_text( - json.dumps( - { - "title": "Arr", - "type": "array", - "contains": {"required": ["type"]}, - } - ) - ) - with contextlib.redirect_stderr(io.StringIO()): - found = postprocess_models.find_array_contains_constraints( - Path(tmp) - ) - self.assertEqual(found, {}) - - def test_injects_after_validator_and_import(self): - out = postprocess_models.inject_array_contains( - self.MODULE, "Totals", self.GROUPS - ) - self.assertIn("AfterValidator(_enforce_contains_totals)", out) - self.assertRegex(out, r"from pydantic import .*AfterValidator") - # Both predicates are present in the injected function (pre-format - # output uses repr() single quotes; ruff restyles them later). - self.assertIn("== 'subtotal'", out) - self.assertIn("== 'total'", out) - - def test_injection_is_idempotent(self): - once = postprocess_models.inject_array_contains( - self.MODULE, "Totals", self.GROUPS - ) - twice = postprocess_models.inject_array_contains( - once, "Totals", self.GROUPS - ) - self.assertEqual(once, twice) - - def test_injects_cleanly_when_annotated_is_line_wrapped(self): - """A line-wrapped Annotated[...] with a trailing comma before the - closing bracket must still parse (see #34/#35: a longer item-type - reference, such as a request-variant $ref, pushes the formatter to - wrap the annotation onto multiple lines with a trailing comma; a - naive "insert before the closing bracket" splice then lands after - that comma and produces "Field(...),\\n, AfterValidator(...)]" - - two commas with nothing between them, a SyntaxError). - """ - out = postprocess_models.inject_array_contains( - self.MODULE_LINE_WRAPPED, "TotalsCreateRequest", self.GROUPS - ) - - # The regression: this must be syntactically valid Python. - ast.parse(out) - - self.assertIn( - "AfterValidator(_enforce_contains_totals_create_request)", out - ) - # No orphaned comma left behind by the splice. - self.assertNotRegex(out, r",\s*,") - - def test_injected_validator_enforces_both_bounds(self): - out = postprocess_models.inject_array_contains( - self.MODULE, "Totals", self.GROUPS - ) - namespace: dict = {} - exec(compile(out, "", "exec"), namespace) # noqa: S102 - adapter = TypeAdapter(namespace["Totals"]) - sub = {"type": "subtotal", "amount": 1} - tot = {"type": "total", "amount": 1} - for bad in ([], [sub], [tot], [sub, sub], [tot, tot]): - with self.assertRaises(ValidationError): - adapter.validate_python(bad) - adapter.validate_python([sub, tot]) - - def test_injected_validator_requires_custom_display_text(self): - out = postprocess_models.inject_array_contains( - self.MODULE, "Totals", self.GROUPS, self.ITEM_CONDITION - ) - namespace: dict = {} - exec(compile(out, "", "exec"), namespace) # noqa: S102 - adapter = TypeAdapter(namespace["Totals"]) - base = [ - {"type": "subtotal", "amount": 1}, - {"type": "total", "amount": 1}, - ] - with self.assertRaisesRegex(ValidationError, "display_text"): - adapter.validate_python(base + [{"type": "surcharge", "amount": 1}]) - adapter.validate_python( - base - + [ - { - "type": "surcharge", - "amount": 1, - "display_text": "Surcharge", - } - ] - ) - - -class UniqueItemsInjectorTest(unittest.TestCase): - """The uniqueItems post-generation injector's own behavior.""" - - SCHEMA_TREE = { - "title": "First", - "properties": { - "tags": { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": True, - }, - "label": {"type": "array", "items": {"type": "string"}}, - "name": {"type": "string"}, - "nested": { - "type": "object", - "properties": { - "codes": { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": True, - } - }, - }, - }, - } - - MODULE = ( - "from __future__ import annotations\n" - "\n" - "from pydantic import BaseModel, ConfigDict\n" - "\n" - "\n" - "class First(BaseModel):\n" - ' """First."""\n' - "\n" - " model_config = ConfigDict(\n" - ' extra="allow",\n' - " )\n" - " tags: list[str] | None = None\n" - " name: str | None = None\n" - "\n" - "\n" - "class Second(BaseModel):\n" - ' """Second."""\n' - "\n" - " model_config = ConfigDict(\n" - ' extra="allow",\n' - " )\n" - " tags: list[str] | None = None\n" - " count: list[int] | None = None\n" - ) - - def test_find_unique_items_fields_walks_nested_properties(self) -> None: - """Root and nested array props with uniqueItems are collected.""" - with tempfile.TemporaryDirectory() as tmp: - (Path(tmp) / "schema.json").write_text(json.dumps(self.SCHEMA_TREE)) - fields = postprocess_models.find_unique_items_fields(Path(tmp)) - self.assertEqual(fields, {"First": {"tags"}, "Nested": {"codes"}}) - - def test_find_unique_items_fields_ignores_false_and_non_arrays( - self, - ) -> None: - """uniqueItems: false and non-array props do not qualify.""" - schema = { - "properties": { - "a": { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": False, - }, - "b": {"type": "string", "uniqueItems": True}, - } - } - with tempfile.TemporaryDirectory() as tmp: - (Path(tmp) / "s.json").write_text(json.dumps(schema)) - fields = postprocess_models.find_unique_items_fields(Path(tmp)) - self.assertEqual(fields, {}) - - def test_inject_targets_matching_list_fields_only(self) -> None: - """Only the declaring class's matching list field gets a validator.""" - out = postprocess_models.inject_unique_items( - self.MODULE, {"First": {"tags"}} - ) - self.assertIn("field_validator", out) - self.assertIn("_enforce_unique_items_tags", out) - self.assertEqual(out.count("def _enforce_unique_items_tags("), 1) - self.assertNotIn("_enforce_unique_items_name", out) - self.assertNotIn("_enforce_unique_items_count", out) - - def test_inject_no_match_leaves_source_unchanged(self) -> None: - """No matching list field means the module is untouched.""" - self.assertEqual( - postprocess_models.inject_unique_items( - self.MODULE, {"First": {"missing"}} - ), - self.MODULE, - ) - - def test_injection_is_idempotent(self) -> None: - """Re-running the injector changes nothing.""" - unique_fields = {"First": {"tags"}} - once = postprocess_models.inject_unique_items( - self.MODULE, unique_fields - ) - twice = postprocess_models.inject_unique_items(once, unique_fields) - self.assertEqual(once, twice) - - def test_injected_validator_rejects_duplicates(self) -> None: - """The injected field_validator enforces uniqueness at runtime.""" - out = postprocess_models.inject_unique_items( - self.MODULE, {"First": {"tags"}} - ) - namespace: dict = {} - exec(compile(out, "", "exec"), namespace) # noqa: S102 - first = namespace["First"] - first(tags=["a", "b"]) # unique passes - first() # None passes - with self.assertRaises(ValidationError): - first(tags=["a", "a"]) # duplicate rejected - - -class UniqueItemsSemanticTest(unittest.TestCase): - """Committed models enforce uniqueItems on declared array fields.""" - - def test_context_eligibility_rejects_duplicates(self) -> None: - """Context.eligibility rejects duplicate reverse-domain entries.""" - from ucp_sdk.models import Context - - with self.assertRaisesRegex(ValidationError, "[Uu]nique"): - Context( - eligibility=[ - "com.example.loyalty_gold", - "com.example.loyalty_gold", - ] - ) - - def test_context_eligibility_accepts_unique_and_none(self) -> None: - """Unique lists and missing values are accepted.""" - from ucp_sdk.models import Context - - ctx = Context( - eligibility=["com.example.loyalty_gold", "org.school.student"] - ) - self.assertEqual( - ctx.eligibility, - ["com.example.loyalty_gold", "org.school.student"], - ) - self.assertIsNone(Context().eligibility) - - def test_constraint_expression_required_rejects_duplicates(self) -> None: - """ConstraintExpression.required rejects duplicate property names.""" - from ucp_sdk.models import ConstraintExpression - - with self.assertRaisesRegex(ValidationError, "[Uu]nique"): - ConstraintExpression(required=["id", "id"]) - - -class AdditionalPropertiesForbidFinderTest(unittest.TestCase): - """additionalProperties:false objects map to generated class names.""" - - def test_root_titled_object(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - Path(tmp, "error_response.json").write_text( - json.dumps( - { - "title": "Error Response", - "type": "object", - "additionalProperties": False, - "properties": {"messages": {"type": "array"}}, - } - ), - encoding="utf-8", - ) - names = postprocess_models.find_extra_forbid_class_names(Path(tmp)) - self.assertEqual(names, {"ErrorResponse"}) - - def test_nested_untitled_object_uses_property_path(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - Path(tmp, "merchant_fulfillment_config.json").write_text( - json.dumps( - { - "title": "Merchant Fulfillment Config", - "type": "object", - "properties": { - "allows_multi_destination": { - "type": "object", - "additionalProperties": False, - "properties": {"shipping": {"type": "boolean"}}, - } - }, - } - ), - encoding="utf-8", - ) - names = postprocess_models.find_extra_forbid_class_names(Path(tmp)) - self.assertEqual(names, {"AllowsMultiDestination"}) - - def test_loose_and_map_objects_are_excluded(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - Path(tmp, "open.json").write_text( - json.dumps( - { - "title": "Open Object", - "type": "object", - "properties": {"a": {"type": "string"}}, - } - ), - encoding="utf-8", - ) - Path(tmp, "map.json").write_text( - json.dumps( - { - "title": "Map Object", - "type": "object", - "additionalProperties": {"type": "string"}, - "properties": {"a": {"type": "string"}}, - } - ), - encoding="utf-8", - ) - names = postprocess_models.find_extra_forbid_class_names(Path(tmp)) - self.assertEqual(names, set()) - - -class AdditionalPropertiesForbidInjectorTest(unittest.TestCase): - """The injector flips only the target class's model_config to forbid.""" - - SOURCE = '''\ -class AllowsMultiDestination(BaseModel): - """ - Permits multiple destinations per method type. - """ - - model_config = ConfigDict( - extra="allow", - ) - shipping: bool | None = None - - -class MerchantFulfillmentConfig(BaseModel): - """ - Merchant's fulfillment configuration. - """ - - model_config = ConfigDict( - extra="allow", - ) - allows_multi_destination: AllowsMultiDestination | None = None -''' - - def test_flips_only_target_class(self) -> None: - updated = postprocess_models.inject_extra_forbid( - self.SOURCE, "AllowsMultiDestination" - ) - # Target class body now forbids extra keys. - self.assertIn('extra="forbid"', updated) - # The sibling class in the same module keeps extra="allow". - sibling = """class MerchantFulfillmentConfig(BaseModel): - \"\"\" - Merchant's fulfillment configuration. - \"\"\" - - model_config = ConfigDict( - extra="allow", - )""" - self.assertIn(sibling, updated) - - def test_idempotent_after_flip(self) -> None: - once = postprocess_models.inject_extra_forbid( - self.SOURCE, "AllowsMultiDestination" - ) - twice = postprocess_models.inject_extra_forbid( - once, "AllowsMultiDestination" - ) - self.assertEqual(once, twice) - - def test_unknown_class_untouched(self) -> None: - self.assertEqual( - postprocess_models.inject_extra_forbid(self.SOURCE, "Nope"), - self.SOURCE, - ) - - -class AdditionalPropertiesForbidSemanticTest(unittest.TestCase): - """Committed models reject unknown keys on additionalProperties:false.""" - - def test_error_response_rejects_unknown_keys(self) -> None: - from ucp_sdk.models import ErrorResponse - - with self.assertRaises(ValidationError): - ErrorResponse.model_validate( - { - "ucp": {"version": "2026-04-08", "status": "error"}, - "messages": [ - { - "type": "error", - "code": "not_found", - "severity": "unrecoverable", - "content": "boom", - } - ], - "bogus": "x", - } - ) - - def test_error_response_accepts_declared_fields(self) -> None: - from ucp_sdk.models import ErrorResponse - - obj = ErrorResponse.model_validate( - { - "ucp": {"version": "2026-04-08", "status": "error"}, - "messages": [ - { - "type": "error", - "code": "not_found", - "severity": "unrecoverable", - "content": "boom", - } - ], - } - ) - self.assertEqual(obj.messages[0].content, "boom") - - -class EntityVersionValidationSemanticTest(unittest.TestCase): - """Committed entity-derived models enforce version pattern validation.""" - - def test_capability_base_accepts_valid_version(self) -> None: - from ucp_sdk.models import CapabilityBase - - model = CapabilityBase.model_validate( - {"version": "2026-04-08", "id": "test"} - ) - self.assertEqual(model.version, "2026-04-08") - - def test_capability_base_rejects_invalid_version(self) -> None: - from ucp_sdk.models import CapabilityBase - - with self.assertRaises(ValidationError): - CapabilityBase.model_validate( - {"version": "not-a-version", "id": "test"} - ) - - def test_service_base_rejects_invalid_version(self) -> None: - from ucp_sdk.models import ServiceBase - - with self.assertRaises(ValidationError): - ServiceBase.model_validate({"version": "invalid-format"}) - - def test_payment_handler_base_rejects_invalid_version(self) -> None: - from ucp_sdk.models import PaymentHandlerBase - - with self.assertRaises(ValidationError): - PaymentHandlerBase.model_validate({"version": {"not": "a version"}}) - - -class JwkConditionalRulesSemanticTest(unittest.TestCase): - """profile.json's jwk_public_key carries five if/then rules, all five - dropped by the generator today: two conditional-required rules (an EC - key needs crv/x/y, an OKP key needs crv/x) and three conditional - const-pin rules pairing a curve with its algorithm (P-256/ES256, - P-384/ES384, Ed25519/EdDSA). Security-adjacent: a profile publishing an - EC key with no curve, or an algorithm that does not match its curve, - currently passes SDK validation and would only fail (or silently - misverify) downstream at signature-verification time. - """ - - def _jwk(self): - from ucp_sdk.models import JwkPublicKey - - return JwkPublicKey - - def test_ec_key_without_curve_and_coordinates_rejected(self): - with self.assertRaises(ValidationError): - self._jwk()(kid="k1", kty="EC") - - def test_ec_key_with_curve_and_coordinates_accepted(self): - key = self._jwk()( - kid="k1", kty="EC", crv="P-256", x="AA", y="BB", alg="ES256" - ) - self.assertEqual(key.crv, "P-256") - - def test_okp_key_without_curve_rejected(self): - with self.assertRaises(ValidationError): - self._jwk()(kid="k2", kty="OKP") - - def test_okp_key_with_curve_accepted(self): - key = self._jwk()(kid="k2", kty="OKP", crv="Ed25519", x="AA") - self.assertEqual(key.crv, "Ed25519") - - def test_p256_with_mismatched_algorithm_rejected(self): - with self.assertRaises(ValidationError): - self._jwk()( - kid="k3", kty="EC", crv="P-256", x="AA", y="BB", alg="EdDSA" - ) - - def test_p256_with_matching_algorithm_accepted(self): - self._jwk()( - kid="k3", kty="EC", crv="P-256", x="AA", y="BB", alg="ES256" - ) - - def test_p384_with_mismatched_algorithm_rejected(self): - with self.assertRaises(ValidationError): - self._jwk()( - kid="k4", kty="EC", crv="P-384", x="AA", y="BB", alg="ES256" - ) - - def test_p384_with_matching_algorithm_accepted(self): - self._jwk()( - kid="k4", kty="EC", crv="P-384", x="AA", y="BB", alg="ES384" - ) - - def test_ed25519_with_mismatched_algorithm_rejected(self): - with self.assertRaises(ValidationError): - self._jwk()(kid="k5", kty="OKP", crv="Ed25519", x="AA", alg="ES256") - - def test_ed25519_with_matching_algorithm_accepted(self): - self._jwk()(kid="k5", kty="OKP", crv="Ed25519", x="AA", alg="EdDSA") - - def test_algorithm_omitted_is_unconstrained(self): - # alg is optional; verifiers derive it from crv when absent. - self._jwk()(kid="k6", kty="EC", crv="P-256", x="AA", y="BB") - - def test_unrecognized_curve_is_unconstrained(self): - # The crv/kty/alg vocabularies are open (see the schema - # description); a curve outside the three well-known pairings - # carries no algorithm rule. - self._jwk()( - kid="k7", kty="EC", crv="secp256k1", x="AA", y="BB", alg="ES256K" - ) - - -class UnitScaleSemanticTest(unittest.TestCase): - """unit.json: when unit is C62, scale (if present) MUST be 0.""" - - def _unit(self): - from ucp_sdk.models import Unit - - return Unit - - def test_c62_with_nonzero_scale_rejected(self): - with self.assertRaises(ValidationError): - self._unit()(unit="C62", scale=5, display_text="pieces") - - def test_c62_with_zero_scale_accepted(self): - unit = self._unit()(unit="C62", scale=0, display_text="pieces") - self.assertEqual(unit.scale, 0) - - def test_c62_with_scale_omitted_defaults_to_zero(self): - # scale defaults to 0, which already satisfies the C62 pin. - unit = self._unit()(unit="C62", display_text="pieces") - self.assertEqual(unit.scale, 0) - - def test_other_unit_with_nonzero_scale_accepted(self): - # The pin is C62-specific; any other unit is unconstrained. - unit = self._unit()(unit="GRM", scale=3, display_text="grams") - self.assertEqual(unit.scale, 3) - - -class TimeIntervalDependentRequiredSemanticTest(unittest.TestCase): - """TimeInterval requires opens and closes to be provided together.""" - - def _interval(self): - from ucp_sdk.models import TimeInterval - - return TimeInterval - - def _exception_hour(self): - from ucp_sdk.models import ExceptionHour - - return ExceptionHour - - def test_single_opening_time_rejected(self): - with self.assertRaisesRegex(ValidationError, "dependentRequired"): - self._interval()(opens="09:00") - - def test_single_closing_time_rejected(self): - with self.assertRaisesRegex(ValidationError, "dependentRequired"): - self._interval()(closes="17:00") - - def test_empty_and_complete_intervals_accepted(self): - self._interval()() - interval = self._interval()(opens="09:00", closes="17:00") - self.assertEqual((interval.opens, interval.closes), ("09:00", "17:00")) - - def test_explicit_null_still_counts_as_present(self): - interval = self._interval()(opens=None, closes=None) - self.assertEqual(interval.model_fields_set, {"opens", "closes"}) - with self.assertRaisesRegex(ValidationError, "dependentRequired"): - self._interval()(opens=None) - - def test_inherited_interval_rule_is_enforced(self): - with self.assertRaisesRegex(ValidationError, "dependentRequired"): - self._exception_hour()( - valid_from="2026-01-01", - valid_through="2026-01-02", - opens="09:00", - ) - self._exception_hour()( - valid_from="2026-01-01", - valid_through="2026-01-02", - opens="09:00", - closes="17:00", - ) - - -class FulfillmentMethodDestinationRetypingSemanticTest(unittest.TestCase): - """fulfillment_method.json retypes `destinations` per `type`: a - `shipping` method's destinations are shipping_destination.json items - (`type` const `shipping_address`), a `pickup` method's are - location_destination.json items (`type` const `business_location`). - `ucp-schema generate-types` reifies FulfillmentMethod into ShippingMethod, - PickupMethod, and FulfillmentMethodBase (and directional request variants - ShippingMethodCreateRequest, PickupMethodCreateRequest, etc.). - """ - - def _validate_method(self, data): - from ucp_sdk.models import FulfillmentMethod - - return TypeAdapter(FulfillmentMethod).validate_python(data) - - def test_shipping_method_with_business_location_destination_rejected( - self, - ): - with self.assertRaises(ValidationError): - self._validate_method( - { - "id": "m1", - "type": "shipping", - "line_item_ids": ["li1"], - "destinations": [{"type": "business_location", "id": "d1"}], - } - ) - - def test_pickup_method_with_shipping_address_destination_rejected(self): - with self.assertRaises(ValidationError): - self._validate_method( - { - "id": "m2", - "type": "pickup", - "line_item_ids": ["li1"], - "destinations": [{"type": "shipping_address", "id": "d1"}], - } - ) - - def test_shipping_method_with_shipping_address_destination_accepted( - self, - ): - from ucp_sdk.models import ShippingDestination, ShippingMethod - - method = self._validate_method( - { - "id": "m1", - "type": "shipping", - "line_item_ids": ["li1"], - "destinations": [{"type": "shipping_address", "id": "d1"}], - } - ) - self.assertIsInstance(method, ShippingMethod) - self.assertIsInstance(method.destinations[0], ShippingDestination) - self.assertEqual(method.destinations[0].type, "shipping_address") - - def test_pickup_method_with_business_location_destination_accepted(self): - from ucp_sdk.models import LocationDestination, PickupMethod - - method = self._validate_method( - { - "id": "m2", - "type": "pickup", - "line_item_ids": ["li1"], - "destinations": [ - { - "type": "business_location", - "id": "d1", - "name": "Store 1", - } - ], - } - ) - self.assertIsInstance(method, PickupMethod) - self.assertIsInstance(method.destinations[0], LocationDestination) - self.assertEqual(method.destinations[0].type, "business_location") - - def test_method_type_outside_the_pinned_vocabulary_is_unconstrained( - self, - ): - from ucp_sdk.models import ( - FulfillmentDestinationBase, - FulfillmentMethodBase, - ) - - method = self._validate_method( - { - "id": "m3", - "type": "curbside", - "line_item_ids": ["li1"], - "destinations": [{"type": "anything", "id": "d1"}], - } - ) - self.assertIsInstance(method, FulfillmentMethodBase) - self.assertIsInstance( - method.destinations[0], FulfillmentDestinationBase - ) - - def test_method_without_destinations_is_unconstrained(self): - self._validate_method( - {"id": "m4", "type": "shipping", "line_item_ids": ["li1"]} - ) - - def test_directional_create_request_variants_reify_destinations(self): - from ucp_sdk.models import ( - FulfillmentMethodCreateRequest, - PickupMethodCreateRequest, - ShippingMethodCreateRequest, - ) - - adapter = TypeAdapter(FulfillmentMethodCreateRequest) - shipping_req = adapter.validate_python( - { - "type": "shipping", - "destinations": [ - {"postal_code": "94043", "address_country": "US"} - ], - } - ) - self.assertIsInstance(shipping_req, ShippingMethodCreateRequest) - self.assertEqual(shipping_req.destinations[0].postal_code, "94043") - - pickup_req = adapter.validate_python( - {"type": "pickup", "selected_destination_id": "loc_1"} - ) - self.assertIsInstance(pickup_req, PickupMethodCreateRequest) - self.assertNotIn("destinations", PickupMethodCreateRequest.model_fields) - - def test_directional_update_request_variants_require_type_discriminator( - self, - ): - from ucp_sdk.models import ( - FulfillmentMethodUpdateRequest, - FulfillmentMethodUpdateRequestBase, - PickupMethodUpdateRequest, - ShippingMethodUpdateRequest, - ) - - self.assertTrue( - ShippingMethodUpdateRequest.model_fields["type"].is_required() - ) - self.assertTrue( - PickupMethodUpdateRequest.model_fields["type"].is_required() - ) - with self.assertRaises(ValidationError): - ShippingMethodUpdateRequest.model_validate( - {"id": "m1", "line_item_ids": ["li_1"]} - ) - with self.assertRaises(ValidationError): - PickupMethodUpdateRequest.model_validate( - {"id": "m2", "line_item_ids": ["li_1"]} - ) - ship = ShippingMethodUpdateRequest.model_validate( - {"id": "m1", "type": "shipping", "line_item_ids": ["li_1"]} - ) - self.assertEqual(ship.type, "shipping") - - adapter = TypeAdapter(FulfillmentMethodUpdateRequest) - typeless = adapter.validate_python( - { - "id": "m1", - "line_item_ids": ["li_1"], - "selected_destination_id": "d_1", - } - ) - self.assertIsInstance(typeless, FulfillmentMethodUpdateRequestBase) - self.assertIsNone(typeless.type) - - with self.assertRaisesRegex(ValidationError, "dependentRequired"): - adapter.validate_python( - { - "id": "m1", - "line_item_ids": ["li_1"], - "destinations": [{"id": "d_1", "postal_code": "94043"}], - } - ) - - -class SemanticModelNamingAndConstraintsTest(unittest.TestCase): - """Tests for semantic inline/union model names and restored schema constraints.""" - - def test_semantic_names_present_and_synthetic_wrapper_absent(self): - import ucp_sdk.models as models_mod - - self.assertFalse(hasattr(models_mod, "UcpSchemaTypes")) - self.assertFalse(hasattr(models_mod, "UCPSchemaTypes")) - for expected in ( - "TotalsItem", - "ValueConstraintEnum", - "ValueConstraintConst", - "ServiceBusinessSchemaRest", - "ServiceBusinessSchemaMcp", - "ServiceBusinessSchemaA2a", - "ServiceBusinessSchemaEmbedded", - "LocationServesAddressCountry", - "LocationServesAddressRegion", - "LocationServesAddressPostalCode", - "UnitPriceMeasure", - "UnitPriceReference", - ): - self.assertTrue( - hasattr(models_mod, expected), - f"Expected semantic model {expected} in ucp_sdk.models", - ) - - def test_value_constraint_and_oauth2_provider_enforce_unique_items(self): - from ucp_sdk.models import ( - Oauth2Provider, - Provider, - TotalsItem, - ValueConstraintConst, - ValueConstraintEnum, - ) - - with self.assertRaisesRegex(ValidationError, "[Uu]nique"): - ValueConstraintEnum(enum=["a", "a"]) - with self.assertRaisesRegex(ValidationError, "[Uu]nique"): - ValueConstraintConst(const="x", enum=["a", "a"]) - ValueConstraintEnum(enum=["a", "b"]) - - with self.assertRaisesRegex(ValidationError, "[Uu]nique"): - Oauth2Provider( - type="oauth2", - auth_url="https://auth.example.com", - required_claims=["email", "email"], - ) - Oauth2Provider( - type="oauth2", - auth_url="https://auth.example.com", - required_claims=["email", "sub"], - ) - with self.assertRaises(ValidationError): - TypeAdapter(Provider).validate_python({"type": "oauth2"}) - - with self.assertRaisesRegex(ValidationError, "display_text"): - TotalsItem(type="surcharge", amount=50) - TotalsItem(type="surcharge", amount=50, display_text="Surcharge") - - def test_location_hours_and_exception_hours_require_timezone(self): - from ucp_sdk.models import Location, LookupLocation - - for cls, extra in ( - (Location, {}), - (LookupLocation, {"inputs": [{"id": "loc_1"}]}), - ): - with self.subTest(cls=cls.__name__): - with self.assertRaisesRegex( - ValidationError, "dependentRequired" - ): - cls.model_validate( - { - "id": "loc_1", - "name": "Store", - **extra, - "hours": [ - { - "day": "monday", - "opens": "09:00", - "closes": "17:00", - } - ], - } - ) - with self.assertRaisesRegex( - ValidationError, "dependentRequired" - ): - cls.model_validate( - { - "id": "loc_1", - "name": "Store", - **extra, - "exception_hours": [ - { - "valid_from": "2026-01-01", - "valid_through": "2026-01-01", - } - ], - } - ) - loc = cls.model_validate( - { - "id": "loc_1", - "name": "Store", - **extra, - "timezone": "America/New_York", - "hours": [ - { - "day": "monday", - "opens": "09:00", - "closes": "17:00", - } - ], - } - ) - self.assertEqual(loc.timezone, "America/New_York") - - def test_unit_price_measure_and_reference_enforce_c62_scale_zero(self): - from ucp_sdk.models import ( - Measure, - QuantityUnit, - UnitPriceMeasure, - UnitPriceReference, - ) - - for cls in (Measure, UnitPriceMeasure, UnitPriceReference): - with self.subTest(cls=cls.__name__): - with self.assertRaises(ValidationError): - cls(unit="C62", scale=2, display_text="each", value=1.0) - obj = cls(unit="C62", scale=0, display_text="each", value=1.0) - self.assertEqual(obj.scale, 0) - - with self.assertRaises(ValidationError): - QuantityUnit(unit="C62", scale=2, display_text="each") - qu = QuantityUnit(unit="C62", scale=0, display_text="each") - self.assertEqual(qu.scale, 0) - - def test_jwk_public_key_rejects_forbidden_private_key_material(self): - from ucp_sdk.models import JwkPublicKey - - for forbidden_key in ("d", "p", "q", "dp", "dq", "qi", "oth", "k"): - with ( - self.subTest(forbidden_key=forbidden_key), - self.assertRaisesRegex(ValidationError, "forbidden"), - ): - JwkPublicKey.model_validate( - { - "kid": "k1", - "kty": "OKP", - "crv": "Ed25519", - "x": "AA", - forbidden_key: "secret", - } - ) - - -class DatetimePatternInjectorTest(unittest.TestCase): - """The date-time pattern post-generation rewrite's own behavior.""" - - OFFSET = "(?:[Zz]|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$" - - # Shaped like raw generator output for date-time strings with a pattern. - MODULE = ( - "from __future__ import annotations\n" - "\n" - "from pydantic import AwareDatetime, BaseModel, ConfigDict, Field\n" - "\n" - "\n" - "class Window(BaseModel):\n" - ' """A window."""\n' - "\n" - " model_config = ConfigDict(\n" - ' extra="allow",\n' - " )\n" - " opens_at: AwareDatetime = Field(\n" - f' ..., pattern="{OFFSET}"\n' - " )\n" - ' """When it opens."""\n' - ' closes_at: AwareDatetime | None = Field(None, pattern="Z$")\n' - ' starts_at: AwareDatetime = Field(..., alias="startsAt", pattern="Z$")\n' - ' code: str = Field(..., pattern="^[A-Z]+$")\n' - ) - - ALIAS_MODULE = ( - "from __future__ import annotations\n" - "\n" - "from typing import Annotated\n" - "\n" - "from pydantic import AwareDatetime, Field\n" - "from typing_extensions import TypeAliasType\n" - "\n" - "Stamp = TypeAliasType(\n" - ' "Stamp", Annotated[AwareDatetime, Field(..., pattern="Z$")]\n' - ")\n" - ) - - def test_finds_only_datetime_fields(self) -> None: - """A pattern on a str field is valid pydantic and is left alone.""" - fields, unsupported = postprocess_models.find_datetime_pattern_fields( - self.MODULE - ) - self.assertEqual( - fields, - [ - ("Window", "opens_at", self.OFFSET), - ("Window", "closes_at", "Z$"), - ("Window", "starts_at", "Z$"), - ], - ) - self.assertEqual(unsupported, []) - - def test_rewrite_removes_pattern_and_injects_validators(self) -> None: - out = postprocess_models.inject_datetime_patterns(self.MODULE) - self.assertIn(" opens_at: AwareDatetime\n", out) - self.assertIn(" closes_at: AwareDatetime | None = None\n", out) - self.assertIn( - " starts_at: AwareDatetime = Field(..., alias='startsAt')\n", - out, - ) - self.assertIn(' code: str = Field(..., pattern="^[A-Z]+$")\n', out) - self.assertIn(' """When it opens."""\n', out) - for field in ("opens_at", "closes_at", "starts_at"): - self.assertEqual( - out.count(f"def _enforce_datetime_pattern_{field}("), 1 - ) - self.assertNotIn("_enforce_datetime_pattern_code", out) - self.assertIn("field_validator", out) - self.assertIn("\nimport re\n", out) - self.assertEqual( - postprocess_models.find_datetime_pattern_fields(out), ([], []) - ) - - def test_injection_is_idempotent(self) -> None: - once = postprocess_models.inject_datetime_patterns(self.MODULE) - twice = postprocess_models.inject_datetime_patterns(once) - self.assertEqual(once, twice) - - def test_type_alias_pattern_is_reported_not_rewritten(self) -> None: - self.assertEqual( - postprocess_models.find_datetime_pattern_fields(self.ALIAS_MODULE), - ([], [9]), - ) - self.assertEqual( - postprocess_models.inject_datetime_patterns(self.ALIAS_MODULE), - self.ALIAS_MODULE, - ) - - def test_patch_rewrites_tree_and_fails_on_type_alias(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - module = Path(tmp) / "window.py" - module.write_text(self.MODULE, encoding="utf-8") - with ( - mock.patch.object(postprocess_models, "OUTPUT_DIR", Path(tmp)), - contextlib.redirect_stdout(io.StringIO()), - contextlib.redirect_stderr(io.StringIO()), - ): - self.assertEqual( - postprocess_models._patch_datetime_patterns(), (1, 0) - ) - self.assertEqual( - postprocess_models.find_datetime_pattern_fields( - module.read_text(encoding="utf-8") - ), - ([], []), - ) - (Path(tmp) / "stamp.py").write_text( - self.ALIAS_MODULE, encoding="utf-8" - ) - self.assertEqual( - postprocess_models._patch_datetime_patterns(), (0, 1) - ) - - def test_injected_validator_enforces_pattern(self) -> None: - out = postprocess_models.inject_datetime_patterns(self.MODULE) - namespace: dict = {} - exec(compile(out, "", "exec"), namespace) # noqa: S102 - window = namespace["Window"] - window.model_rebuild(_types_namespace=namespace) - common = {"startsAt": "2026-05-18T09:00:00Z", "code": "A"} - for opens_at in ("2026-05-18T17:00:00Z", "2026-05-18T17:00:00-08:00"): - window.model_validate({**common, "opens_at": opens_at}) - window.model_validate( - {**common, "opens_at": "2026-05-18T17:00:00Z", "closes_at": None} - ) - for opens_at in ("2026-05-18T17:00:00", "2026-05-18T17:00:00+0530"): - with self.assertRaises(ValidationError): - window.model_validate({**common, "opens_at": opens_at}) - - -class LocationFilterOpenAtSemanticTest(unittest.TestCase): - """location_filter.json's hours.open_at is a date-time whose pattern - requires a `Z` or `+hh:mm`/`-hh:mm` offset. The generator put that - pattern on the AwareDatetime field itself, so validating any Location - Search or Lookup request carrying it raised TypeError ("Unable to apply - constraint 'pattern'") instead of ValidationError. - """ - - def _requests(self): - from ucp_sdk.models.schemas import ( - LocationLookupRequest, - LocationSearchRequest, - ) - - return ( - (LocationSearchRequest, {}), - (LocationLookupRequest, {"ids": ["loc_1"]}), - ) - - def _validate(self, model, body, open_at): - return model.model_validate( - {**body, "filters": {"hours": {"open_at": open_at}}} - ) - - def test_utc_and_numeric_offsets_accepted(self): - for model, body in self._requests(): - for open_at, offset in ( - ("2026-05-18T17:00:00Z", 0), - ("2026-05-18T17:00:00+05:30", 19800), - ): - with self.subTest(model=model.__name__, open_at=open_at): - request = self._validate(model, body, open_at) - self.assertEqual( - request.filters.hours.open_at.utcoffset().total_seconds(), - offset, - ) - - def test_naive_datetime_rejected(self): - for model, body in self._requests(): - with ( - self.subTest(model=model.__name__), - self.assertRaises(ValidationError), - ): - self._validate(model, body, "2026-05-18T17:00:00") - - def test_offsets_outside_the_pattern_rejected(self): - # AwareDatetime alone accepts both of these; the pattern does not. - for model, body in self._requests(): - for open_at in ("2026-05-18T17:00:00+0530", "1747587600"): - with ( - self.subTest(model=model.__name__, open_at=open_at), - self.assertRaises(ValidationError), - ): - self._validate(model, body, open_at) - - def test_no_committed_datetime_field_carries_a_pattern(self): - import ucp_sdk.models.schemas as schemas - - for path in sorted(Path(schemas.__file__).parent.rglob("*.py")): - with self.subTest(path=path.name): - self.assertEqual( - postprocess_models.find_datetime_pattern_fields( - path.read_text(encoding="utf-8") - ), - ([], []), - ) - - -class SchemasReexportInitTest(unittest.TestCase): - """Tests for _write_schemas_reexport_init lazy re-export generation.""" - - def test_generates_lazy_reexports_and_filters_private_symbols(self) -> None: - pep695_lines = "" - expected = ["Alpha", "Amount", "Beta", "Label"] - if hasattr(ast, "TypeAlias"): - pep695_lines = "type Zeta = str\ntype _HiddenAlias = int\n" - expected.append("Zeta") - - generated_src = ( - "from __future__ import annotations\n\n" - "class Beta:\n" - " pass\n\n" - "class Alpha:\n" - " pass\n\n" - "class _InternalModel:\n" - " pass\n\n" - "Amount = int\n" - "_CACHE = {}\n" - "Label: type = str\n" - f"{pep695_lines}" - "def _helper() -> None:\n" - " return None\n" - ) - with tempfile.TemporaryDirectory() as tmp: - pkg_root = Path(tmp) - schemas_dir = pkg_root / "temp_schemas_pkg" / "schemas" - schemas_dir.mkdir(parents=True) - (pkg_root / "temp_schemas_pkg" / "__init__.py").write_text( - "", encoding="utf-8" - ) - (schemas_dir / "_generated.py").write_text( - generated_src, encoding="utf-8" - ) - stdout = io.StringIO() - with ( - mock.patch.object( - postprocess_models, - "OUTPUT_DIR", - pkg_root / "temp_schemas_pkg", - ), - contextlib.redirect_stdout(stdout), - ): - rc_first = postprocess_models._write_schemas_reexport_init() - rc_second = postprocess_models._write_schemas_reexport_init() - - self.assertEqual(rc_first, 1) - self.assertEqual(rc_second, 0) - init_text = (schemas_dir / "__init__.py").read_text( - encoding="utf-8" - ) - self.assertIn("if TYPE_CHECKING:", init_text) - self.assertNotIn("_InternalModel", init_text) - self.assertNotIn("_CACHE", init_text) - self.assertNotIn("_helper", init_text) - - import importlib - import sys - - sys.path.insert(0, str(pkg_root)) - try: - pkg = importlib.import_module("temp_schemas_pkg.schemas") - self.assertEqual(pkg.__all__, sorted(expected)) - self.assertNotIn( - "temp_schemas_pkg.schemas._generated", sys.modules - ) - self.assertTrue(set(expected).issubset(dir(pkg))) - self.assertNotIn( - "temp_schemas_pkg.schemas._generated", sys.modules - ) - alpha_cls = pkg.Alpha - self.assertIn( - "temp_schemas_pkg.schemas._generated", sys.modules - ) - self.assertEqual(alpha_cls.__name__, "Alpha") - with self.assertRaises(AttributeError): - _ = pkg.NonExistent - with self.assertRaises(AttributeError): - _ = pkg._InternalModel - finally: - sys.path.remove(str(pkg_root)) - for mod_name in list(sys.modules): - if mod_name == "temp_schemas_pkg" or mod_name.startswith( - "temp_schemas_pkg." - ): - sys.modules.pop(mod_name, None) - - def test_empty_generated_module_produces_valid_idempotent_init( - self, - ) -> None: - with tempfile.TemporaryDirectory() as tmp: - schemas_dir = Path(tmp) / "schemas" - schemas_dir.mkdir(parents=True) - (schemas_dir / "_generated.py").write_text( - "_PRIVATE = 1\n", encoding="utf-8" - ) - with ( - mock.patch.object(postprocess_models, "OUTPUT_DIR", Path(tmp)), - contextlib.redirect_stdout(io.StringIO()), - ): - self.assertEqual( - postprocess_models._write_schemas_reexport_init(), 1 - ) - self.assertEqual( - postprocess_models._write_schemas_reexport_init(), 0 - ) - init_text = (schemas_dir / "__init__.py").read_text( - encoding="utf-8" - ) - ast.parse(init_text) - self.assertIn("__all__: list[str] = []", init_text) - self.assertNotIn("TYPE_CHECKING", init_text) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_models.py b/tests/test_models.py index cb8ca42..ffb89f4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -493,12 +493,6 @@ def test_known_media_type_with_invalid_fields_raises_and_never_falls_back( with self.assertRaises(ValidationError): self.media_adapter.validate_python(bad) - for known_type in ("image", "video", "external_video", "model_3d"): - with self.assertRaises(ValidationError): - MediaBase.model_validate( - {"type": known_type, "url": "https://example.com/x"} - ) - class OpenDiscriminatedUnionModelTest(unittest.TestCase): """Tests for open discriminated union validation and round-trip behavior.""" @@ -729,23 +723,22 @@ def test_embedded_in_checkout_response_payload(self) -> None: "Locker bank 7", ) - def test_missing_discriminator_raises_validation_error(self) -> None: - with self.assertRaises(ValidationError): - self.destination_adapter.validate_python( - {"id": "dest_missing_type", "postal_code": "94043"} - ) + def test_missing_or_non_string_discriminator_raises_validation_error( + self, + ) -> None: + for bad_dest in ( + {"id": "dest_missing_type", "postal_code": "94043"}, + {"id": "dest_int_type", "type": 123}, + {"id": "dest_null_type", "type": None}, + ): + with self.assertRaises(ValidationError): + self.destination_adapter.validate_python(bad_dest) with self.assertRaises(ValidationError): self.method_adapter.validate_python( {"id": "m_missing_type", "line_item_ids": ["li_1"]} ) - - def test_known_discriminator_with_malformed_field_raises_validation_error( - self, - ) -> None: - # postal_code must be a string; known discriminator "shipping_address" - # must NOT silently fall back to FulfillmentDestinationBase. with self.assertRaises(ValidationError): - self.destination_adapter.validate_python( + ShippingDestination.model_validate( { "type": "shipping_address", "id": "dest_bad", @@ -781,9 +774,11 @@ def test_provider_open_discriminated_union(self) -> None: ) with self.assertRaises(ValidationError): - self.provider_adapter.validate_python({"type": "oauth2"}) + Oauth2Provider.model_validate({"type": "oauth2"}) with self.assertRaises(ValidationError): - ProviderBase.model_validate({"type": "oauth2"}) + self.provider_adapter.validate_python({}) + with self.assertRaises(ValidationError): + self.provider_adapter.validate_python({"type": 123}) def test_fulfillment_method_create_and_update_request_open_unions( self, @@ -811,7 +806,7 @@ def test_fulfillment_method_create_and_update_request_open_unions( ) with self.assertRaises(ValidationError): - self.method_create_adapter.validate_python( + ShippingMethodCreateRequest.model_validate( {"type": "shipping", "destinations": [{"postal_code": 123}]} ) @@ -838,7 +833,130 @@ def test_fulfillment_method_create_and_update_request_open_unions( ) with self.assertRaises(ValidationError): - self.method_update_adapter.validate_python({"type": "shipping"}) + ShippingMethodUpdateRequest.model_validate({"type": "shipping"}) + + +class IdentityLinkingRoleSchemaTest(unittest.TestCase): + """Tests for IdentityLinkingBusinessSchema and IdentityLinkingPlatformSchema.""" + + BASE = { + "version": "2026-08-25", + "schema": "https://ucp.dev/2026-08-25/schemas/common/identity_linking", + } + + def test_platform_and_business_role_schemas(self) -> None: + plat_cls = models_module.IdentityLinkingPlatformSchema + biz_cls = models_module.IdentityLinkingBusinessSchema + spec_url = "https://ucp.dev/specification/common/identity-linking" + plat = plat_cls.model_validate({**self.BASE, "spec": spec_url}) + self.assertEqual(str(plat.version), "2026-08-25") + + scope = "dev.ucp.shopping.order:read" + biz = biz_cls.model_validate( + {**self.BASE, "config": {"scopes": {scope: {}}}} + ) + self.assertEqual(list(biz.config.scopes), [scope]) + self.assertIsNone(biz.config.scopes[scope].description) + for bad in ( + self.BASE, + {**self.BASE, "config": {}}, + {**self.BASE, "config": {"scopes": {"BAD": {}}}}, + ): + with self.assertRaises(ValidationError): + biz_cls.model_validate(bad) + + +class PureDtoStructureAndOpennessTest(unittest.TestCase): + """Tests for semantic model naming, __all__ hygiene, LocationFilter, and DTO openness.""" + + def test_semantic_names_present_and_synthetic_wrapper_absent(self) -> None: + forbidden = ( + *("UCPSchemaTypes", "UcpSchemaTypes", "AnyUrl", "AwareDatetime"), + *("BaseModel", "ConfigDict", "Field", "RootModel"), + *("date", "timedelta", "TypeAliasType"), + ) + for sym in forbidden: + self.assertNotIn(sym, schemas_module.__all__) + self.assertNotIn(sym, models_module.__all__) + self.assertFalse(hasattr(models_module, sym), sym) + expected = ( + *("TotalsItem", "ValueConstraintEnum", "ValueConstraintConst"), + *("ServiceBusinessSchemaRest", "ServiceBusinessSchemaMcp"), + *("ServiceBusinessSchemaA2a", "ServiceBusinessSchemaEmbedded"), + *("LocationServesAddressCountry", "LocationServesAddressRegion"), + *("LocationServesAddressPostalCode", "UnitPriceMeasure"), + "UnitPriceReference", + ) + for name in expected: + self.assertIn(name, schemas_module.__all__) + self.assertTrue(hasattr(models_module, name), name) + + def test_location_filter_open_at_utc_offset_and_naive_datetime( + self, + ) -> None: + search_cls = models_module.LocationSearchRequest + lookup_cls = models_module.LocationLookupRequest + for model, base in ((search_cls, {}), (lookup_cls, {"ids": ["loc_1"]})): + for open_at, offset in ( + ("2026-05-18T17:00:00Z", 0), + ("2026-05-18T17:00:00+05:30", 19800), + ): + hours = {"open_at": open_at} + req = model.model_validate( + {**base, "filters": {"hours": hours}} + ) + assert req.filters is not None and req.filters.hours is not None + assert req.filters.hours.open_at is not None + utcoffset = req.filters.hours.open_at.utcoffset() + assert utcoffset is not None + self.assertEqual(utcoffset.total_seconds(), offset) + naive = {"open_at": "2026-05-18T17:00:00"} + with self.assertRaises(ValidationError): + model.model_validate({**base, "filters": {"hours": naive}}) + + def test_dto_openness_preserves_extra_fields(self) -> None: + msg = { + "type": "error", + "code": "invalid", + "content": "bad", + "severity": "recoverable", + } + err = models_module.ErrorResponse.model_validate( + { + "ucp": {"version": "2026-08-25", "status": "error"}, + "messages": [msg], + "com.example.trace": "t_1", + } + ) + self.assertEqual( + err.model_dump(exclude_none=True)["com.example.trace"], "t_1" + ) + + rc = models_module.RequestConstraints.model_validate( + {"path": "$.buyer", "com.example.meta": 42} + ) + self.assertEqual( + rc.model_dump(exclude_none=True)["com.example.meta"], 42 + ) + + vc_adapter: TypeAdapter[Any] = TypeAdapter( + models_module.ValueConstraint + ) + vc_enum = vc_adapter.validate_python( + {"enum": ["a", "b"], "com.example.extra": True} + ) + self.assertIsInstance(vc_enum, models_module.ValueConstraintEnum) + self.assertIs( + vc_enum.model_dump(exclude_none=True)["com.example.extra"], True + ) + + vc_const = vc_adapter.validate_python( + {"const": "a", "com.example.extra": "ok"} + ) + self.assertIsInstance(vc_const, models_module.ValueConstraintConst) + self.assertEqual( + vc_const.model_dump(exclude_none=True)["com.example.extra"], "ok" + ) if __name__ == "__main__":