Skip to content

Commit ce385e2

Browse files
fix: scope uniqueItems validators to declaring classes (#61)
* fix: scope uniqueItems validators to declaring classes * fix: resolve uniqueItems mapping for untitled nested schemas and regenerate models * style: format code with pre-commit hooks --------- Co-authored-by: damaz91 <federico.damato91@gmail.com>
1 parent ba23d97 commit ce385e2

2 files changed

Lines changed: 101 additions & 56 deletions

File tree

postprocess_models.py

Lines changed: 80 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,12 @@ def _alias_name(title):
248248
return "".join(title.split())
249249

250250

251+
def _to_camel_case(string):
252+
"""Convert a string (snake, kebab, space-separated) to CamelCase."""
253+
parts = re.split(r"[^a-zA-Z0-9]", string)
254+
return "".join(p.capitalize() for p in parts if p)
255+
256+
251257
def _snake_name(name):
252258
"""CamelCase alias -> snake_case suffix for a unique function name."""
253259
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
@@ -346,65 +352,84 @@ def inject_array_contains(source, alias_name, groups):
346352
return _ensure_pydantic_import(out, "AfterValidator")
347353

348354

349-
def _iter_nodes(root):
350-
"""Yield every dict/list node in a JSON tree (cycle-safe)."""
351-
stack = [root]
352-
seen = {id(root)}
353-
while stack:
354-
cur = stack.pop()
355-
yield cur
356-
if isinstance(cur, dict):
357-
children = cur.values()
358-
elif isinstance(cur, list):
359-
children = cur
360-
else:
361-
children = ()
362-
for child in children:
363-
if isinstance(child, (dict, list)) and id(child) not in seen:
364-
seen.add(id(child))
365-
stack.append(child)
366-
367-
368355
def find_unique_items_fields(schema_dir):
369-
"""Collect property names whose array value carries ``uniqueItems``.
356+
"""Map generated class names to fields carrying ``uniqueItems``.
370357
371-
Walks every schema (root and nested) for object properties declared as an
372-
array with ``uniqueItems: true``. Returns the set of property names so the
373-
injector can locate the matching generated list fields by name.
358+
A schema node needs a title so its constraint can be associated with a
359+
generated class. Untitled nodes are resolved using their property path.
374360
"""
375-
fields = set()
361+
fields_by_class = {}
362+
363+
def walk(node, current_class_name, path_str):
364+
if not isinstance(node, dict):
365+
return
366+
367+
if isinstance(node.get("title"), str):
368+
current_class_name = _alias_name(node["title"])
369+
370+
props = node.get("properties")
371+
if isinstance(props, dict):
372+
for name, prop in props.items():
373+
if not isinstance(prop, dict):
374+
continue
375+
376+
if prop.get("uniqueItems") is True and (
377+
prop.get("type") == "array" or "items" in prop
378+
):
379+
if current_class_name is None:
380+
sys.stderr.write(
381+
f" ! {path_str}: uniqueItems field '{name}' "
382+
"belongs to an untitled object; cannot map to a class\n"
383+
)
384+
continue
385+
fields_by_class.setdefault(current_class_name, set()).add(
386+
name
387+
)
388+
389+
# Recurse into properties
390+
next_class_name = (
391+
_to_camel_case(name) if current_class_name else None
392+
)
393+
walk(prop, next_class_name, path_str)
394+
395+
# Recurse into $defs
396+
defs = node.get("$defs")
397+
if isinstance(defs, dict):
398+
for def_name, def_node in defs.items():
399+
walk(def_node, _to_camel_case(def_name), path_str)
400+
401+
# Recurse into combinators (allOf, anyOf, oneOf)
402+
for key in ("allOf", "anyOf", "oneOf"):
403+
if isinstance(node.get(key), list):
404+
for item in node[key]:
405+
walk(item, current_class_name, path_str)
406+
376407
for path in sorted(Path(schema_dir).rglob("*.json")):
377408
try:
378409
schema = json.loads(path.read_text(encoding="utf-8"))
379410
except (OSError, json.JSONDecodeError):
380411
continue
381412
if not isinstance(schema, dict):
382413
continue
383-
for node in _iter_nodes(schema):
384-
if not isinstance(node, dict):
385-
continue
386-
props = node.get("properties")
387-
if not isinstance(props, dict):
388-
continue
389-
for name, prop in props.items():
390-
if (
391-
isinstance(prop, dict)
392-
and prop.get("uniqueItems") is True
393-
and (prop.get("type") == "array" or "items" in prop)
394-
):
395-
fields.add(name)
396-
return fields
397414

415+
root_title = schema.get("title")
416+
initial_class = (
417+
_alias_name(root_title) if root_title else _to_camel_case(path.stem)
418+
)
419+
walk(schema, initial_class, str(path))
420+
421+
return fields_by_class
398422

399-
def inject_unique_items(source, unique_fields):
423+
424+
def inject_unique_items(source, unique_fields_by_class):
400425
"""Inject uniqueness validators for list fields declared ``uniqueItems``.
401426
402-
Scans each generated class for list-typed fields whose name is in
403-
``unique_fields`` and appends a ``field_validator`` to the class body.
427+
A validator is added only when both the generated class name and list
428+
field name match the scoped schema constraints.
404429
"""
405-
if not unique_fields:
430+
if not unique_fields_by_class:
406431
return source
407-
class_re = re.compile(r"^class \w+\(", re.M)
432+
class_re = re.compile(r"^class (\w+)\(", re.M)
408433
matches = list(class_re.finditer(source))
409434
if not matches:
410435
return source
@@ -413,6 +438,9 @@ def inject_unique_items(source, unique_fields):
413438
# Process from the last class to the first so earlier insert offsets
414439
# (computed against the original source) stay valid as text is appended.
415440
for match in reversed(matches):
441+
unique_fields = unique_fields_by_class.get(match.group(1), set())
442+
if not unique_fields:
443+
continue
416444
body_start = match.end()
417445
tail = re.compile(r"^\S", re.M)
418446
end_match = tail.search(source, body_start)
@@ -549,21 +577,26 @@ def _patch_array_contains():
549577

550578
def _patch_unique_items():
551579
"""Inject uniqueItems validators; return (patched_count, exit_code)."""
552-
unique_fields = find_unique_items_fields(SCHEMA_DIR)
553-
if not unique_fields:
580+
unique_fields_by_class = find_unique_items_fields(SCHEMA_DIR)
581+
if not unique_fields_by_class:
554582
sys.stdout.write("postprocess: no uniqueItems constraints found\n")
555583
return 0, 0
556584
unique_patched = 0
557585
touched = []
558586
for path in sorted(OUTPUT_DIR.rglob("*.py")):
559587
source = path.read_text(encoding="utf-8")
560-
updated = inject_unique_items(source, unique_fields)
588+
updated = inject_unique_items(source, unique_fields_by_class)
561589
if updated != source:
562590
path.write_text(updated, encoding="utf-8")
563591
unique_patched += 1
564592
touched.append(path)
593+
labels = sorted(
594+
f"{class_name}.{field}"
595+
for class_name, fields in unique_fields_by_class.items()
596+
for field in fields
597+
)
565598
sys.stdout.write(
566-
f" uniqueItems fields {sorted(unique_fields)} -> "
599+
f" uniqueItems fields {labels} -> "
567600
f"{unique_patched} module(s) patched"
568601
f" ({', '.join(str(t) for t in touched) or 'none'})\n"
569602
)

tests/test_codegen_pipeline.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -912,6 +912,7 @@ class UniqueItemsInjectorTest(unittest.TestCase):
912912
"""The uniqueItems post-generation injector's own behavior."""
913913

914914
SCHEMA_TREE = {
915+
"title": "First",
915916
"properties": {
916917
"tags": {
917918
"type": "array",
@@ -930,7 +931,7 @@ class UniqueItemsInjectorTest(unittest.TestCase):
930931
}
931932
},
932933
},
933-
}
934+
},
934935
}
935936

