@@ -152,7 +152,12 @@ def validate_spec(spec: Mapping[str, Any], config: Mapping[str, Any]) -> Validat
152152 endpoint = _endpoint_config (config )
153153 all_operations = list (_iter_operations (spec ))
154154 _validate_unique_operation_ids (all_operations )
155- operations = _selected_operations (all_operations , endpoint ["generated_tags" ])
155+ _validate_specialized_operations (all_operations , endpoint )
156+ operations = _selected_operations (
157+ all_operations ,
158+ endpoint ["generated_tags" ],
159+ endpoint ["specialized_operations" ],
160+ )
156161 reference_roots = []
157162 for _ , _ , _ , operation , path_item in operations :
158163 reference_roots .append (operation )
@@ -209,14 +214,58 @@ def _generated_operation_tags(operation: Mapping[str, Any], generated_tags: Sequ
209214def _selected_operations (
210215 operations : Sequence [Tuple [str , str , Any , Mapping [str , Any ], Mapping [str , Any ]]],
211216 generated_tags : Sequence [str ],
217+ specialized_operations : Sequence [str ],
212218) -> List [Tuple [str , str , Any , Mapping [str , Any ], Mapping [str , Any ]]]:
219+ specialized = set (specialized_operations )
213220 return [
214221 operation_entry
215222 for operation_entry in operations
216- if operation_entry [0 ] != "options" and _generated_operation_tags (operation_entry [3 ], generated_tags )
223+ if operation_entry [0 ] != "options"
224+ and operation_entry [2 ] not in specialized
225+ and _generated_operation_tags (operation_entry [3 ], generated_tags )
217226 ]
218227
219228
229+ def _extract_colliding_inline_models (spec : Mapping [str , Any ]) -> Dict [str , Any ]:
230+ """Give anonymous object models contextual names when they collide with components."""
231+
232+ rewritten = copy .deepcopy (spec )
233+ schemas = rewritten .get ("components" , {}).get ("schemas" , {})
234+ component_names = {_python_type_name (name ) for name in schemas }
235+ extracted : Dict [str , Mapping [str , Any ]] = {}
236+
237+ def visit (value : Any , owner : str , path : List [str ]) -> None :
238+ if isinstance (value , dict ):
239+ properties = value .get ("properties" )
240+ if isinstance (properties , dict ):
241+ for property_name , schema in list (properties .items ()):
242+ if not isinstance (property_name , str ) or not isinstance (schema , dict ):
243+ continue
244+ if (
245+ "$ref" not in schema
246+ and (schema .get ("type" ) == "object" or isinstance (schema .get ("properties" ), dict ))
247+ and _python_type_name (property_name ) in component_names
248+ ):
249+ extracted_name = _python_type_name ("_" .join ([owner , * path , property_name ]))
250+ previous = extracted .setdefault (extracted_name , schema )
251+ if previous != schema :
252+ raise CodegenError (f"Conflicting extracted inline model { extracted_name !r} " )
253+ properties [property_name ] = {"$ref" : f"#/components/schemas/{ extracted_name } " }
254+ else :
255+ visit (schema , owner , [* path , property_name ])
256+ for key , child in value .items ():
257+ if key != "properties" :
258+ visit (child , owner , path )
259+ elif isinstance (value , list ):
260+ for child in value :
261+ visit (child , owner , path )
262+
263+ for name , schema in list (schemas .items ()):
264+ visit (schema , name , [])
265+ schemas .update (extracted )
266+ return rewritten
267+
268+
220269def _slice_model_spec (spec : Mapping [str , Any ], operation_ids : Set [str ]) -> Dict [str , Any ]:
221270 """Keep selected operations and the transitive component closure they reference."""
222271 selected_paths : Dict [str , Any ] = {}
@@ -410,7 +459,7 @@ def generate_tree(output_root: Path, config: Mapping[str, Any], spec: Mapping[st
410459 report = validate_spec (spec , config )
411460 operations , inline_models = _collect_generated_operations (spec , config )
412461 selected_spec = _slice_model_spec (spec , {operation .operation_id for operation in operations })
413- model_spec = _with_inline_models (selected_spec , inline_models )
462+ model_spec = _with_inline_models (_extract_colliding_inline_models ( selected_spec ) , inline_models )
414463 output_root .mkdir (parents = True , exist_ok = True )
415464 selected_spec_path = output_root .parent / "selected-spec.json"
416465 monolithic_models_path = output_root .parent / "models.py"
@@ -528,6 +577,64 @@ def visit(node: Any) -> None:
528577 return dict (sorted (aliases .items ()))
529578
530579
580+ def _rewrite_dunder_typeddicts (path : Path ) -> None :
581+ """Use functional TypedDict syntax when class syntax would mangle a wire key."""
582+
583+ source = path .read_text (encoding = "utf-8" )
584+ tree = ast .parse (source )
585+ lines = source .splitlines (keepends = True )
586+ offsets = [0 ]
587+ for line in lines :
588+ offsets .append (offsets [- 1 ] + len (line ))
589+
590+ replacements : List [Tuple [int , int , str ]] = []
591+ for node in tree .body :
592+ if not isinstance (node , ast .ClassDef ):
593+ continue
594+ fields = [
595+ statement
596+ for statement in node .body
597+ if isinstance (statement , ast .AnnAssign ) and isinstance (statement .target , ast .Name )
598+ ]
599+ if not any (field .target .id .startswith ("__" ) and not field .target .id .endswith ("__" ) for field in fields ):
600+ continue
601+ if len (node .bases ) != 1 or not isinstance (node .bases [0 ], ast .Name ) or node .bases [0 ].id != "TypedDict" :
602+ raise CodegenError (f"Generated TypedDict { node .name !r} with a dunder field has unsupported bases" )
603+ unsupported = [
604+ statement
605+ for statement in node .body
606+ if not isinstance (statement , ast .AnnAssign )
607+ and not (isinstance (statement , ast .Expr ) and isinstance (statement .value , ast .Constant ))
608+ ]
609+ if unsupported or node .keywords :
610+ raise CodegenError (f"Generated TypedDict { node .name !r} with a dunder field has unsupported contents" )
611+
612+ field_lines = []
613+ for field in fields :
614+ annotation = ast .get_source_segment (source , field .annotation )
615+ if annotation is None :
616+ raise CodegenError (f"Could not recover annotation for { node .name } .{ field .target .id } " )
617+ field_lines .append (f" { field .target .id !r} : { annotation } ," )
618+ replacement = "\n " .join (
619+ [
620+ f"{ node .name } = TypedDict(" ,
621+ f" { node .name !r} ," ,
622+ " {" ,
623+ * field_lines ,
624+ " }," ,
625+ ")" ,
626+ ]
627+ )
628+ start = offsets [node .lineno - 1 ] + node .col_offset
629+ end = offsets [node .end_lineno - 1 ] + node .end_col_offset
630+ replacements .append ((start , end , replacement ))
631+
632+ for start , end , replacement in reversed (replacements ):
633+ source = source [:start ] + replacement + source [end :]
634+ if replacements :
635+ path .write_text (source , encoding = "utf-8" )
636+
637+
531638def _generate_models (spec_path : Path , output_path : Path , config : Mapping [str , Any ]) -> None :
532639 output_path .parent .mkdir (parents = True , exist_ok = True )
533640 header = _generated_header (config , "CONTENT_HASH_PLACEHOLDER" ).rstrip ()
@@ -552,6 +659,7 @@ def _generate_models(spec_path: Path, output_path: Path, config: Mapping[str, An
552659 raise CodegenError (f"datamodel-code-generator failed: { detail } " ) from exc
553660 if not output_path .is_file ():
554661 raise CodegenError ("datamodel-code-generator did not emit the model module" )
662+ _rewrite_dunder_typeddicts (output_path )
555663 model_paths = [output_path ]
556664 # datamodel-code-generator's own formatter pass is not a fixed point; one pinned Ruff pass over
557665 # the complete module tree makes the committed output stable and finalizes each content hash.
@@ -604,6 +712,24 @@ def _model_package_source(model_modules: Mapping[str, str]) -> str:
604712 return "\n " .join (lines )
605713
606714
715+ def _validate_specialized_operations (
716+ operations : Sequence [Tuple [str , str , Any , Mapping [str , Any ], Mapping [str , Any ]]],
717+ endpoint : Mapping [str , Any ],
718+ ) -> None :
719+ configured = set (endpoint ["specialized_operations" ])
720+ tagged_operation_ids = {
721+ operation_id
722+ for method , _ , operation_id , operation , _ in operations
723+ if method != "options" and _generated_operation_tags (operation , endpoint ["generated_tags" ])
724+ }
725+ stale = configured - tagged_operation_ids
726+ if stale :
727+ operation_id = sorted (stale )[0 ]
728+ raise CodegenError (
729+ f"endpoint_generator.specialized_operations references an operation outside generated tags { operation_id !r} "
730+ )
731+
732+
607733def _validate_selected_operations (
608734 operations : Sequence [Tuple [str , str , Any , Mapping [str , Any ], Mapping [str , Any ]]],
609735 endpoint : Mapping [str , Any ],
@@ -669,10 +795,13 @@ def _collect_generated_operations(
669795 idempotent_writes = set (endpoint ["idempotent_writes" ])
670796 operations : List [GeneratedOperation ] = []
671797 inline_models : Dict [str , Mapping [str , Any ]] = {}
672- for method , path , operation_id , operation , path_item in _iter_operations (spec ):
798+ selected_operations = _selected_operations (
799+ list (_iter_operations (spec )),
800+ endpoint ["generated_tags" ],
801+ endpoint ["specialized_operations" ],
802+ )
803+ for method , path , operation_id , operation , path_item in selected_operations :
673804 operation_generated_tags = _generated_operation_tags (operation , endpoint ["generated_tags" ])
674- if method == "options" or not operation_generated_tags :
675- continue
676805 parameters = _operation_parameters (path_item , operation , spec )
677806 request_body_type , request_body_required = _operation_request_body (operation , spec )
678807 response_type , statuses , json_statuses , inline_schema = _operation_response (operation_id , operation , spec )
@@ -992,7 +1121,7 @@ def _endpoint_config(config: Mapping[str, Any]) -> Mapping[str, Any]:
9921121 or len (generated_tags ) != len (set (generated_tags ))
9931122 ):
9941123 raise CodegenError ("endpoint_generator.generated_tags must be a unique list of non-empty strings" )
995- for key in ("safe_reads" , "idempotent_writes" ):
1124+ for key in ("safe_reads" , "idempotent_writes" , "specialized_operations" ):
9961125 values = endpoint .get (key )
9971126 if (
9981127 not isinstance (values , list )
0 commit comments