Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
3 changes: 3 additions & 0 deletions generate_models.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
145 changes: 145 additions & 0 deletions postprocess_models.py
Original file line number Diff line number Diff line change
@@ -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())
12 changes: 11 additions & 1 deletion src/ucp_sdk/models/schemas/shopping/types/description.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from __future__ import annotations

from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, model_validator


class Description(BaseModel):
Expand All @@ -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
136 changes: 136 additions & 0 deletions tests/test_min_properties.py
Original file line number Diff line number Diff line change
@@ -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>p</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, "<injected>", "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()
Loading