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
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+
10481308def 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+
13811676def _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
15361833if __name__ == "__main__" :
0 commit comments