Skip to content

Commit 0ec841f

Browse files
committed
feat: pydantic v2 support
1 parent 83ec9e9 commit 0ec841f

File tree

25 files changed

+305
-11
lines changed

25 files changed

+305
-11
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ celerybeat.pid
107107
# Environments
108108
.env
109109
.venv
110+
.venv-v1
110111
env/
111112
venv/
112113
ENV/
@@ -143,3 +144,6 @@ cython_debug/
143144

144145
# VS Code config
145146
.vscode
147+
148+
# test outputs
149+
output_debug.ts

pydantic2ts/cli/script.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77
import shutil
88
import sys
99
from importlib.util import module_from_spec, spec_from_file_location
10+
from pathlib import Path
1011
from tempfile import mkdtemp
1112
from types import ModuleType
1213
from typing import Any, Dict, List, Tuple, Type
1314
from uuid import uuid4
1415

15-
from pydantic import BaseModel, Extra, create_model
16+
from pydantic import VERSION, BaseModel, Extra, create_model
1617

1718
try:
1819
from pydantic.generics import GenericModel
@@ -22,6 +23,11 @@
2223
logger = logging.getLogger("pydantic2ts")
2324

2425

26+
DEBUG = os.environ.get("DEBUG", False)
27+
28+
V2 = True if VERSION.startswith("2") else False
29+
30+
2531
def import_module(path: str) -> ModuleType:
2632
"""
2733
Helper which allows modules to be specified by either dotted path notation or by filepath.
@@ -61,12 +67,15 @@ def is_concrete_pydantic_model(obj) -> bool:
6167
Return true if an object is a concrete subclass of pydantic's BaseModel.
6268
'concrete' meaning that it's not a GenericModel.
6369
"""
70+
generic_metadata = getattr(obj, "__pydantic_generic_metadata__", None)
6471
if not inspect.isclass(obj):
6572
return False
6673
elif obj is BaseModel:
6774
return False
68-
elif GenericModel and issubclass(obj, GenericModel):
75+
elif not V2 and GenericModel and issubclass(obj, GenericModel):
6976
return bool(obj.__concrete__)
77+
elif V2 and generic_metadata:
78+
return not bool(generic_metadata["parameters"])
7079
else:
7180
return issubclass(obj, BaseModel)
7281

@@ -141,7 +150,7 @@ def clean_schema(schema: Dict[str, Any]) -> None:
141150
del schema["description"]
142151

143152

