Skip to content

Commit f864c2d

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 8672472 commit f864c2d

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
@@ -94,6 +94,24 @@
9494
As with conditional required rules above, a branch's own documentation
9595
``title`` is never adopted as the enclosing class name.
9696
97+
* A discriminator retyping an array PROPERTY's items to a schema file
98+
different from the property's own base ``$ref`` is dropped entirely, a
99+
third if/then shape distinct from the required-fields and numeric-bounds
100+
families above. ``fulfillment_method.json``'s ``destinations`` stays typed
101+
to the base ``FulfillmentDestination`` regardless of ``type``, even though
102+
a `shipping` method's destinations are really ``ShippingDestination``
103+
(postal address fields, `type` const `shipping_address`) and a `pickup`
104+
method's are really ``LocationDestination`` (`type` const
105+
`business_location`) — so a `shipping` method can currently list a
106+
destination typed `business_location` and it validates. Pydantic has no
107+
clean way to retype a field's item type from a source-text splice, so
108+
this is enforced with a runtime check instead of a static type change:
109+
each item is checked against the referenced schema's own (root-level,
110+
post-merge) required keys and const-pinned properties — an approximation,
111+
not a full re-derivation of the retyped type (a schema the retyped file
112+
itself ``allOf``-references, e.g. ``postal_address.json``, is not
113+
inspected).
114+
97115
* ``additionalProperties: false`` on an object schema with named properties is
98116
normally overridden by the generator's ``--extra-fields=allow`` flag. The
99117
script detects schemas with ``additionalProperties: false`` and flips their
@@ -212,6 +230,47 @@ def {marker}(self):
212230
return self
213231
'''
214232

233+
_RETYPE_MARKER = "_enforce_conditional_item_retyping"
234+
235+
_RETYPE_TEMPLATE = '''
236+
@model_validator(mode="after")
237+
def {marker}(self):
238+
"""JSON Schema if/then: approximate a discriminator's array-item
239+
retyping to a different referenced schema, via that schema's own
240+
required keys and const-pinned fields."""
241+
rules = {rules!r}
242+
for rule in rules:
243+
actual = getattr(self, rule["discriminator"], None)
244+
if actual not in rule["values"]:
245+
continue
246+
for _item in getattr(self, rule["field"], None) or []:
247+
_provided = (
248+
set(_item.keys())
249+
if isinstance(_item, dict)
250+
else _item.model_fields_set | set(_item.model_extra or {{}})
251+
)
252+
for _required in rule["required"]:
253+
if _required not in _provided:
254+
raise ValueError(
255+
f"Field {{_required!r}} is required for "
256+
f"{{rule['field']}} items when "
257+
f"{{rule['discriminator']}} is {{actual!r}}"
258+
)
259+
for _const_field, _const_value in rule["consts"].items():
260+
_actual_value = (
261+
_item.get(_const_field)
262+
if isinstance(_item, dict)
263+
else getattr(_item, _const_field, None)
264+
)
265+
if _actual_value != _const_value:
266+
raise ValueError(
267+
f"Field {{_const_field!r}} must equal "
268+
f"{{_const_value!r}} for {{rule['field']}} items "
269+
f"when {{rule['discriminator']}} is {{actual!r}}"
270+
)
271+
return self
272+
'''
273+
215274
_UNIQUE_VALIDATOR_TEMPLATE = '''
216275
@field_validator("{field}", mode="after")
217276
def {marker}_{field}(cls, value): # noqa: N805
@@ -1045,6 +1104,207 @@ def inject_conditional_bounds(source, class_name, rules):
10451104
return _ensure_pydantic_import(out, "model_validator")
10461105

10471106

