Skip to content

Commit 5c3f0d4

Browse files
committed
fix: enforce conditional numeric bounds in generated models
1 parent a0d8308 commit 5c3f0d4

4 files changed

Lines changed: 446 additions & 4 deletions

File tree

postprocess_models.py

Lines changed: 245 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
"""Post-generation fixes for constraints datamodel-code-generator ignores.
1616
17-
Six constraint families are handled:
17+
Seven constraint families are handled:
1818
1919
* ``minProperties`` on an object schema WITH declared properties is dropped by
2020
the generator (issue #49): every field is optional, so an empty instance
@@ -74,6 +74,15 @@
7474
then injects a ``model_validator(mode="after")``. More complex conditions are
7575
skipped rather than approximated.
7676
77+
* Conditional numeric bounds are dropped for the same reason: ``total.json``
78+
requires a ``discount`` amount to be negative and a ``tax`` amount to be
79+
non-negative via if/then branches, but the generated ``Total`` carries no
80+
validator, so a positive discount validates. These rules are carried as
81+
``allOf`` branches, which have no sibling ``properties`` of their own, so the
82+
scan validates them against the enclosing object's property set. A rule whose
83+
fields were stripped by request-variant projection is inapplicable rather than
84+
malformed and is skipped silently.
85+
7786
* ``additionalProperties: false`` on an object schema with named properties is
7887
normally overridden by the generator's ``--extra-fields=allow`` flag. The
7988
script detects schemas with ``additionalProperties: false`` and flips their
@@ -148,6 +157,46 @@ def {marker}(self):
148157
return self
149158
'''
150159

160+
_CONDITIONAL_BOUNDS_MARKER = "_enforce_conditional_bounds"
161+
162+
# Returned when a rule is well-formed but names fields absent from the class it
163+
# would apply to — distinct from None, which means the shape is unsupported and
164+
# warrants a warning.
165+
_RULE_NOT_APPLICABLE = object()
166+
167+
# Keyword -> (comparison rendered in the message, python operator name). The
168+
# operator is applied as "value <op> limit" and a true result is a violation.
169+
_BOUND_KEYWORDS = {
170+
"minimum": (">=", "lt"),
171+
"maximum": ("<=", "gt"),
172+
"exclusiveMinimum": (">", "le"),
173+
"exclusiveMaximum": ("<", "ge"),
174+
}
175+
176+
_CONDITIONAL_BOUNDS_TEMPLATE = '''
177+
@model_validator(mode="after")
178+
def {marker}(self):
179+
"""JSON Schema if/then: enforce conditional numeric bounds."""
180+
rules = {rules!r}
181+
checks = {checks!r}
182+
for rule in rules:
183+
actual = getattr(self, rule["discriminator"], None)
184+
if actual not in rule["values"]:
185+
continue
186+
for field, bounds in rule["bounds"].items():
187+
value = getattr(self, field, None)
188+
if value is None:
189+
continue
190+
for keyword, limit in bounds.items():
191+
symbol, op_name = checks[keyword]
192+
if getattr(operator, op_name)(value, limit):
193+
raise ValueError(
194+
f"Field {{field!r}} must be {{symbol}} {{limit}} "
195+
f"when {{rule['discriminator']}} is {{actual!r}}"
196+
)
197+
return self
198+
'''
199+
151200
_UNIQUE_VALIDATOR_TEMPLATE = '''
152201
@field_validator("{field}", mode="after")
153202
def {marker}_{field}(cls, value): # noqa: N805
@@ -690,6 +739,169 @@ def inject_conditional_required(source, class_name, rules):
690739
return _ensure_pydantic_import(out, "model_validator")
691740

692741

742+
def find_conditional_bounds(schema_dir):
743+
"""Map generated class names to if/then numeric-bound rules.
744+
745+
Complements find_conditional_required, which only handles a ``then`` that
746+
adds required fields. A ``then`` that instead narrows a numeric range is
747+
dropped by datamodel-code-generator, so the constraint would otherwise be
748+
absent from the generated model entirely.
749+
"""
750+
rules_by_class = {}
751+
752+
def describe(node, properties):
753+
if not isinstance(node, dict) or set(node) != {"if", "then"}:
754+
return None
755+
condition = node["if"]
756+
consequence = node["then"]
757+
if (
758+
not isinstance(condition, dict)
759+
or set(condition) != {"properties", "required"}
760+
or not isinstance(consequence, dict)
761+
or set(consequence) != {"properties"}
762+
):
763+
return None
764+
condition_props = condition["properties"]
765+
condition_required = condition["required"]
766+
consequence_props = consequence["properties"]
767+
if (
768+
not isinstance(condition_props, dict)
769+
or len(condition_props) != 1
770+
or not isinstance(condition_required, list)
771+
or len(condition_required) != 1
772+
or not isinstance(consequence_props, dict)
773+
or not consequence_props
774+
):
775+
return None
776+
discriminator, predicate = next(iter(condition_props.items()))
777+
if condition_required != [discriminator] or not isinstance(
778+
predicate, dict
779+
):
780+
return None
781+
if set(predicate) == {"const"}:
782+
values = [predicate["const"]]
783+
elif (
784+
set(predicate) == {"enum"}
785+
and isinstance(predicate["enum"], list)
786+
and predicate["enum"]
787+
):
788+
values = predicate["enum"]
789+
else:
790+
return None
791+
if any(
792+
not isinstance(value, (str, int, float, bool)) for value in values
793+
):
794+
return None
795+
bounds = {}
796+
for name, constraint in consequence_props.items():
797+
if (
798+
not isinstance(name, str)
799+
or not isinstance(constraint, dict)
800+
or not constraint
801+
or set(constraint) - set(_BOUND_KEYWORDS)
802+
):
803+
return None
804+
if any(
805+
not isinstance(limit, (int, float)) or isinstance(limit, bool)
806+
for limit in constraint.values()
807+
):
808+
return None
809+
bounds[name] = dict(constraint)
810+
# A request variant strips the fields a platform must not send, so a
811+
# rule naming one is inapplicable to that class rather than malformed.
812+
if discriminator not in properties or any(
813+
name not in properties for name in bounds
814+
):
815+
return _RULE_NOT_APPLICABLE
816+
return {
817+
"discriminator": discriminator,
818+
"values": values,
819+
"bounds": bounds,
820+
}
821+
822+
def walk(node, current_class_name, path_str, enclosing_properties=None):
823+
if not isinstance(node, dict):
824+
return
825+
if isinstance(node.get("title"), str):
826+
current_class_name = _alias_name(node["title"])
827+
properties = node.get("properties")
828+
# An if/then pair carried as an allOf branch has no sibling properties:
829+
# the object it constrains is the enclosing schema, so its property set
830+
# is what the rule must be validated against.
831+
scope = (
832+
properties if isinstance(properties, dict) else enclosing_properties
833+
)
834+
then = node.get("then")
835+
is_bounds_rule = (
836+
isinstance(then, dict)
837+
and "properties" in then
838+
and "required" not in then
839+
)
840+
if isinstance(scope, dict) and is_bounds_rule:
841+
rule = (
842+
None
843+
if "else" in node
844+
else describe(
845+
{key: node[key] for key in ("if", "then") if key in node},
846+
scope,
847+
)
848+
)
849+
if rule is None:
850+
sys.stderr.write(
851+
f" ! {path_str}: unsupported conditional bounds rule; skipped\n"
852+
)
853+
elif rule is not _RULE_NOT_APPLICABLE and current_class_name:
854+
rules_by_class.setdefault(current_class_name, []).append(rule)
855+
if isinstance(properties, dict):
856+
for name, prop in properties.items():
857+
walk(prop, _to_camel_case(name), path_str)
858+
defs = node.get("$defs")
859+
if isinstance(defs, dict):
860+
for def_name, def_node in defs.items():
861+
walk(def_node, _to_camel_case(def_name), path_str)
862+
for key in ("allOf", "anyOf", "oneOf"):
863+
if isinstance(node.get(key), list):
864+
for item in node[key]:
865+
walk(item, current_class_name, path_str, scope)
866+
867+
for path in sorted(Path(schema_dir).rglob("*.json")):
868+
try:
869+
schema = json.loads(path.read_text(encoding="utf-8"))
870+
except (OSError, json.JSONDecodeError):
871+
continue
872+
if not isinstance(schema, dict):
873+
continue
874+
root_title = schema.get("title")
875+
initial_class = (
876+
_alias_name(root_title) if root_title else _to_camel_case(path.stem)
877+
)
878+
walk(schema, initial_class, str(path))
879+
return rules_by_class
880+
881+
882+
def inject_conditional_bounds(source, class_name, rules):
883+
"""Inject conditional numeric-bound checks into one generated class."""
884+
class_re = re.compile(rf"^class {re.escape(class_name)}\(", re.M)
885+
match = class_re.search(source)
886+
if not match:
887+
return source
888+
tail = re.compile(r"^\S", re.M)
889+
end_match = tail.search(source, match.end())
890+
end = end_match.start() if end_match else len(source)
891+
if f"def {_CONDITIONAL_BOUNDS_MARKER}(" in source[match.start() : end]:
892+
return source
893+
method = _CONDITIONAL_BOUNDS_TEMPLATE.format(
894+
marker=_CONDITIONAL_BOUNDS_MARKER,
895+
rules=rules,
896+
checks=_BOUND_KEYWORDS,
897+
)
898+
body = source[:end].rstrip("\n")
899+
rest = source[end:]
900+
out = body + "\n" + method + ("\n" + rest if rest else "")
901+
out = _ensure_stdlib_import(out, "import operator")
902+
return _ensure_pydantic_import(out, "model_validator")
903+
904+
693905
def find_unique_items_fields(schema_dir):
694906
"""Map generated class names to fields carrying ``uniqueItems``.
695907
@@ -982,6 +1194,35 @@ def _patch_conditional_required():
9821194
return patched, 0
9831195

