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, "