1107+
def _resolve_referenced_shape(ref, schema_path):
1108+
"""Load ``ref`` (relative to ``schema_path``) and return its own
1109+
(root-level, post-merge) required keys and const-pinned properties.
1110+
1111+
Returns ``None`` if the file cannot be loaded. Deliberately shallow: it
1112+
reads only the referenced schema's own ``required``/``properties``, not
1113+
anything it in turn ``allOf``-references (e.g. shipping_destination.json
1114+
``allOf``-refs postal_address.json, whose fields are not inspected) --
1115+
an approximation, not a full re-derivation of the retyped shape.
1116+
"""
1117+
file_part = ref.split("#", 1)[0]
1118+
if not file_part:
1119+
return None
1120+
target_path = (Path(schema_path).parent / file_part).resolve()
1121+
try:
1122+
referenced = json.loads(target_path.read_text(encoding="utf-8"))
1123+
except (OSError, json.JSONDecodeError):
1124+
sys.stderr.write(
1125+
f" ! {schema_path}: retyped $ref {ref!r} could not be loaded; "
1126+
"rule skipped\n"
1127+
)
1128+
return None
1129+
if not isinstance(referenced, dict):
1130+
return None
1131+
required = sorted(
1132+
name for name in referenced.get("required", []) if isinstance(name, str)
1133+
)
1134+
consts = {
1135+
name: prop["const"]
1136+
for name, prop in (referenced.get("properties") or {}).items()
1137+
if isinstance(prop, dict) and "const" in prop
1138+
}
1139+
return {"required": required, "consts": consts}
1140+
1141+
1142+
def _is_ref_array(node):
1143+
"""True when ``node`` is an array property typed via ``items.$ref``."""
1144+
return (
1145+
isinstance(node, dict)
1146+
and node.get("type") == "array"
1147+
and isinstance(node.get("items"), dict)
1148+
and isinstance(node["items"].get("$ref"), str)
1149+
)
1150+
1151+
1152+
def _describe_retyping_branch(branch, properties, schema_path):
1153+
"""Describe one allOf if/then branch that retypes an array property's
1154+
items to a schema file different from the property's own base ``$ref``.
1155+
1156+
Mechanical and narrow by design, mirroring the other conditional
1157+
scanners in this module: a single-key ``const``/``enum`` discriminator
1158+
naming a property present on the enclosing object, and a ``then`` that
1159+
narrows exactly one array property (also present on the enclosing
1160+
object) to a different ``items.$ref``. Anything else -- multiple
1161+
discriminators, a non-array or non-$ref field, a `then` naming a field
1162+
absent from the enclosing object (a request variant that omits it, as
1163+
fulfillment_method_create_request.json does for ``destinations``) --
1164+
returns ``None`` silently: those are either a different rule shape
1165+
(left to find_conditional_required/find_conditional_bounds, which scan
1166+
the same branches) or legitimately inapplicable, not malformed.
1167+
"""
1168+
if not isinstance(branch, dict) or set(branch) != {"if", "then"}:
1169+
return None
1170+
condition = branch["if"]
1171+
consequence = branch["then"]
1172+
if (
1173+
not isinstance(condition, dict)
1174+
or set(condition) != {"properties", "required"}
1175+
or not isinstance(consequence, dict)
1176+
or set(consequence) != {"properties"}
1177+
):
1178+
return None
1179+
condition_props = condition["properties"]
1180+
condition_required = condition["required"]
1181+
if (
1182+
not isinstance(condition_props, dict)
1183+
or len(condition_props) != 1
1184+
or not isinstance(condition_required, list)
1185+
or len(condition_required) != 1
1186+
):
1187+
return None
1188+
discriminator, predicate = next(iter(condition_props.items()))
1189+
if condition_required != [discriminator] or not isinstance(predicate, dict):
1190+
return None
1191+
if set(predicate) == {"const"}:
1192+
values = [predicate["const"]]
1193+
elif (
1194+
set(predicate) == {"enum"}
1195+
and isinstance(predicate["enum"], list)
1196+
and predicate["enum"]
1197+
):
1198+
values = predicate["enum"]
1199+
else:
1200+
return None
1201+
if discriminator not in properties or any(
1202+
not isinstance(value, (str, int, float, bool)) for value in values
1203+
):
1204+
return None
1205+
consequence_props = consequence["properties"]
1206+
if not isinstance(consequence_props, dict) or len(consequence_props) != 1:
1207+
return None
1208+
field, field_schema = next(iter(consequence_props.items()))
1209+
if field not in properties or not _is_ref_array(field_schema):
1210+
return None
1211+
base_field_schema = properties[field]
1212+
if not _is_ref_array(base_field_schema):
1213+
return None
1214+
base_ref = base_field_schema["items"]["$ref"]
1215+
new_ref = field_schema["items"]["$ref"]
1216+
if new_ref == base_ref:
1217+
return None
1218+
target = _resolve_referenced_shape(new_ref, schema_path)
1219+
if target is None:
1220+
return None
1221+
return {
1222+
"discriminator": discriminator,
1223+
"values": values,
1224+
"field": field,
1225+
"required": target["required"],
1226+
"consts": target["consts"],
1227+
}
1228+
1229+
1230+
def find_conditional_array_retyping(schema_dir):
1231+
"""Map generated class names to array-item retyping rules.
1232+
1233+
Complements find_conditional_required/find_conditional_bounds, which
1234+
only handle a ``then`` that adds required fields or narrows a numeric
1235+
range. A ``then`` that instead retypes an array PROPERTY's items to a
1236+
schema file different from the property's own base ``$ref`` is a third
1237+
shape the generator drops entirely: fulfillment_method.json's
1238+
``destinations`` stays typed to the base FulfillmentDestination
1239+
regardless of ``type``, even though a `shipping` method's destinations
1240+
are really ShippingDestination (postal address fields, `type` const
1241+
`shipping_address`) and a `pickup` method's are really
1242+
LocationDestination (`type` const `business_location`). Pydantic has no
1243+
clean way to retype a field's item type from a source-text splice, so
1244+
this is enforced with a runtime check instead of a static type change:
1245+
each item is checked against the referenced schema's own required keys
1246+
and const-pinned fields (see _resolve_referenced_shape), an
1247+
approximation rather than a full re-derivation of the retyped type.
1248+
"""
1249+
rules_by_class = {}
1250+
1251+
def walk(node, current_class_name, schema_path):
1252+
if not isinstance(node, dict):
1253+
return
1254+
if isinstance(node.get("title"), str):
1255+
current_class_name = _alias_name(node["title"])
1256+
properties = node.get("properties")
1257+
allof = node.get("allOf")
1258+
if isinstance(properties, dict) and isinstance(allof, list):
1259+
for branch in allof:
1260+
rule = _describe_retyping_branch(
1261+
branch, properties, schema_path
1262+
)
1263+
if rule is not None and current_class_name is not None:
1264+
rules_by_class.setdefault(current_class_name, []).append(
1265+
rule
1266+
)
1267+
if isinstance(properties, dict):
1268+
for name, prop in properties.items():
1269+
walk(prop, _to_camel_case(name), schema_path)
1270+
defs = node.get("$defs")
1271+
if isinstance(defs, dict):
1272+
for def_name, def_node in defs.items():
1273+
walk(def_node, _to_camel_case(def_name), schema_path)
1274+
1275+
for path in sorted(Path(schema_dir).rglob("*.json")):
1276+
try:
1277+
schema = json.loads(path.read_text(encoding="utf-8"))
1278+
except (OSError, json.JSONDecodeError):
1279+
continue
1280+
if not isinstance(schema, dict):
1281+
continue
1282+
root_title = schema.get("title")
1283+
initial_class = (
1284+
_alias_name(root_title) if root_title else _to_camel_case(path.stem)
1285+
)
1286+
walk(schema, initial_class, path)
1287+
return rules_by_class
1288+
1289+
1290+
def inject_conditional_array_retyping(source, class_name, rules):
1291+
"""Inject array-item retyping checks into one generated class."""
1292+
class_re = re.compile(rf"^class {re.escape(class_name)}\(", re.M)
1293+
match = class_re.search(source)
1294+
if not match:
1295+
return source
1296+
tail = re.compile(r"^\S", re.M)
1297+
end_match = tail.search(source, match.end())
1298+
end = end_match.start() if end_match else len(source)
1299+
if f"def {_RETYPE_MARKER}(" in source[match.start() : end]:
1300+
return source
1301+
method = _RETYPE_TEMPLATE.format(marker=_RETYPE_MARKER, rules=rules)
1302+
body = source[:end].rstrip("\n")
1303+
rest = source[end:]
1304+
out = body + "\n" + method + ("\n" + rest if rest else "")
1305+
return _ensure_pydantic_import(out, "model_validator")
1306+
1307+
10481308
def find_unique_items_fields(schema_dir):
10491309
"""Map generated class names to fields carrying ``uniqueItems``.
10501310
@@ -1378,6 +1638,41 @@ def _patch_conditional_bounds():
13781638
return patched, 0
13791639

13801640

1641+
def _patch_conditional_array_retyping():
1642+
"""Inject array-item retyping checks; return counts and status."""
1643+
rules_by_class = find_conditional_array_retyping(SCHEMA_DIR)
1644+
if not rules_by_class:
1645+
sys.stdout.write(
1646+
"postprocess: no conditional array-item retyping rules found\n"
1647+
)
1648+
return 0, 0
1649+
patched = 0
1650+
for class_name, rules in sorted(rules_by_class.items()):
1651+
hits = []
1652+
for path in sorted(OUTPUT_DIR.rglob("*.py")):
1653+
source = path.read_text(encoding="utf-8")
1654+
if not re.search(
1655+
rf"^class {re.escape(class_name)}\(", source, re.M
1656+
):
1657+
continue
1658+
updated = inject_conditional_array_retyping(
1659+
source, class_name, rules
1660+
)
1661+
if updated != source:
1662+
path.write_text(updated, encoding="utf-8")
1663+
patched += 1
1664+
hits.append(path)
1665+
label = (
1666+
", ".join(str(path) for path in hits) or "NO GENERATED CLASS FOUND"
1667+
)
1668+
sys.stdout.write(
1669+
f" conditional array-item retyping on '{class_name}' -> {label}\n"
1670+
)
1671+
if not hits:
1672+
return patched, 1
1673+
return patched, 0
1674+
1675+
13811676
def _patch_unique_items():
13821677
"""Inject uniqueItems validators; return (patched_count, exit_code)."""
13831678
unique_fields_by_class = find_unique_items_fields(SCHEMA_DIR)
@@ -1518,6 +1813,7 @@ def main():
15181813
patched_ac, rc_ac = _patch_array_contains()
15191814
patched_cr, rc_cr = _patch_conditional_required()
15201815
patched_cb, rc_cb = _patch_conditional_bounds()
1816+
patched_rt, rc_rt = _patch_conditional_array_retyping()
15211817
patched_ui, rc_ui = _patch_unique_items()
15221818
patched_ef, rc_ef = _patch_extra_forbid()
15231819
total = (
@@ -1526,11 +1822,12 @@ def main():
15261822
+ patched_ac
15271823
+ patched_cr
15281824
+ patched_cb
1825+
+ patched_rt
15291826
+ patched_ui
15301827
+ patched_ef
15311828
)
15321829
sys.stdout.write(f"postprocess: {total} module(s) patched\n")
1533-
return rc_mp or rc_pn or rc_ac or rc_cr or rc_cb or rc_ui or rc_ef
1830+
return rc_mp or rc_pn or rc_ac or rc_cr or rc_cb or rc_rt or rc_ui or rc_ef
15341831

15351832

15361833
if __name__ == "__main__":

0 commit comments

Comments
 (0)