Skip to content

Commit a94e5e7

Browse files
committed
fix(codegen): approximate discriminator array-item retyping
Adds an eighth constraint family: a discriminator retyping an array PROPERTY's items to a schema file different from the property's own base $ref, a shape neither find_conditional_required (adds required fields) nor find_conditional_bounds (narrows numeric ranges) handles. fulfillment_method.json's destinations stays typed to the base FulfillmentDestination (bare type: str, id: str) regardless of type, even though a shipping method's destinations are really ShippingDestination (postal address fields, type const shipping_address) and a pickup method's are really LocationDestination (type const business_location). Pydantic has no clean way to retype a field's item type from a source-text splice the way the other families rewrite an annotation or add a validator against the field's own declared type, so this is enforced with a runtime check instead of a static type change: find_conditional_array_retyping scans for the shape (single-key const/enum discriminator naming a property on the enclosing object, then narrowing exactly one array property, also on the enclosing object, to a different items.$ref) and resolves the retyped file's own (root-level, post-merge) required keys and const-pinned properties via _resolve_referenced_shape. inject_conditional_array_retyping then injects a model_validator that, for each item in the array field when the discriminator matches, checks the item carries every required key (via model_fields_set | model_extra, same key-counting idiom used throughout this module) and that every const-pinned field matches its expected value. This is deliberately an approximation, not a full re-derivation of the retyped type: only the referenced schema's own required/const fields are checked, not fields it in turn allOf-references (shipping_ destination.json's own allOf-ref to postal_address.json is not inspected). For fulfillment_method.json the meaningful check is primarily the type const pin -- id/type were already required by the base FulfillmentDestination, so checking them again is redundant here, but the mechanism is general and both required-keys and const-pins are checked mechanically from whatever the referenced schema declares, not hardcoded to this one case. A request variant that omits the retyped field entirely (fulfillment_ method_create_request.json never carries destinations at all -- ucp_request: "omit") makes the rule inapplicable rather than malformed, silently, mirroring find_conditional_bounds's existing "stripped field" handling. Generator-level change only (postprocess_models.py); no generated model files touched in this commit. Regeneration follows in a separate commit, which is what turns the two still-red semantic tests green (all injector-level unit tests from the RED commit -- which exercise find_conditional_array_retyping/inject_conditional_array_ retyping directly against synthetic fixtures mirroring the real schema shape, not the committed models -- already pass). 101 tests, 2 failures (FulfillmentMethodDestinationRetypingSemanticTest), 4 documented skips.
1 parent 21ab471 commit a94e5e7

1 file changed

Lines changed: 299 additions & 2 deletions

File tree

postprocess_models.py

Lines changed: 299 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-
Seven constraint families are handled:
17+
Eight 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
@@ -83,6 +83,24 @@
8383
fields were stripped by request-variant projection is inapplicable rather than
8484
malformed and is skipped silently.
8585
86+
* A discriminator retyping an array PROPERTY's items to a schema file
87+
different from the property's own base ``$ref`` is dropped entirely, a
88+
third if/then shape distinct from the required-fields and numeric-bounds
89+
families above. ``fulfillment_method.json``'s ``destinations`` stays typed
90+
to the base ``FulfillmentDestination`` regardless of ``type``, even though
91+
a `shipping` method's destinations are really ``ShippingDestination``
92+
(postal address fields, `type` const `shipping_address`) and a `pickup`
93+
method's are really ``LocationDestination`` (`type` const
94+
`business_location`) — so a `shipping` method can currently list a
95+
destination typed `business_location` and it validates. Pydantic has no
96+
clean way to retype a field's item type from a source-text splice, so
97+
this is enforced with a runtime check instead of a static type change:
98+
each item is checked against the referenced schema's own (root-level,
99+
post-merge) required keys and const-pinned properties — an approximation,
100+
not a full re-derivation of the retyped type (a schema the retyped file
101+
itself ``allOf``-references, e.g. ``postal_address.json``, is not
102+
inspected).
103+
86104
* ``additionalProperties: false`` on an object schema with named properties is
87105
normally overridden by the generator's ``--extra-fields=allow`` flag. The
88106
script detects schemas with ``additionalProperties: false`` and flips their
@@ -197,6 +215,47 @@ def {marker}(self):
197215
return self
198216
'''
199217

218+
_RETYPE_MARKER = "_enforce_conditional_item_retyping"
219+
220+
_RETYPE_TEMPLATE = '''
221+
@model_validator(mode="after")
222+
def {marker}(self):
223+
"""JSON Schema if/then: approximate a discriminator's array-item
224+
retyping to a different referenced schema, via that schema's own
225+
required keys and const-pinned fields."""
226+
rules = {rules!r}
227+
for rule in rules:
228+
actual = getattr(self, rule["discriminator"], None)
229+
if actual not in rule["values"]:
230+
continue
231+
for _item in getattr(self, rule["field"], None) or []:
232+
_provided = (
233+
set(_item.keys())
234+
if isinstance(_item, dict)
235+
else _item.model_fields_set | set(_item.model_extra or {{}})
236+
)
237+
for _required in rule["required"]:
238+
if _required not in _provided:
239+
raise ValueError(
240+
f"Field {{_required!r}} is required for "
241+
f"{{rule['field']}} items when "
242+
f"{{rule['discriminator']}} is {{actual!r}}"
243+
)
244+
for _const_field, _const_value in rule["consts"].items():
245+
_actual_value = (
246+
_item.get(_const_field)
247+
if isinstance(_item, dict)
248+
else getattr(_item, _const_field, None)
249+
)
250+
if _actual_value != _const_value:
251+
raise ValueError(
252+
f"Field {{_const_field!r}} must equal "
253+
f"{{_const_value!r}} for {{rule['field']}} items "
254+
f"when {{rule['discriminator']}} is {{actual!r}}"
255+
)
256+
return self
257+
'''
258+
200259
_UNIQUE_VALIDATOR_TEMPLATE = '''
201260
@field_validator("{field}", mode="after")
202261
def {marker}_{field}(cls, value): # noqa: N805
@@ -987,6 +1046,207 @@ def inject_conditional_bounds(source, class_name, rules):
9871046
return _ensure_pydantic_import(out, "model_validator")
9881047

