Skip to content

Commit b0d4924

Browse files
authored
fix: enforce conditional required fields in generated models (#69)
1 parent 816bbab commit b0d4924

3 files changed

Lines changed: 302 additions & 3 deletions

File tree

postprocess_models.py

Lines changed: 192 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@
6767
injects a ``field_validator(mode="after")`` into each generated class that
6868
declares a matching list field.
6969
70+
* Simple conditional ``required`` constraints are dropped: pagination requires
71+
``cursor`` when ``has_next_page`` is true, but the generated response model
72+
always treats it as optional. The script accepts only an unambiguous single
73+
required discriminator using ``const``/``enum`` and a ``then.required`` list,
74+
then injects a ``model_validator(mode="after")``. More complex conditions are
75+
skipped rather than approximated.
76+
7077
Runs from generate_models.sh between generation and formatting; idempotent.
7178
"""
7279

@@ -117,6 +124,24 @@ def {marker}(self):
117124

118125
_UNIQUE_MARKER = "_enforce_unique_items"
119126

127+
_CONDITIONAL_REQUIRED_MARKER = "_enforce_conditional_required"
128+
129+
_CONDITIONAL_REQUIRED_TEMPLATE = '''
130+
@model_validator(mode="after")
131+
def {marker}(self):
132+
"""JSON Schema if/then: enforce conditionally required fields."""
133+
rules = {rules!r}
134+
for rule in rules:
135+
if getattr(self, rule["discriminator"], None) not in rule["values"]:
136+
continue
137+
for field in rule["required"]:
138+
if field not in self.model_fields_set:
139+
raise ValueError(
140+
f"Field {{field!r}} is required by a schema condition"
141+
)
142+
return self
143+
'''
144+
120145
_UNIQUE_VALIDATOR_TEMPLATE = '''
121146
@field_validator("{field}", mode="after")
122147
def {marker}_{field}(cls, value): # noqa: N805
@@ -528,6 +553,137 @@ def inject_array_contains(source, alias_name, groups):
528553
return _ensure_pydantic_import(out, "AfterValidator")
529554

530555

556+
def find_conditional_required(schema_dir):
557+
"""Map generated class names to simple if/then required rules."""
558+
rules_by_class = {}
559+
560+
def describe(node, properties):
561+
if not isinstance(node, dict) or set(node) != {"if", "then"}:
562+
return None
563+
condition = node["if"]
564+
consequence = node["then"]
565+
if (
566+
not isinstance(condition, dict)
567+
or set(condition) != {"properties", "required"}
568+
or not isinstance(consequence, dict)
569+
or set(consequence) != {"required"}
570+
):
571+
return None
572+
condition_props = condition["properties"]
573+
condition_required = condition["required"]
574+
consequence_required = consequence["required"]
575+
if (
576+
not isinstance(condition_props, dict)
577+
or len(condition_props) != 1
578+
or not isinstance(condition_required, list)
579+
or len(condition_required) != 1
580+
or not isinstance(consequence_required, list)
581+
or not consequence_required
582+
):
583+
return None
584+
discriminator, predicate = next(iter(condition_props.items()))
585+
if condition_required != [discriminator] or not isinstance(
586+
predicate, dict
587+
):
588+
return None
589+
if set(predicate) == {"const"}:
590+
values = [predicate["const"]]
591+
elif (
592+
set(predicate) == {"enum"}
593+
and isinstance(predicate["enum"], list)
594+
and predicate["enum"]
595+
):
596+
values = predicate["enum"]
597+
else:
598+
return None
599+
if (
600+
discriminator not in properties
601+
or any(
602+
not isinstance(name, str) or name not in properties
603+
for name in consequence_required
604+
)
605+
or any(
606+
not isinstance(value, (str, int, float, bool))
607+
for value in values
608+
)
609+
):
610+
return None
611+
return {
612+
"discriminator": discriminator,
613+
"values": values,
614+
"required": sorted(consequence_required),
615+
}
616+
617+
def walk(node, current_class_name, path_str):
618+
if not isinstance(node, dict):
619+
return
620+
if isinstance(node.get("title"), str):
621+
current_class_name = _alias_name(node["title"])
622+
properties = node.get("properties")
623+
then = node.get("then")
624+
is_required_rule = isinstance(then, dict) and "required" in then
625+
if isinstance(properties, dict) and is_required_rule:
626+
if "else" in node:
627+
rule = None
628+
else:
629+
rule = describe(
630+
{key: node[key] for key in ("if", "then") if key in node},
631+
properties,
632+
)
633+
if rule is None:
634+
sys.stderr.write(
635+
f" ! {path_str}: unsupported conditional required rule; skipped\n"
636+
)
637+
elif current_class_name is not None:
638+
rules_by_class.setdefault(current_class_name, []).append(rule)
639+
if isinstance(properties, dict):
640+
for name, prop in properties.items():
641+
walk(prop, _to_camel_case(name), path_str)
642+
defs = node.get("$defs")
643+
if isinstance(defs, dict):
644+
for def_name, def_node in defs.items():
645+
walk(def_node, _to_camel_case(def_name), path_str)
646+
for key in ("allOf", "anyOf", "oneOf"):
647+
if isinstance(node.get(key), list):
648+
for item in node[key]:
649+
walk(item, current_class_name, path_str)
650+
651+
for path in sorted(Path(schema_dir).rglob("*.json")):
652+
try:
653+
schema = json.loads(path.read_text(encoding="utf-8"))
654+
except (OSError, json.JSONDecodeError):
655+
continue
656+
if not isinstance(schema, dict):
657+
continue
658+
root_title = schema.get("title")
659+
initial_class = (
660+
_alias_name(root_title) if root_title else _to_camel_case(path.stem)
661+
)
662+
walk(schema, initial_class, str(path))
663+
return rules_by_class
664+
665+
666+
def inject_conditional_required(source, class_name, rules):
667+
"""Inject simple conditional-required checks into one generated class."""
668+
class_re = re.compile(rf"^class {re.escape(class_name)}\(", re.M)
669+
match = class_re.search(source)
670+
if not match:
671+
return source
672+
tail = re.compile(r"^\S", re.M)
673+
end_match = tail.search(source, match.end())
674+
end = end_match.start() if end_match else len(source)
675+
if f"def {_CONDITIONAL_REQUIRED_MARKER}(" in source[match.start() : end]:
676+
return source
677+
method = _CONDITIONAL_REQUIRED_TEMPLATE.format(
678+
marker=_CONDITIONAL_REQUIRED_MARKER,
679+
rules=rules,
680+
)
681+
body = source[:end].rstrip("\n")
682+
rest = source[end:]
683+
out = body + "\n" + method + ("\n" + rest if rest else "")
684+
return _ensure_pydantic_import(out, "model_validator")
685+
686+
531687
def find_unique_items_fields(schema_dir):
532688
"""Map generated class names to fields carrying ``uniqueItems``.
533689
@@ -787,6 +943,39 @@ def _patch_array_contains():
787943
return patched, 0
788944

789945

946+
def _patch_conditional_required():
947+
"""Inject conditional-required validators; return counts and status."""
948+
rules_by_class = find_conditional_required(SCHEMA_DIR)
949+
if not rules_by_class:
950+
sys.stdout.write(
951+
"postprocess: no simple conditional required rules found\n"
952+
)
953+
return 0, 0
954+
patched = 0
955+
for class_name, rules in sorted(rules_by_class.items()):
956+
hits = []
957+
for path in sorted(OUTPUT_DIR.rglob("*.py")):
958+
source = path.read_text(encoding="utf-8")
959+
if not re.search(
960+
rf"^class {re.escape(class_name)}\(", source, re.M
961+
):
962+
continue
963+
updated = inject_conditional_required(source, class_name, rules)
964+
if updated != source:
965+
path.write_text(updated, encoding="utf-8")
966+
patched += 1
967+
hits.append(path)
968+
label = (
969+
", ".join(str(path) for path in hits) or "NO GENERATED CLASS FOUND"
970+
)
971+
sys.stdout.write(
972+
f" conditional required on '{class_name}' -> {label}\n"
973+
)
974+
if not hits:
975+
return patched, 1
976+
return patched, 0
977+
978+
790979
def _patch_unique_items():
791980
"""Inject uniqueItems validators; return (patched_count, exit_code)."""
792981
unique_fields_by_class = find_unique_items_fields(SCHEMA_DIR)
@@ -820,10 +1009,11 @@ def main():
8201009
patched_mp, rc_mp = _patch_min_properties()
8211010
patched_pn, rc_pn = _patch_property_names()
8221011
patched_ac, rc_ac = _patch_array_contains()
1012+
patched_cr, rc_cr = _patch_conditional_required()
8231013
patched_ui, rc_ui = _patch_unique_items()
824-
total = patched_mp + patched_pn + patched_ac + patched_ui
1014+
total = patched_mp + patched_pn + patched_ac + patched_cr + patched_ui
8251015
sys.stdout.write(f"postprocess: {total} module(s) patched\n")
826-
return rc_mp or rc_pn or rc_ac or rc_ui
1016+
return rc_mp or rc_pn or rc_ac or rc_cr or rc_ui
8271017

8281018

8291019
if __name__ == "__main__":

src/ucp_sdk/models/schemas/shopping/types/pagination.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from __future__ import annotations
2020

21-
from pydantic import BaseModel, ConfigDict, Field
21+
from pydantic import BaseModel, ConfigDict, Field, model_validator
2222

2323

2424
class Pagination(BaseModel):
@@ -69,3 +69,23 @@ class Response(BaseModel):
6969
"""
7070
Total number of matching items, if available.
7171
"""
72+
73+
@model_validator(mode="after")
74+
def _enforce_conditional_required(self):
75+
"""JSON Schema if/then: enforce conditionally required fields."""
76+
rules = [
77+
{
78+
"discriminator": "has_next_page",
79+
"values": [True],
80+
"required": ["cursor"],
81+
}
82+
]
83+
for rule in rules:
84+
if getattr(self, rule["discriminator"], None) not in rule["values"]:
85+
continue
86+
for field in rule["required"]:
87+
if field not in self.model_fields_set:
88+
raise ValueError(
89+
f"Field {field!r} is required by a schema condition"
90+
)
91+
return self

tests/test_codegen_pipeline.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -984,6 +984,95 @@ def test_injected_validator_enforces_pattern(self):
984984
signals_cls.model_validate({"com.example.ok": "v"})
985985

986986

987+
class ConditionalRequiredInjectorTest(unittest.TestCase):
988+
"""Simple JSON Schema if/then required constraints are restored."""
989+
990+
MODULE = (
991+
"from __future__ import annotations\n"
992+
"\n"
993+
"from pydantic import BaseModel, ConfigDict\n"
994+
"\n"
995+
"\n"
996+
"class Response(BaseModel):\n"
997+
' model_config = ConfigDict(extra="allow")\n'
998+
" cursor: str | None = None\n"
999+
" has_next_page: bool\n"
1000+
)
1001+
RULES = [
1002+
{
1003+
"discriminator": "has_next_page",
1004+
"values": [True],
1005+
"required": ["cursor"],
1006+
}
1007+
]
1008+
1009+
def test_schema_scan_maps_nested_definition_to_generated_class(self):
1010+
schema = {
1011+
"title": "Pagination",
1012+
"type": "object",
1013+
"$defs": {
1014+
"response": {
1015+
"type": "object",
1016+
"properties": {
1017+
"cursor": {"type": "string"},
1018+
"has_next_page": {"type": "boolean"},
1019+
},
1020+
"if": {
1021+
"properties": {"has_next_page": {"const": True}},
1022+
"required": ["has_next_page"],
1023+
},
1024+
"then": {"required": ["cursor"]},
1025+
}
1026+
},
1027+
}
1028+
with tempfile.TemporaryDirectory() as tmp:
1029+
Path(tmp, "pagination.json").write_text(json.dumps(schema))
1030+
found = postprocess_models.find_conditional_required(Path(tmp))
1031+
self.assertEqual(found, {"Response": self.RULES})
1032+
1033+
def test_schema_scan_skips_else_branches(self):
1034+
schema = {
1035+
"title": "Response",
1036+
"type": "object",
1037+
"properties": {
1038+
"cursor": {"type": "string"},
1039+
"has_next_page": {"type": "boolean"},
1040+
},
1041+
"if": {
1042+
"properties": {"has_next_page": {"const": True}},
1043+
"required": ["has_next_page"],
1044+
},
1045+
"then": {"required": ["cursor"]},
1046+
"else": {"required": ["other"]},
1047+
}
1048+
with tempfile.TemporaryDirectory() as tmp:
1049+
Path(tmp, "response.json").write_text(json.dumps(schema))
1050+
found = postprocess_models.find_conditional_required(Path(tmp))
1051+
self.assertEqual(found, {})
1052+
1053+
def test_injection_is_idempotent(self):
1054+
once = postprocess_models.inject_conditional_required(
1055+
self.MODULE, "Response", self.RULES
1056+
)
1057+
twice = postprocess_models.inject_conditional_required(
1058+
once, "Response", self.RULES
1059+
)
1060+
self.assertEqual(once, twice)
1061+
1062+
@unittest.skipUnless(HAVE_SDK, "executing the module needs pydantic")
1063+
def test_injected_validator_enforces_conditional_required(self):
1064+
out = postprocess_models.inject_conditional_required(
1065+
self.MODULE, "Response", self.RULES
1066+
)
1067+
namespace: dict = {}
1068+
exec(compile(out, "<injected>", "exec"), namespace) # noqa: S102
1069+
response = namespace["Response"]
1070+
with self.assertRaises(ValidationError):
1071+
response(has_next_page=True)
1072+
response(has_next_page=True, cursor="next-page")
1073+
response(has_next_page=False)
1074+
1075+
9871076
class InjectorTest(unittest.TestCase):
9881077
"""The post-generation injector's own behavior."""
9891078

0 commit comments

Comments
 (0)