@@ -488,17 +488,66 @@ def find_array_contains_constraints(schema_dir):
488488 groups = _extract_contains_groups (schema , path )
489489 if not groups :
490490 continue
491+ item_condition = _extract_item_required_condition (schema )
491492 title = schema .get ("title" )
492493 if not title :
493494 sys .stderr .write (
494495 f" ! { path } : array contains constraint but no title; "
495496 "cannot map to a model\n "
496497 )
497498 continue
498- found [path .stem ] = {"title" : title , "groups" : groups }
499+ found [path .stem ] = {
500+ "title" : title ,
501+ "groups" : groups ,
502+ "item_condition" : item_condition ,
503+ }
499504 return found
500505
501506
507+ def _extract_item_required_condition (schema ):
508+ """Read a simple array-item ``not.enum`` + ``then.required`` rule."""
509+ items = schema .get ("items" )
510+ if not isinstance (items , dict ):
511+ return None
512+ nodes = [items ]
513+ nodes .extend (
514+ node for node in items .get ("allOf" , []) if isinstance (node , dict )
515+ )
516+ for node in nodes :
517+ condition = node .get ("if" )
518+ consequence = node .get ("then" )
519+ if not isinstance (condition , dict ) or not isinstance (consequence , dict ):
520+ continue
521+ props = condition .get ("properties" )
522+ required = consequence .get ("required" )
523+ if not isinstance (props , dict ) or len (props ) != 1 :
524+ continue
525+ field , predicate = next (iter (props .items ()))
526+ if (
527+ not isinstance (predicate , dict )
528+ or set (node ) != {"if" , "then" }
529+ or set (condition ) != {"properties" , "required" }
530+ or set (consequence ) != {"required" }
531+ ):
532+ continue
533+ excluded = predicate .get ("not" )
534+ values = excluded .get ("enum" ) if isinstance (excluded , dict ) else None
535+ if (
536+ condition .get ("required" ) == [field ]
537+ and set (predicate ) == {"not" }
538+ and isinstance (excluded , dict )
539+ and set (excluded ) == {"enum" }
540+ and isinstance (values , list )
541+ and values
542+ and all (isinstance (value , str ) for value in values )
543+ and isinstance (required , list )
544+ and required
545+ and all (isinstance (name , str ) for name in required )
546+ ):
547+ return {"field" : field , "excluded" : values , "required" : required }
548+ return None
549+
550+
502551def _alias_name (title ):
503552 """Derive the generated alias name from a schema title (drop spaces)."""
504553 return "" .join (title .split ())
@@ -530,7 +579,7 @@ def _predicate_expr(pairs):
530579 return " and " .join (parts )
531580
532581
533- def _build_contains_function (func_name , groups ):
582+ def _build_contains_function (func_name , groups , item_condition = None ):
534583 """Render the module-level ``AfterValidator`` counting function."""
535584 lines = [
536585 f"def { func_name } (value):" ,
@@ -565,11 +614,28 @@ def _build_contains_function(func_name, groups):
565614 f' "matching { desc } (schema maxContains={ maximum } )"' ,
566615 " )" ,
567616 ]
617+ if item_condition :
618+ field = item_condition ["field" ]
619+ lines += [
620+ f" _excluded = { item_condition ['excluded' ]!r} " ,
621+ " for _item in value:" ,
622+ f" _actual = (_item.get({ field !r} ) if isinstance(_item, dict) " ,
623+ f" else getattr(_item, { field !r} , None))" ,
624+ " if _actual in _excluded:" ,
625+ " continue" ,
626+ ]
627+ for required in item_condition ["required" ]:
628+ lines += [
629+ f" if isinstance(_item, dict) and { required !r} not in _item:" ,
630+ f' raise ValueError("Field { required !r} is required for custom { field } ")' ,
631+ f" if not isinstance(_item, dict) and { required !r} not in _item.model_fields_set:" ,
632+ f' raise ValueError("Field { required !r} is required for custom { field } ")' ,
633+ ]
568634 lines .append (" return value" )
569635 return "\n " .join (lines ) + "\n "
570636
571637
572- def inject_array_contains (source , alias_name , groups ):
638+ def inject_array_contains (source , alias_name , groups , item_condition = None ):
573639 """Thread an ``AfterValidator`` into ``alias_name``'s alias metadata.
574640
575641 Array roots are emitted as ``NAME = TypeAliasType("NAME", Annotated[...])``,
@@ -602,7 +668,7 @@ def inject_array_contains(source, alias_name, groups):
602668 if close is None :
603669 return source
604670 out = source [:close ] + f", AfterValidator({ func_name } )" + source [close :]
605- func_src = _build_contains_function (func_name , groups )
671+ func_src = _build_contains_function (func_name , groups , item_condition )
606672 insert_at = assign_re .search (out ).start ()
607673 out = out [:insert_at ] + func_src + "\n \n " + out [insert_at :]
608674 return _ensure_pydantic_import (out , "AfterValidator" )
@@ -1082,11 +1148,20 @@ def _array_contains_targets():
10821148 None ,
10831149 )
10841150 if origin is not None :
1085- targets [info ["title" ]] = raw [origin ]["groups" ]
1151+ targets [info ["title" ]] = {
1152+ "groups" : raw [origin ]["groups" ],
1153+ "item_condition" : raw [origin ]["item_condition" ],
1154+ }
10861155 # Defensive: cover each raw base title even if the preprocessed base lost
10871156 # its contains entirely.
10881157 for info in raw .values ():
1089- targets .setdefault (info ["title" ], info ["groups" ])
1158+ targets .setdefault (
1159+ info ["title" ],
1160+ {
1161+ "groups" : info ["groups" ],
1162+ "item_condition" : info ["item_condition" ],
1163+ },
1164+ )
10901165 return targets
10911166
10921167
@@ -1133,7 +1208,8 @@ def _patch_array_contains():
11331208 sys .stdout .write ("postprocess: no array contains constraints found\n " )
11341209 return 0 , 0
11351210 patched = 0
1136- for title , groups in sorted (targets .items ()):
1211+ for title , constraint in sorted (targets .items ()):
1212+ groups = constraint ["groups" ]
11371213 alias = _alias_name (title )
11381214 hits = []
11391215 for path in sorted (OUTPUT_DIR .rglob ("*.py" )):
@@ -1142,7 +1218,9 @@ def _patch_array_contains():
11421218 rf"^{ re .escape (alias )} = TypeAliasType\(" , source , re .M
11431219 ):
11441220 continue
1145- updated = inject_array_contains (source , alias , groups )
1221+ updated = inject_array_contains (
1222+ source , alias , groups , constraint ["item_condition" ]
1223+ )
11461224 if updated != source :
11471225 path .write_text (updated , encoding = "utf-8" )
11481226 patched += 1
0 commit comments