Skip to content

Commit 53d3098

Browse files
committed
Add 'pydantic_v1' and 'pydantic_v2' submodules, these serve as safe namespaces for the relevant objects/metadata available for each pydantic version
1 parent a1eed34 commit 53d3098

File tree

4 files changed

+100
-94
lines changed

4 files changed

+100
-94
lines changed

pydantic2ts/cli/script.py

Lines changed: 38 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -23,24 +23,8 @@
2323
)
2424
from uuid import uuid4
2525

26-
try:
27-
from pydantic import BaseModel as BaseModelV2
28-
from pydantic import create_model as create_model_v2
29-
from pydantic.v1 import BaseModel as BaseModelV1
30-
from pydantic.v1 import create_model as create_model_v1
31-
except ImportError:
32-
BaseModelV2 = None
33-
create_model_v2 = None
34-
from pydantic import BaseModel as BaseModelV1
35-
from pydantic import create_model as create_model_v1
36-
37-
try:
38-
from pydantic.v1.generics import GenericModel as GenericModelV1
39-
except ImportError:
40-
try:
41-
from pydantic.generics import GenericModel as GenericModelV1
42-
except ImportError:
43-
GenericModelV1 = None
26+
import pydantic2ts.pydantic_v1 as v1
27+
import pydantic2ts.pydantic_v2 as v2
4428

4529
if TYPE_CHECKING:
4630
from pydantic.config import ConfigDict
@@ -63,12 +47,10 @@ def _import_module(path: str) -> ModuleType:
6347
if os.path.exists(path):
6448
name = uuid4().hex
6549
spec = spec_from_file_location(name, path, submodule_search_locations=[])
66-
if spec is None:
67-
raise ImportError(f"spec_from_file_location failed for {path}")
50+
assert spec is not None, f"spec_from_file_location failed for {path}"
6851
module = module_from_spec(spec)
6952
sys.modules[name] = module
70-
if spec.loader is None:
71-
raise ImportError(f"loader is None for {path}")
53+
assert spec.loader is not None, f"loader is None for {path}"
7254
spec.loader.exec_module(module)
7355
return module
7456
else:
@@ -87,40 +69,45 @@ def _is_submodule(obj: Any, module_name: str) -> bool:
8769
return inspect.ismodule(obj) and getattr(obj, "__name__", "").startswith(f"{module_name}.")
8870

8971

90-
def _is_pydantic_v1_model(obj: Any) -> bool:
72+
def _is_v1_model(obj: Any) -> bool:
9173
"""
92-
Return true if the object is a 'concrete' pydantic V1 model.
74+
Return true if an object is a 'concrete' pydantic V1 model.
9375
"""
9476
if not inspect.isclass(obj):
9577
return False
96-
elif obj is BaseModelV1 or obj is GenericModelV1:
78+
elif obj is v1.BaseModel:
9779
return False
98-
elif GenericModelV1 and issubclass(obj, GenericModelV1):
99-
return getattr(obj, "__concrete__", False)
100-
return issubclass(obj, BaseModelV1)
80+
elif v1.GenericModel and issubclass(obj, v1.GenericModel):
81+
return bool(obj.__concrete__)
82+
else:
83+
return issubclass(obj, v1.BaseModel)
10184

10285

103-
def _is_pydantic_v2_model(obj: Any) -> bool:
86+
def _is_v2_model(obj: Any) -> bool:
10487
"""
10588
Return true if an object is a 'concrete' pydantic V2 model.
10689
"""
107-
if not inspect.isclass(obj):
90+
if not v2.enabled:
10891
return False
109-
elif obj is BaseModelV2 or BaseModelV2 is None:
92+
elif not inspect.isclass(obj):
11093
return False
111-
return issubclass(obj, BaseModelV2) and not getattr(
112-
obj, "__pydantic_generic_metadata__", {}
113-
).get("parameters")
94+
elif obj is v2.BaseModel:
95+
return False
96+
elif not issubclass(obj, v2.BaseModel):
97+
return False
98+
generic_metadata = getattr(obj, "__pydantic_generic_metadata__", {})
99+
generic_parameters = generic_metadata.get("parameters")
100+
return not generic_parameters
114101

115102

116103
def _is_pydantic_model(obj: Any) -> bool:
117104
"""
118-
Return true if an object is a valid model for either V1 or V2 of pydantic.
105+
Return true if an object is a concrete model for either V1 or V2 of pydantic.
119106
"""
120-
return _is_pydantic_v1_model(obj) or _is_pydantic_v2_model(obj)
107+
return _is_v1_model(obj) or _is_v2_model(obj)
121108

122109

