diff --git a/.gitignore b/.gitignore index 67eb3af..759ea7a 100644 --- a/.gitignore +++ b/.gitignore @@ -107,6 +107,7 @@ celerybeat.pid # Environments .env .venv +.venv-v1 env/ venv/ ENV/ @@ -143,3 +144,7 @@ cython_debug/ # VS Code config .vscode + +# test outputs +output_debug.ts +schema_debug.json \ No newline at end of file diff --git a/pydantic2ts/cli/script.py b/pydantic2ts/cli/script.py index 8518395..496cf88 100644 --- a/pydantic2ts/cli/script.py +++ b/pydantic2ts/cli/script.py @@ -7,21 +7,28 @@ import shutil import sys from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path from tempfile import mkdtemp from types import ModuleType from typing import Any, Dict, List, Tuple, Type from uuid import uuid4 -from pydantic import BaseModel, Extra, create_model +from pydantic import VERSION, BaseModel, Extra, create_model -try: - from pydantic.generics import GenericModel -except ImportError: - GenericModel = None +V2 = True if VERSION.startswith("2") else False + +if not V2: + try: + from pydantic.generics import GenericModel + except ImportError: + GenericModel = None logger = logging.getLogger("pydantic2ts") +DEBUG = os.environ.get("DEBUG", False) + + def import_module(path: str) -> ModuleType: """ Helper which allows modules to be specified by either dotted path notation or by filepath. @@ -61,12 +68,15 @@ def is_concrete_pydantic_model(obj) -> bool: Return true if an object is a concrete subclass of pydantic's BaseModel. 'concrete' meaning that it's not a GenericModel. """ + generic_metadata = getattr(obj, "__pydantic_generic_metadata__", None) if not inspect.isclass(obj): return False elif obj is BaseModel: return False - elif GenericModel and issubclass(obj, GenericModel): + elif not V2 and GenericModel and issubclass(obj, GenericModel): return bool(obj.__concrete__) + elif V2 and generic_metadata: + return not bool(generic_metadata["parameters"]) else: return issubclass(obj, BaseModel) @@ -141,7 +151,7 @@ def clean_schema(schema: Dict[str, Any]) -> None: del schema["description"] -def generate_json_schema(models: List[Type[BaseModel]]) -> str: +def generate_json_schema_v1(models: List[Type[BaseModel]]) -> str: """ Create a top-level '_Master_' model with references to each of the actual models. Generate the schema for this model, which will include the schemas for all the @@ -178,6 +188,43 @@ def generate_json_schema(models: List[Type[BaseModel]]) -> str: m.Config.extra = x +def generate_json_schema_v2(models: List[Type[BaseModel]]) -> str: + """ + Create a top-level '_Master_' model with references to each of the actual models. + Generate the schema for this model, which will include the schemas for all the + nested models. Then clean up the schema. + + One weird thing we do is we temporarily override the 'extra' setting in models, + changing it to 'forbid' UNLESS it was explicitly set to 'allow'. This prevents + '[k: string]: any' from being added to every interface. This change is reverted + once the schema has been generated. + """ + model_extras = [m.model_config.get("extra") for m in models] + + try: + for m in models: + if m.model_config.get("extra") != "allow": + m.model_config["extra"] = "forbid" + + master_model: BaseModel = create_model( + "_Master_", **{m.__name__: (m, ...) for m in models} + ) + master_model.model_config["extra"] = "forbid" + master_model.model_config["json_schema_extra"] = staticmethod(clean_schema) + + schema: dict = master_model.model_json_schema(mode="serialization") + + for d in schema.get("$defs", {}).values(): + clean_schema(d) + + return json.dumps(schema, indent=2) + + finally: + for m, x in zip(models, model_extras): + if x is not None: + m.model_config["extra"] = x + + def generate_typescript_defs( module: str, output: str, exclude: Tuple[str] = (), json2ts_cmd: str = "json2ts" ) -> None: @@ -205,13 +252,20 @@ def generate_typescript_defs( logger.info("Generating JSON schema from pydantic models...") - schema = generate_json_schema(models) + schema = generate_json_schema_v2(models) if V2 else generate_json_schema_v1(models) + schema_dir = mkdtemp() schema_file_path = os.path.join(schema_dir, "schema.json") with open(schema_file_path, "w") as f: f.write(schema) + if DEBUG: + debug_schema_file_path = Path(module).parent / "schema_debug.json" + # raise ValueError(module) + with open(debug_schema_file_path, "w") as f: + f.write(schema) + logger.info("Converting JSON schema to typescript definitions...") json2ts_exit_code = os.system( diff --git a/setup.py b/setup.py index dff85f4..3a500c7 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -from setuptools import setup, find_packages +from setuptools import find_packages, setup def readme(): @@ -25,7 +25,7 @@ def readme(): setup( name="pydantic-to-typescript", - version="1.0.10", + version="1.1.11", description="Convert pydantic models to typescript interfaces", license="MIT", long_description=readme(), diff --git a/tests/expected_results/computed_fields/v2/input.py b/tests/expected_results/computed_fields/v2/input.py new file mode 100644 index 0000000..c05f9b1 --- /dev/null +++ b/tests/expected_results/computed_fields/v2/input.py @@ -0,0 +1,13 @@ +# https://docs.pydantic.dev/latest/usage/computed_fields/ + +from pydantic import BaseModel, computed_field + + +class Rectangle(BaseModel): + width: int + length: int + + @computed_field + @property + def area(self) -> int: + return self.width * self.length diff --git a/tests/expected_results/computed_fields/v2/output.ts b/tests/expected_results/computed_fields/v2/output.ts new file mode 100644 index 0000000..b371eac --- /dev/null +++ b/tests/expected_results/computed_fields/v2/output.ts @@ -0,0 +1,12 @@ +/* tslint:disable */ +/* eslint-disable */ +/** +/* This file was automatically generated from pydantic models by running pydantic2ts. +/* Do not modify it by hand - just update the pydantic models and then re-run the script +*/ + +export interface Rectangle { + width: number; + length: number; + area: number; +} diff --git a/tests/expected_results/excluding_models/input.py b/tests/expected_results/excluding_models/v1/input.py similarity index 100% rename from tests/expected_results/excluding_models/input.py rename to tests/expected_results/excluding_models/v1/input.py diff --git a/tests/expected_results/excluding_models/output.ts b/tests/expected_results/excluding_models/v1/output.ts similarity index 100% rename from tests/expected_results/excluding_models/output.ts rename to tests/expected_results/excluding_models/v1/output.ts diff --git a/tests/expected_results/single_module/input.py b/tests/expected_results/excluding_models/v2/input.py similarity index 100% rename from tests/expected_results/single_module/input.py rename to tests/expected_results/excluding_models/v2/input.py diff --git a/tests/expected_results/excluding_models/v2/output.ts b/tests/expected_results/excluding_models/v2/output.ts new file mode 100644 index 0000000..af83361 --- /dev/null +++ b/tests/expected_results/excluding_models/v2/output.ts @@ -0,0 +1,12 @@ +/* tslint:disable */ +/* eslint-disable */ +/** +/* This file was automatically generated from pydantic models by running pydantic2ts. +/* Do not modify it by hand - just update the pydantic models and then re-run the script +*/ + +export interface Profile { + username: string; + age: number | null; + hobbies: string[]; +} diff --git a/tests/expected_results/extra_fields/v1/input.py b/tests/expected_results/extra_fields/v1/input.py new file mode 100644 index 0000000..29d3032 --- /dev/null +++ b/tests/expected_results/extra_fields/v1/input.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, Extra + + +class ModelAllow(BaseModel, extra=Extra.allow): + a: str + +class ModelDefault(BaseModel): + a: str + diff --git a/tests/expected_results/extra_fields/v1/output.ts b/tests/expected_results/extra_fields/v1/output.ts new file mode 100644 index 0000000..fafbfbe --- /dev/null +++ b/tests/expected_results/extra_fields/v1/output.ts @@ -0,0 +1,14 @@ +/* tslint:disable */ +/* eslint-disable */ +/** +/* This file was automatically generated from pydantic models by running pydantic2ts. +/* Do not modify it by hand - just update the pydantic models and then re-run the script +*/ + +export interface ModelAllow { + a: string; + [k: string]: unknown; +} +export interface ModelDefault { + a: string; +} diff --git a/tests/expected_results/extra_fields/v2/input.py b/tests/expected_results/extra_fields/v2/input.py new file mode 100644 index 0000000..f6010ad --- /dev/null +++ b/tests/expected_results/extra_fields/v2/input.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel, ConfigDict + + +class ModelExtraAllow(BaseModel): + model_config = ConfigDict(extra="allow") + a: str + + +class ModelExtraForbid(BaseModel): + model_config = ConfigDict(extra="forbid") + a: str + + +class ModelExtraIgnore(BaseModel): + model_config = ConfigDict(extra="ignore") + a: str + + +class ModelExtraNone(BaseModel): + a: str diff --git a/tests/expected_results/extra_fields/v2/output.ts b/tests/expected_results/extra_fields/v2/output.ts new file mode 100644 index 0000000..eba327c --- /dev/null +++ b/tests/expected_results/extra_fields/v2/output.ts @@ -0,0 +1,20 @@ +/* tslint:disable */ +/* eslint-disable */ +/** +/* This file was automatically generated from pydantic models by running pydantic2ts. +/* Do not modify it by hand - just update the pydantic models and then re-run the script +*/ + +export interface ModelExtraAllow { + a: string; + [k: string]: unknown; +} +export interface ModelExtraForbid { + a: string; +} +export interface ModelExtraIgnore { + a: string; +} +export interface ModelExtraNone { + a: string; +} diff --git a/tests/expected_results/generics/input.py b/tests/expected_results/generics/v1/input.py similarity index 94% rename from tests/expected_results/generics/input.py rename to tests/expected_results/generics/v1/input.py index a37bc55..e79a9a8 100644 --- a/tests/expected_results/generics/input.py +++ b/tests/expected_results/generics/v1/input.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Generic, TypeVar, Optional, List, Type, cast, Union +from typing import Generic, List, Optional, Type, TypeVar, cast from pydantic import BaseModel from pydantic.generics import GenericModel diff --git a/tests/expected_results/generics/output.ts b/tests/expected_results/generics/v1/output.ts similarity index 100% rename from tests/expected_results/generics/output.ts rename to tests/expected_results/generics/v1/output.ts diff --git a/tests/expected_results/generics/v2/input.py b/tests/expected_results/generics/v2/input.py new file mode 100644 index 0000000..7b0e166 --- /dev/null +++ b/tests/expected_results/generics/v2/input.py @@ -0,0 +1,52 @@ +from datetime import datetime +from typing import Generic, List, Optional, Type, TypeVar, cast + +from pydantic import BaseModel + +T = TypeVar("T") + + +class Error(BaseModel): + code: int + message: str + + +class ApiResponse(BaseModel, Generic[T]): + data: Optional[T] + error: Optional[Error] + + +def create_response_type(data_type: T, name: str) -> "Type[ApiResponse[T]]": + """ + Create a concrete implementation of ApiResponse and then applies the specified name. + This is necessary because the name automatically generated by __concrete_name__ is + really ugly, it just doesn't look good. + """ + t = ApiResponse[data_type] + t.__name__ = name + t.__qualname__ = name + return cast(Type[ApiResponse[T]], t) + + +class User(BaseModel): + name: str + email: str + + +class UserProfile(User): + joined: datetime + last_active: datetime + age: int + + +class Article(BaseModel): + author: User + content: str + published: datetime + + +ListUsersResponse = create_response_type(List[User], "ListUsersResponse") + +ListArticlesResponse = create_response_type(List[Article], "ListArticlesResponse") + +UserProfileResponse = create_response_type(UserProfile, "UserProfileResponse") diff --git a/tests/expected_results/generics/v2/output.ts b/tests/expected_results/generics/v2/output.ts new file mode 100644 index 0000000..afcd6da --- /dev/null +++ b/tests/expected_results/generics/v2/output.ts @@ -0,0 +1,39 @@ +/* tslint:disable */ +/* eslint-disable */ +/** +/* This file was automatically generated from pydantic models by running pydantic2ts. +/* Do not modify it by hand - just update the pydantic models and then re-run the script +*/ + +export interface Article { + author: User; + content: string; + published: string; +} +export interface User { + name: string; + email: string; +} +export interface Error { + code: number; + message: string; +} +export interface ListArticlesResponse { + data: Article[] | null; + error: Error | null; +} +export interface ListUsersResponse { + data: User[] | null; + error: Error | null; +} +export interface UserProfile { + name: string; + email: string; + joined: string; + last_active: string; + age: number; +} +export interface UserProfileResponse { + data: UserProfile | null; + error: Error | null; +} diff --git a/tests/expected_results/single_module/v1/input.py b/tests/expected_results/single_module/v1/input.py new file mode 100644 index 0000000..e37ee05 --- /dev/null +++ b/tests/expected_results/single_module/v1/input.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel +from typing import Optional, List + + +class LoginCredentials(BaseModel): + username: str + password: str + + +class Profile(BaseModel): + username: str + age: Optional[int] + hobbies: List[str] + + +class LoginResponseData(BaseModel): + token: str + profile: Profile diff --git a/tests/expected_results/single_module/output.ts b/tests/expected_results/single_module/v1/output.ts similarity index 100% rename from tests/expected_results/single_module/output.ts rename to tests/expected_results/single_module/v1/output.ts diff --git a/tests/expected_results/single_module/v2/input.py b/tests/expected_results/single_module/v2/input.py new file mode 100644 index 0000000..e37ee05 --- /dev/null +++ b/tests/expected_results/single_module/v2/input.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel +from typing import Optional, List + + +class LoginCredentials(BaseModel): + username: str + password: str + + +class Profile(BaseModel): + username: str + age: Optional[int] + hobbies: List[str] + + +class LoginResponseData(BaseModel): + token: str + profile: Profile diff --git a/tests/expected_results/single_module/v2/output.ts b/tests/expected_results/single_module/v2/output.ts new file mode 100644 index 0000000..56ea42c --- /dev/null +++ b/tests/expected_results/single_module/v2/output.ts @@ -0,0 +1,20 @@ +/* tslint:disable */ +/* eslint-disable */ +/** +/* This file was automatically generated from pydantic models by running pydantic2ts. +/* Do not modify it by hand - just update the pydantic models and then re-run the script +*/ + +export interface LoginCredentials { + username: string; + password: string; +} +export interface LoginResponseData { + token: string; + profile: Profile; +} +export interface Profile { + username: string; + age: number | null; + hobbies: string[]; +} diff --git a/tests/expected_results/submodules/animals/__init__.py b/tests/expected_results/submodules/v1/animals/__init__.py similarity index 100% rename from tests/expected_results/submodules/animals/__init__.py rename to tests/expected_results/submodules/v1/animals/__init__.py diff --git a/tests/expected_results/submodules/animals/cats.py b/tests/expected_results/submodules/v1/animals/cats.py similarity index 100% rename from tests/expected_results/submodules/animals/cats.py rename to tests/expected_results/submodules/v1/animals/cats.py diff --git a/tests/expected_results/submodules/animals/dogs.py b/tests/expected_results/submodules/v1/animals/dogs.py similarity index 100% rename from tests/expected_results/submodules/animals/dogs.py rename to tests/expected_results/submodules/v1/animals/dogs.py diff --git a/tests/expected_results/submodules/input.py b/tests/expected_results/submodules/v1/input.py similarity index 99% rename from tests/expected_results/submodules/input.py rename to tests/expected_results/submodules/v1/input.py index c769f5c..672c90f 100644 --- a/tests/expected_results/submodules/input.py +++ b/tests/expected_results/submodules/v1/input.py @@ -1,5 +1,7 @@ -from pydantic import BaseModel from typing import List + +from pydantic import BaseModel + from .animals.cats import Cat from .animals.dogs import Dog diff --git a/tests/expected_results/submodules/output.ts b/tests/expected_results/submodules/v1/output.ts similarity index 100% rename from tests/expected_results/submodules/output.ts rename to tests/expected_results/submodules/v1/output.ts diff --git a/tests/expected_results/submodules/v2/animals/__init__.py b/tests/expected_results/submodules/v2/animals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/expected_results/submodules/v2/animals/cats.py b/tests/expected_results/submodules/v2/animals/cats.py new file mode 100644 index 0000000..3db89d3 --- /dev/null +++ b/tests/expected_results/submodules/v2/animals/cats.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel +from typing import Optional, Literal +from enum import Enum + + +class CatBreed(str, Enum): + domestic_shorthair = "domestic shorthair" + bengal = "bengal" + persian = "persian" + siamese = "siamese" + + +class Cat(BaseModel): + name: str + age: int + declawed: bool + breed: CatBreed diff --git a/tests/expected_results/submodules/v2/animals/dogs.py b/tests/expected_results/submodules/v2/animals/dogs.py new file mode 100644 index 0000000..07ec007 --- /dev/null +++ b/tests/expected_results/submodules/v2/animals/dogs.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel +from typing import Optional +from enum import Enum + + +class DogBreed(str, Enum): + mutt = "mutt" + labrador = "labrador" + golden_retriever = "golden retriever" + + +class Dog(BaseModel): + name: str + age: int + breed: DogBreed diff --git a/tests/expected_results/submodules/v2/input.py b/tests/expected_results/submodules/v2/input.py new file mode 100644 index 0000000..672c90f --- /dev/null +++ b/tests/expected_results/submodules/v2/input.py @@ -0,0 +1,12 @@ +from typing import List + +from pydantic import BaseModel + +from .animals.cats import Cat +from .animals.dogs import Dog + + +class AnimalShelter(BaseModel): + address: str + cats: List[Cat] + dogs: List[Dog] diff --git a/tests/expected_results/submodules/v2/output.ts b/tests/expected_results/submodules/v2/output.ts new file mode 100644 index 0000000..3091266 --- /dev/null +++ b/tests/expected_results/submodules/v2/output.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** +/* This file was automatically generated from pydantic models by running pydantic2ts. +/* Do not modify it by hand - just update the pydantic models and then re-run the script +*/ + +export type CatBreed = "domestic shorthair" | "bengal" | "persian" | "siamese"; +export type DogBreed = "mutt" | "labrador" | "golden retriever"; + +export interface AnimalShelter { + address: string; + cats: Cat[]; + dogs: Dog[]; +} +export interface Cat { + name: string; + age: number; + declawed: boolean; + breed: CatBreed; +} +export interface Dog { + name: string; + age: number; + breed: DogBreed; +} diff --git a/tests/test_script.py b/tests/test_script.py index 8ff3e1f..c7e2ff7 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -1,11 +1,14 @@ import os import subprocess import sys +from pathlib import Path import pytest from pydantic2ts import generate_typescript_defs -from pydantic2ts.cli.script import parse_cli_args +from pydantic2ts.cli.script import DEBUG, V2, parse_cli_args + +version = "v2" if V2 else "v1" def _results_directory() -> str: @@ -13,11 +16,11 @@ def _results_directory() -> str: def get_input_module(test_name: str) -> str: - return os.path.join(_results_directory(), test_name, "input.py") + return os.path.join(_results_directory(), test_name, version, "input.py") def get_expected_output(test_name: str) -> str: - path = os.path.join(_results_directory(), test_name, "output.ts") + path = os.path.join(_results_directory(), test_name, version, "output.ts") with open(path, "r") as f: return f.read() @@ -38,10 +41,15 @@ def run_test( cmd = f"pydantic2ts --module {module_path} --output {output_path}" for model_to_exclude in exclude: cmd += f" --exclude {model_to_exclude}" - subprocess.run(cmd, shell=True) + subprocess.run(cmd, shell=True, check=True) with open(output_path, "r") as f: output = f.read() + + if DEBUG: + out_dir = Path(module_path).parent + output_path = out_dir / "output_debug.ts" + assert output == get_expected_output(test_name) @@ -74,10 +82,19 @@ def test_excluding_models(tmpdir): ) +def test_computed_fields(tmpdir): + if version == "v1": + pytest.skip("Computed fields are a pydantic v2 feature") + run_test(tmpdir, "computed_fields") + +def test_extra_fields(tmpdir): + run_test(tmpdir, "extra_fields") + + def test_relative_filepath(tmpdir): test_name = "single_module" relative_path = os.path.join( - ".", "tests", "expected_results", test_name, "input.py" + ".", "tests", "expected_results", test_name, version, "input.py" ) run_test( tmpdir, @@ -135,7 +152,7 @@ def test_error_if_json2ts_not_installed(tmpdir): def test_error_if_invalid_module_path(tmpdir): with pytest.raises(ModuleNotFoundError): generate_typescript_defs( - "fake_module", tmpdir.join(f"fake_module_output.ts").strpath + "fake_module", tmpdir.join("fake_module_output.ts").strpath )