936937
MODULE = (
@@ -955,6 +956,7 @@ class UniqueItemsInjectorTest(unittest.TestCase):
955956
" model_config = ConfigDict(\n"
956957
' extra="allow",\n'
957958
" )\n"
959+
" tags: list[str] | None = None\n"
958960
" count: list[int] | None = None\n"
959961
)
960962

@@ -963,7 +965,7 @@ def test_find_unique_items_fields_walks_nested_properties(self) -> None:
963965
with tempfile.TemporaryDirectory() as tmp:
964966
(Path(tmp) / "schema.json").write_text(json.dumps(self.SCHEMA_TREE))
965967
fields = postprocess_models.find_unique_items_fields(Path(tmp))
966-
self.assertEqual(fields, {"tags", "codes"})
968+
self.assertEqual(fields, {"First": {"tags"}, "Nested": {"codes"}})
967969

968970
def test_find_unique_items_fields_ignores_false_and_non_arrays(
969971
self,
@@ -982,33 +984,43 @@ def test_find_unique_items_fields_ignores_false_and_non_arrays(
982984
with tempfile.TemporaryDirectory() as tmp:
983985
(Path(tmp) / "s.json").write_text(json.dumps(schema))
984986
fields = postprocess_models.find_unique_items_fields(Path(tmp))
985-
self.assertEqual(fields, set())
987+
self.assertEqual(fields, {})
986988

987989
def test_inject_targets_matching_list_fields_only(self) -> None:
988-
"""Only list fields named in the set get a validator."""
989-
out = postprocess_models.inject_unique_items(self.MODULE, {"tags"})
990+
"""Only the declaring class's matching list field gets a validator."""
991+
out = postprocess_models.inject_unique_items(
992+
self.MODULE, {"First": {"tags"}}
993+
)
990994
self.assertIn("field_validator", out)
991995
self.assertIn("_enforce_unique_items_tags", out)
996+
self.assertEqual(out.count("def _enforce_unique_items_tags("), 1)
992997
self.assertNotIn("_enforce_unique_items_name", out)
993998
self.assertNotIn("_enforce_unique_items_count", out)
994999

9951000
def test_inject_no_match_leaves_source_unchanged(self) -> None:
9961001
"""No matching list field means the module is untouched."""
9971002
self.assertEqual(
998-
postprocess_models.inject_unique_items(self.MODULE, {"missing"}),
1003+
postprocess_models.inject_unique_items(
1004+
self.MODULE, {"First": {"missing"}}
1005+
),
9991006
self.MODULE,
10001007
)
10011008

10021009
def test_injection_is_idempotent(self) -> None:
10031010
"""Re-running the injector changes nothing."""
1004-
once = postprocess_models.inject_unique_items(self.MODULE, {"tags"})
1005-
twice = postprocess_models.inject_unique_items(once, {"tags"})
1011+
unique_fields = {"First": {"tags"}}
1012+
once = postprocess_models.inject_unique_items(
1013+
self.MODULE, unique_fields
1014+
)
1015+
twice = postprocess_models.inject_unique_items(once, unique_fields)
10061016
self.assertEqual(once, twice)
10071017

10081018
@unittest.skipUnless(HAVE_SDK, "executing the module needs pydantic")
10091019
def test_injected_validator_rejects_duplicates(self) -> None:
10101020
"""The injected field_validator enforces uniqueness at runtime."""
1011-
out = postprocess_models.inject_unique_items(self.MODULE, {"tags"})
1021+
out = postprocess_models.inject_unique_items(
1022+
self.MODULE, {"First": {"tags"}}
1023+
)
10121024
namespace: dict = {}
10131025
exec(compile(out, "<injected>", "exec"), namespace) # noqa: S102
10141026
first = namespace["First"]

0 commit comments

Comments
 (0)