1
- import click
2
- import inspect
3
1
import importlib
2
+ import inspect
3
+ import json
4
+ import logging
4
5
import os
5
6
import shutil
6
- from pydantic import BaseModel , Extra , create_model
7
+ import sys
8
+ from importlib .util import spec_from_file_location , module_from_spec
7
9
from tempfile import mkdtemp
8
- from typing import Type , Dict , Any , List
9
10
from types import ModuleType
11
+ from typing import Type , Dict , Any , List
12
+ from uuid import uuid4
10
13
14
+ import click
15
+ from pydantic import BaseModel , Extra , create_model
11
16
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
15
21
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" )
24
23
25
- if model .Config .extra != Extra .allow :
26
- schema ['additionalProperties' ] = False
24
+ logger = logging .getLogger ("pydantic2ts" )
27
25
28
26
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
32
50
33
51
34
52
def 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
+ )
37
59
38
60
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 )
42
74
43
75
44
76
def extract_pydantic_models (module : ModuleType ) -> List [Type [BaseModel ]]:
@@ -48,10 +80,12 @@ def extract_pydantic_models(module: ModuleType) -> List[Type[BaseModel]]:
48
80
models = []
49
81
module_name = module .__name__
50
82
51
- for _ , model in inspect .getmembers (module , is_pydantic_model ):
83
+ for _ , model in inspect .getmembers (module , is_concrete_pydantic_model ):
52
84
models .append (model )
53
85
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
+ ):
55
89
models .extend (extract_pydantic_models (submodule ))
56
90
57
91
return models
@@ -63,30 +97,73 @@ def remove_master_model_from_output(output: str) -> None:
63
97
clean typescript definitions without any duplicates, but we don't actually want it in the
64
98
output. This function handles removing it from the generated typescript file.
65
99
"""
66
- with open (output , 'r' ) as f :
100
+ with open (output , "r" ) as f :
67
101
lines = f .readlines ()
68
102
69
103
start , end = None , None
70
104
for i , line in enumerate (lines ):
71
- if line .rstrip (' \r \n ' ) == ' export interface _Master_ {' :
105
+ if line .rstrip (" \r \n " ) == " export interface _Master_ {" :
72
106
start = i
73
- elif (start is not None ) and line .rstrip (' \r \n ' ) == '}' :
107
+ elif (start is not None ) and line .rstrip (" \r \n " ) == "}" :
74
108
end = i
75
109
break
76
110
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 :
79
113
f .writelines (new_lines )
80
114
81
115
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"
90
167
) -> None :
91
168
"""
92
169
Convert the pydantic models in a python module into typescript interfaces.
@@ -96,35 +173,54 @@ def main(
96
173
:param json2ts_cmd: optional, the command that will execute json2ts. Use this if it's installed in a strange spot.
97
174
"""
98
175
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
+ )
101
180
102
- models = extract_pydantic_models ( importlib . import_module ( module ) )
181
+ logger . info ( "Finding pydantic models..." )
103
182
104
- for m in models :
105
- m .Config .schema_extra = staticmethod (clean_schema )
183
+ models = extract_pydantic_models (import_module (module ))
106
184
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..." )
109
186
187
+ schema = generate_json_schema (models )
110
188
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 )
112
210
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 } ." )
115
212
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
- ])
123
213
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 )
127
223
128
224
129
- if __name__ == ' __main__' :
225
+ if __name__ == " __main__" :
130
226
main ()
0 commit comments