Skip to content

Commit 97a09c9

Browse files
committed
Rework the V1 logic for 'nullable' field schemas, update the tests for Generic models
1 parent 6c9d5a3 commit 97a09c9

File tree

3 files changed

+90
-76
lines changed

3 files changed

+90
-76
lines changed

pydantic2ts/cli/script.py

Lines changed: 80 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -89,38 +89,50 @@ def _is_submodule(obj: Any, module_name: str) -> bool:
8989

9090
def _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

104103
def _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

116116
def _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+
124136
def _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-
197171
def _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
242250
def _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():

tests/expected_results/generics/v2/input.py

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

44
from pydantic import BaseModel
55

@@ -12,8 +12,8 @@ class Error(BaseModel):
1212

1313

1414
class ApiResponse(BaseModel, Generic[T]):
15-
data: Optional[T]
16-
error: Optional[Error]
15+
data: Optional[T] = None
16+
error: Optional[Error] = None
1717

1818

1919
def create_response_type(data_type: T, name: str) -> "Type[ApiResponse[T]]":
@@ -25,7 +25,7 @@ def create_response_type(data_type: T, name: str) -> "Type[ApiResponse[T]]":
2525
t = ApiResponse[data_type]
2626
t.__name__ = name
2727
t.__qualname__ = name
28-
return cast(Type[ApiResponse[T]], t)
28+
return t
2929

3030

3131
class User(BaseModel):

tests/expected_results/generics/v2/output.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ export interface Error {
1919
message: string;
2020
}
2121
export interface ListArticlesResponse {
22-
data: Article[] | null;
23-
error: Error | null;
22+
data?: Article[] | null;
23+
error?: Error | null;
2424
}
2525
export interface ListUsersResponse {
26-
data: User[] | null;
27-
error: Error | null;
26+
data?: User[] | null;
27+
error?: Error | null;
2828
}
2929
export interface UserProfile {
3030
name: string;
@@ -34,6 +34,6 @@ export interface UserProfile {
3434
age: number;
3535
}
3636
export interface UserProfileResponse {
37-
data: UserProfile | null;
38-
error: Error | null;
37+
data?: UserProfile | null;
38+
error?: Error | null;
3939
}

0 commit comments

Comments
 (0)