123-
def _has_null_variant(schema: Dict[str, Any]) -> bool:
110+
def _is_nullable(schema: Dict[str, Any]) -> bool:
124111
"""
125112
Return true if a JSON schema has 'null' as one of its types.
126113
"""
@@ -129,7 +116,7 @@ def _has_null_variant(schema: Dict[str, Any]) -> bool:
129116
if isinstance(schema.get("type"), list) and "null" in schema["type"]:
130117
return True
131118
if isinstance(schema.get("anyOf"), list):
132-
return any(_has_null_variant(s) for s in schema["anyOf"])
119+
return any(_is_nullable(s) for s in schema["anyOf"])
133120
return False
134121

135122

@@ -139,15 +126,15 @@ def _get_model_config(model: Type[Any]) -> "Union[ConfigDict, Type[BaseConfig]]"
139126
In version 1 of pydantic, this is a class. In version 2, it's a dictionary.
140127
"""
141128
if hasattr(model, "Config") and inspect.isclass(model.Config):
142-
return model.Config # type: ignore
129+
return model.Config
143130
return model.model_config
144131

145132

146133
def _get_model_json_schema(model: Type[Any]) -> Dict[str, Any]:
147134
"""
148135
Generate the JSON schema for a pydantic model.
149136
"""
150-
if _is_pydantic_v1_model(model):
137+
if _is_v1_model(model):
151138
return json.loads(model.schema_json())
152139
return model.model_json_schema(mode="serialization")
153140

@@ -188,7 +175,7 @@ def _clean_json_schema(schema: Dict[str, Any], model: Any = None) -> None:
188175
for prop in properties.values():
189176
prop.pop("title", None)
190177

191-
if _is_pydantic_v1_model(model):
178+
if _is_v1_model(model):
192179
fields: List["ModelField"] = list(model.__fields__.values())
193180
for field in fields:
194181
try:
@@ -198,7 +185,7 @@ def _clean_json_schema(schema: Dict[str, Any], model: Any = None) -> None:
198185
prop = properties.get(name)
199186
if prop is None:
200187
continue
201-
if _has_null_variant(prop):
188+
if _is_nullable(prop):
202189
continue
203190
properties[name] = {"anyOf": [prop, {"type": "null"}]}
204191
except Exception:
@@ -254,8 +241,6 @@ def _schema_generation_overrides(
254241
Temporarily override the 'extra' setting for a model,
255242
changing it to 'forbid' unless it was EXPLICITLY set to 'allow'.
256243
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.
259244
"""
260245
revert: Dict[str, Any] = {}
261246
config = _get_model_config(model)
@@ -264,14 +249,10 @@ def _schema_generation_overrides(
264249
if config.get("extra") != "allow":
265250
revert["extra"] = config.get("extra")
266251
config["extra"] = "forbid"
267-
revert["json_schema_extra"] = config.get("json_schema_extra")
268-
config["json_schema_extra"] = staticmethod(_clean_json_schema)
269252
else:
270253
if config.extra != "allow":
271254
revert["extra"] = config.extra
272255
config.extra = "forbid" # type: ignore
273-
revert["schema_extra"] = config.schema_extra
274-
config.schema_extra = staticmethod(_clean_json_schema) # type: ignore
275256
yield
276257
finally:
277258
for key, value in revert.items():
@@ -297,8 +278,8 @@ def _generate_json_schema(models: List[type]) -> str:
297278
models_by_name[name] = model
298279
models_as_fields[name] = (model, ...)
299280

300-
use_v1_tools = any(issubclass(m, BaseModelV1) for m in models)
301-
create_model = create_model_v1 if use_v1_tools else create_model_v2 # type: ignore
281+
use_v1_tools = any(issubclass(m, v1.BaseModel) for m in models)
282+
create_model = v1.create_model if use_v1_tools else v2.create_model # type: ignore
302283
master_model = create_model("_Master_", **models_as_fields) # type: ignore
303284
master_schema = _get_model_json_schema(master_model) # type: ignore
304285

@@ -320,11 +301,14 @@ def generate_typescript_defs(
320301
"""
321302
Convert the pydantic models in a python module into typescript interfaces.
322303
323-
:param module: python module containing pydantic model definitions, ex: my_project.api.schemas
304+
:param module: python module containing pydantic model definitions.
305+
example: my_project.api.schemas
324306
:param output: file that the typescript definitions will be written to
325-
:param exclude: optional, a tuple of names for pydantic models which should be omitted from the typescript output.
326-
:param json2ts_cmd: optional, the command that will execute json2ts. Provide this if the executable is not
327-
discoverable or if it's locally installed (ex: 'yarn json2ts').
307+
:param exclude: optional, a tuple of names for pydantic models which
308+
should be omitted from the typescript output.
309+
:param json2ts_cmd: optional, the command that will execute json2ts.
310+
Provide this if the executable is not discoverable
311+
or if it's locally installed (ex: 'yarn json2ts').
328312
"""
329313
if " " not in json2ts_cmd and not shutil.which(json2ts_cmd):
330314
raise Exception(

pydantic2ts/pydantic_v1.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
try:
2+
from pydantic.v1 import BaseModel, create_model
3+
from pydantic.v1.generics import GenericModel
4+
5+
enabled = True
6+
except ImportError:
7+
from pydantic import BaseModel, create_model
8+
9+
enabled = True
10+
11+
try:
12+
from pydantic.generics import GenericModel
13+
except ImportError:
14+
GenericModel = None
15+
16+
__all__ = ("BaseModel", "GenericModel", "create_model", "enabled")

pydantic2ts/pydantic_v2.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
try:
2+
from pydantic.version import VERSION
3+
4+
assert VERSION.startswith("2")
5+
6+
from pydantic import BaseModel, create_model
7+
8+
enabled = True
9+
except (ImportError, AssertionError, AttributeError):
10+
BaseModel = None
11+
create_model = None
12+
enabled = False
13+
14+
__all__ = ("BaseModel", "create_model", "enabled")

tests/test_script.py

Lines changed: 32 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,49 +2,43 @@
22
import subprocess
33
from itertools import product
44
from pathlib import Path
5+
from typing import Optional, Tuple
56

67
import pytest
78

89
from pydantic2ts import generate_typescript_defs
910
from pydantic2ts.cli.script import parse_cli_args
11+
from pydantic2ts.pydantic_v2 import enabled as v2_enabled
1012

11-
try:
12-
from pydantic import BaseModel
13-
from pydantic.v1 import BaseModel as BaseModelV1
14-
15-
assert BaseModel is not BaseModelV1
16-
_PYDANTIC_VERSIONS = ("v1", "v2")
17-
except (ImportError, AssertionError):
18-
_PYDANTIC_VERSIONS = ("v1",)
19-
13+
_PYDANTIC_VERSIONS = ("v1", "v2") if v2_enabled else ("v1",)
2014
_RESULTS_DIRECTORY = Path(
2115
os.path.join(os.path.dirname(os.path.realpath(__file__)), "expected_results")
2216
)
2317

2418

25-
def _get_input_module(test_name: str, pydantic_version: str) -> Path:
26-
return _RESULTS_DIRECTORY / test_name / pydantic_version / "input.py"
19+
def _get_input_module(test_name: str, pydantic_version: str) -> str:
20+
return str(_RESULTS_DIRECTORY / test_name / pydantic_version / "input.py")
2721

2822

2923
def _get_expected_output(test_name: str, pydantic_version: str) -> str:
3024
return (_RESULTS_DIRECTORY / test_name / pydantic_version / "output.ts").read_text()
3125

3226

3327
def _run_test(
34-
tmpdir,
35-
test_name,
36-
pydantic_version,
28+
tmp_path: Path,
29+
test_name: str,
30+
pydantic_version: str,
3731
*,
38-
module_path=None,
39-
call_from_python=False,
40-
exclude=(),
32+
module_path: Optional[str] = None,
33+
call_from_python: bool = False,
34+
exclude: Tuple[str, ...] = (),
4135
):
4236
"""
4337
Execute pydantic2ts logic for converting pydantic models into tyepscript definitions.
4438
Compare the output with the expected output, verifying it is identical.
4539
"""
4640
module_path = module_path or _get_input_module(test_name, pydantic_version)
47-
output_path = tmpdir.join(f"cli_{test_name}_{pydantic_version}.ts").strpath
41+
output_path = str(tmp_path / f"{test_name}_{pydantic_version}.ts")
4842

4943
if call_from_python:
5044
generate_typescript_defs(module_path, output_path, exclude)
@@ -61,33 +55,33 @@ def _run_test(
6155
"pydantic_version, call_from_python",
6256
product(_PYDANTIC_VERSIONS, [False, True]),
6357
)
64-
def test_single_module(tmpdir, pydantic_version, call_from_python):
65-
_run_test(tmpdir, "single_module", pydantic_version, call_from_python=call_from_python)
58+
def test_single_module(tmp_path: Path, pydantic_version: str, call_from_python: bool):
59+
_run_test(tmp_path, "single_module", pydantic_version, call_from_python=call_from_python)
6660

6761

6862
@pytest.mark.parametrize(
6963
"pydantic_version, call_from_python",
7064
product(_PYDANTIC_VERSIONS, [False, True]),
7165
)
72-
def test_submodules(tmpdir, pydantic_version, call_from_python):
73-
_run_test(tmpdir, "submodules", pydantic_version, call_from_python=call_from_python)
66+
def test_submodules(tmp_path: Path, pydantic_version: str, call_from_python: bool):
67+
_run_test(tmp_path, "submodules", pydantic_version, call_from_python=call_from_python)
7468

7569

7670
@pytest.mark.parametrize(
7771
"pydantic_version, call_from_python",
7872
product(_PYDANTIC_VERSIONS, [False, True]),
7973
)
80-
def test_generics(tmpdir, pydantic_version, call_from_python):
81-
_run_test(tmpdir, "generics", pydantic_version, call_from_python=call_from_python)
74+
def test_generics(tmp_path: Path, pydantic_version: str, call_from_python: bool):
75+
_run_test(tmp_path, "generics", pydantic_version, call_from_python=call_from_python)
8276

8377

8478
@pytest.mark.parametrize(
8579
"pydantic_version, call_from_python",
8680
product(_PYDANTIC_VERSIONS, [False, True]),
8781
)
88-
def test_excluding_models(tmpdir, pydantic_version, call_from_python):
82+
def test_excluding_models(tmp_path: Path, pydantic_version: str, call_from_python: bool):
8983
_run_test(
90-
tmpdir,
84+
tmp_path,
9185
"excluding_models",
9286
pydantic_version,
9387
call_from_python=call_from_python,
@@ -97,39 +91,37 @@ def test_excluding_models(tmpdir, pydantic_version, call_from_python):
9791

9892
@pytest.mark.parametrize(
9993
"pydantic_version, call_from_python",
100-
product(_PYDANTIC_VERSIONS, [False, True]),
94+
product([v for v in _PYDANTIC_VERSIONS if v != "v1"], [False, True]),
10195
)
102-
def test_computed_fields(tmpdir, pydantic_version, call_from_python):
103-
if pydantic_version == "v1":
104-
pytest.skip("Computed fields are a pydantic v2 feature")
105-
_run_test(tmpdir, "computed_fields", pydantic_version, call_from_python=call_from_python)
96+
def test_computed_fields(tmp_path: Path, pydantic_version: str, call_from_python: bool):
97+
_run_test(tmp_path, "computed_fields", pydantic_version, call_from_python=call_from_python)
10698

10799

108100
@pytest.mark.parametrize(
109101
"pydantic_version, call_from_python",
110102
product(_PYDANTIC_VERSIONS, [False, True]),
111103
)
112-
def test_extra_fields(tmpdir, pydantic_version, call_from_python):
113-
_run_test(tmpdir, "extra_fields", pydantic_version, call_from_python=call_from_python)
104+
def test_extra_fields(tmp_path: Path, pydantic_version: str, call_from_python: bool):
105+
_run_test(tmp_path, "extra_fields", pydantic_version, call_from_python=call_from_python)
114106

115107

116-
def test_relative_filepath(tmpdir):
108+
def test_relative_filepath(tmp_path: Path):
117109
test_name = "single_module"
118110
pydantic_version = _PYDANTIC_VERSIONS[0]
119111
relative_path = (
120112
Path(".") / "tests" / "expected_results" / test_name / pydantic_version / "input.py"
121113
)
122114
_run_test(
123-
tmpdir,
115+
tmp_path,
124116
test_name,
125117
pydantic_version,
126-
module_path=relative_path,
118+
module_path=str(relative_path),
127119
)
128120

129121

130-
def test_error_if_json2ts_not_installed(tmpdir):
122+
def test_error_if_json2ts_not_installed(tmp_path: Path):
131123
module_path = _get_input_module("single_module", _PYDANTIC_VERSIONS[0])
132-
output_path = tmpdir.join("json2ts_test_output.ts").strpath
124+
output_path = str(tmp_path / "json2ts_test_output.ts")
133125

134126
# If the json2ts command has no spaces and the executable cannot be found,
135127
# that means the user either hasn't installed json-schema-to-typescript or they made a typo.
@@ -159,9 +151,9 @@ def test_error_if_json2ts_not_installed(tmpdir):
159151
assert str(exc2.value).startswith(f'"{invalid_local_cmd}" failed with exit code ')
160152

161153

162-
def test_error_if_invalid_module_path(tmpdir):
154+
def test_error_if_invalid_module_path(tmp_path: Path):
163155
with pytest.raises(ModuleNotFoundError):
164-
generate_typescript_defs("fake_module", tmpdir.join("fake_module_output.ts").strpath)
156+
generate_typescript_defs("fake_module", str(tmp_path / "fake_module_output.ts"))
165157

166158

167159
def test_parse_cli_args():

0 commit comments

Comments
 (0)