Skip to content

Commit 54f9fee

Browse files
committed
Bugfixes, black formatting, updated the --module argument to accept a filepath as well as a module path, and added comprehensive tests
1 parent 22bf717 commit 54f9fee

File tree

18 files changed

+433
-115
lines changed

18 files changed

+433
-115
lines changed

README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# pydantic-to-typescript
22

3+
[![PyPI version](https://badge.fury.io/py/pydantic-to-typescript.svg)](https://badge.fury.io/py/pydantic-to-typescript)
4+
35
A simple CLI tool for converting pydantic models into typescript interfaces. Useful for any scenario in which python and javascript applications are interacting, since it allows you to have a single source of truth for type definitions.
46

57
This tool requires that you have the lovely json2ts CLI utility installed. Instructions can be found here: https://www.npmjs.com/package/json-schema-to-typescript
@@ -13,7 +15,7 @@ $ pip install pydantic-to-typescript
1315

1416
|Prop|Description|
1517
|:----------|:-----------|
16-
|‑‑module|name of the python module you would like to convert. All the pydantic models within it will be converted to typescript interfaces. Discoverable submodules will also be checked. Ex: 'pydantic2ts.examples.pydantic_models'|
18+
|‑‑module|name or filepath of the python module you would like to convert. All the pydantic models within it will be converted to typescript interfaces. Discoverable submodules will also be checked.|
1719
|‑‑output|name of the file the typescript definitions should be written to. Ex: './frontend/apiTypes.ts'|
1820
|‑‑json2ts‑cmd|optional, the command used to invoke json2ts. The default is 'json2ts'. Specify this if you have it installed in a strange location and need to provide the exact path (ex: /myproject/node_modules/bin/json2ts)|
1921
---
@@ -44,10 +46,21 @@ def login(body: LoginCredentials):
4446
profile = Profile(**body.dict(), age=72, hobbies=['cats'])
4547
return LoginResponseData(token='very-secure', profile=profile)
4648
```
47-
Execute the command for converting these models into typescript definitions:
49+
Execute the command for converting these models into typescript definitions, via:
4850
```bash
4951
$ pydantic2ts --module backend.api --output ./frontend/apiTypes.ts
5052
```
53+
or:
54+
```bash
55+
$ pydantic2ts --module ./backend/api.py --output ./frontend/apiTypes.ts
56+
```
57+
or:
58+
```python
59+
from pydantic2ts import generate_typescript_defs
60+
61+
generate_typescript_defs("backend.api", "./frontend/apiTypes.ts")
62+
```
63+
5164
The models are now defined in typescript...
5265
```ts
5366
/* tslint:disable */
@@ -91,4 +104,4 @@ async function login(
91104
reject(error.message);
92105
}
93106
}
94-
```
107+
```

pydantic2ts/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from pydantic2ts.cli.script import generate_typescript_defs

pydantic2ts/cli/script.py

Lines changed: 157 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,76 @@
1-
import click
2-
import inspect
31
import importlib
2+
import inspect
3+
import json
4+
import logging
45
import os
56
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
79
from tempfile import mkdtemp
8-
from typing import Type, Dict, Any, List
910
from 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

3452
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+
)
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

4476
def 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()

pydantic2ts/examples/api.py

Lines changed: 0 additions & 27 deletions
This file was deleted.

pydantic2ts/examples/convert.sh

Lines changed: 0 additions & 2 deletions
This file was deleted.

requirements.txt

Lines changed: 0 additions & 2 deletions
This file was deleted.

0 commit comments

Comments
 (0)