9891048

1049+
def _resolve_referenced_shape(ref, schema_path):
1050+
"""Load ``ref`` (relative to ``schema_path``) and return its own
1051+
(root-level, post-merge) required keys and const-pinned properties.
1052+
1053+
Returns ``None`` if the file cannot be loaded. Deliberately shallow: it
1054+
reads only the referenced schema's own ``required``/``properties``, not
1055+
anything it in turn ``allOf``-references (e.g. shipping_destination.json
1056+
``allOf``-refs postal_address.json, whose fields are not inspected) --
1057+
an approximation, not a full re-derivation of the retyped shape.
1058+
"""
1059+
file_part = ref.split("#", 1)[0]
1060+
if not file_part:
1061+
return None
1062+
target_path = (Path(schema_path).parent / file_part).resolve()
1063+
try:
1064+
referenced = json.loads(target_path.read_text(encoding="utf-8"))
1065+
except (OSError, json.JSONDecodeError):
1066+
sys.stderr.write(
1067+
f" ! {schema_path}: retyped $ref {ref!r} could not be loaded; "
1068+
"rule skipped\n"
1069+
)
1070+
return None
1071+
if not isinstance(referenced, dict):
1072+
return None
1073+
required = sorted(
1074+
name for name in referenced.get("required", []) if isinstance(name, str)
1075+
)
1076+
consts = {
1077+
name: prop["const"]
1078+
for name, prop in (referenced.get("properties") or {}).items()
1079+
if isinstance(prop, dict) and "const" in prop
1080+
}
1081+
return {"required": required, "consts": consts}
1082+
1083+
1084+
def _is_ref_array(node):
1085+
"""True when ``node`` is an array property typed via ``items.$ref``."""
1086+
return (
1087+
isinstance(node, dict)
1088+
and node.get("type") == "array"
1089+
and isinstance(node.get("items"), dict)
1090+
and isinstance(node["items"].get("$ref"), str)
1091+
)
1092+
1093+
1094+
def _describe_retyping_branch(branch, properties, schema_path):
1095+
"""Describe one allOf if/then branch that retypes an array property's
1096+
items to a schema file different from the property's own base ``$ref``.
1097+
1098+
Mechanical and narrow by design, mirroring the other conditional
1099+
scanners in this module: a single-key ``const``/``enum`` discriminator
1100+
naming a property present on the enclosing object, and a ``then`` that
1101+
narrows exactly one array property (also present on the enclosing
1102+
object) to a different ``items.$ref``. Anything else -- multiple
1103+
discriminators, a non-array or non-$ref field, a `then` naming a field
1104+
absent from the enclosing object (a request variant that omits it, as
1105+
fulfillment_method_create_request.json does for ``destinations``) --
1106+
returns ``None`` silently: those are either a different rule shape
1107+
(left to find_conditional_required/find_conditional_bounds, which scan
1108+
the same branches) or legitimately inapplicable, not malformed.
1109+
"""
1110+
if not isinstance(branch, dict) or set(branch) != {"if", "then"}:
1111+
return None
1112+
condition = branch["if"]
1113+
consequence = branch["then"]
1114+
if (
1115+
not isinstance(condition, dict)
1116+
or set(condition) != {"properties", "required"}
1117+
or not isinstance(consequence, dict)
1118+
or set(consequence) != {"properties"}
1119+
):
1120+
return None
1121+
condition_props = condition["properties"]
1122+
condition_required = condition["required"]
1123+
if (
1124+
not isinstance(condition_props, dict)
1125+
or len(condition_props) != 1
1126+
or not isinstance(condition_required, list)
1127+
or len(condition_required) != 1
1128+
):
1129+
return None
1130+
discriminator, predicate = next(iter(condition_props.items()))
1131+
if condition_required != [discriminator] or not isinstance(predicate, dict):
1132+
return None
1133+
if set(predicate) == {"const"}:
1134+
values = [predicate["const"]]
1135+
elif (
1136+
set(predicate) == {"enum"}
1137+
and isinstance(predicate["enum"], list)
1138+
and predicate["enum"]
1139+
):
1140+
values = predicate["enum"]
1141+
else:
1142+
return None
1143+
if discriminator not in properties or any(
1144+
not isinstance(value, (str, int, float, bool)) for value in values
1145+
):
1146+
return None
1147+
consequence_props = consequence["properties"]
1148+
if not isinstance(consequence_props, dict) or len(consequence_props) != 1:
1149+
return None
1150+
field, field_schema = next(iter(consequence_props.items()))
1151+
if field not in properties or not _is_ref_array(field_schema):
1152+
return None
1153+
base_field_schema = properties[field]
1154+
if not _is_ref_array(base_field_schema):
1155+
return None
1156+
base_ref = base_field_schema["items"]["$ref"]
1157+
new_ref = field_schema["items"]["$ref"]
1158+
if new_ref == base_ref:
1159+
return None
1160+
target = _resolve_referenced_shape(new_ref, schema_path)
1161+
if target is None:
1162+
return None
1163+
return {
1164+
"discriminator": discriminator,
1165+
"values": values,
1166+
"field": field,
1167+
"required": target["required"],
1168+
"consts": target["consts"],
1169+
}
1170+
1171+
1172+
def find_conditional_array_retyping(schema_dir):
1173+
"""Map generated class names to array-item retyping rules.
1174+
1175+
Complements find_conditional_required/find_conditional_bounds, which
1176+
only handle a ``then`` that adds required fields or narrows a numeric
1177+
range. A ``then`` that instead retypes an array PROPERTY's items to a
1178+
schema file different from the property's own base ``$ref`` is a third
1179+
shape the generator drops entirely: fulfillment_method.json's
1180+
``destinations`` stays typed to the base FulfillmentDestination
1181+
regardless of ``type``, even though a `shipping` method's destinations
1182+
are really ShippingDestination (postal address fields, `type` const
1183+
`shipping_address`) and a `pickup` method's are really
1184+
LocationDestination (`type` const `business_location`). Pydantic has no
1185+
clean way to retype a field's item type from a source-text splice, so
1186+
this is enforced with a runtime check instead of a static type change:
1187+
each item is checked against the referenced schema's own required keys
1188+
and const-pinned fields (see _resolve_referenced_shape), an
1189+
approximation rather than a full re-derivation of the retyped type.
1190+
"""
1191+
rules_by_class = {}
1192+
1193+
def walk(node, current_class_name, schema_path):
1194+
if not isinstance(node, dict):
1195+
return
1196+
if isinstance(node.get("title"), str):
1197+
current_class_name = _alias_name(node["title"])
1198+
properties = node.get("properties")
1199+
allof = node.get("allOf")
1200+
if isinstance(properties, dict) and isinstance(allof, list):
1201+
for branch in allof:
1202+
rule = _describe_retyping_branch(
1203+
branch, properties, schema_path
1204+
)
1205+
if rule is not None and current_class_name is not None:
1206+
rules_by_class.setdefault(current_class_name, []).append(
1207+
rule
1208+
)
1209+
if isinstance(properties, dict):
1210+
for name, prop in properties.items():
1211+
walk(prop, _to_camel_case(name), schema_path)
1212+
defs = node.get("$defs")
1213+
if isinstance(defs, dict):
1214+
for def_name, def_node in defs.items():
1215+
walk(def_node, _to_camel_case(def_name), schema_path)
1216+
1217+
for path in sorted(Path(schema_dir).rglob("*.json")):
1218+
try:
1219+
schema = json.loads(path.read_text(encoding="utf-8"))
1220+
except (OSError, json.JSONDecodeError):
1221+
continue
1222+
if not isinstance(schema, dict):
1223+
continue
1224+
root_title = schema.get("title")
1225+
initial_class = (
1226+
_alias_name(root_title) if root_title else _to_camel_case(path.stem)
1227+
)
1228+
walk(schema, initial_class, path)
1229+
return rules_by_class
1230+
1231+
1232+
def inject_conditional_array_retyping(source, class_name, rules):
1233+
"""Inject array-item retyping checks into one generated class."""
1234+
class_re = re.compile(rf"^class {re.escape(class_name)}\(", re.M)
1235+
match = class_re.search(source)
1236+
if not match:
1237+
return source
1238+
tail = re.compile(r"^\S", re.M)
1239+
end_match = tail.search(source, match.end())
1240+
end = end_match.start() if end_match else len(source)
1241+
if f"def {_RETYPE_MARKER}(" in source[match.start() : end]:
1242+
return source
1243+
method = _RETYPE_TEMPLATE.format(marker=_RETYPE_MARKER, rules=rules)
1244+
body = source[:end].rstrip("\n")
1245+
rest = source[end:]
1246+
out = body + "\n" + method + ("\n" + rest if rest else "")
1247+
return _ensure_pydantic_import(out, "model_validator")
1248+
1249+
9901250
def find_unique_items_fields(schema_dir):
9911251
"""Map generated class names to fields carrying ``uniqueItems``.
9921252
@@ -1320,6 +1580,41 @@ def _patch_conditional_bounds():
13201580
return patched, 0
13211581

13221582

1583+
def _patch_conditional_array_retyping():
1584+
"""Inject array-item retyping checks; return counts and status."""
1585+
rules_by_class = find_conditional_array_retyping(SCHEMA_DIR)
1586+
if not rules_by_class:
1587+
sys.stdout.write(
1588+
"postprocess: no conditional array-item retyping rules found\n"
1589+
)
1590+
return 0, 0
1591+
patched = 0
1592+
for class_name, rules in sorted(rules_by_class.items()):
1593+
hits = []
1594+
for path in sorted(OUTPUT_DIR.rglob("*.py")):
1595+
source = path.read_text(encoding="utf-8")
1596+
if not re.search(
1597+
rf"^class {re.escape(class_name)}\(", source, re.M
1598+
):
1599+
continue
1600+
updated = inject_conditional_array_retyping(
1601+
source, class_name, rules
1602+
)
1603+
if updated != source:
1604+
path.write_text(updated, encoding="utf-8")
1605+
patched += 1
1606+
hits.append(path)
1607+
label = (
1608+
", ".join(str(path) for path in hits) or "NO GENERATED CLASS FOUND"
1609+
)
1610+
sys.stdout.write(
1611+
f" conditional array-item retyping on '{class_name}' -> {label}\n"
1612+
)
1613+
if not hits:
1614+
return patched, 1
1615+
return patched, 0
1616+
1617+
13231618
def _patch_unique_items():
13241619
"""Inject uniqueItems validators; return (patched_count, exit_code)."""
13251620
unique_fields_by_class = find_unique_items_fields(SCHEMA_DIR)
@@ -1460,6 +1755,7 @@ def main():
14601755
patched_ac, rc_ac = _patch_array_contains()
14611756
patched_cr, rc_cr = _patch_conditional_required()
14621757
patched_cb, rc_cb = _patch_conditional_bounds()
1758+
patched_rt, rc_rt = _patch_conditional_array_retyping()
14631759
patched_ui, rc_ui = _patch_unique_items()
14641760
patched_ef, rc_ef = _patch_extra_forbid()
14651761
total = (
@@ -1468,11 +1764,12 @@ def main():
14681764
+ patched_ac
14691765
+ patched_cr
14701766
+ patched_cb
1767+
+ patched_rt
14711768
+ patched_ui
14721769
+ patched_ef
14731770
)
14741771
sys.stdout.write(f"postprocess: {total} module(s) patched\n")
1475-
return rc_mp or rc_pn or rc_ac or rc_cr or rc_cb or rc_ui or rc_ef
1772+
return rc_mp or rc_pn or rc_ac or rc_cr or rc_cb or rc_rt or rc_ui or rc_ef
14761773

14771774

14781775
if __name__ == "__main__":

0 commit comments

Comments
 (0)