2727keys (``model_extra``) — an explicit null is a present key, and unknown keys
2828on ``extra="allow"`` models count too.
2929
30+ ``contains`` / ``minContains`` / ``maxContains`` on an array schema is likewise
31+ dropped by the generator: ``totals.json`` requires *exactly one* ``subtotal``
32+ *and exactly one* ``total`` entry, but the generated ``Totals`` is a bare
33+ ``list[Total]`` alias, so an empty array (or one missing either required entry,
34+ or with duplicates) validates in violation of the schema. An array root is
35+ emitted as a ``TypeAliasType`` wrapping ``Annotated[list[...], ...]`` rather
36+ than a ``BaseModel`` subclass, so ``model_validator`` cannot apply; this script
37+ instead injects a module-level counting function, threaded into the alias
38+ metadata as a ``pydantic.AfterValidator``. Every predicate is derived from
39+ ``contains.properties.<field>.const`` — nothing is hard-coded — and one function
40+ enforces *all* of a schema's contains bounds.
41+
42+ The pristine (pre-preprocessing) schemas are read for this: ``totals.json``
43+ carries its two containment rules as two ``allOf`` branches, and
44+ ``preprocess_schemas.py`` merges ``allOf`` into the root, where a JSON node can
45+ hold only one ``contains`` — so the second (``total``) would be lost if the
46+ preprocessed output were scanned. generate_models.sh snapshots the originals to
47+ ``ucp/raw_schemas`` before preprocessing for exactly this reason. The bound is
48+ applied to the base model and to its generated request variants (linked by file
49+ stem), and travels wherever the alias is reused as a field type.
50+
3051Runs from generate_models.sh between generation and formatting; idempotent.
3152"""
3253
3657from pathlib import Path
3758
3859SCHEMA_DIR = Path ("ucp/source/schemas" )
60+ # Pristine schemas snapshotted by generate_models.sh before preprocessing.
61+ # Array contains bounds are read from here, not SCHEMA_DIR, because
62+ # preprocessing merges allOf and can drop a second contains keyword.
63+ RAW_SCHEMA_DIR = Path ("ucp/raw_schemas" )
3964OUTPUT_DIR = Path ("src/ucp_sdk/models/schemas" )
4065
4166_MARKER = "_enforce_min_properties"
@@ -79,13 +104,15 @@ def find_root_min_properties(schema_dir):
79104 return found
80105
81106
82- def _ensure_validator_import (source ):
83- """Add model_validator to the existing pydantic import if missing."""
84- if re .search (r"^from pydantic import .*\bmodel_validator\b" , source , re .M ):
107+ def _ensure_pydantic_import (source , symbol ):
108+ """Add ``symbol`` to the ``from pydantic import`` line if absent."""
109+ if re .search (
110+ rf"^from pydantic import .*\b{ re .escape (symbol )} \b" , source , re .M
111+ ):
85112 return source
86113 return re .sub (
87114 r"^(from pydantic import [^\n]+)$" ,
88- lambda m : f"{ m .group (1 )} , model_validator " ,
115+ lambda m : f"{ m .group (1 )} , { symbol } " ,
89116 source ,
90117 count = 1 ,
91118 flags = re .M ,
@@ -112,17 +139,196 @@ def inject_min_properties(source, class_name, minimum):
112139 body = source [:end ].rstrip ("\n " )
113140 rest = source [end :]
114141 out = body + "\n " + method + ("\n " + rest if rest else "" )
115- return _ensure_validator_import (out )
142+ return _ensure_pydantic_import (out , "model_validator" )
116143
117144
118- def main ():
119- """Main entry point to scan schemas and patch generated models."""
145+ def _extract_contains_groups (schema , path = None ):
146+ """Collect every array ``contains`` group from a schema's root + allOf.
147+
148+ Each group is ``{"pairs": [(field, const), ...], "min": int,
149+ "max": int | None}``, derived from ``contains.properties.<field>.const``
150+ with its ``minContains`` / ``maxContains`` bounds. A ``contains`` keyword
151+ may sit at the schema root or inside any ``allOf`` branch; each contributes
152+ a group, so "exactly one subtotal and one total" yields two. The predicate
153+ is read from the schema, never hard-coded.
154+ """
155+ nodes = [schema ]
156+ if isinstance (schema .get ("allOf" ), list ):
157+ nodes .extend (n for n in schema ["allOf" ] if isinstance (n , dict ))
158+ groups = []
159+ for node in nodes :
160+ contains = node .get ("contains" )
161+ if not isinstance (contains , dict ):
162+ continue
163+ props = contains .get ("properties" )
164+ pairs = []
165+ if isinstance (props , dict ):
166+ for field , spec in props .items ():
167+ if isinstance (spec , dict ) and "const" in spec :
168+ pairs .append ((field , spec ["const" ]))
169+ if not pairs :
170+ if path is not None :
171+ sys .stderr .write (
172+ f" ! { path } : contains predicate has no "
173+ "properties.*.const; cannot derive a check\n "
174+ )
175+ continue
176+ # JSON Schema: minContains defaults to 1 when contains is present.
177+ groups .append (
178+ {
179+ "pairs" : pairs ,
180+ "min" : node .get ("minContains" , 1 ),
181+ "max" : node .get ("maxContains" ),
182+ }
183+ )
184+ return groups
185+
186+
187+ def find_array_contains_constraints (schema_dir ):
188+ """Map file stem -> ``{"title": str, "groups": [...]}`` for array schemas.
189+
190+ Keyed by file stem (not title) so a base schema can be linked to its
191+ generated request variants, whose stems extend it (``totals`` ->
192+ ``totals_create_request``). Scanned against the *pristine* schemas
193+ (``RAW_SCHEMA_DIR``); see the module docstring for why the preprocessed
194+ output must not be used here.
195+ """
196+ found = {}
197+ for path in sorted (Path (schema_dir ).rglob ("*.json" )):
198+ try :
199+ schema = json .loads (path .read_text (encoding = "utf-8" ))
200+ except (OSError , json .JSONDecodeError ):
201+ continue
202+ if not isinstance (schema , dict ):
203+ continue
204+ # ``contains`` only constrains arrays; skip anything else.
205+ if schema .get ("type" ) != "array" and "items" not in schema :
206+ continue
207+ groups = _extract_contains_groups (schema , path )
208+ if not groups :
209+ continue
210+ title = schema .get ("title" )
211+ if not title :
212+ sys .stderr .write (
213+ f" ! { path } : array contains constraint but no title; "
214+ "cannot map to a model\n "
215+ )
216+ continue
217+ found [path .stem ] = {"title" : title , "groups" : groups }
218+ return found
219+
220+
221+ def _alias_name (title ):
222+ """Derive the generated alias name from a schema title (drop spaces)."""
223+ return "" .join (title .split ())
224+
225+
226+ def _snake_name (name ):
227+ """CamelCase alias -> snake_case suffix for a unique function name."""
228+ return re .sub (r"(?<!^)(?=[A-Z])" , "_" , name ).lower ()
229+
230+
231+ def _predicate_expr (pairs ):
232+ """Build a per-item boolean expression matching all (field, const) pairs.
233+
234+ Items are ``Total`` instances after inner validation, but a mapping is
235+ handled too so the check is robust regardless of the item representation.
236+ """
237+ parts = []
238+ for field , const in pairs :
239+ parts .append (
240+ f"(_item.get({ field !r} ) if isinstance(_item, dict) "
241+ f"else getattr(_item, { field !r} , None)) == { const !r} "
242+ )
243+ return " and " .join (parts )
244+
245+
246+ def _build_contains_function (func_name , groups ):
247+ """Render the module-level ``AfterValidator`` counting function."""
248+ lines = [
249+ f"def { func_name } (value):" ,
250+ ' """JSON Schema contains/minContains/maxContains (see #49)."""' ,
251+ ]
252+ for index , group in enumerate (groups ):
253+ count = "_matched" if len (groups ) == 1 else f"_matched_{ index } "
254+ desc = ", " .join (f"{ f } =={ c !r} " for f , c in group ["pairs" ])
255+ lines += [
256+ f" { count } = sum(" ,
257+ " 1" ,
258+ " for _item in value" ,
259+ f" if { _predicate_expr (group ['pairs' ])} " ,
260+ " )" ,
261+ ]
262+ minimum = group ["min" ]
263+ noun = "entry" if minimum == 1 else "entries"
264+ lines += [
265+ f" if { count } < { minimum } :" ,
266+ " raise ValueError(" ,
267+ f' "Array must contain at least { minimum } { noun } "' ,
268+ f' "matching { desc } (schema minContains={ minimum } )"' ,
269+ " )" ,
270+ ]
271+ maximum = group ["max" ]
272+ if maximum is not None :
273+ noun = "entry" if maximum == 1 else "entries"
274+ lines += [
275+ f" if { count } > { maximum } :" ,
276+ " raise ValueError(" ,
277+ f' "Array must contain at most { maximum } { noun } "' ,
278+ f' "matching { desc } (schema maxContains={ maximum } )"' ,
279+ " )" ,
280+ ]
281+ lines .append (" return value" )
282+ return "\n " .join (lines ) + "\n "
283+
284+
285+ def inject_array_contains (source , alias_name , groups ):
286+ """Thread an ``AfterValidator`` into ``alias_name``'s alias metadata.
287+
288+ Array roots are emitted as ``NAME = TypeAliasType("NAME", Annotated[...])``,
289+ not a ``BaseModel`` subclass, so the constraint is enforced by inserting
290+ ``AfterValidator(<fn>)`` into the ``Annotated[...]`` metadata and defining
291+ ``<fn>`` just above the assignment. Idempotent via the function name.
292+ """
293+ func_name = f"_enforce_contains_{ _snake_name (alias_name )} "
294+ if f"def { func_name } (" in source :
295+ return source
296+ assign_re = re .compile (rf"^{ re .escape (alias_name )} = TypeAliasType\(" , re .M )
297+ match = assign_re .search (source )
298+ if not match :
299+ return source
300+ ann_start = source .find ("Annotated[" , match .end ())
301+ if ann_start == - 1 :
302+ return source
303+ # Bracket-match to the ``]`` that closes ``Annotated[``.
304+ depth = 0
305+ close = None
306+ for pos in range (ann_start + len ("Annotated" ), len (source )):
307+ char = source [pos ]
308+ if char == "[" :
309+ depth += 1
310+ elif char == "]" :
311+ depth -= 1
312+ if depth == 0 :
313+ close = pos
314+ break
315+ if close is None :
316+ return source
317+ out = source [:close ] + f", AfterValidator({ func_name } )" + source [close :]
318+ func_src = _build_contains_function (func_name , groups )
319+ insert_at = assign_re .search (out ).start ()
320+ out = out [:insert_at ] + func_src + "\n \n " + out [insert_at :]
321+ return _ensure_pydantic_import (out , "AfterValidator" )
322+
323+
324+ def _patch_min_properties ():
325+ """Inject minProperties validators; return (patched_count, exit_code)."""
120326 constraints = find_root_min_properties (SCHEMA_DIR )
121327 if not constraints :
122328 sys .stdout .write (
123329 "postprocess: no root-level minProperties constraints found\n "
124330 )
125- return 0
331+ return 0 , 0
126332 patched = 0
127333 for title , minimum in sorted (constraints .items ()):
128334 hits = []
@@ -142,9 +348,90 @@ def main():
142348 f" ! '{ title } ' has no generated class; "
143349 "constraint not enforced\n "
144350 )
145- return 1
146- sys .stdout .write (f"postprocess: { patched } module(s) patched\n " )
147- return 0
351+ return patched , 1
352+ return patched , 0
353+
354+
355+ def _array_contains_targets ():
356+ """Resolve ``title -> groups`` for every model needing a contains bound.
357+
358+ The authoritative (complete) groups come from the pristine schemas. The
359+ preprocessed tree is consulted only to enumerate which models actually
360+ exist — the base plus its generated request variants — so each variant
361+ inherits its base schema's full set of containment rules. Variants are
362+ linked to their base by file stem (``totals_create_request`` -> ``totals``).
363+ """
364+ raw = find_array_contains_constraints (RAW_SCHEMA_DIR )
365+ if not raw :
366+ # Fallback keeps a standalone run working if the snapshot is absent,
367+ # though the pipeline always provides it (see module docstring).
368+ raw = find_array_contains_constraints (SCHEMA_DIR )
369+ if raw :
370+ sys .stderr .write (
371+ f" ! { RAW_SCHEMA_DIR } missing; falling back to preprocessed "
372+ "schemas (multi-branch contains may be incomplete)\n "
373+ )
374+ if not raw :
375+ return {}
376+ raw_stems = sorted (raw , key = len , reverse = True )
377+ targets = {}
378+ # Enumerate base + variants from the preprocessed tree; attach raw groups.
379+ for stem , info in find_array_contains_constraints (SCHEMA_DIR ).items ():
380+ origin = next (
381+ (s for s in raw_stems if stem == s or stem .startswith (s + "_" )),
382+ None ,
383+ )
384+ if origin is not None :
385+ targets [info ["title" ]] = raw [origin ]["groups" ]
386+ # Defensive: cover each raw base title even if the preprocessed base lost
387+ # its contains entirely.
388+ for info in raw .values ():
389+ targets .setdefault (info ["title" ], info ["groups" ])
390+ return targets
391+
392+
393+ def _patch_array_contains ():
394+ """Inject array-contains validators; return (patched_count, exit_code)."""
395+ targets = _array_contains_targets ()
396+ if not targets :
397+ sys .stdout .write ("postprocess: no array contains constraints found\n " )
398+ return 0 , 0
399+ patched = 0
400+ for title , groups in sorted (targets .items ()):
401+ alias = _alias_name (title )
402+ hits = []
403+ for path in sorted (OUTPUT_DIR .rglob ("*.py" )):
404+ source = path .read_text (encoding = "utf-8" )
405+ if not re .search (
406+ rf"^{ re .escape (alias )} = TypeAliasType\(" , source , re .M
407+ ):
408+ continue
409+ updated = inject_array_contains (source , alias , groups )
410+ if updated != source :
411+ path .write_text (updated , encoding = "utf-8" )
412+ patched += 1
413+ hits .append (path )
414+ preds = "; " .join (
415+ " & " .join (f"{ f } =={ c !r} " for f , c in g ["pairs" ]) for g in groups
416+ )
417+ label = ", " .join (str (h ) for h in hits ) or "NO GENERATED ALIAS FOUND"
418+ sys .stdout .write (f" contains [{ preds } ] on '{ title } ' -> { label } \n " )
419+ if not hits :
420+ sys .stderr .write (
421+ f" ! '{ title } ' has no generated alias; "
422+ "constraint not enforced\n "
423+ )
424+ return patched , 1
425+ return patched , 0
426+
427+
428+ def main ():
429+ """Main entry point to scan schemas and patch generated models."""
430+ patched_mp , rc_mp = _patch_min_properties ()
431+ patched_ac , rc_ac = _patch_array_contains ()
432+ total = patched_mp + patched_ac
433+ sys .stdout .write (f"postprocess: { total } module(s) patched\n " )
434+ return rc_mp or rc_ac
148435
149436
150437if __name__ == "__main__" :
0 commit comments