@@ -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+
251257def _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-
368355def 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
550578def _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 )
0 commit comments