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
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+
9901250def 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+
13231618def _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
14781775if __name__ == "__main__" :
0 commit comments