@@ -89,38 +89,50 @@ def _is_submodule(obj: Any, module_name: str) -> bool:
8989
9090def _is_pydantic_v1_model (obj : Any ) -> bool :
9191 """
92- Return true if an object is a pydantic V1 model.
93- """
94- return inspect .isclass (obj ) and (
95- (obj is not BaseModelV1 and issubclass (obj , BaseModelV1 ))
96- or (
97- GenericModelV1 is not None
98- and issubclass (obj , GenericModelV1 )
99- and getattr (obj , "__concrete__" , False )
100- )
101- )
92+ Return true if the object is a 'concrete' pydantic V1 model.
93+ """
94+ if not inspect .isclass (obj ):
95+ return False
96+ elif obj is BaseModelV1 or obj is GenericModelV1 :
97+ return False
98+ elif GenericModelV1 and issubclass (obj , GenericModelV1 ):
99+ return getattr (obj , "__concrete__" , False )
100+ return issubclass (obj , BaseModelV1 )
102101
103102
104103def _is_pydantic_v2_model (obj : Any ) -> bool :
105104 """
106- Return true if an object is a pydantic V2 model.
105+ Return true if an object is a 'concrete' pydantic V2 model.
107106 """
108- return inspect .isclass (obj ) and (
109- BaseModelV2 is not None
110- and obj is not BaseModelV2
111- and issubclass (obj , BaseModelV2 )
112- and not getattr (obj , "__pydantic_generic_metadata__" , {}).get ("parameters" )
113- )
107+ if not inspect .isclass (obj ):
108+ return False
109+ elif obj is BaseModelV2 or BaseModelV2 is None :
110+ return False
111+ return issubclass (obj , BaseModelV2 ) and not getattr (
112+ obj , "__pydantic_generic_metadata__" , {}
113+ ).get ("parameters" )
114114
115115
116116def _is_pydantic_model (obj : Any ) -> bool :
117117 """
118- Return true if an object is a concrete subclass of pydantic's BaseModel.
119- 'concrete' meaning that it's not a generic model.
118+ Return true if an object is a valid model for either V1 or V2 of pydantic.
120119 """
121120 return _is_pydantic_v1_model (obj ) or _is_pydantic_v2_model (obj )
122121
123122
123+ def _has_null_variant (schema : Dict [str , Any ]) -> bool :
124+ """
125+ Return true if a JSON schema has 'null' as one of its types.
126+ """
127+ if schema .get ("type" ) == "null" :
128+ return True
129+ if isinstance (schema .get ("type" ), list ) and "null" in schema ["type" ]:
130+ return True
131+ if isinstance (schema .get ("anyOf" ), list ):
132+ return any (_has_null_variant (s ) for s in schema ["anyOf" ])
133+ return False
134+
135+
124136def _get_model_config (model : Type [Any ]) -> "Union[ConfigDict, Type[BaseConfig]]" :
125137 """
126138 Return the 'config' for a pydantic model.
@@ -156,47 +168,9 @@ def _extract_pydantic_models(module: ModuleType) -> List[type]:
156168 return models
157169
158170
159- def _clean_output_file (output_filename : str ) -> None :
160- """
161- Clean up the output file typescript definitions were written to by:
162- 1. Removing the 'master model'.
163- This is a faux pydantic model with references to all the *actual* models necessary for generating
164- clean typescript definitions without any duplicates. We don't actually want it in the output, so
165- this function removes it from the generated typescript file.
166- 2. Adding a banner comment with clear instructions for how to regenerate the typescript definitions.
167- """
168- with open (output_filename , "r" ) as f :
169- lines = f .readlines ()
170-
171- start , end = None , None
172- for i , line in enumerate (lines ):
173- if line .rstrip ("\r \n " ) == "export interface _Master_ {" :
174- start = i
175- elif (start is not None ) and line .rstrip ("\r \n " ) == "}" :
176- end = i
177- break
178-
179- assert start is not None , "Could not find the start of the _Master_ interface."
180- assert end is not None , "Could not find the end of the _Master_ interface."
181-
182- banner_comment_lines = [
183- "/* tslint:disable */\n " ,
184- "/* eslint-disable */\n " ,
185- "/**\n " ,
186- "/* This file was automatically generated from pydantic models by running pydantic2ts.\n " ,
187- "/* Do not modify it by hand - just update the pydantic models and then re-run the script\n " ,
188- "*/\n \n " ,
189- ]
190-
191- new_lines = banner_comment_lines + lines [:start ] + lines [(end + 1 ) :]
192-
193- with open (output_filename , "w" ) as f :
194- f .writelines (new_lines )
195-
196-
197171def _clean_json_schema (schema : Dict [str , Any ], model : Any = None ) -> None :
198172 """
199- Clean up the resulting JSON schemas by :
173+ Clean up the resulting JSON schemas via the following steps :
200174
201175 1) Get rid of the useless "An enumeration." description applied to Enums
202176 which don't have a docstring.
@@ -220,24 +194,58 @@ def _clean_json_schema(schema: Dict[str, Any], model: Any = None) -> None:
220194 try :
221195 if not field .allow_none :
222196 continue
223- prop = properties .get (field .alias )
197+ name = field .alias
198+ prop = properties .get (name )
224199 if prop is None :
225200 continue
226- prop_types : List [Any ] = prop .setdefault ("anyOf" , [])
227- if any (t .get ("type" ) == "null" for t in prop_types ):
201+ if _has_null_variant (prop ):
228202 continue
229- if "type" in prop :
230- prop_types .append ({"type" : prop .pop ("type" )})
231- if "$ref" in prop :
232- prop_types .append ({"$ref" : prop .pop ("$ref" )})
233- prop_types .append ({"type" : "null" })
203+ properties [name ] = {"anyOf" : [prop , {"type" : "null" }]}
234204 except Exception :
235205 LOG .error (
236206 f"Failed to ensure nullability for field { field .alias } ." ,
237207 exc_info = True ,
238208 )
239209
240210
211+ def _clean_output_file (output_filename : str ) -> None :
212+ """
213+ Clean up the resulting typescript definitions via the following steps:
214+
215+ 1. Remove the "_Master_" model.
216+ It exists solely to serve as a namespace for the target models.
217+ By rolling them all up into a single model, we can generate a single output file.
218+ 2. Add a banner comment with clear instructions for regenerating the typescript definitions.
219+ """
220+ with open (output_filename , "r" ) as f :
221+ lines = f .readlines ()
222+
223+ start , end = None , None
224+ for i , line in enumerate (lines ):
225+ if line .rstrip ("\r \n " ) == "export interface _Master_ {" :
226+ start = i
227+ elif (start is not None ) and line .rstrip ("\r \n " ) == "}" :
228+ end = i
229+ break
230+
231+ assert start is not None , "Could not find the start of the _Master_ interface."
232+ assert end is not None , "Could not find the end of the _Master_ interface."
233+
234+ banner_comment_lines = [
235+ "/* tslint:disable */\n " ,
236+ "/* eslint-disable */\n " ,
237+ "/**\n " ,
238+ "/* This file was automatically generated from pydantic models by running pydantic2ts.\n " ,
239+ "/* Do not modify it by hand - just update the pydantic models and then re-run the script\n " ,
240+ "*/\n \n " ,
241+ ]
242+
243+ new_lines = banner_comment_lines + lines [:start ] + lines [(end + 1 ) :]
244+
245+ with open (output_filename , "w" ) as f :
246+ f .writelines (new_lines )
247+
248+
241249@contextmanager
242250def _schema_generation_overrides (
243251 model : Type [Any ],
@@ -246,6 +254,8 @@ def _schema_generation_overrides(
246254 Temporarily override the 'extra' setting for a model,
247255 changing it to 'forbid' unless it was EXPLICITLY set to 'allow'.
248256 This prevents '[k: string]: any' from automatically being added to every interface.
257+
258+ TODO: check if overriding 'schema_extra' is necessary, or if there's a better way.
249259 """
250260 revert : Dict [str , Any ] = {}
251261 config = _get_model_config (model )
@@ -254,10 +264,14 @@ def _schema_generation_overrides(
254264 if config .get ("extra" ) != "allow" :
255265 revert ["extra" ] = config .get ("extra" )
256266 config ["extra" ] = "forbid"
267+ revert ["json_schema_extra" ] = config .get ("json_schema_extra" )
268+ config ["json_schema_extra" ] = staticmethod (_clean_json_schema )
257269 else :
258270 if config .extra != "allow" :
259271 revert ["extra" ] = config .extra
260272 config .extra = "forbid" # type: ignore
273+ revert ["schema_extra" ] = config .schema_extra
274+ config .schema_extra = staticmethod (_clean_json_schema ) # type: ignore
261275 yield
262276 finally :
263277 for key , value in revert .items ():
0 commit comments