1- import click
2- import inspect
31import importlib
2+ import inspect
3+ import json
4+ import logging
45import os
56import shutil
6- from pydantic import BaseModel , Extra , create_model
7+ import sys
8+ from importlib .util import spec_from_file_location , module_from_spec
79from tempfile import mkdtemp
8- from typing import Type , Dict , Any , List
910from types import ModuleType
11+ from typing import Type , Dict , Any , List
12+ from uuid import uuid4
1013
14+ import click
15+ from pydantic import BaseModel , Extra , create_model
1116
12- def clean_schema (schema : Dict [str , Any ], model : Type [BaseModel ]) -> None :
13- """
14- Monkey-patched method for cleaning up resulting JSON schemas by:
17+ try :
18+ from pydantic .generics import GenericModel
19+ except ImportError :
20+ GenericModel = None
1521
16- 1) Removing titles from JSON schema properties.
17- If we don't do this, each property will have its own interface in the
18- resulting typescript file (which is a LOT of unnecessary noise).
19- 2) Setting 'additionalProperties' to False UNLESS Config.extra is explicitly
20- set to "allow". This keeps the typescript interfaces clean (ie useful).
21- """
22- for prop in schema .get ('properties' , {}).values ():
23- prop .pop ('title' , None )
22+ logging .basicConfig (level = logging .DEBUG , format = "%(asctime)s %(message)s" )
2423
25- if model .Config .extra != Extra .allow :
26- schema ['additionalProperties' ] = False
24+ logger = logging .getLogger ("pydantic2ts" )
2725
2826
29- def not_private (obj ) -> bool :
30- """Return true if an object does not seem to be private"""
31- return not getattr (obj , '__name__' , '' ).startswith ('_' )
27+ def import_module (path : str ) -> ModuleType :
28+ """
29+ Helper which allows modules to be specified by either dotted path notation or by filepath.
30+
31+ If we import by filepath, we must also assign a name to it and add it to sys.modules BEFORE
32+ calling 'spec.loader.exec_module' because there is code in pydantic which requires that the
33+ definition exist in sys.modules under that name.
34+ """
35+ try :
36+ if os .path .exists (path ):
37+ name = uuid4 ().hex
38+ spec = spec_from_file_location (name , path , submodule_search_locations = [])
39+ module = module_from_spec (spec )
40+ sys .modules [name ] = module
41+ spec .loader .exec_module (module )
42+ return module
43+ else :
44+ return importlib .import_module (path )
45+ except BaseException as e :
46+ logger .error (
47+ "The --module argument must be a module path separated by dots or a valid filepath"
48+ )
49+ raise e
3250
3351
3452def is_submodule (obj , module_name : str ) -> bool :
35- """Return true if an object is a submodule"""
36- return not_private (obj ) and inspect .ismodule (obj ) and getattr (obj , '__name__' , '' ).startswith (f'{ module_name } .' )
53+ """
54+ Return true if an object is a submodule
55+ """
56+ return inspect .ismodule (obj ) and getattr (obj , "__name__" , "" ).startswith (
57+ f"{ module_name } ."
58+ )
3759
3860
39- def is_pydantic_model (obj ) -> bool :
40- """Return true if an object is a subclass of pydantic's BaseModel"""
41- return not_private (obj ) and inspect .isclass (obj ) and issubclass (obj , BaseModel ) and obj != BaseModel
61+ def is_concrete_pydantic_model (obj ) -> bool :
62+ """
63+ Return true if an object is a concrete subclass of pydantic's BaseModel.
64+ 'concrete' meaning that it's not a GenericModel.
65+ """
66+ if not inspect .isclass (obj ):
67+ return False
68+ elif obj is BaseModel :
69+ return False
70+ elif GenericModel and issubclass (obj , GenericModel ):
71+ return bool (obj .__concrete__ )
72+ else :
73+ return issubclass (obj , BaseModel )
4274
4375
4476def extract_pydantic_models (module : ModuleType ) -> List [Type [BaseModel ]]:
@@ -48,10 +80,12 @@ def extract_pydantic_models(module: ModuleType) -> List[Type[BaseModel]]:
4880 models = []
4981 module_name = module .__name__
5082
51- for _ , model in inspect .getmembers (module , is_pydantic_model ):
83+ for _ , model in inspect .getmembers (module , is_concrete_pydantic_model ):
5284 models .append (model )
5385
54- for _ , submodule in inspect .getmembers (module , lambda obj : is_submodule (obj , module_name )):
86+ for _ , submodule in inspect .getmembers (
87+ module , lambda obj : is_submodule (obj , module_name )
88+ ):
5589 models .extend (extract_pydantic_models (submodule ))
5690
5791 return models
@@ -63,30 +97,73 @@ def remove_master_model_from_output(output: str) -> None:
6397 clean typescript definitions without any duplicates, but we don't actually want it in the
6498 output. This function handles removing it from the generated typescript file.
6599 """
66- with open (output , 'r' ) as f :
100+ with open (output , "r" ) as f :
67101 lines = f .readlines ()
68102
69103 start , end = None , None
70104 for i , line in enumerate (lines ):
71- if line .rstrip (' \r \n ' ) == ' export interface _Master_ {' :
105+ if line .rstrip (" \r \n " ) == " export interface _Master_ {" :
72106 start = i
73- elif (start is not None ) and line .rstrip (' \r \n ' ) == '}' :
107+ elif (start is not None ) and line .rstrip (" \r \n " ) == "}" :
74108 end = i
75109 break
76110
77- new_lines = lines [:start ] + lines [(end + 1 ):]
78- with open (output , 'w' ) as f :
111+ new_lines = lines [:start ] + lines [(end + 1 ) :]
112+ with open (output , "w" ) as f :
79113 f .writelines (new_lines )
80114
81115
82- @click .command ()
83- @click .option ('--module' )
84- @click .option ('--output' )
85- @click .option ('--json2ts-cmd' , default = 'json2ts' )
86- def main (
87- module : str ,
88- output : str ,
89- json2ts_cmd : str = 'json2ts' ,
116+ def clean_schema (schema : Dict [str , Any ]) -> None :
117+ """
118+ Clean up the resulting JSON schemas by:
119+
120+ 1) Removing titles from JSON schema properties.
121+ If we don't do this, each property will have its own interface in the
122+ resulting typescript file (which is a LOT of unnecessary noise).
123+ 2) Getting rid of the useless "An enumeration." description applied to Enums
124+ which don't have a docstring.
125+ """
126+ for prop in schema .get ("properties" , {}).values ():
127+ prop .pop ("title" , None )
128+
129+ if "enum" in schema and schema .get ("description" ) == "An enumeration." :
130+ del schema ["description" ]
131+
132+
133+ def generate_json_schema (models : List [Type [BaseModel ]]) -> str :
134+ """
135+ Create a top-level '_Master_' model with references to each of the actual models.
136+ Generate the schema for this model, which will include the schemas for all the
137+ nested models. Then clean up the schema.
138+
139+ One weird thing we do is we temporarily override the 'extra' setting in models,
140+ changing it to 'forbid' UNLESS it was explicitly set to 'allow'. This prevents
141+ '[k: string]: any' from being added to every interface. This change is reverted
142+ once the schema has been generated.
143+ """
144+ model_extras = [m .Config .extra for m in models ]
145+
146+ for m in models :
147+ if m .Config .extra != Extra .allow :
148+ m .Config .extra = Extra .forbid
149+
150+ master_model = create_model ("_Master_" , ** {m .__name__ : (m , ...) for m in models })
151+ master_model .Config .extra = Extra .forbid
152+ master_model .Config .schema_extra = staticmethod (clean_schema )
153+
154+ schema = json .loads (master_model .schema_json ())
155+
156+ for d in schema .get ("definitions" , {}).values ():
157+ clean_schema (d )
158+
159+ for m , x in zip (models , model_extras ):
160+ m .Config .extra = x
161+
162+ return json .dumps (schema , indent = 2 )
163+
164+
165+ def generate_typescript_defs (
166+ module : str , output : str , json2ts_cmd : str = "json2ts"
90167) -> None :
91168 """
92169 Convert the pydantic models in a python module into typescript interfaces.
@@ -96,35 +173,54 @@ def main(
96173 :param json2ts_cmd: optional, the command that will execute json2ts. Use this if it's installed in a strange spot.
97174 """
98175 if not shutil .which (json2ts_cmd ):
99- raise Exception ('json2ts must be installed. Instructions can be found here: '
100- 'https://www.npmjs.com/package/json-schema-to-typescript' )
176+ raise Exception (
177+ "json2ts must be installed. Instructions can be found here: "
178+ "https://www.npmjs.com/package/json-schema-to-typescript"
179+ )
101180
102- models = extract_pydantic_models ( importlib . import_module ( module ) )
181+ logger . info ( "Finding pydantic models..." )
103182
104- for m in models :
105- m .Config .schema_extra = staticmethod (clean_schema )
183+ models = extract_pydantic_models (import_module (module ))
106184
107- master_model = create_model ('_Master_' , ** {m .__name__ : (m , ...) for m in models })
108- master_model .Config .schema_extra = staticmethod (clean_schema )
185+ logger .info ("Generating JSON schema from pydantic models..." )
109186
187+ schema = generate_json_schema (models )
110188 schema_dir = mkdtemp ()
111- schema_file_path = os .path .join (schema_dir , 'schema.json' )
189+ schema_file_path = os .path .join (schema_dir , "schema.json" )
190+
191+ with open (schema_file_path , "w" ) as f :
192+ f .write (schema )
193+
194+ logger .info ("Converting JSON schema to typescript definitions..." )
195+
196+ banner_comment = "\n " .join (
197+ [
198+ "/* tslint:disable */" ,
199+ "/**" ,
200+ "/* This file was automatically generated from pydantic models by running pydantic2ts." ,
201+ "/* Do not modify it by hand - just update the pydantic models and then re-run the script" ,
202+ "*/" ,
203+ ]
204+ )
205+ os .system (
206+ f'{ json2ts_cmd } -i { schema_file_path } -o { output } --bannerComment "{ banner_comment } "'
207+ )
208+ shutil .rmtree (schema_dir )
209+ remove_master_model_from_output (output )
112210
113- with open (schema_file_path , 'w' ) as f :
114- f .write (master_model .schema_json (indent = 2 ))
211+ logger .info (f"Saved typescript definitions to { output } ." )
115212
116- banner_comment = '\n ' .join ([
117- '/* tslint:disable */' ,
118- '/**' ,
119- '/* This file was automatically generated from pydantic models by running pydantic2ts.' ,
120- '/* Do not modify it by hand - just update the pydantic models and then re-run the script' ,
121- '*/' ,
122- ])
123213
124- os .system (f'{ json2ts_cmd } -i { schema_file_path } -o { output } --bannerComment "{ banner_comment } "' )
125- shutil .rmtree (schema_dir )
126- remove_master_model_from_output (output )
214+ @click .command ()
215+ @click .option ("--module" )
216+ @click .option ("--output" )
217+ @click .option ("--json2ts-cmd" , default = "json2ts" )
218+ def main (module : str , output : str , json2ts_cmd : str = "json2ts" ) -> None :
219+ """
220+ CLI entrypoint to run :func:`generate_typescript_defs`
221+ """
222+ return generate_typescript_defs (module , output , json2ts_cmd )
127223
128224
129- if __name__ == ' __main__' :
225+ if __name__ == " __main__" :
130226 main ()
0 commit comments