9841196

1197+
def _patch_conditional_bounds():
1198+
"""Inject conditional numeric-bound validators; return counts and status."""
1199+
rules_by_class = find_conditional_bounds(SCHEMA_DIR)
1200+
if not rules_by_class:
1201+
sys.stdout.write("postprocess: no conditional bounds rules found\n")
1202+
return 0, 0
1203+
patched = 0
1204+
for class_name, rules in sorted(rules_by_class.items()):
1205+
hits = []
1206+
for path in sorted(OUTPUT_DIR.rglob("*.py")):
1207+
source = path.read_text(encoding="utf-8")
1208+
if not re.search(
1209+
rf"^class {re.escape(class_name)}\(", source, re.M
1210+
):
1211+
continue
1212+
updated = inject_conditional_bounds(source, class_name, rules)
1213+
if updated != source:
1214+
path.write_text(updated, encoding="utf-8")
1215+
patched += 1
1216+
hits.append(path)
1217+
label = (
1218+
", ".join(str(path) for path in hits) or "NO GENERATED CLASS FOUND"
1219+
)
1220+
sys.stdout.write(f" conditional bounds on '{class_name}' -> {label}\n")
1221+
if not hits:
1222+
return patched, 1
1223+
return patched, 0
1224+
1225+
9851226
def _patch_unique_items():
9861227
"""Inject uniqueItems validators; return (patched_count, exit_code)."""
9871228
unique_fields_by_class = find_unique_items_fields(SCHEMA_DIR)
@@ -1121,18 +1362,20 @@ def main():
11211362
patched_pn, rc_pn = _patch_property_names()
11221363
patched_ac, rc_ac = _patch_array_contains()
11231364
patched_cr, rc_cr = _patch_conditional_required()
1365+
patched_cb, rc_cb = _patch_conditional_bounds()
11241366
patched_ui, rc_ui = _patch_unique_items()
11251367
patched_ef, rc_ef = _patch_extra_forbid()
11261368
total = (
11271369
patched_mp
11281370
+ patched_pn
11291371
+ patched_ac
11301372
+ patched_cr
1373+
+ patched_cb
11311374
+ patched_ui
11321375
+ patched_ef
11331376
)
11341377
sys.stdout.write(f"postprocess: {total} module(s) patched\n")
1135-
return rc_mp or rc_pn or rc_ac or rc_cr or rc_ui or rc_ef
1378+
return rc_mp or rc_pn or rc_ac or rc_cr or rc_cb or rc_ui or rc_ef
11361379

