Skip to content

Commit bbf715b

Browse files
vishkatykatyalaidamaz91
authored
fix: enforce array contains/minContains/maxContains on generated models (#57)
* fix: enforce array contains/minContains/maxContains on generated models datamodel-code-generator drops contains/minContains/maxContains, so the generated Totals (a bare list[Total] alias) accepts arrays that violate totals.json's "exactly one subtotal and one total" rule. Snapshot the pristine schemas before preprocessing (which merges allOf and would drop the second contains), derive every predicate from contains.properties.*.const, and thread a pydantic AfterValidator enforcing all bounds into the alias metadata — base model and request variants alike. Also fix the moved description import so the SDK-guarded codegen tests run. Data-driven, idempotent, dependency-free. * chore: ignore N801 in generated schemas in ruff config --------- Co-authored-by: Vishal Katyal <vishal@katyal.ai> Co-authored-by: damaz91 <federico.damato91@gmail.com>
1 parent a763549 commit bbf715b

7 files changed

Lines changed: 660 additions & 19 deletions

File tree

generate_models.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ OUTPUT_DIR="src/ucp_sdk/models/schemas"
4646
# Schema directory (relative to this script)
4747
SCHEMA_DIR="ucp/source/schemas"
4848

49+
# Snapshot the pristine schemas before preprocessing. postprocess_models.py
50+
# reads array contains/minContains/maxContains from these originals because
51+
# preprocessing merges allOf branches and a JSON node holds only one contains,
52+
# so a second contains keyword (e.g. "exactly one total") would otherwise be
53+
# silently dropped before the post-processor could see it.
54+
RAW_SCHEMA_DIR="ucp/raw_schemas"
55+
rm -rf "$RAW_SCHEMA_DIR"
56+
cp -R "$SCHEMA_DIR" "$RAW_SCHEMA_DIR"
57+
4958
echo "Preprocessing schemas..."
5059
uv run python preprocess_schemas.py
5160

postprocess_models.py

Lines changed: 298 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,27 @@
2727
keys (``model_extra``) — an explicit null is a present key, and unknown keys
2828
on ``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+
3051
Runs from generate_models.sh between generation and formatting; idempotent.
3152
"""
3253

@@ -36,6 +57,10 @@
3657
from pathlib import Path
3758

3859
SCHEMA_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")
3964
OUTPUT_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

150437
if __name__ == "__main__":

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ skip-magic-trailing-comma = false
6969
line-ending = "auto"
7070

7171
[tool.ruff.lint.per-file-ignores]
72-
"src/ucp_sdk/models/schemas/**/*.py" = ["E501", "D"]
72+
"src/ucp_sdk/models/schemas/**/*.py" = ["E501", "D", "N801"]
7373

7474
[tool.ruff.lint]
7575
select = ["E", "F", "W", "B", "C4", "SIM", "N", "UP", "D", "PTH", "T20"]

0 commit comments

Comments
 (0)