From 3fc0434c5227d04693439229b40ff33b19f3d0bf Mon Sep 17 00:00:00 2001 From: vishkaty Date: Wed, 15 Jul 2026 22:40:35 -0400 Subject: [PATCH] fix: enforce minProperties on generated models (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit datamodel-code-generator drops minProperties on object schemas with declared properties, so Description() validated with zero fields in violation of description.json's minProperties: 1. (minProperties on free-form object properties is unaffected — the generator already maps those to Field(min_length=...) on the dict field.) A new post-generation step (postprocess_models.py, wired into generate_models.sh before formatting) scans the preprocessed schemas for root-level minProperties constraints and injects a model_validator(mode="after") into each matching generated 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. The step is idempotent, data-driven from the schemas (future minProperties additions are picked up on regeneration), and fails generation loudly if a constraint can't be mapped to a generated class. The committed Description model is regenerated with the validator. Tests cover the injector (dependency-free) and the enforced semantics on Description; the workflow now installs the package so those semantic tests run in CI. --- .github/workflows/tests.yml | 2 + generate_models.sh | 3 + postprocess_models.py | 145 ++++++++++++++++++ .../schemas/shopping/types/description.py | 12 +- tests/test_min_properties.py | 136 ++++++++++++++++ 5 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 postprocess_models.py create mode 100644 tests/test_min_properties.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2e6a3a2..2daad35 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,5 +38,7 @@ jobs: uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} + - name: Install package (generated-model tests import it) + run: pip install -e . - name: Run preprocessing tests run: python -m unittest discover -s tests -p "test_*.py" diff --git a/generate_models.sh b/generate_models.sh index a45543c..4fd43d8 100755 --- a/generate_models.sh +++ b/generate_models.sh @@ -87,6 +87,9 @@ uv run \ --additional-imports pydantic.ConfigDict +echo "Post-processing generated models (constraints the generator ignores)..." +uv run python postprocess_models.py || exit 1 + echo "Formatting generated models..." uv run ruff format uv run ruff check --fix "$OUTPUT_DIR" diff --git a/postprocess_models.py b/postprocess_models.py new file mode 100644 index 0000000..cb01ebd --- /dev/null +++ b/postprocess_models.py @@ -0,0 +1,145 @@ +# 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. + +``minProperties`` on an object schema WITH declared properties is dropped by +the generator (issue #49): every field is optional, so an empty instance +passes validation in violation of the schema. (``minProperties`` on a +free-form object property is already handled natively — the generator maps it +to ``Field(min_length=...)`` on the dict field.) + +This script scans the preprocessed schemas for root-level ``minProperties`` +constraints and injects a ``model_validator(mode="after")`` into the matching +generated classes. 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. + +Runs from generate_models.sh between generation and formatting; idempotent. +""" + +import json +import re +import sys +from pathlib import Path + +SCHEMA_DIR = Path("ucp/source/schemas") +OUTPUT_DIR = Path("src/ucp_sdk/models/schemas") + +_MARKER = "_enforce_min_properties" + +_VALIDATOR_TEMPLATE = ''' + @model_validator(mode="after") + def {marker}(self): + """JSON Schema minProperties: require at least {minimum} provided propert{y_ies}.""" + provided = self.model_fields_set | set(self.model_extra or {{}}) + if len(provided) < {minimum}: + raise ValueError( + "At least {minimum} propert{y_ies} must be provided " + "(schema minProperties={minimum})" + ) + return self +''' + + +def find_root_min_properties(schema_dir): + """Map schema title -> minProperties for root-level object constraints.""" + found = {} + for path in sorted(Path(schema_dir).rglob("*.json")): + try: + schema = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(schema, dict): + continue + minimum = schema.get("minProperties") + if not minimum or not schema.get("properties"): + continue + title = schema.get("title") + if not title: + print( + f" ! {path}: root minProperties but no title; cannot map to a class" + ) + continue + found[title] = minimum + return found + + +def _ensure_validator_import(source): + """Add model_validator to the existing pydantic import if missing.""" + if re.search(r"^from pydantic import .*\bmodel_validator\b", source, re.M): + return source + return re.sub( + r"^(from pydantic import [^\n]+)$", + lambda m: f"{m.group(1)}, model_validator", + source, + count=1, + flags=re.M, + ) + + +def inject_min_properties(source, class_name, minimum): + """Inject the minProperties validator at the end of ``class_name``.""" + if f"def {_MARKER}(" in source: + return source + 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) + method = _VALIDATOR_TEMPLATE.format( + marker=_MARKER, + minimum=minimum, + y_ies="y" if minimum == 1 else "ies", + ) + body = source[:end].rstrip("\n") + rest = source[end:] + out = body + "\n" + method + ("\n" + rest if rest else "") + return _ensure_validator_import(out) + + +def main(): + constraints = find_root_min_properties(SCHEMA_DIR) + if not constraints: + print("postprocess: no root-level minProperties constraints found") + return 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" + print(f" minProperties={minimum} on '{title}' -> {label}") + if not hits: + print( + f" ! '{title}' has no generated class; constraint not enforced" + ) + return 1 + print(f"postprocess: {patched} module(s) patched") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/ucp_sdk/models/schemas/shopping/types/description.py b/src/ucp_sdk/models/schemas/shopping/types/description.py index 18f6582..6207d51 100644 --- a/src/ucp_sdk/models/schemas/shopping/types/description.py +++ b/src/ucp_sdk/models/schemas/shopping/types/description.py @@ -18,7 +18,7 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, model_validator class Description(BaseModel): @@ -41,3 +41,13 @@ class Description(BaseModel): """ Markdown-formatted content. """ + + @model_validator(mode="after") + def _enforce_min_properties(self): + """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 diff --git a/tests/test_min_properties.py b/tests/test_min_properties.py new file mode 100644 index 0000000..0842ee3 --- /dev/null +++ b/tests/test_min_properties.py @@ -0,0 +1,136 @@ +# 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. + +"""minProperties enforcement on generated models (issue #49). + +JSON Schema's ``minProperties`` counts the keys present on the object, so the +generated model must reject an instance with fewer provided properties — +including the no-argument case — while accepting any combination of declared +fields (explicit nulls are present keys) and extra fields (``extra="allow"`` +models accept unknown keys, which count too). + +The injector tests are dependency-free; the ``Description`` semantic tests +need the package importable (``pip install -e .``) and skip otherwise. +""" + +import json +import tempfile +import unittest +from pathlib import Path + +import postprocess_models + +try: + from pydantic import ValidationError + + from ucp_sdk.models.schemas.shopping.types.description import Description + + HAVE_SDK = True +except ImportError: # pragma: no cover - exercised only without install + HAVE_SDK = False + + +@unittest.skipUnless( + HAVE_SDK, "requires the installed package (pip install -e .)" +) +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 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()) + + @unittest.skipUnless(HAVE_SDK, "executing the module needs pydantic") + 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}) + + +if __name__ == "__main__": + unittest.main()