11371380

11381381
if __name__ == "__main__":

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

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

1919
from __future__ import annotations
2020

21-
from pydantic import BaseModel, ConfigDict
21+
import operator
22+
23+
from pydantic import BaseModel, ConfigDict, model_validator
2224

2325
from . import signed_amount
2426

@@ -40,3 +42,41 @@ class Total(BaseModel):
4042
Text to display against the amount. Should reflect appropriate method (e.g., 'Shipping', 'Delivery').
4143
"""
4244
amount: signed_amount.SignedAmount
45+
46+
@model_validator(mode="after")
47+
def _enforce_conditional_bounds(self):
48+
"""JSON Schema if/then: enforce conditional numeric bounds."""
49+
rules = [
50+
{
51+
"discriminator": "type",
52+
"values": ["discount", "items_discount"],
53+
"bounds": {"amount": {"exclusiveMaximum": 0}},
54+
},
55+
{
56+
"discriminator": "type",
57+
"values": ["subtotal", "fulfillment", "tax", "fee"],
58+
"bounds": {"amount": {"minimum": 0}},
59+
},
60+
]
61+
checks = {
62+
"minimum": (">=", "lt"),
63+
"maximum": ("<=", "gt"),
64+
"exclusiveMinimum": (">", "le"),
65+
"exclusiveMaximum": ("<", "ge"),
66+
}
67+
for rule in rules:
68+
actual = getattr(self, rule["discriminator"], None)
69+
if actual not in rule["values"]:
70+
continue
71+
for field, bounds in rule["bounds"].items():
72+
value = getattr(self, field, None)
73+
if value is None:
74+
continue
75+
for keyword, limit in bounds.items():
76+
symbol, op_name = checks[keyword]
77+
if getattr(operator, op_name)(value, limit):
78+
raise ValueError(
79+
f"Field {field!r} must be {symbol} {limit} "
80+
f"when {rule['discriminator']} is {actual!r}"
81+
)
82+
return self

0 commit comments

Comments
 (0)