144-
def generate_json_schema(models: List[Type[BaseModel]]) -> str:
153+
def generate_json_schema_v1(models: List[Type[BaseModel]]) -> str:
145154
"""
146155
Create a top-level '_Master_' model with references to each of the actual models.
147156
Generate the schema for this model, which will include the schemas for all the
@@ -178,6 +187,43 @@ def generate_json_schema(models: List[Type[BaseModel]]) -> str:
178187
m.Config.extra = x
179188

180189

190+
def generate_json_schema_v2(models: List[Type[BaseModel]]) -> str:
191+
"""
192+
Create a top-level '_Master_' model with references to each of the actual models.
193+
Generate the schema for this model, which will include the schemas for all the
194+
nested models. Then clean up the schema.
195+
196+
One weird thing we do is we temporarily override the 'extra' setting in models,
197+
changing it to 'forbid' UNLESS it was explicitly set to 'allow'. This prevents
198+
'[k: string]: any' from being added to every interface. This change is reverted
199+
once the schema has been generated.
200+
"""
201+
model_extras = [m.model_config.get("extra") for m in models]
202+
203+
try:
204+
for m in models:
205+
if m.model_config.get("extra") != Extra.allow:
206+
m.model_config["extra"] = Extra.forbid
207+
208+
master_model: BaseModel = create_model(
209+
"_Master_", **{m.__name__: (m, ...) for m in models}
210+
)
211+
master_model.model_config["extra"] = Extra.forbid
212+
master_model.model_config["json_schema_extra"] = staticmethod(clean_schema)
213+
214+
schema: dict = master_model.model_json_schema()
215+
216+
for d in schema.get("$defs", {}).values():
217+
clean_schema(d)
218+
219+
return json.dumps(schema, indent=2)
220+
221+
finally:
222+
for m, x in zip(models, model_extras):
223+
if x is not None:
224+
m.model_config["extra"] = x
225+
226+
181227
def generate_typescript_defs(
182228
module: str, output: str, exclude: Tuple[str] = (), json2ts_cmd: str = "json2ts"
183229
) -> None:
@@ -205,13 +251,18 @@ def generate_typescript_defs(
205251

206252
logger.info("Generating JSON schema from pydantic models...")
207253

208-
schema = generate_json_schema(models)
254+
schema = generate_json_schema_v2(models) if V2 else generate_json_schema_v1(models)
255+
209256
schema_dir = mkdtemp()
210257
schema_file_path = os.path.join(schema_dir, "schema.json")
211258

212259
with open(schema_file_path, "w") as f:
213260
f.write(schema)
214261

262+
if DEBUG:
263+
with open(Path(output).parent / "schema.json", "w") as f:
264+
f.write(schema)
265+
215266
logger.info("Converting JSON schema to typescript definitions...")
216267

217268
json2ts_exit_code = os.system(
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/* tslint:disable */
2+
/* eslint-disable */
3+
/**
4+
/* This file was automatically generated from pydantic models by running pydantic2ts.
5+
/* Do not modify it by hand - just update the pydantic models and then re-run the script
6+
*/
7+
8+
export interface Profile {
9+
username: string;
10+
age: number | null;
11+
hobbies: string[];
12+
}

tests/expected_results/generics/input.py renamed to tests/expected_results/generics/v1/input.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from datetime import datetime
2-
from typing import Generic, TypeVar, Optional, List, Type, cast, Union
2+
from typing import Generic, List, Optional, Type, TypeVar, cast
33

44
from pydantic import BaseModel
55
from pydantic.generics import GenericModel
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from datetime import datetime
2+
from typing import Generic, List, Optional, Type, TypeVar, cast
3+
4+
from pydantic import BaseModel
5+
6+
T = TypeVar("T")
7+
8+
9+
class Error(BaseModel):
10+
code: int
11+
message: str
12+
13+
14+
class ApiResponse(BaseModel, Generic[T]):
15+
data: Optional[T]
16+
error: Optional[Error]
17+
18+
19+
def create_response_type(data_type: T, name: str) -> "Type[ApiResponse[T]]":
20+
"""
21+
Create a concrete implementation of ApiResponse and then applies the specified name.
22+
This is necessary because the name automatically generated by __concrete_name__ is
23+
really ugly, it just doesn't look good.
24+
"""
25+
t = ApiResponse[data_type]
26+
t.__name__ = name
27+
t.__qualname__ = name
28+
return cast(Type[ApiResponse[T]], t)
29+
30+
31+
class User(BaseModel):
32+
name: str
33+
email: str
34+
35+
36+
class UserProfile(User):
37+
joined: datetime
38+
last_active: datetime
39+
age: int
40+
41+
42+
class Article(BaseModel):
43+
author: User
44+
content: str
45+
published: datetime
46+
47+
48+
ListUsersResponse = create_response_type(List[User], "ListUsersResponse")
49+
50+
ListArticlesResponse = create_response_type(List[Article], "ListArticlesResponse")
51+
52+
UserProfileResponse = create_response_type(UserProfile, "UserProfileResponse")
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/* tslint:disable */
2+
/* eslint-disable */
3+
/**
4+
/* This file was automatically generated from pydantic models by running pydantic2ts.
5+
/* Do not modify it by hand - just update the pydantic models and then re-run the script
6+
*/
7+
8+
export interface Article {
9+
author: User;
10+
content: string;
11+
published: string;
12+
}
13+
export interface User {
14+
name: string;
15+
email: string;
16+
}
17+
export interface Error {
18+
code: number;
19+
message: string;
20+
}
21+
export interface ListArticlesResponse {
22+
data: Article[] | null;
23+
error: Error | null;
24+
}
25+
export interface ListUsersResponse {
26+
data: User[] | null;
27+
error: Error | null;
28+
}
29+
export interface UserProfile {
30+
name: string;
31+
email: string;
32+
joined: string;
33+
last_active: string;
34+
age: number;
35+
}
36+
export interface UserProfileResponse {
37+
data: UserProfile | null;
38+
error: Error | null;
39+
}

0 commit comments

Comments
 (0)