From 0ec841f7b0b02042d2a999846a4380206f7ea72d Mon Sep 17 00:00:00 2001 From: khalo-sa <14795990+khalo-sa@users.noreply.github.com> Date: Thu, 27 Jul 2023 23:37:00 +0200 Subject: [PATCH 01/38] feat: pydantic v2 support --- .gitignore | 4 ++ pydantic2ts/cli/script.py | 59 +++++++++++++++++-- .../excluding_models/{ => v1}/input.py | 0 .../excluding_models/{ => v1}/output.ts | 0 .../v2}/input.py | 0 .../excluding_models/v2/output.ts | 12 ++++ .../generics/{ => v1}/input.py | 2 +- .../generics/{ => v1}/output.ts | 0 tests/expected_results/generics/v2/input.py | 52 ++++++++++++++++ tests/expected_results/generics/v2/output.ts | 39 ++++++++++++ .../single_module/v1/input.py | 18 ++++++ .../single_module/{ => v1}/output.ts | 0 .../single_module/v2/input.py | 18 ++++++ .../single_module/v2/output.ts | 20 +++++++ .../submodules/{ => v1}/animals/__init__.py | 0 .../submodules/{ => v1}/animals/cats.py | 0 .../submodules/{ => v1}/animals/dogs.py | 0 .../submodules/{ => v1}/input.py | 4 +- .../submodules/{ => v1}/output.ts | 0 .../submodules/v2/animals/__init__.py | 0 .../submodules/v2/animals/cats.py | 17 ++++++ .../submodules/v2/animals/dogs.py | 15 +++++ tests/expected_results/submodules/v2/input.py | 12 ++++ .../expected_results/submodules/v2/output.ts | 26 ++++++++ tests/test_script.py | 18 ++++-- 25 files changed, 305 insertions(+), 11 deletions(-) rename tests/expected_results/excluding_models/{ => v1}/input.py (100%) rename tests/expected_results/excluding_models/{ => v1}/output.ts (100%) rename tests/expected_results/{single_module => excluding_models/v2}/input.py (100%) create mode 100644 tests/expected_results/excluding_models/v2/output.ts rename tests/expected_results/generics/{ => v1}/input.py (94%) rename tests/expected_results/generics/{ => v1}/output.ts (100%) create mode 100644 tests/expected_results/generics/v2/input.py create mode 100644 tests/expected_results/generics/v2/output.ts create mode 100644 tests/expected_results/single_module/v1/input.py rename tests/expected_results/single_module/{ => v1}/output.ts (100%) create mode 100644 tests/expected_results/single_module/v2/input.py create mode 100644 tests/expected_results/single_module/v2/output.ts rename tests/expected_results/submodules/{ => v1}/animals/__init__.py (100%) rename tests/expected_results/submodules/{ => v1}/animals/cats.py (100%) rename tests/expected_results/submodules/{ => v1}/animals/dogs.py (100%) rename tests/expected_results/submodules/{ => v1}/input.py (99%) rename tests/expected_results/submodules/{ => v1}/output.ts (100%) create mode 100644 tests/expected_results/submodules/v2/animals/__init__.py create mode 100644 tests/expected_results/submodules/v2/animals/cats.py create mode 100644 tests/expected_results/submodules/v2/animals/dogs.py create mode 100644 tests/expected_results/submodules/v2/input.py create mode 100644 tests/expected_results/submodules/v2/output.ts diff --git a/.gitignore b/.gitignore index 67eb3af..a87a1b5 100644 --- a/.gitignore +++ b/.gitignore @@ -107,6 +107,7 @@ celerybeat.pid # Environments .env .venv +.venv-v1 env/ venv/ ENV/ @@ -143,3 +144,6 @@ cython_debug/ # VS Code config .vscode + +# test outputs +output_debug.ts \ No newline at end of file diff --git a/pydantic2ts/cli/script.py b/pydantic2ts/cli/script.py index 8518395..c466312 100644 --- a/pydantic2ts/cli/script.py +++ b/pydantic2ts/cli/script.py @@ -7,12 +7,13 @@ 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 @@ -22,6 +23,11 @@ logger = logging.getLogger("pydantic2ts") +DEBUG = os.environ.get("DEBUG", False) + +V2 = True if VERSION.startswith("2") else False + + def import_module(path: str) -> ModuleType: """ 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: 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 +150,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 +187,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") != Extra.allow: + m.model_config["extra"] = Extra.forbid + + master_model: BaseModel = create_model( + "_Master_", **{m.__name__: (m, ...) for m in models} + ) + master_model.model_config["extra"] = Extra.forbid + master_model.model_config["json_schema_extra"] = staticmethod(clean_schema) + + schema: dict = master_model.model_json_schema() + + 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 +251,18 @@ 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: + with open(Path(output).parent / "schema.json", "w") as f: + f.write(schema) + logger.info("Converting JSON schema to typescript definitions...") json2ts_exit_code = os.system( 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/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..b72be50 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() @@ -42,6 +45,11 @@ def run_test( 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) @@ -77,7 +85,7 @@ def test_excluding_models(tmpdir): 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 +143,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 ) From 21799cd202081dda76034861604c3f01c6044991 Mon Sep 17 00:00:00 2001 From: khalo-sa <14795990+khalo-sa@users.noreply.github.com> Date: Fri, 28 Jul 2023 09:22:13 +0200 Subject: [PATCH 02/38] bump version to 1.1.10 --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index dff85f4..50486a1 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.10", description="Convert pydantic models to typescript interfaces", license="MIT", long_description=readme(), From 630ae4d9c0c717a77e61d4c37fbfca4924b205f7 Mon Sep 17 00:00:00 2001 From: khalo-sa <14795990+khalo-sa@users.noreply.github.com> Date: Wed, 9 Aug 2023 11:45:54 +0200 Subject: [PATCH 03/38] feat: v2 - convert computed fields --- .gitignore | 3 ++- pydantic2ts/cli/script.py | 25 +++++++++++-------- .../computed_fields/v2/input.py | 13 ++++++++++ .../computed_fields/v2/output.ts | 12 +++++++++ tests/test_script.py | 8 +++++- 5 files changed, 48 insertions(+), 13 deletions(-) create mode 100644 tests/expected_results/computed_fields/v2/input.py create mode 100644 tests/expected_results/computed_fields/v2/output.ts diff --git a/.gitignore b/.gitignore index a87a1b5..759ea7a 100644 --- a/.gitignore +++ b/.gitignore @@ -146,4 +146,5 @@ cython_debug/ .vscode # test outputs -output_debug.ts \ No newline at end of file +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 c466312..496cf88 100644 --- a/pydantic2ts/cli/script.py +++ b/pydantic2ts/cli/script.py @@ -15,18 +15,19 @@ 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) -V2 = True if VERSION.startswith("2") else False - def import_module(path: str) -> ModuleType: """ @@ -202,16 +203,16 @@ def generate_json_schema_v2(models: List[Type[BaseModel]]) -> str: try: for m in models: - if m.model_config.get("extra") != Extra.allow: - m.model_config["extra"] = Extra.forbid + 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"] = Extra.forbid + master_model.model_config["extra"] = "forbid" master_model.model_config["json_schema_extra"] = staticmethod(clean_schema) - schema: dict = master_model.model_json_schema() + schema: dict = master_model.model_json_schema(mode="serialization") for d in schema.get("$defs", {}).values(): clean_schema(d) @@ -260,7 +261,9 @@ def generate_typescript_defs( f.write(schema) if DEBUG: - with open(Path(output).parent / "schema.json", "w") as f: + 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...") 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/test_script.py b/tests/test_script.py index b72be50..5645b2f 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -41,7 +41,7 @@ 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() @@ -82,6 +82,12 @@ 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_relative_filepath(tmpdir): test_name = "single_module" relative_path = os.path.join( From 787f229155c43eae43a69260f050aa6f07cdc0db Mon Sep 17 00:00:00 2001 From: khalo-sa <14795990+khalo-sa@users.noreply.github.com> Date: Wed, 9 Aug 2023 11:53:38 +0200 Subject: [PATCH 04/38] bump version to 1.1.11 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 50486a1..3a500c7 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ def readme(): setup( name="pydantic-to-typescript", - version="1.1.10", + version="1.1.11", description="Convert pydantic models to typescript interfaces", license="MIT", long_description=readme(), From cdae02e793e5a95352bc9e97e4aef1cb5100d377 Mon Sep 17 00:00:00 2001 From: khalo-sa <14795990+khalo-sa@users.noreply.github.com> Date: Wed, 9 Aug 2023 16:00:46 +0200 Subject: [PATCH 05/38] test: add test for extra config --- .../expected_results/extra_fields/v1/input.py | 9 +++++++++ .../extra_fields/v1/output.ts | 14 +++++++++++++ .../expected_results/extra_fields/v2/input.py | 20 +++++++++++++++++++ .../extra_fields/v2/output.ts | 20 +++++++++++++++++++ tests/test_script.py | 3 +++ 5 files changed, 66 insertions(+) create mode 100644 tests/expected_results/extra_fields/v1/input.py create mode 100644 tests/expected_results/extra_fields/v1/output.ts create mode 100644 tests/expected_results/extra_fields/v2/input.py create mode 100644 tests/expected_results/extra_fields/v2/output.ts 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/test_script.py b/tests/test_script.py index 5645b2f..c7e2ff7 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -87,6 +87,9 @@ def test_computed_fields(tmpdir): 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" From 3c3ff0a1d188ac67ea32632e4806c424e23f1910 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Sun, 10 Nov 2024 18:09:42 -0500 Subject: [PATCH 06/38] Tweaks so v1 pydantic models are supported even if version 2.0.0+ is installed --- .github/workflows/cicd.yml | 2 +- pydantic2ts/cli/script.py | 133 ++++++++++++++++++++++++------------- tests/test_script.py | 14 ++-- 3 files changed, 94 insertions(+), 55 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 030f449..d9632a5 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -78,4 +78,4 @@ jobs: uses: pypa/gh-action-pypi-publish@v1.5.0 with: user: __token__ - password: ${{ secrets.pypi_password }} \ No newline at end of file + password: ${{ secrets.pypi_password }} diff --git a/pydantic2ts/cli/script.py b/pydantic2ts/cli/script.py index 496cf88..c4faf38 100644 --- a/pydantic2ts/cli/script.py +++ b/pydantic2ts/cli/script.py @@ -10,26 +10,50 @@ from pathlib import Path from tempfile import mkdtemp from types import ModuleType -from typing import Any, Dict, List, Tuple, Type +from typing import Any, Dict, List, Tuple, Type, TypeVar, Union from uuid import uuid4 -from pydantic import VERSION, BaseModel, Extra, create_model +try: + from pydantic.v1 import ( + BaseModel as BaseModelV1, + Extra as ExtraV1, + create_model as create_model_v1, + ) + from pydantic import BaseModel as BaseModelV2, create_model as create_model_v2 + + BaseModelType = TypeVar("BaseModelType", Type[BaseModelV1], Type[BaseModelV2]) +except ImportError: + from pydantic import ( + BaseModel as BaseModelV1, + Extra as ExtraV1, + create_model as create_model_v1, + ) -V2 = True if VERSION.startswith("2") else False + BaseModelV2 = None + create_model_v2 = None + BaseModelType = TypeVar("BaseModelType", Type[BaseModelV1]) -if not V2: +try: + from pydantic.v1.generics import GenericModel +except ImportError: try: from pydantic.generics import GenericModel except ImportError: GenericModel = None -logger = logging.getLogger("pydantic2ts") +# V2 = VERSION.startswith("2") -DEBUG = os.environ.get("DEBUG", False) +# if not V2: +# try: +# from pydantic.generics import GenericModel +# except ImportError: +# GenericModel = None + +logger = logging.getLogger("pydantic2ts") -def import_module(path: str) -> ModuleType: +def _import_module(path: str) -> ModuleType: """ Helper which allows modules to be specified by either dotted path notation or by filepath. @@ -38,7 +62,7 @@ def import_module(path: str) -> ModuleType: definition exist in sys.modules under that name. """ try: - if os.path.exists(path): + if Path(path).exists(): name = uuid4().hex spec = spec_from_file_location(name, path, submodule_search_locations=[]) module = module_from_spec(spec) @@ -54,7 +78,7 @@ def import_module(path: str) -> ModuleType: raise e -def is_submodule(obj, module_name: str) -> bool: +def _is_submodule(obj, module_name: str) -> bool: """ Return true if an object is a submodule """ @@ -63,43 +87,54 @@ def is_submodule(obj, module_name: str) -> bool: ) -def is_concrete_pydantic_model(obj) -> bool: +def _is_pydantic_v1_model(obj: Any) -> bool: + """ + Return true if an object is a pydantic V1 model. + """ + return inspect.isclass(obj) and ( + (obj is not BaseModelV1 and issubclass(obj, BaseModelV1)) + or (GenericModel and issubclass(obj, GenericModel) and obj.__concrete__) + ) + + +def _is_pydantic_v2_model(obj: Any) -> bool: + """ + Return true if an object is a pydantic V2 model. + """ + return inspect.isclass(obj) and ( + obj is not BaseModelV2 + and issubclass(obj, BaseModelV2) + and not getattr(obj, "__pydantic_generic_metadata__", {}).get("parameters") + ) + + +def _is_concrete_pydantic_model(obj: Any) -> bool: """ Return true if an object is a concrete subclass of pydantic's BaseModel. - 'concrete' meaning that it's not a GenericModel. + 'concrete' meaning that it's not a generic model. """ - generic_metadata = getattr(obj, "__pydantic_generic_metadata__", None) - if not inspect.isclass(obj): - return False - elif obj is BaseModel: - return False - 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) + return _is_pydantic_v1_model(obj) or _is_pydantic_v2_model(obj) -def extract_pydantic_models(module: ModuleType) -> List[Type[BaseModel]]: +def _extract_pydantic_models(module: ModuleType) -> List[BaseModelType]: """ Given a module, return a list of the pydantic models contained within it. """ models = [] module_name = module.__name__ - for _, model in inspect.getmembers(module, is_concrete_pydantic_model): + for _, model in inspect.getmembers(module, _is_concrete_pydantic_model): models.append(model) for _, submodule in inspect.getmembers( - module, lambda obj: is_submodule(obj, module_name) + module, lambda obj: _is_submodule(obj, module_name) ): - models.extend(extract_pydantic_models(submodule)) + models.extend(_extract_pydantic_models(submodule)) return models -def clean_output_file(output_filename: str) -> None: +def _clean_output_file(output_filename: str) -> None: """ Clean up the output file typescript definitions were written to by: 1. Removing the 'master model'. @@ -134,7 +169,7 @@ def clean_output_file(output_filename: str) -> None: f.writelines(new_lines) -def clean_schema(schema: Dict[str, Any]) -> None: +def _clean_schema(schema: Dict[str, Any]) -> None: """ Clean up the resulting JSON schemas by: @@ -151,7 +186,7 @@ def clean_schema(schema: Dict[str, Any]) -> None: del schema["description"] -def generate_json_schema_v1(models: List[Type[BaseModel]]) -> str: +def _generate_json_schema_v1(models: List[Type[BaseModelV1]]) -> 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 @@ -166,19 +201,19 @@ def generate_json_schema_v1(models: List[Type[BaseModel]]) -> str: try: for m in models: - if getattr(m.Config, "extra", None) != Extra.allow: - m.Config.extra = Extra.forbid + if getattr(m.Config, "extra", None) != ExtraV1.allow: + m.Config.extra = ExtraV1.forbid - master_model = create_model( + master_model = create_model_v1( "_Master_", **{m.__name__: (m, ...) for m in models} ) - master_model.Config.extra = Extra.forbid - master_model.Config.schema_extra = staticmethod(clean_schema) + master_model.Config.extra = ExtraV1.forbid + master_model.Config.schema_extra = staticmethod(_clean_schema) schema = json.loads(master_model.schema_json()) for d in schema.get("definitions", {}).values(): - clean_schema(d) + _clean_schema(d) return json.dumps(schema, indent=2) @@ -188,7 +223,7 @@ def generate_json_schema_v1(models: List[Type[BaseModel]]) -> str: m.Config.extra = x -def generate_json_schema_v2(models: List[Type[BaseModel]]) -> str: +def _generate_json_schema_v2(models: List[Type[BaseModelV2]]) -> 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 @@ -206,16 +241,16 @@ def generate_json_schema_v2(models: List[Type[BaseModel]]) -> str: if m.model_config.get("extra") != "allow": m.model_config["extra"] = "forbid" - master_model: BaseModel = create_model( + master_model = create_model_v2( "_Master_", **{m.__name__: (m, ...) for m in models} ) master_model.model_config["extra"] = "forbid" - master_model.model_config["json_schema_extra"] = staticmethod(clean_schema) + 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) + _clean_schema(d) return json.dumps(schema, indent=2) @@ -245,14 +280,22 @@ def generate_typescript_defs( logger.info("Finding pydantic models...") - models = extract_pydantic_models(import_module(module)) + models = _extract_pydantic_models(_import_module(module)) if exclude: models = [m for m in models if m.__name__ not in exclude] + if not models: + logger.info("No pydantic models found, exiting.") + return + logger.info("Generating JSON schema from pydantic models...") - schema = generate_json_schema_v2(models) if V2 else generate_json_schema_v1(models) + schema = ( + _generate_json_schema_v1(models) + if any(issubclass(m, BaseModelV1) for m in models) + else _generate_json_schema_v2(models) + ) schema_dir = mkdtemp() schema_file_path = os.path.join(schema_dir, "schema.json") @@ -260,12 +303,6 @@ def generate_typescript_defs( 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( @@ -275,7 +312,7 @@ def generate_typescript_defs( shutil.rmtree(schema_dir) if json2ts_exit_code == 0: - clean_output_file(output) + _clean_output_file(output) logger.info(f"Saved typescript definitions to {output}.") else: raise RuntimeError( diff --git a/tests/test_script.py b/tests/test_script.py index c7e2ff7..5568e27 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -4,11 +4,12 @@ from pathlib import Path import pytest +from pydantic import VERSION as PYDANTIC_VERSION from pydantic2ts import generate_typescript_defs -from pydantic2ts.cli.script import DEBUG, V2, parse_cli_args +from pydantic2ts.cli.script import parse_cli_args -version = "v2" if V2 else "v1" +version = "v2" if PYDANTIC_VERSION.startswith("2") else "v1" def _results_directory() -> str: @@ -46,9 +47,9 @@ def run_test( 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" + # if DEBUG: + # out_dir = Path(module_path).parent + # output_path = out_dir / "output_debug.ts" assert output == get_expected_output(test_name) @@ -87,6 +88,7 @@ def test_computed_fields(tmpdir): pytest.skip("Computed fields are a pydantic v2 feature") run_test(tmpdir, "computed_fields") + def test_extra_fields(tmpdir): run_test(tmpdir, "extra_fields") @@ -119,7 +121,7 @@ def test_calling_from_python(tmpdir): def test_error_if_json2ts_not_installed(tmpdir): module_path = get_input_module("single_module") - output_path = tmpdir.join(f"cli_single_module.ts").strpath + output_path = tmpdir.join("cli_single_module.ts").strpath # If the json2ts command has no spaces and the executable cannot be found, # that means the user either hasn't installed json-schema-to-typescript or they made a typo. From 1bf83dd2c484f720d509f5e1c5e9faff695e1b62 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Sun, 10 Nov 2024 22:57:37 -0500 Subject: [PATCH 07/38] Parametrize tests for both versions of pydantic, update test results to reflect the changes to schema generation for optional/nullable fields --- pydantic2ts/cli/script.py | 26 ++- .../excluding_models/v1/input.py | 2 +- .../excluding_models/v1/output.ts | 2 +- .../excluding_models/v2/input.py | 2 +- .../excluding_models/v2/output.ts | 2 +- tests/expected_results/generics/v1/input.py | 4 +- tests/expected_results/generics/v1/output.ts | 12 +- .../single_module/v1/input.py | 2 +- .../single_module/v1/output.ts | 2 +- .../single_module/v2/input.py | 2 +- .../single_module/v2/output.ts | 2 +- tests/test_script.py | 151 ++++++++++-------- 12 files changed, 110 insertions(+), 99 deletions(-) diff --git a/pydantic2ts/cli/script.py b/pydantic2ts/cli/script.py index c4faf38..c09383f 100644 --- a/pydantic2ts/cli/script.py +++ b/pydantic2ts/cli/script.py @@ -10,22 +10,25 @@ from pathlib import Path from tempfile import mkdtemp from types import ModuleType -from typing import Any, Dict, List, Tuple, Type, TypeVar, Union +from typing import Any, Dict, List, Tuple, Type, TypeVar from uuid import uuid4 try: + from pydantic import BaseModel as BaseModelV2 + from pydantic import create_model as create_model_v2 from pydantic.v1 import ( BaseModel as BaseModelV1, - Extra as ExtraV1, + ) + from pydantic.v1 import ( create_model as create_model_v1, ) - from pydantic import BaseModel as BaseModelV2, create_model as create_model_v2 BaseModelType = TypeVar("BaseModelType", Type[BaseModelV1], Type[BaseModelV2]) except ImportError: from pydantic import ( BaseModel as BaseModelV1, - Extra as ExtraV1, + ) + from pydantic import ( create_model as create_model_v1, ) @@ -41,15 +44,6 @@ except ImportError: GenericModel = None - -# V2 = VERSION.startswith("2") - -# if not V2: -# try: -# from pydantic.generics import GenericModel -# except ImportError: -# GenericModel = None - logger = logging.getLogger("pydantic2ts") @@ -201,13 +195,13 @@ def _generate_json_schema_v1(models: List[Type[BaseModelV1]]) -> str: try: for m in models: - if getattr(m.Config, "extra", None) != ExtraV1.allow: - m.Config.extra = ExtraV1.forbid + if getattr(m.Config, "extra", None) != "allow": + m.Config.extra = "forbid" master_model = create_model_v1( "_Master_", **{m.__name__: (m, ...) for m in models} ) - master_model.Config.extra = ExtraV1.forbid + master_model.Config.extra = "forbid" master_model.Config.schema_extra = staticmethod(_clean_schema) schema = json.loads(master_model.schema_json()) diff --git a/tests/expected_results/excluding_models/v1/input.py b/tests/expected_results/excluding_models/v1/input.py index e37ee05..63b4359 100644 --- a/tests/expected_results/excluding_models/v1/input.py +++ b/tests/expected_results/excluding_models/v1/input.py @@ -9,7 +9,7 @@ class LoginCredentials(BaseModel): class Profile(BaseModel): username: str - age: Optional[int] + age: Optional[int] = None hobbies: List[str] diff --git a/tests/expected_results/excluding_models/v1/output.ts b/tests/expected_results/excluding_models/v1/output.ts index 451bb73..24a09cf 100644 --- a/tests/expected_results/excluding_models/v1/output.ts +++ b/tests/expected_results/excluding_models/v1/output.ts @@ -7,6 +7,6 @@ export interface Profile { username: string; - age?: number; + age?: number | null; hobbies: string[]; } diff --git a/tests/expected_results/excluding_models/v2/input.py b/tests/expected_results/excluding_models/v2/input.py index e37ee05..63b4359 100644 --- a/tests/expected_results/excluding_models/v2/input.py +++ b/tests/expected_results/excluding_models/v2/input.py @@ -9,7 +9,7 @@ class LoginCredentials(BaseModel): class Profile(BaseModel): username: str - age: Optional[int] + age: Optional[int] = None hobbies: List[str] diff --git a/tests/expected_results/excluding_models/v2/output.ts b/tests/expected_results/excluding_models/v2/output.ts index af83361..24a09cf 100644 --- a/tests/expected_results/excluding_models/v2/output.ts +++ b/tests/expected_results/excluding_models/v2/output.ts @@ -7,6 +7,6 @@ export interface Profile { username: string; - age: number | null; + age?: number | null; hobbies: string[]; } diff --git a/tests/expected_results/generics/v1/input.py b/tests/expected_results/generics/v1/input.py index e79a9a8..db5e78b 100644 --- a/tests/expected_results/generics/v1/input.py +++ b/tests/expected_results/generics/v1/input.py @@ -13,8 +13,8 @@ class Error(BaseModel): class ApiResponse(GenericModel, Generic[T]): - data: Optional[T] - error: Optional[Error] + data: Optional[T] = None + error: Optional[Error] = None def create_response_type(data_type: T, name: str) -> "Type[ApiResponse[T]]": diff --git a/tests/expected_results/generics/v1/output.ts b/tests/expected_results/generics/v1/output.ts index 5da2624..eafac05 100644 --- a/tests/expected_results/generics/v1/output.ts +++ b/tests/expected_results/generics/v1/output.ts @@ -19,12 +19,12 @@ export interface Error { message: string; } export interface ListArticlesResponse { - data?: Article[]; - error?: Error; + data?: Article[] | null; + error?: Error | null; } export interface ListUsersResponse { - data?: User[]; - error?: Error; + data?: User[] | null; + error?: Error | null; } export interface UserProfile { name: string; @@ -34,6 +34,6 @@ export interface UserProfile { age: number; } export interface UserProfileResponse { - data?: UserProfile; - error?: Error; + 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 index e37ee05..63b4359 100644 --- a/tests/expected_results/single_module/v1/input.py +++ b/tests/expected_results/single_module/v1/input.py @@ -9,7 +9,7 @@ class LoginCredentials(BaseModel): class Profile(BaseModel): username: str - age: Optional[int] + age: Optional[int] = None hobbies: List[str] diff --git a/tests/expected_results/single_module/v1/output.ts b/tests/expected_results/single_module/v1/output.ts index 4defc90..05bf779 100644 --- a/tests/expected_results/single_module/v1/output.ts +++ b/tests/expected_results/single_module/v1/output.ts @@ -15,6 +15,6 @@ export interface LoginResponseData { } export interface Profile { username: string; - age?: number; + age?: number | null; hobbies: string[]; } diff --git a/tests/expected_results/single_module/v2/input.py b/tests/expected_results/single_module/v2/input.py index e37ee05..63b4359 100644 --- a/tests/expected_results/single_module/v2/input.py +++ b/tests/expected_results/single_module/v2/input.py @@ -9,7 +9,7 @@ class LoginCredentials(BaseModel): class Profile(BaseModel): username: str - age: Optional[int] + age: Optional[int] = None hobbies: List[str] diff --git a/tests/expected_results/single_module/v2/output.ts b/tests/expected_results/single_module/v2/output.ts index 56ea42c..05bf779 100644 --- a/tests/expected_results/single_module/v2/output.ts +++ b/tests/expected_results/single_module/v2/output.ts @@ -15,6 +15,6 @@ export interface LoginResponseData { } export interface Profile { username: string; - age: number | null; + age?: number | null; hobbies: string[]; } diff --git a/tests/test_script.py b/tests/test_script.py index 5568e27..9af1dd4 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -1,40 +1,46 @@ import os import subprocess -import sys +from itertools import product from pathlib import Path import pytest -from pydantic import VERSION as PYDANTIC_VERSION +from pydantic import VERSION as _PYDANTIC_VERSION from pydantic2ts import generate_typescript_defs from pydantic2ts.cli.script import parse_cli_args -version = "v2" if PYDANTIC_VERSION.startswith("2") else "v1" - +_PYDANTIC_VERSIONS = ( + ("v1",) if int(_PYDANTIC_VERSION.split(".")[0]) < 2 else ("v1", "v2") +) -def _results_directory() -> str: - return os.path.join(os.path.dirname(os.path.realpath(__file__)), "expected_results") +_RESULTS_DIRECTORY = Path( + os.path.join(os.path.dirname(os.path.realpath(__file__)), "expected_results") +) -def get_input_module(test_name: str) -> str: - return os.path.join(_results_directory(), test_name, version, "input.py") +def _get_input_module(test_name: str, pydantic_version: str) -> str: + return _RESULTS_DIRECTORY / test_name / pydantic_version / "input.py" -def get_expected_output(test_name: str) -> str: - path = os.path.join(_results_directory(), test_name, version, "output.ts") - with open(path, "r") as f: - return f.read() +def _get_expected_output(test_name: str, pydantic_version: str) -> str: + return (_RESULTS_DIRECTORY / test_name / pydantic_version / "output.ts").read_text() -def run_test( - tmpdir, test_name, *, module_path=None, call_from_python=False, exclude=() +def _run_test( + tmpdir, + test_name, + pydantic_version, + *, + module_path=None, + call_from_python=False, + exclude=(), ): """ Execute pydantic2ts logic for converting pydantic models into tyepscript definitions. Compare the output with the expected output, verifying it is identical. """ - module_path = module_path or get_input_module(test_name) - output_path = tmpdir.join(f"cli_{test_name}.ts").strpath + module_path = module_path or _get_input_module(test_name, pydantic_version) + output_path = tmpdir.join(f"cli_{test_name}_{pydantic_version}.ts").strpath if call_from_python: generate_typescript_defs(module_path, output_path, exclude) @@ -44,84 +50,95 @@ def run_test( cmd += f" --exclude {model_to_exclude}" 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) + assert Path(output_path).read_text() == _get_expected_output( + test_name, pydantic_version + ) -def test_single_module(tmpdir): - run_test(tmpdir, "single_module") +@pytest.mark.parametrize( + "pydantic_version, call_from_python", + product(_PYDANTIC_VERSIONS, [False, True]), +) +def test_single_module(tmpdir, pydantic_version, call_from_python): + _run_test( + tmpdir, "single_module", pydantic_version, call_from_python=call_from_python + ) -@pytest.mark.skipif( - sys.version_info < (3, 8), - reason="Literal requires python 3.8 or higher (Ref.: PEP 586)", +@pytest.mark.parametrize( + "pydantic_version, call_from_python", + product(_PYDANTIC_VERSIONS, [False, True]), ) -def test_submodules(tmpdir): - run_test(tmpdir, "submodules") +def test_submodules(tmpdir, pydantic_version, call_from_python): + _run_test(tmpdir, "submodules", pydantic_version, call_from_python=call_from_python) -@pytest.mark.skipif( - sys.version_info < (3, 7), - reason=( - "GenericModel is only supported for python>=3.7 " - "(Ref.: https://pydantic-docs.helpmanual.io/usage/models/#generic-models)" - ), +@pytest.mark.parametrize( + "pydantic_version, call_from_python", + product(_PYDANTIC_VERSIONS, [False, True]), ) -def test_generics(tmpdir): - run_test(tmpdir, "generics") +def test_generics(tmpdir, pydantic_version, call_from_python): + _run_test(tmpdir, "generics", pydantic_version, call_from_python=call_from_python) -def test_excluding_models(tmpdir): - run_test( - tmpdir, "excluding_models", exclude=("LoginCredentials", "LoginResponseData") +@pytest.mark.parametrize( + "pydantic_version, call_from_python", + product(_PYDANTIC_VERSIONS, [False, True]), +) +def test_excluding_models(tmpdir, pydantic_version, call_from_python): + _run_test( + tmpdir, + "excluding_models", + pydantic_version, + call_from_python=call_from_python, + exclude=("LoginCredentials", "LoginResponseData"), ) -def test_computed_fields(tmpdir): - if version == "v1": +@pytest.mark.parametrize( + "pydantic_version, call_from_python", + product(_PYDANTIC_VERSIONS, [False, True]), +) +def test_computed_fields(tmpdir, pydantic_version, call_from_python): + if pydantic_version == "v1": pytest.skip("Computed fields are a pydantic v2 feature") - run_test(tmpdir, "computed_fields") + _run_test( + tmpdir, "computed_fields", pydantic_version, call_from_python=call_from_python + ) -def test_extra_fields(tmpdir): - run_test(tmpdir, "extra_fields") +@pytest.mark.parametrize( + "pydantic_version, call_from_python", + product(_PYDANTIC_VERSIONS, [False, True]), +) +def test_extra_fields(tmpdir, pydantic_version, call_from_python): + _run_test( + tmpdir, "extra_fields", pydantic_version, call_from_python=call_from_python + ) def test_relative_filepath(tmpdir): test_name = "single_module" - relative_path = os.path.join( - ".", "tests", "expected_results", test_name, version, "input.py" + pydantic_version = _PYDANTIC_VERSIONS[0] + relative_path = ( + Path(".") + / "tests" + / "expected_results" + / test_name + / pydantic_version + / "input.py" ) - run_test( + _run_test( tmpdir, - "single_module", + test_name, + pydantic_version, module_path=relative_path, ) -def test_calling_from_python(tmpdir): - run_test(tmpdir, "single_module", call_from_python=True) - if sys.version_info >= (3, 8): - run_test(tmpdir, "submodules", call_from_python=True) - if sys.version_info >= (3, 7): - run_test(tmpdir, "generics", call_from_python=True) - run_test( - tmpdir, - "excluding_models", - call_from_python=True, - exclude=("LoginCredentials", "LoginResponseData"), - ) - - def test_error_if_json2ts_not_installed(tmpdir): - module_path = get_input_module("single_module") - output_path = tmpdir.join("cli_single_module.ts").strpath + module_path = _get_input_module("single_module", _PYDANTIC_VERSIONS[0]) + output_path = tmpdir.join("json2ts_test_output.ts").strpath # If the json2ts command has no spaces and the executable cannot be found, # that means the user either hasn't installed json-schema-to-typescript or they made a typo. From 833b938b9d63b4f80201e28cd3850c3a758ee6cf Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Sun, 10 Nov 2024 23:03:59 -0500 Subject: [PATCH 08/38] Build changes: temporarily disable black linting requirement, run tests on modern python versions, update nodejs to v20 --- .github/workflows/cicd.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index d9632a5..87b7ec6 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -11,28 +11,29 @@ jobs: - uses: psf/black@stable test: name: Run unit tests - needs: lint + # needs: lint runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, windows-latest, macOS-latest] python-version: ["3.8"] - include: - - os: ubuntu-latest - python-version: "3.6" - - os: ubuntu-latest - python-version: "3.7" - os: ubuntu-latest python-version: "3.9" - os: ubuntu-latest python-version: "3.10" + - os: ubuntu-latest + python-version: "3.11" + - os: ubuntu-latest + python-version: "3.12" + - os: ubuntu-latest + python-version: "3.13" steps: - name: Check out repo uses: actions/checkout@v3 - - name: Set up Node.js 16 + - name: Set up Node.js 20 uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 20 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: From 205c8455a75cb84255fd89b91914d88338e10423 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Sun, 10 Nov 2024 23:04:41 -0500 Subject: [PATCH 09/38] Fix invalid yaml --- .github/workflows/cicd.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 87b7ec6..c053d67 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -11,7 +11,6 @@ jobs: - uses: psf/black@stable test: name: Run unit tests - # needs: lint runs-on: ${{ matrix.os }} strategy: matrix: From 7088d2f4467ed2bde90ff2b7ebe784565a10ba31 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Sun, 10 Nov 2024 23:07:33 -0500 Subject: [PATCH 10/38] Fix invalid yaml --- .github/workflows/cicd.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index c053d67..04c9ca9 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -16,6 +16,7 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macOS-latest] python-version: ["3.8"] + include: - os: ubuntu-latest python-version: "3.9" - os: ubuntu-latest From 060eff975f1f1d80061bb4e7b5bc8b5272a655a3 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Sun, 10 Nov 2024 23:20:33 -0500 Subject: [PATCH 11/38] Combine tests against pydantic V1 and V2 into a single coverage report --- .github/workflows/cicd.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 04c9ca9..9ac1494 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -45,13 +45,21 @@ jobs: run: | python -m pip install -U pip wheel pytest pytest-cov coverage python -m pip install -U . - - name: Run tests + - name: Run tests against 'pydantic@latest' run: | - python -m pytest --cov=pydantic2ts + python -m pytest --cov=pydantic2ts --cov-append + - name: Run tests using 'pydantic==1.*.*' + run: | + python -m pip install 'pydantic==1.*.*' + python -m pytest --cov=pydantic2ts --cov-append + - name: Combine coverage data + run: | + coverage combine + coverage report - name: Generate LCOV File if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.8' }} run: | - coverage lcov + coverage lcov -o coverage.lcov - name: Coveralls if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.8' }} uses: coverallsapp/github-action@master From 4ede2249df9d73088ed2027c665853fe61d357d7 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Sun, 10 Nov 2024 23:22:38 -0500 Subject: [PATCH 12/38] Fix pip install for pydantic v1 --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 9ac1494..91574ce 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -50,7 +50,7 @@ jobs: python -m pytest --cov=pydantic2ts --cov-append - name: Run tests using 'pydantic==1.*.*' run: | - python -m pip install 'pydantic==1.*.*' + python -m pip install 'pydantic<2' python -m pytest --cov=pydantic2ts --cov-append - name: Combine coverage data run: | From 7ed40e31dbc44229dda7aa6394c3e6ab0e8a3cb2 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Mon, 11 Nov 2024 00:31:33 -0500 Subject: [PATCH 13/38] clean up handling of v1/v2 branching --- pydantic2ts/cli/script.py | 121 +++++++++++++++++--------------------- 1 file changed, 53 insertions(+), 68 deletions(-) diff --git a/pydantic2ts/cli/script.py b/pydantic2ts/cli/script.py index c09383f..fc68d55 100644 --- a/pydantic2ts/cli/script.py +++ b/pydantic2ts/cli/script.py @@ -6,34 +6,29 @@ import os import shutil import sys +from contextlib import contextmanager 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, TypeVar from uuid import uuid4 try: - from pydantic import BaseModel as BaseModelV2 - from pydantic import create_model as create_model_v2 + from pydantic import BaseModel as BaseModelV2, create_model as create_model_v2 from pydantic.v1 import ( BaseModel as BaseModelV1, - ) - from pydantic.v1 import ( create_model as create_model_v1, ) BaseModelType = TypeVar("BaseModelType", Type[BaseModelV1], Type[BaseModelV2]) except ImportError: + BaseModelV2 = None + create_model_v2 = None from pydantic import ( BaseModel as BaseModelV1, - ) - from pydantic import ( create_model as create_model_v1, ) - BaseModelV2 = None - create_model_v2 = None BaseModelType = TypeVar("BaseModelType", Type[BaseModelV1]) try: @@ -56,7 +51,7 @@ def _import_module(path: str) -> ModuleType: definition exist in sys.modules under that name. """ try: - if Path(path).exists(): + if os.path.exists(path): name = uuid4().hex spec = spec_from_file_location(name, path, submodule_search_locations=[]) module = module_from_spec(spec) @@ -102,7 +97,7 @@ def _is_pydantic_v2_model(obj: Any) -> bool: ) -def _is_concrete_pydantic_model(obj: Any) -> bool: +def _is_pydantic_model(obj: Any) -> bool: """ Return true if an object is a concrete subclass of pydantic's BaseModel. 'concrete' meaning that it's not a generic model. @@ -117,7 +112,7 @@ def _extract_pydantic_models(module: ModuleType) -> List[BaseModelType]: models = [] module_name = module.__name__ - for _, model in inspect.getmembers(module, _is_concrete_pydantic_model): + for _, model in inspect.getmembers(module, _is_pydantic_model): models.append(model) for _, submodule in inspect.getmembers( @@ -179,79 +174,70 @@ def _clean_schema(schema: Dict[str, Any]) -> None: if "enum" in schema and schema.get("description") == "An enumeration.": del schema["description"] + # TODO: add check for if it is truly pydantic v1. If so, fix nullable fields. Do the thing to add "null" to union. + # https://github.com/pydantic/pydantic/issues/1270#issuecomment-729555558 -def _generate_json_schema_v1(models: List[Type[BaseModelV1]]) -> str: + +def _generate_json_schema(models: List[BaseModelType]) -> 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 = [getattr(m.Config, "extra", None) for m in models] + with _forbid_extras(models): + v1 = any(issubclass(m, BaseModelV1) for m in models) - try: - for m in models: - if getattr(m.Config, "extra", None) != "allow": - m.Config.extra = "forbid" - - master_model = create_model_v1( + master_model = (create_model_v1 if v1 else create_model_v2)( "_Master_", **{m.__name__: (m, ...) for m in models} ) - master_model.Config.extra = "forbid" - master_model.Config.schema_extra = staticmethod(_clean_schema) - schema = json.loads(master_model.schema_json()) + if v1: + master_model.Config.extra = "forbid" + master_model.Config.schema_extra = staticmethod(_clean_schema) + else: + master_model.model_config["extra"] = "forbid" + master_model.model_config["json_schema_extra"] = staticmethod(_clean_schema) - for d in schema.get("definitions", {}).values(): + schema = ( + json.loads(master_model.schema_json()) + if v1 + else master_model.model_json_schema(mode="serialization") + ) + + for d in schema.get("definitions" if v1 else "$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.Config.extra = x - -def _generate_json_schema_v2(models: List[Type[BaseModelV2]]) -> str: +@contextmanager +def _forbid_extras(models: List[BaseModelType]) -> None: """ - 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. + Temporarily override the 'extra' setting in models, + changing it to 'forbid' UNLESS it was explicitly set to 'allow'. - 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. + 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] - + v1 = any(issubclass(m, BaseModelV1) for m in models) + extras = [ + getattr(m.Config, "extra", None) if v1 else m.model_config.get("extra") + for m in models + ] try: for m in models: - if m.model_config.get("extra") != "allow": + if v1: + m.Config.extra = "forbid" + else: m.model_config["extra"] = "forbid" - - master_model = create_model_v2( - "_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) - + yield finally: - for m, x in zip(models, model_extras): + for m, x in zip(models, extras): if x is not None: - m.model_config["extra"] = x + if v1: + m.Config.extra = x + else: + m.model_config["extra"] = x def generate_typescript_defs( @@ -277,7 +263,11 @@ def generate_typescript_defs( models = _extract_pydantic_models(_import_module(module)) if exclude: - models = [m for m in models if m.__name__ not in exclude] + models = [ + m + for m in models + if (m.__name__ not in exclude and m.__qualname__ not in exclude) + ] if not models: logger.info("No pydantic models found, exiting.") @@ -285,12 +275,7 @@ def generate_typescript_defs( logger.info("Generating JSON schema from pydantic models...") - schema = ( - _generate_json_schema_v1(models) - if any(issubclass(m, BaseModelV1) for m in models) - else _generate_json_schema_v2(models) - ) - + schema = _generate_json_schema(models) schema_dir = mkdtemp() schema_file_path = os.path.join(schema_dir, "schema.json") From fe5bd468cf08ac341da7b0b9b1c36afa771f076f Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Mon, 11 Nov 2024 18:16:52 -0500 Subject: [PATCH 14/38] Basic ruff setup, still need to update cicd steps to remove black fully --- ruff.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 ruff.toml diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..3f2d235 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,10 @@ +line-length = 100 +indent-width = 4 + +[format] +quote-style = "double" + +[lint] +select = ["E", "F", "I", "B", "W"] +fixable = ["ALL"] + From 2006f585066e502b205bdd45993c6911238aace8 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Mon, 11 Nov 2024 18:19:07 -0500 Subject: [PATCH 15/38] V2, update setup.py -- drop support for python<3.8, add support for 3.11, 3.12, 3.13 --- setup.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 3a500c7..70bdc3c 100644 --- a/setup.py +++ b/setup.py @@ -12,11 +12,12 @@ def readme(): "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] install_requires = [ @@ -25,7 +26,7 @@ def readme(): setup( name="pydantic-to-typescript", - version="1.1.11", + version="2.0.0", description="Convert pydantic models to typescript interfaces", license="MIT", long_description=readme(), From 5a839c7ca44c411d518c9ca2204434aaaed4cb6a Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Mon, 11 Nov 2024 18:20:48 -0500 Subject: [PATCH 16/38] LOTS of changes, todo - better commit msg --- pydantic2ts/cli/script.py | 262 +++++++++++++++++++++++--------------- 1 file changed, 159 insertions(+), 103 deletions(-) diff --git a/pydantic2ts/cli/script.py b/pydantic2ts/cli/script.py index fc68d55..4e7e2ff 100644 --- a/pydantic2ts/cli/script.py +++ b/pydantic2ts/cli/script.py @@ -6,40 +6,49 @@ import os import shutil import sys -from contextlib import contextmanager +from contextlib import ExitStack, contextmanager from importlib.util import module_from_spec, spec_from_file_location from tempfile import mkdtemp from types import ModuleType -from typing import Any, Dict, List, Tuple, Type, TypeVar +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Generator, + List, + Optional, + Tuple, + Type, + Union, +) from uuid import uuid4 try: - from pydantic import BaseModel as BaseModelV2, create_model as create_model_v2 - from pydantic.v1 import ( - BaseModel as BaseModelV1, - create_model as create_model_v1, - ) - - BaseModelType = TypeVar("BaseModelType", Type[BaseModelV1], Type[BaseModelV2]) + from pydantic import BaseModel as BaseModelV2 + from pydantic import create_model as create_model_v2 + from pydantic.v1 import BaseModel as BaseModelV1 + from pydantic.v1 import create_model as create_model_v1 except ImportError: BaseModelV2 = None create_model_v2 = None - from pydantic import ( - BaseModel as BaseModelV1, - create_model as create_model_v1, - ) - - BaseModelType = TypeVar("BaseModelType", Type[BaseModelV1]) + from pydantic import BaseModel as BaseModelV1 + from pydantic import create_model as create_model_v1 try: - from pydantic.v1.generics import GenericModel + from pydantic.v1.generics import GenericModel as GenericModelV1 except ImportError: try: - from pydantic.generics import GenericModel + from pydantic.generics import GenericModel as GenericModelV1 except ImportError: - GenericModel = None + GenericModelV1 = None -logger = logging.getLogger("pydantic2ts") +if TYPE_CHECKING: + from pydantic.config import ConfigDict + from pydantic.v1.config import BaseConfig + from pydantic.v1.fields import ModelField + + +LOG = logging.getLogger("pydantic2ts") def _import_module(path: str) -> ModuleType: @@ -54,26 +63,28 @@ def _import_module(path: str) -> ModuleType: if os.path.exists(path): name = uuid4().hex spec = spec_from_file_location(name, path, submodule_search_locations=[]) + if spec is None: + raise ImportError(f"spec_from_file_location failed for {path}") module = module_from_spec(spec) sys.modules[name] = module + if spec.loader is None: + raise ImportError(f"loader is None for {path}") spec.loader.exec_module(module) return module else: return importlib.import_module(path) except Exception as e: - logger.error( + LOG.error( "The --module argument must be a module path separated by dots or a valid filepath" ) raise e -def _is_submodule(obj, module_name: str) -> bool: +def _is_submodule(obj: Any, module_name: str) -> bool: """ Return true if an object is a submodule """ - return inspect.ismodule(obj) and getattr(obj, "__name__", "").startswith( - f"{module_name}." - ) + return inspect.ismodule(obj) and getattr(obj, "__name__", "").startswith(f"{module_name}.") def _is_pydantic_v1_model(obj: Any) -> bool: @@ -82,7 +93,11 @@ def _is_pydantic_v1_model(obj: Any) -> bool: """ return inspect.isclass(obj) and ( (obj is not BaseModelV1 and issubclass(obj, BaseModelV1)) - or (GenericModel and issubclass(obj, GenericModel) and obj.__concrete__) + or ( + GenericModelV1 is not None + and issubclass(obj, GenericModelV1) + and getattr(obj, "__concrete__", False) + ) ) @@ -91,7 +106,8 @@ def _is_pydantic_v2_model(obj: Any) -> bool: Return true if an object is a pydantic V2 model. """ return inspect.isclass(obj) and ( - obj is not BaseModelV2 + BaseModelV2 is not None + and obj is not BaseModelV2 and issubclass(obj, BaseModelV2) and not getattr(obj, "__pydantic_generic_metadata__", {}).get("parameters") ) @@ -105,19 +121,36 @@ def _is_pydantic_model(obj: Any) -> bool: return _is_pydantic_v1_model(obj) or _is_pydantic_v2_model(obj) -def _extract_pydantic_models(module: ModuleType) -> List[BaseModelType]: +def _get_model_config(model: Type[Any]) -> "Union[ConfigDict, Type[BaseConfig]]": + """ + Return the 'config' for a pydantic model. + In version 1 of pydantic, this is a class. In version 2, it's a dictionary. + """ + if hasattr(model, "Config") and inspect.isclass(model.Config): + return model.Config # type: ignore + return model.model_config + + +def _get_model_json_schema(model: Type[Any]) -> Dict[str, Any]: + """ + Generate the JSON schema for a pydantic model. + """ + if _is_pydantic_v1_model(model): + return json.loads(model.schema_json()) + return model.model_json_schema(mode="serialization") + + +def _extract_pydantic_models(module: ModuleType) -> List[type]: """ Given a module, return a list of the pydantic models contained within it. """ - models = [] + models: List[type] = [] module_name = module.__name__ for _, model in inspect.getmembers(module, _is_pydantic_model): models.append(model) - for _, submodule in inspect.getmembers( - module, lambda obj: _is_submodule(obj, module_name) - ): + for _, submodule in inspect.getmembers(module, lambda obj: _is_submodule(obj, module_name)): models.extend(_extract_pydantic_models(submodule)) return models @@ -143,6 +176,9 @@ def _clean_output_file(output_filename: str) -> None: end = i break + assert start is not None, "Could not find the start of the _Master_ interface." + assert end is not None, "Could not find the end of the _Master_ interface." + banner_comment_lines = [ "/* tslint:disable */\n", "/* eslint-disable */\n", @@ -158,90 +194,114 @@ def _clean_output_file(output_filename: str) -> None: f.writelines(new_lines) -def _clean_schema(schema: Dict[str, Any]) -> None: +def _clean_json_schema(schema: Dict[str, Any], model: Any = None) -> None: """ Clean up the resulting JSON schemas by: - 1) Removing titles from JSON schema properties. + 1) Get rid of the useless "An enumeration." description applied to Enums + which don't have a docstring. + 2) Remove titles from JSON schema properties. If we don't do this, each property will have its own interface in the resulting typescript file (which is a LOT of unnecessary noise). - 2) Getting rid of the useless "An enumeration." description applied to Enums - which don't have a docstring. + 3) If it's a V1 model, ensure that nullability is properly represented. + https://github.com/pydantic/pydantic/issues/1270 """ - for prop in schema.get("properties", {}).values(): - prop.pop("title", None) - if "enum" in schema and schema.get("description") == "An enumeration.": del schema["description"] - # TODO: add check for if it is truly pydantic v1. If so, fix nullable fields. Do the thing to add "null" to union. - # https://github.com/pydantic/pydantic/issues/1270#issuecomment-729555558 + properties: Dict[str, Dict[str, Any]] = schema.get("properties", {}) + + for prop in properties.values(): + prop.pop("title", None) + + if _is_pydantic_v1_model(model): + fields: List["ModelField"] = list(model.__fields__.values()) + for field in fields: + try: + if not field.allow_none: + continue + prop = properties.get(field.alias) + if prop is None: + continue + prop_types: List[Any] = prop.setdefault("anyOf", []) + if any(t.get("type") == "null" for t in prop_types): + continue + if "type" in prop: + prop_types.append({"type": prop.pop("type")}) + if "$ref" in prop: + prop_types.append({"$ref": prop.pop("$ref")}) + prop_types.append({"type": "null"}) + except Exception: + LOG.error( + f"Failed to ensure nullability for field {field.alias}.", + exc_info=True, + ) -def _generate_json_schema(models: List[BaseModelType]) -> str: +@contextmanager +def _schema_generation_overrides( + model: Type[Any], +) -> Generator[None, None, None]: + """ + Temporarily override the 'extra' setting for a model, + changing it to 'forbid' unless it was EXPLICITLY set to 'allow'. + This prevents '[k: string]: any' from automatically being added to every interface. + """ + revert: Dict[str, Any] = {} + config = _get_model_config(model) + try: + if isinstance(config, dict): + if config.get("extra") != "allow": + revert["extra"] = config.get("extra") + config["extra"] = "forbid" + else: + if config.extra != "allow": + revert["extra"] = config.extra + config.extra = "forbid" # type: ignore + yield + finally: + for key, value in revert.items(): + if isinstance(config, dict): + config[key] = value + else: + setattr(config, key, value) + + +def _generate_json_schema(models: List[type]) -> 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. """ - with _forbid_extras(models): - v1 = any(issubclass(m, BaseModelV1) for m in models) - - master_model = (create_model_v1 if v1 else create_model_v2)( - "_Master_", **{m.__name__: (m, ...) for m in models} - ) - - if v1: - master_model.Config.extra = "forbid" - master_model.Config.schema_extra = staticmethod(_clean_schema) - else: - master_model.model_config["extra"] = "forbid" - master_model.model_config["json_schema_extra"] = staticmethod(_clean_schema) - - schema = ( - json.loads(master_model.schema_json()) - if v1 - else master_model.model_json_schema(mode="serialization") - ) + with ExitStack() as stack: + models_by_name: Dict[str, type] = {} + models_as_fields: Dict[str, Tuple[type, Any]] = {} - for d in schema.get("definitions" if v1 else "$defs", {}).values(): - _clean_schema(d) + for model in models: + stack.enter_context(_schema_generation_overrides(model)) + name = model.__name__ + models_by_name[name] = model + models_as_fields[name] = (model, ...) - return json.dumps(schema, indent=2) + use_v1_tools = any(issubclass(m, BaseModelV1) for m in models) + create_model = create_model_v1 if use_v1_tools else create_model_v2 # type: ignore + master_model = create_model("_Master_", **models_as_fields) # type: ignore + master_schema = _get_model_json_schema(master_model) # type: ignore + defs_key = "$defs" if "$defs" in master_schema else "definitions" + defs: Dict[str, Any] = master_schema.get(defs_key, {}) -@contextmanager -def _forbid_extras(models: List[BaseModelType]) -> None: - """ - Temporarily override the 'extra' setting in models, - changing it to 'forbid' UNLESS it was explicitly set to 'allow'. + for name, schema in defs.items(): + _clean_json_schema(schema, models_by_name.get(name)) - This prevents '[k: string]: any' from being added to every interface. - This change is reverted once the schema has been generated. - """ - v1 = any(issubclass(m, BaseModelV1) for m in models) - extras = [ - getattr(m.Config, "extra", None) if v1 else m.model_config.get("extra") - for m in models - ] - try: - for m in models: - if v1: - m.Config.extra = "forbid" - else: - m.model_config["extra"] = "forbid" - yield - finally: - for m, x in zip(models, extras): - if x is not None: - if v1: - m.Config.extra = x - else: - m.model_config["extra"] = x + return json.dumps(master_schema, indent=2) def generate_typescript_defs( - module: str, output: str, exclude: Tuple[str] = (), json2ts_cmd: str = "json2ts" + module: str, + output: str, + exclude: Tuple[str, ...] = (), + json2ts_cmd: str = "json2ts", ) -> None: """ Convert the pydantic models in a python module into typescript interfaces. @@ -258,22 +318,20 @@ def generate_typescript_defs( "https://www.npmjs.com/package/json-schema-to-typescript" ) - logger.info("Finding pydantic models...") + LOG.info("Finding pydantic models...") models = _extract_pydantic_models(_import_module(module)) if exclude: models = [ - m - for m in models - if (m.__name__ not in exclude and m.__qualname__ not in exclude) + m for m in models if (m.__name__ not in exclude and m.__qualname__ not in exclude) ] if not models: - logger.info("No pydantic models found, exiting.") + LOG.info("No pydantic models found, exiting.") return - logger.info("Generating JSON schema from pydantic models...") + LOG.info("Generating JSON schema from pydantic models...") schema = _generate_json_schema(models) schema_dir = mkdtemp() @@ -282,7 +340,7 @@ def generate_typescript_defs( with open(schema_file_path, "w") as f: f.write(schema) - logger.info("Converting JSON schema to typescript definitions...") + LOG.info("Converting JSON schema to typescript definitions...") json2ts_exit_code = os.system( f'{json2ts_cmd} -i {schema_file_path} -o {output} --bannerComment ""' @@ -292,14 +350,12 @@ def generate_typescript_defs( if json2ts_exit_code == 0: _clean_output_file(output) - logger.info(f"Saved typescript definitions to {output}.") + LOG.info(f"Saved typescript definitions to {output}.") else: - raise RuntimeError( - f'"{json2ts_cmd}" failed with exit code {json2ts_exit_code}.' - ) + raise RuntimeError(f'"{json2ts_cmd}" failed with exit code {json2ts_exit_code}.') -def parse_cli_args(args: List[str] = None) -> argparse.Namespace: +def parse_cli_args(args: Optional[List[str]] = None) -> argparse.Namespace: """ Parses the command-line arguments passed to pydantic2ts. """ From 6c9d5a37083534b4020886dea9d3c5521f543c29 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Mon, 11 Nov 2024 18:35:40 -0500 Subject: [PATCH 17/38] Update test_script, update v1 tests to work with both pydantic<2 and pydantic>=2 --- .../excluding_models/v1/input.py | 8 +++- .../expected_results/extra_fields/v1/input.py | 7 +++- tests/expected_results/generics/v1/input.py | 12 ++++-- .../single_module/v1/input.py | 8 +++- .../submodules/v1/animals/cats.py | 7 +++- .../submodules/v1/animals/dogs.py | 7 +++- tests/expected_results/submodules/v1/input.py | 5 ++- tests/test_script.py | 41 +++++++------------ 8 files changed, 54 insertions(+), 41 deletions(-) diff --git a/tests/expected_results/excluding_models/v1/input.py b/tests/expected_results/excluding_models/v1/input.py index 63b4359..09de964 100644 --- a/tests/expected_results/excluding_models/v1/input.py +++ b/tests/expected_results/excluding_models/v1/input.py @@ -1,5 +1,9 @@ -from pydantic import BaseModel -from typing import Optional, List +from typing import List, Optional + +try: + from pydantic.v1 import BaseModel +except ImportError: + from pydantic import BaseModel class LoginCredentials(BaseModel): diff --git a/tests/expected_results/extra_fields/v1/input.py b/tests/expected_results/extra_fields/v1/input.py index 29d3032..5090f49 100644 --- a/tests/expected_results/extra_fields/v1/input.py +++ b/tests/expected_results/extra_fields/v1/input.py @@ -1,9 +1,12 @@ -from pydantic import BaseModel, Extra +try: + from pydantic.v1 import BaseModel, Extra +except ImportError: + from pydantic import BaseModel, Extra class ModelAllow(BaseModel, extra=Extra.allow): a: str + class ModelDefault(BaseModel): a: str - diff --git a/tests/expected_results/generics/v1/input.py b/tests/expected_results/generics/v1/input.py index db5e78b..6790d80 100644 --- a/tests/expected_results/generics/v1/input.py +++ b/tests/expected_results/generics/v1/input.py @@ -1,8 +1,12 @@ from datetime import datetime -from typing import Generic, List, Optional, Type, TypeVar, cast +from typing import Generic, List, Optional, Type, TypeVar -from pydantic import BaseModel -from pydantic.generics import GenericModel +try: + from pydantic.v1 import BaseModel + from pydantic.v1.generics import GenericModel +except ImportError: + from pydantic import BaseModel + from pydantic.generics import GenericModel T = TypeVar("T") @@ -26,7 +30,7 @@ def create_response_type(data_type: T, name: str) -> "Type[ApiResponse[T]]": t = ApiResponse[data_type] t.__name__ = name t.__qualname__ = name - return cast(Type[ApiResponse[T]], t) + return t class User(BaseModel): diff --git a/tests/expected_results/single_module/v1/input.py b/tests/expected_results/single_module/v1/input.py index 63b4359..09de964 100644 --- a/tests/expected_results/single_module/v1/input.py +++ b/tests/expected_results/single_module/v1/input.py @@ -1,5 +1,9 @@ -from pydantic import BaseModel -from typing import Optional, List +from typing import List, Optional + +try: + from pydantic.v1 import BaseModel +except ImportError: + from pydantic import BaseModel class LoginCredentials(BaseModel): diff --git a/tests/expected_results/submodules/v1/animals/cats.py b/tests/expected_results/submodules/v1/animals/cats.py index 3db89d3..9b8b803 100644 --- a/tests/expected_results/submodules/v1/animals/cats.py +++ b/tests/expected_results/submodules/v1/animals/cats.py @@ -1,7 +1,10 @@ -from pydantic import BaseModel -from typing import Optional, Literal from enum import Enum +try: + from pydantic.v1 import BaseModel +except ImportError: + from pydantic import BaseModel + class CatBreed(str, Enum): domestic_shorthair = "domestic shorthair" diff --git a/tests/expected_results/submodules/v1/animals/dogs.py b/tests/expected_results/submodules/v1/animals/dogs.py index 07ec007..754b449 100644 --- a/tests/expected_results/submodules/v1/animals/dogs.py +++ b/tests/expected_results/submodules/v1/animals/dogs.py @@ -1,7 +1,10 @@ -from pydantic import BaseModel -from typing import Optional from enum import Enum +try: + from pydantic.v1 import BaseModel +except ImportError: + from pydantic import BaseModel + class DogBreed(str, Enum): mutt = "mutt" diff --git a/tests/expected_results/submodules/v1/input.py b/tests/expected_results/submodules/v1/input.py index 672c90f..6c872e3 100644 --- a/tests/expected_results/submodules/v1/input.py +++ b/tests/expected_results/submodules/v1/input.py @@ -1,6 +1,9 @@ from typing import List -from pydantic import BaseModel +try: + from pydantic.v1 import BaseModel +except ImportError: + from pydantic import BaseModel from .animals.cats import Cat from .animals.dogs import Dog diff --git a/tests/test_script.py b/tests/test_script.py index 9af1dd4..3bacbf0 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -4,21 +4,25 @@ from pathlib import Path import pytest -from pydantic import VERSION as _PYDANTIC_VERSION from pydantic2ts import generate_typescript_defs from pydantic2ts.cli.script import parse_cli_args -_PYDANTIC_VERSIONS = ( - ("v1",) if int(_PYDANTIC_VERSION.split(".")[0]) < 2 else ("v1", "v2") -) +try: + from pydantic import BaseModel + from pydantic.v1 import BaseModel as BaseModelV1 + + assert BaseModel is not BaseModelV1 + _PYDANTIC_VERSIONS = ("v1", "v2") +except (ImportError, AttributeError): + _PYDANTIC_VERSIONS = ("v1",) _RESULTS_DIRECTORY = Path( os.path.join(os.path.dirname(os.path.realpath(__file__)), "expected_results") ) -def _get_input_module(test_name: str, pydantic_version: str) -> str: +def _get_input_module(test_name: str, pydantic_version: str) -> Path: return _RESULTS_DIRECTORY / test_name / pydantic_version / "input.py" @@ -50,9 +54,7 @@ def _run_test( cmd += f" --exclude {model_to_exclude}" subprocess.run(cmd, shell=True, check=True) - assert Path(output_path).read_text() == _get_expected_output( - test_name, pydantic_version - ) + assert Path(output_path).read_text() == _get_expected_output(test_name, pydantic_version) @pytest.mark.parametrize( @@ -60,9 +62,7 @@ def _run_test( product(_PYDANTIC_VERSIONS, [False, True]), ) def test_single_module(tmpdir, pydantic_version, call_from_python): - _run_test( - tmpdir, "single_module", pydantic_version, call_from_python=call_from_python - ) + _run_test(tmpdir, "single_module", pydantic_version, call_from_python=call_from_python) @pytest.mark.parametrize( @@ -102,9 +102,7 @@ def test_excluding_models(tmpdir, pydantic_version, call_from_python): def test_computed_fields(tmpdir, pydantic_version, call_from_python): if pydantic_version == "v1": pytest.skip("Computed fields are a pydantic v2 feature") - _run_test( - tmpdir, "computed_fields", pydantic_version, call_from_python=call_from_python - ) + _run_test(tmpdir, "computed_fields", pydantic_version, call_from_python=call_from_python) @pytest.mark.parametrize( @@ -112,21 +110,14 @@ def test_computed_fields(tmpdir, pydantic_version, call_from_python): product(_PYDANTIC_VERSIONS, [False, True]), ) def test_extra_fields(tmpdir, pydantic_version, call_from_python): - _run_test( - tmpdir, "extra_fields", pydantic_version, call_from_python=call_from_python - ) + _run_test(tmpdir, "extra_fields", pydantic_version, call_from_python=call_from_python) def test_relative_filepath(tmpdir): test_name = "single_module" pydantic_version = _PYDANTIC_VERSIONS[0] relative_path = ( - Path(".") - / "tests" - / "expected_results" - / test_name - / pydantic_version - / "input.py" + Path(".") / "tests" / "expected_results" / test_name / pydantic_version / "input.py" ) _run_test( tmpdir, @@ -170,9 +161,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("fake_module_output.ts").strpath - ) + generate_typescript_defs("fake_module", tmpdir.join("fake_module_output.ts").strpath) def test_parse_cli_args(): From 97a09c92fc2184f4fc2273d5f840246846de1650 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Mon, 11 Nov 2024 22:37:29 -0500 Subject: [PATCH 18/38] Rework the V1 logic for 'nullable' field schemas, update the tests for Generic models --- pydantic2ts/cli/script.py | 146 ++++++++++--------- tests/expected_results/generics/v2/input.py | 8 +- tests/expected_results/generics/v2/output.ts | 12 +- 3 files changed, 90 insertions(+), 76 deletions(-) diff --git a/pydantic2ts/cli/script.py b/pydantic2ts/cli/script.py index 4e7e2ff..4261ddd 100644 --- a/pydantic2ts/cli/script.py +++ b/pydantic2ts/cli/script.py @@ -89,38 +89,50 @@ def _is_submodule(obj: Any, module_name: str) -> bool: def _is_pydantic_v1_model(obj: Any) -> bool: """ - Return true if an object is a pydantic V1 model. - """ - return inspect.isclass(obj) and ( - (obj is not BaseModelV1 and issubclass(obj, BaseModelV1)) - or ( - GenericModelV1 is not None - and issubclass(obj, GenericModelV1) - and getattr(obj, "__concrete__", False) - ) - ) + Return true if the object is a 'concrete' pydantic V1 model. + """ + if not inspect.isclass(obj): + return False + elif obj is BaseModelV1 or obj is GenericModelV1: + return False + elif GenericModelV1 and issubclass(obj, GenericModelV1): + return getattr(obj, "__concrete__", False) + return issubclass(obj, BaseModelV1) def _is_pydantic_v2_model(obj: Any) -> bool: """ - Return true if an object is a pydantic V2 model. + Return true if an object is a 'concrete' pydantic V2 model. """ - return inspect.isclass(obj) and ( - BaseModelV2 is not None - and obj is not BaseModelV2 - and issubclass(obj, BaseModelV2) - and not getattr(obj, "__pydantic_generic_metadata__", {}).get("parameters") - ) + if not inspect.isclass(obj): + return False + elif obj is BaseModelV2 or BaseModelV2 is None: + return False + return issubclass(obj, BaseModelV2) and not getattr( + obj, "__pydantic_generic_metadata__", {} + ).get("parameters") def _is_pydantic_model(obj: Any) -> bool: """ - Return true if an object is a concrete subclass of pydantic's BaseModel. - 'concrete' meaning that it's not a generic model. + Return true if an object is a valid model for either V1 or V2 of pydantic. """ return _is_pydantic_v1_model(obj) or _is_pydantic_v2_model(obj) +def _has_null_variant(schema: Dict[str, Any]) -> bool: + """ + Return true if a JSON schema has 'null' as one of its types. + """ + if schema.get("type") == "null": + return True + if isinstance(schema.get("type"), list) and "null" in schema["type"]: + return True + if isinstance(schema.get("anyOf"), list): + return any(_has_null_variant(s) for s in schema["anyOf"]) + return False + + def _get_model_config(model: Type[Any]) -> "Union[ConfigDict, Type[BaseConfig]]": """ Return the 'config' for a pydantic model. @@ -156,47 +168,9 @@ def _extract_pydantic_models(module: ModuleType) -> List[type]: return models -def _clean_output_file(output_filename: str) -> None: - """ - Clean up the output file typescript definitions were written to by: - 1. Removing the 'master model'. - This is a faux pydantic model with references to all the *actual* models necessary for generating - clean typescript definitions without any duplicates. We don't actually want it in the output, so - this function removes it from the generated typescript file. - 2. Adding a banner comment with clear instructions for how to regenerate the typescript definitions. - """ - with open(output_filename, "r") as f: - lines = f.readlines() - - start, end = None, None - for i, line in enumerate(lines): - if line.rstrip("\r\n") == "export interface _Master_ {": - start = i - elif (start is not None) and line.rstrip("\r\n") == "}": - end = i - break - - assert start is not None, "Could not find the start of the _Master_ interface." - assert end is not None, "Could not find the end of the _Master_ interface." - - banner_comment_lines = [ - "/* tslint:disable */\n", - "/* eslint-disable */\n", - "/**\n", - "/* This file was automatically generated from pydantic models by running pydantic2ts.\n", - "/* Do not modify it by hand - just update the pydantic models and then re-run the script\n", - "*/\n\n", - ] - - new_lines = banner_comment_lines + lines[:start] + lines[(end + 1) :] - - with open(output_filename, "w") as f: - f.writelines(new_lines) - - def _clean_json_schema(schema: Dict[str, Any], model: Any = None) -> None: """ - Clean up the resulting JSON schemas by: + Clean up the resulting JSON schemas via the following steps: 1) Get rid of the useless "An enumeration." description applied to Enums which don't have a docstring. @@ -220,17 +194,13 @@ def _clean_json_schema(schema: Dict[str, Any], model: Any = None) -> None: try: if not field.allow_none: continue - prop = properties.get(field.alias) + name = field.alias + prop = properties.get(name) if prop is None: continue - prop_types: List[Any] = prop.setdefault("anyOf", []) - if any(t.get("type") == "null" for t in prop_types): + if _has_null_variant(prop): continue - if "type" in prop: - prop_types.append({"type": prop.pop("type")}) - if "$ref" in prop: - prop_types.append({"$ref": prop.pop("$ref")}) - prop_types.append({"type": "null"}) + properties[name] = {"anyOf": [prop, {"type": "null"}]} except Exception: LOG.error( f"Failed to ensure nullability for field {field.alias}.", @@ -238,6 +208,44 @@ def _clean_json_schema(schema: Dict[str, Any], model: Any = None) -> None: ) +def _clean_output_file(output_filename: str) -> None: + """ + Clean up the resulting typescript definitions via the following steps: + + 1. Remove the "_Master_" model. + It exists solely to serve as a namespace for the target models. + By rolling them all up into a single model, we can generate a single output file. + 2. Add a banner comment with clear instructions for regenerating the typescript definitions. + """ + with open(output_filename, "r") as f: + lines = f.readlines() + + start, end = None, None + for i, line in enumerate(lines): + if line.rstrip("\r\n") == "export interface _Master_ {": + start = i + elif (start is not None) and line.rstrip("\r\n") == "}": + end = i + break + + assert start is not None, "Could not find the start of the _Master_ interface." + assert end is not None, "Could not find the end of the _Master_ interface." + + banner_comment_lines = [ + "/* tslint:disable */\n", + "/* eslint-disable */\n", + "/**\n", + "/* This file was automatically generated from pydantic models by running pydantic2ts.\n", + "/* Do not modify it by hand - just update the pydantic models and then re-run the script\n", + "*/\n\n", + ] + + new_lines = banner_comment_lines + lines[:start] + lines[(end + 1) :] + + with open(output_filename, "w") as f: + f.writelines(new_lines) + + @contextmanager def _schema_generation_overrides( model: Type[Any], @@ -246,6 +254,8 @@ def _schema_generation_overrides( Temporarily override the 'extra' setting for a model, changing it to 'forbid' unless it was EXPLICITLY set to 'allow'. This prevents '[k: string]: any' from automatically being added to every interface. + + TODO: check if overriding 'schema_extra' is necessary, or if there's a better way. """ revert: Dict[str, Any] = {} config = _get_model_config(model) @@ -254,10 +264,14 @@ def _schema_generation_overrides( if config.get("extra") != "allow": revert["extra"] = config.get("extra") config["extra"] = "forbid" + revert["json_schema_extra"] = config.get("json_schema_extra") + config["json_schema_extra"] = staticmethod(_clean_json_schema) else: if config.extra != "allow": revert["extra"] = config.extra config.extra = "forbid" # type: ignore + revert["schema_extra"] = config.schema_extra + config.schema_extra = staticmethod(_clean_json_schema) # type: ignore yield finally: for key, value in revert.items(): diff --git a/tests/expected_results/generics/v2/input.py b/tests/expected_results/generics/v2/input.py index 7b0e166..8a71733 100644 --- a/tests/expected_results/generics/v2/input.py +++ b/tests/expected_results/generics/v2/input.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Generic, List, Optional, Type, TypeVar, cast +from typing import Generic, List, Optional, Type, TypeVar from pydantic import BaseModel @@ -12,8 +12,8 @@ class Error(BaseModel): class ApiResponse(BaseModel, Generic[T]): - data: Optional[T] - error: Optional[Error] + data: Optional[T] = None + error: Optional[Error] = None 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]]": t = ApiResponse[data_type] t.__name__ = name t.__qualname__ = name - return cast(Type[ApiResponse[T]], t) + return t class User(BaseModel): diff --git a/tests/expected_results/generics/v2/output.ts b/tests/expected_results/generics/v2/output.ts index afcd6da..eafac05 100644 --- a/tests/expected_results/generics/v2/output.ts +++ b/tests/expected_results/generics/v2/output.ts @@ -19,12 +19,12 @@ export interface Error { message: string; } export interface ListArticlesResponse { - data: Article[] | null; - error: Error | null; + data?: Article[] | null; + error?: Error | null; } export interface ListUsersResponse { - data: User[] | null; - error: Error | null; + data?: User[] | null; + error?: Error | null; } export interface UserProfile { name: string; @@ -34,6 +34,6 @@ export interface UserProfile { age: number; } export interface UserProfileResponse { - data: UserProfile | null; - error: Error | null; + data?: UserProfile | null; + error?: Error | null; } From a1eed340cacf2dcf8064b78dfc376996210610c5 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Mon, 11 Nov 2024 23:34:08 -0500 Subject: [PATCH 19/38] Fix typo, should be 'AssertionError' and not 'AttributeError' --- tests/test_script.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_script.py b/tests/test_script.py index 3bacbf0..2657e62 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -14,7 +14,7 @@ assert BaseModel is not BaseModelV1 _PYDANTIC_VERSIONS = ("v1", "v2") -except (ImportError, AttributeError): +except (ImportError, AssertionError): _PYDANTIC_VERSIONS = ("v1",) _RESULTS_DIRECTORY = Path( From 53d3098d6238377bad05eda2e4e1a83d5cc83c6c Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Tue, 12 Nov 2024 21:38:00 -0500 Subject: [PATCH 20/38] Add 'pydantic_v1' and 'pydantic_v2' submodules, these serve as safe namespaces for the relevant objects/metadata available for each pydantic version --- pydantic2ts/cli/script.py | 92 ++++++++++++++++---------------------- pydantic2ts/pydantic_v1.py | 16 +++++++ pydantic2ts/pydantic_v2.py | 14 ++++++ tests/test_script.py | 72 +++++++++++++---------------- 4 files changed, 100 insertions(+), 94 deletions(-) create mode 100644 pydantic2ts/pydantic_v1.py create mode 100644 pydantic2ts/pydantic_v2.py diff --git a/pydantic2ts/cli/script.py b/pydantic2ts/cli/script.py index 4261ddd..1bee25f 100644 --- a/pydantic2ts/cli/script.py +++ b/pydantic2ts/cli/script.py @@ -23,24 +23,8 @@ ) from uuid import uuid4 -try: - from pydantic import BaseModel as BaseModelV2 - from pydantic import create_model as create_model_v2 - from pydantic.v1 import BaseModel as BaseModelV1 - from pydantic.v1 import create_model as create_model_v1 -except ImportError: - BaseModelV2 = None - create_model_v2 = None - from pydantic import BaseModel as BaseModelV1 - from pydantic import create_model as create_model_v1 - -try: - from pydantic.v1.generics import GenericModel as GenericModelV1 -except ImportError: - try: - from pydantic.generics import GenericModel as GenericModelV1 - except ImportError: - GenericModelV1 = None +import pydantic2ts.pydantic_v1 as v1 +import pydantic2ts.pydantic_v2 as v2 if TYPE_CHECKING: from pydantic.config import ConfigDict @@ -63,12 +47,10 @@ def _import_module(path: str) -> ModuleType: if os.path.exists(path): name = uuid4().hex spec = spec_from_file_location(name, path, submodule_search_locations=[]) - if spec is None: - raise ImportError(f"spec_from_file_location failed for {path}") + assert spec is not None, f"spec_from_file_location failed for {path}" module = module_from_spec(spec) sys.modules[name] = module - if spec.loader is None: - raise ImportError(f"loader is None for {path}") + assert spec.loader is not None, f"loader is None for {path}" spec.loader.exec_module(module) return module else: @@ -87,40 +69,45 @@ def _is_submodule(obj: Any, module_name: str) -> bool: return inspect.ismodule(obj) and getattr(obj, "__name__", "").startswith(f"{module_name}.") -def _is_pydantic_v1_model(obj: Any) -> bool: +def _is_v1_model(obj: Any) -> bool: """ - Return true if the object is a 'concrete' pydantic V1 model. + Return true if an object is a 'concrete' pydantic V1 model. """ if not inspect.isclass(obj): return False - elif obj is BaseModelV1 or obj is GenericModelV1: + elif obj is v1.BaseModel: return False - elif GenericModelV1 and issubclass(obj, GenericModelV1): - return getattr(obj, "__concrete__", False) - return issubclass(obj, BaseModelV1) + elif v1.GenericModel and issubclass(obj, v1.GenericModel): + return bool(obj.__concrete__) + else: + return issubclass(obj, v1.BaseModel) -def _is_pydantic_v2_model(obj: Any) -> bool: +def _is_v2_model(obj: Any) -> bool: """ Return true if an object is a 'concrete' pydantic V2 model. """ - if not inspect.isclass(obj): + if not v2.enabled: return False - elif obj is BaseModelV2 or BaseModelV2 is None: + elif not inspect.isclass(obj): return False - return issubclass(obj, BaseModelV2) and not getattr( - obj, "__pydantic_generic_metadata__", {} - ).get("parameters") + elif obj is v2.BaseModel: + return False + elif not issubclass(obj, v2.BaseModel): + return False + generic_metadata = getattr(obj, "__pydantic_generic_metadata__", {}) + generic_parameters = generic_metadata.get("parameters") + return not generic_parameters def _is_pydantic_model(obj: Any) -> bool: """ - Return true if an object is a valid model for either V1 or V2 of pydantic. + Return true if an object is a concrete model for either V1 or V2 of pydantic. """ - return _is_pydantic_v1_model(obj) or _is_pydantic_v2_model(obj) + return _is_v1_model(obj) or _is_v2_model(obj) -def _has_null_variant(schema: Dict[str, Any]) -> bool: +def _is_nullable(schema: Dict[str, Any]) -> bool: """ Return true if a JSON schema has 'null' as one of its types. """ @@ -129,7 +116,7 @@ def _has_null_variant(schema: Dict[str, Any]) -> bool: if isinstance(schema.get("type"), list) and "null" in schema["type"]: return True if isinstance(schema.get("anyOf"), list): - return any(_has_null_variant(s) for s in schema["anyOf"]) + return any(_is_nullable(s) for s in schema["anyOf"]) return False @@ -139,7 +126,7 @@ def _get_model_config(model: Type[Any]) -> "Union[ConfigDict, Type[BaseConfig]]" In version 1 of pydantic, this is a class. In version 2, it's a dictionary. """ if hasattr(model, "Config") and inspect.isclass(model.Config): - return model.Config # type: ignore + return model.Config return model.model_config @@ -147,7 +134,7 @@ def _get_model_json_schema(model: Type[Any]) -> Dict[str, Any]: """ Generate the JSON schema for a pydantic model. """ - if _is_pydantic_v1_model(model): + if _is_v1_model(model): return json.loads(model.schema_json()) return model.model_json_schema(mode="serialization") @@ -188,7 +175,7 @@ def _clean_json_schema(schema: Dict[str, Any], model: Any = None) -> None: for prop in properties.values(): prop.pop("title", None) - if _is_pydantic_v1_model(model): + if _is_v1_model(model): fields: List["ModelField"] = list(model.__fields__.values()) for field in fields: try: @@ -198,7 +185,7 @@ def _clean_json_schema(schema: Dict[str, Any], model: Any = None) -> None: prop = properties.get(name) if prop is None: continue - if _has_null_variant(prop): + if _is_nullable(prop): continue properties[name] = {"anyOf": [prop, {"type": "null"}]} except Exception: @@ -254,8 +241,6 @@ def _schema_generation_overrides( Temporarily override the 'extra' setting for a model, changing it to 'forbid' unless it was EXPLICITLY set to 'allow'. This prevents '[k: string]: any' from automatically being added to every interface. - - TODO: check if overriding 'schema_extra' is necessary, or if there's a better way. """ revert: Dict[str, Any] = {} config = _get_model_config(model) @@ -264,14 +249,10 @@ def _schema_generation_overrides( if config.get("extra") != "allow": revert["extra"] = config.get("extra") config["extra"] = "forbid" - revert["json_schema_extra"] = config.get("json_schema_extra") - config["json_schema_extra"] = staticmethod(_clean_json_schema) else: if config.extra != "allow": revert["extra"] = config.extra config.extra = "forbid" # type: ignore - revert["schema_extra"] = config.schema_extra - config.schema_extra = staticmethod(_clean_json_schema) # type: ignore yield finally: for key, value in revert.items(): @@ -297,8 +278,8 @@ def _generate_json_schema(models: List[type]) -> str: models_by_name[name] = model models_as_fields[name] = (model, ...) - use_v1_tools = any(issubclass(m, BaseModelV1) for m in models) - create_model = create_model_v1 if use_v1_tools else create_model_v2 # type: ignore + use_v1_tools = any(issubclass(m, v1.BaseModel) for m in models) + create_model = v1.create_model if use_v1_tools else v2.create_model # type: ignore master_model = create_model("_Master_", **models_as_fields) # type: ignore master_schema = _get_model_json_schema(master_model) # type: ignore @@ -320,11 +301,14 @@ def generate_typescript_defs( """ Convert the pydantic models in a python module into typescript interfaces. - :param module: python module containing pydantic model definitions, ex: my_project.api.schemas + :param module: python module containing pydantic model definitions. + example: my_project.api.schemas :param output: file that the typescript definitions will be written to - :param exclude: optional, a tuple of names for pydantic models which should be omitted from the typescript output. - :param json2ts_cmd: optional, the command that will execute json2ts. Provide this if the executable is not - discoverable or if it's locally installed (ex: 'yarn json2ts'). + :param exclude: optional, a tuple of names for pydantic models which + should be omitted from the typescript output. + :param json2ts_cmd: optional, the command that will execute json2ts. + Provide this if the executable is not discoverable + or if it's locally installed (ex: 'yarn json2ts'). """ if " " not in json2ts_cmd and not shutil.which(json2ts_cmd): raise Exception( diff --git a/pydantic2ts/pydantic_v1.py b/pydantic2ts/pydantic_v1.py new file mode 100644 index 0000000..467280e --- /dev/null +++ b/pydantic2ts/pydantic_v1.py @@ -0,0 +1,16 @@ +try: + from pydantic.v1 import BaseModel, create_model + from pydantic.v1.generics import GenericModel + + enabled = True +except ImportError: + from pydantic import BaseModel, create_model + + enabled = True + + try: + from pydantic.generics import GenericModel + except ImportError: + GenericModel = None + +__all__ = ("BaseModel", "GenericModel", "create_model", "enabled") diff --git a/pydantic2ts/pydantic_v2.py b/pydantic2ts/pydantic_v2.py new file mode 100644 index 0000000..5633bd7 --- /dev/null +++ b/pydantic2ts/pydantic_v2.py @@ -0,0 +1,14 @@ +try: + from pydantic.version import VERSION + + assert VERSION.startswith("2") + + from pydantic import BaseModel, create_model + + enabled = True +except (ImportError, AssertionError, AttributeError): + BaseModel = None + create_model = None + enabled = False + +__all__ = ("BaseModel", "create_model", "enabled") diff --git a/tests/test_script.py b/tests/test_script.py index 2657e62..9162fff 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -2,28 +2,22 @@ import subprocess from itertools import product from pathlib import Path +from typing import Optional, Tuple import pytest from pydantic2ts import generate_typescript_defs from pydantic2ts.cli.script import parse_cli_args +from pydantic2ts.pydantic_v2 import enabled as v2_enabled -try: - from pydantic import BaseModel - from pydantic.v1 import BaseModel as BaseModelV1 - - assert BaseModel is not BaseModelV1 - _PYDANTIC_VERSIONS = ("v1", "v2") -except (ImportError, AssertionError): - _PYDANTIC_VERSIONS = ("v1",) - +_PYDANTIC_VERSIONS = ("v1", "v2") if v2_enabled else ("v1",) _RESULTS_DIRECTORY = Path( os.path.join(os.path.dirname(os.path.realpath(__file__)), "expected_results") ) -def _get_input_module(test_name: str, pydantic_version: str) -> Path: - return _RESULTS_DIRECTORY / test_name / pydantic_version / "input.py" +def _get_input_module(test_name: str, pydantic_version: str) -> str: + return str(_RESULTS_DIRECTORY / test_name / pydantic_version / "input.py") def _get_expected_output(test_name: str, pydantic_version: str) -> str: @@ -31,20 +25,20 @@ def _get_expected_output(test_name: str, pydantic_version: str) -> str: def _run_test( - tmpdir, - test_name, - pydantic_version, + tmp_path: Path, + test_name: str, + pydantic_version: str, *, - module_path=None, - call_from_python=False, - exclude=(), + module_path: Optional[str] = None, + call_from_python: bool = False, + exclude: Tuple[str, ...] = (), ): """ Execute pydantic2ts logic for converting pydantic models into tyepscript definitions. Compare the output with the expected output, verifying it is identical. """ module_path = module_path or _get_input_module(test_name, pydantic_version) - output_path = tmpdir.join(f"cli_{test_name}_{pydantic_version}.ts").strpath + output_path = str(tmp_path / f"{test_name}_{pydantic_version}.ts") if call_from_python: generate_typescript_defs(module_path, output_path, exclude) @@ -61,33 +55,33 @@ def _run_test( "pydantic_version, call_from_python", product(_PYDANTIC_VERSIONS, [False, True]), ) -def test_single_module(tmpdir, pydantic_version, call_from_python): - _run_test(tmpdir, "single_module", pydantic_version, call_from_python=call_from_python) +def test_single_module(tmp_path: Path, pydantic_version: str, call_from_python: bool): + _run_test(tmp_path, "single_module", pydantic_version, call_from_python=call_from_python) @pytest.mark.parametrize( "pydantic_version, call_from_python", product(_PYDANTIC_VERSIONS, [False, True]), ) -def test_submodules(tmpdir, pydantic_version, call_from_python): - _run_test(tmpdir, "submodules", pydantic_version, call_from_python=call_from_python) +def test_submodules(tmp_path: Path, pydantic_version: str, call_from_python: bool): + _run_test(tmp_path, "submodules", pydantic_version, call_from_python=call_from_python) @pytest.mark.parametrize( "pydantic_version, call_from_python", product(_PYDANTIC_VERSIONS, [False, True]), ) -def test_generics(tmpdir, pydantic_version, call_from_python): - _run_test(tmpdir, "generics", pydantic_version, call_from_python=call_from_python) +def test_generics(tmp_path: Path, pydantic_version: str, call_from_python: bool): + _run_test(tmp_path, "generics", pydantic_version, call_from_python=call_from_python) @pytest.mark.parametrize( "pydantic_version, call_from_python", product(_PYDANTIC_VERSIONS, [False, True]), ) -def test_excluding_models(tmpdir, pydantic_version, call_from_python): +def test_excluding_models(tmp_path: Path, pydantic_version: str, call_from_python: bool): _run_test( - tmpdir, + tmp_path, "excluding_models", pydantic_version, call_from_python=call_from_python, @@ -97,39 +91,37 @@ def test_excluding_models(tmpdir, pydantic_version, call_from_python): @pytest.mark.parametrize( "pydantic_version, call_from_python", - product(_PYDANTIC_VERSIONS, [False, True]), + product([v for v in _PYDANTIC_VERSIONS if v != "v1"], [False, True]), ) -def test_computed_fields(tmpdir, pydantic_version, call_from_python): - if pydantic_version == "v1": - pytest.skip("Computed fields are a pydantic v2 feature") - _run_test(tmpdir, "computed_fields", pydantic_version, call_from_python=call_from_python) +def test_computed_fields(tmp_path: Path, pydantic_version: str, call_from_python: bool): + _run_test(tmp_path, "computed_fields", pydantic_version, call_from_python=call_from_python) @pytest.mark.parametrize( "pydantic_version, call_from_python", product(_PYDANTIC_VERSIONS, [False, True]), ) -def test_extra_fields(tmpdir, pydantic_version, call_from_python): - _run_test(tmpdir, "extra_fields", pydantic_version, call_from_python=call_from_python) +def test_extra_fields(tmp_path: Path, pydantic_version: str, call_from_python: bool): + _run_test(tmp_path, "extra_fields", pydantic_version, call_from_python=call_from_python) -def test_relative_filepath(tmpdir): +def test_relative_filepath(tmp_path: Path): test_name = "single_module" pydantic_version = _PYDANTIC_VERSIONS[0] relative_path = ( Path(".") / "tests" / "expected_results" / test_name / pydantic_version / "input.py" ) _run_test( - tmpdir, + tmp_path, test_name, pydantic_version, - module_path=relative_path, + module_path=str(relative_path), ) -def test_error_if_json2ts_not_installed(tmpdir): +def test_error_if_json2ts_not_installed(tmp_path: Path): module_path = _get_input_module("single_module", _PYDANTIC_VERSIONS[0]) - output_path = tmpdir.join("json2ts_test_output.ts").strpath + output_path = str(tmp_path / "json2ts_test_output.ts") # If the json2ts command has no spaces and the executable cannot be found, # 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): assert str(exc2.value).startswith(f'"{invalid_local_cmd}" failed with exit code ') -def test_error_if_invalid_module_path(tmpdir): +def test_error_if_invalid_module_path(tmp_path: Path): with pytest.raises(ModuleNotFoundError): - generate_typescript_defs("fake_module", tmpdir.join("fake_module_output.ts").strpath) + generate_typescript_defs("fake_module", str(tmp_path / "fake_module_output.ts")) def test_parse_cli_args(): From 05a7526c52bf3b60db8b3e56a33af4154a2eae09 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Tue, 12 Nov 2024 22:29:16 -0500 Subject: [PATCH 21/38] Add build step for running tests against pydantic@1.8.2 --- .github/workflows/cicd.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 91574ce..452a924 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -48,9 +48,11 @@ jobs: - name: Run tests against 'pydantic@latest' run: | python -m pytest --cov=pydantic2ts --cov-append - - name: Run tests using 'pydantic==1.*.*' + - name: Run tests against 'pydantic==1.8.2' + if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.8' }} run: | - python -m pip install 'pydantic<2' + python -m pip install 'pydantic==1.8.2' + python -m pip install -U . python -m pytest --cov=pydantic2ts --cov-append - name: Combine coverage data run: | From 1ac82449bd7d6818563f3ec8191c39b100555259 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Tue, 12 Nov 2024 22:34:54 -0500 Subject: [PATCH 22/38] Update base python version from 3.8 to 3.9 --- .github/workflows/cicd.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 452a924..29d182a 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -15,10 +15,10 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macOS-latest] - python-version: ["3.8"] + python-version: ["3.9"] include: - os: ubuntu-latest - python-version: "3.9" + python-version: "3.8" - os: ubuntu-latest python-version: "3.10" - os: ubuntu-latest @@ -49,7 +49,7 @@ jobs: run: | python -m pytest --cov=pydantic2ts --cov-append - name: Run tests against 'pydantic==1.8.2' - if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.8' }} + if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' }} run: | python -m pip install 'pydantic==1.8.2' python -m pip install -U . @@ -59,11 +59,11 @@ jobs: coverage combine coverage report - name: Generate LCOV File - if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.8' }} + if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' }} run: | coverage lcov -o coverage.lcov - name: Coveralls - if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.8' }} + if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' }} uses: coverallsapp/github-action@master with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -75,10 +75,10 @@ jobs: needs: test steps: - uses: actions/checkout@v3 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v4 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | python -m pip install -U pip wheel From ef1b56e0e40edbbcc77ce504a9732e5d145d5568 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Thu, 21 Nov 2024 16:11:16 -0500 Subject: [PATCH 23/38] Clean up logic for making fields nullable in v1, also clean up how tests are run against different versions --- pydantic2ts/cli/script.py | 14 +++++--------- tests/test_script.py | 30 +++++++++++++++--------------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/pydantic2ts/cli/script.py b/pydantic2ts/cli/script.py index 1bee25f..b1ab52d 100644 --- a/pydantic2ts/cli/script.py +++ b/pydantic2ts/cli/script.py @@ -177,17 +177,13 @@ def _clean_json_schema(schema: Dict[str, Any], model: Any = None) -> None: if _is_v1_model(model): fields: List["ModelField"] = list(model.__fields__.values()) - for field in fields: + fields_that_should_be_nullable = [f for f in fields if f.allow_none] + for field in fields_that_should_be_nullable: try: - if not field.allow_none: - continue name = field.alias - prop = properties.get(name) - if prop is None: - continue - if _is_nullable(prop): - continue - properties[name] = {"anyOf": [prop, {"type": "null"}]} + prop = properties.get(field.alias) + if prop and not _is_nullable(prop): + properties[name] = {"anyOf": [prop, {"type": "null"}]} except Exception: LOG.error( f"Failed to ensure nullability for field {field.alias}.", diff --git a/tests/test_script.py b/tests/test_script.py index 9162fff..0826773 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -10,24 +10,24 @@ from pydantic2ts.cli.script import parse_cli_args from pydantic2ts.pydantic_v2 import enabled as v2_enabled -_PYDANTIC_VERSIONS = ("v1", "v2") if v2_enabled else ("v1",) +_PYDANTIC_VERSIONS = (1, 2) if v2_enabled else (1,) _RESULTS_DIRECTORY = Path( os.path.join(os.path.dirname(os.path.realpath(__file__)), "expected_results") ) -def _get_input_module(test_name: str, pydantic_version: str) -> str: - return str(_RESULTS_DIRECTORY / test_name / pydantic_version / "input.py") +def _get_input_module(test_name: str, pydantic_version: int) -> str: + return str(_RESULTS_DIRECTORY / test_name / f"v{pydantic_version}" / "input.py") -def _get_expected_output(test_name: str, pydantic_version: str) -> str: - return (_RESULTS_DIRECTORY / test_name / pydantic_version / "output.ts").read_text() +def _get_expected_output(test_name: str, pydantic_version: int) -> str: + return (_RESULTS_DIRECTORY / test_name / f"v{pydantic_version}" / "output.ts").read_text() def _run_test( tmp_path: Path, test_name: str, - pydantic_version: str, + pydantic_version: int, *, module_path: Optional[str] = None, call_from_python: bool = False, @@ -38,7 +38,7 @@ def _run_test( Compare the output with the expected output, verifying it is identical. """ module_path = module_path or _get_input_module(test_name, pydantic_version) - output_path = str(tmp_path / f"{test_name}_{pydantic_version}.ts") + output_path = str(tmp_path / f"{test_name}_v{pydantic_version}.ts") if call_from_python: generate_typescript_defs(module_path, output_path, exclude) @@ -55,7 +55,7 @@ def _run_test( "pydantic_version, call_from_python", product(_PYDANTIC_VERSIONS, [False, True]), ) -def test_single_module(tmp_path: Path, pydantic_version: str, call_from_python: bool): +def test_single_module(tmp_path: Path, pydantic_version: int, call_from_python: bool): _run_test(tmp_path, "single_module", pydantic_version, call_from_python=call_from_python) @@ -63,7 +63,7 @@ def test_single_module(tmp_path: Path, pydantic_version: str, call_from_python: "pydantic_version, call_from_python", product(_PYDANTIC_VERSIONS, [False, True]), ) -def test_submodules(tmp_path: Path, pydantic_version: str, call_from_python: bool): +def test_submodules(tmp_path: Path, pydantic_version: int, call_from_python: bool): _run_test(tmp_path, "submodules", pydantic_version, call_from_python=call_from_python) @@ -71,7 +71,7 @@ def test_submodules(tmp_path: Path, pydantic_version: str, call_from_python: boo "pydantic_version, call_from_python", product(_PYDANTIC_VERSIONS, [False, True]), ) -def test_generics(tmp_path: Path, pydantic_version: str, call_from_python: bool): +def test_generics(tmp_path: Path, pydantic_version: int, call_from_python: bool): _run_test(tmp_path, "generics", pydantic_version, call_from_python=call_from_python) @@ -79,7 +79,7 @@ def test_generics(tmp_path: Path, pydantic_version: str, call_from_python: bool) "pydantic_version, call_from_python", product(_PYDANTIC_VERSIONS, [False, True]), ) -def test_excluding_models(tmp_path: Path, pydantic_version: str, call_from_python: bool): +def test_excluding_models(tmp_path: Path, pydantic_version: int, call_from_python: bool): _run_test( tmp_path, "excluding_models", @@ -91,9 +91,9 @@ def test_excluding_models(tmp_path: Path, pydantic_version: str, call_from_pytho @pytest.mark.parametrize( "pydantic_version, call_from_python", - product([v for v in _PYDANTIC_VERSIONS if v != "v1"], [False, True]), + product([v for v in _PYDANTIC_VERSIONS if v > 1], [False, True]), ) -def test_computed_fields(tmp_path: Path, pydantic_version: str, call_from_python: bool): +def test_computed_fields(tmp_path: Path, pydantic_version: int, call_from_python: bool): _run_test(tmp_path, "computed_fields", pydantic_version, call_from_python=call_from_python) @@ -101,7 +101,7 @@ def test_computed_fields(tmp_path: Path, pydantic_version: str, call_from_python "pydantic_version, call_from_python", product(_PYDANTIC_VERSIONS, [False, True]), ) -def test_extra_fields(tmp_path: Path, pydantic_version: str, call_from_python: bool): +def test_extra_fields(tmp_path: Path, pydantic_version: int, call_from_python: bool): _run_test(tmp_path, "extra_fields", pydantic_version, call_from_python=call_from_python) @@ -109,7 +109,7 @@ def test_relative_filepath(tmp_path: Path): test_name = "single_module" pydantic_version = _PYDANTIC_VERSIONS[0] relative_path = ( - Path(".") / "tests" / "expected_results" / test_name / pydantic_version / "input.py" + Path(".") / "tests" / "expected_results" / test_name / f"v{pydantic_version}" / "input.py" ) _run_test( tmp_path, From 5711c9103f8ef0c5fac572477ec8899eb9691049 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Thu, 21 Nov 2024 17:59:52 -0500 Subject: [PATCH 24/38] Start migrating to uv for project management --- .github/workflows/cicd.yml | 18 +- .python-version | 1 + pydantic2ts/__init__.py | 2 + pyproject.toml | 61 +++++++ ruff.toml | 10 - setup.py | 45 ----- uv.lock | 365 +++++++++++++++++++++++++++++++++++++ 7 files changed, 439 insertions(+), 63 deletions(-) create mode 100644 .python-version create mode 100644 pyproject.toml delete mode 100644 ruff.toml delete mode 100644 setup.py create mode 100644 uv.lock diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 29d182a..17febf4 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -4,11 +4,13 @@ on: pull_request: jobs: lint: - name: Lint code with black + name: Lint code with ruff runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: psf/black@stable + - uses: actions/checkout@v4 + - uses: astral-sh/ruff-action@v1 + with: + args: check pydantic2ts test: name: Run unit tests runs-on: ${{ matrix.os }} @@ -29,13 +31,13 @@ jobs: python-version: "3.13" steps: - name: Check out repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Node.js 20 - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install json-schema-to-typescript @@ -74,9 +76,9 @@ jobs: if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') needs: test steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3.9 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.9 - name: Install dependencies diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/pydantic2ts/__init__.py b/pydantic2ts/__init__.py index 33ac1c5..0ffe0bc 100644 --- a/pydantic2ts/__init__.py +++ b/pydantic2ts/__init__.py @@ -1 +1,3 @@ from pydantic2ts.cli.script import generate_typescript_defs + +__all__ = ("generate_typescript_defs",) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d08b4eb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,61 @@ +[project] +name = "pydantic-to-typescript" +version = "2.0.0" +description = "Convert pydantic models to typescript interfaces" +authors = [ + {name = "Phillip Dupuis", email = "phillip_dupuis@alumni.brown.edu"}, +] +license = {text = "MIT"} +readme = "README.md" +requires-python = ">=3.8" +keywords = ["pydantic", "typescript", "annotations", "validation", "interface"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "pydantic", +] + +[project.urls] +Homepage = "https://github.com/phillipdupuis/pydantic-to-typescript" +Repository = "https://github.com/phillipdupuis/pydantic-to-typescript" + +[project.scripts] +pydantic2ts = "pydantic2ts.cli.script:main" + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-cov", + "coverage", + "ruff", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["pydantic2ts"] + +[tool.ruff] +line-length = 100 +indent-width = 4 + +[tool.ruff.format] +quote-style = "double" + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "W"] +fixable = ["ALL"] +ignore = ["E501"] \ No newline at end of file diff --git a/ruff.toml b/ruff.toml deleted file mode 100644 index 3f2d235..0000000 --- a/ruff.toml +++ /dev/null @@ -1,10 +0,0 @@ -line-length = 100 -indent-width = 4 - -[format] -quote-style = "double" - -[lint] -select = ["E", "F", "I", "B", "W"] -fixable = ["ALL"] - diff --git a/setup.py b/setup.py deleted file mode 100644 index 70bdc3c..0000000 --- a/setup.py +++ /dev/null @@ -1,45 +0,0 @@ -from setuptools import find_packages, setup - - -def readme(): - with open("README.md", "r") as infile: - return infile.read() - - -classifiers = [ - "Development Status :: 5 - Production/Stable", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", -] - -install_requires = [ - "pydantic", -] - -setup( - name="pydantic-to-typescript", - version="2.0.0", - description="Convert pydantic models to typescript interfaces", - license="MIT", - long_description=readme(), - long_description_content_type="text/markdown", - keywords="pydantic typescript annotations validation interface", - author="Phillip Dupuis", - author_email="phillip_dupuis@alumni.brown.edu", - url="https://github.com/phillipdupuis/pydantic-to-typescript", - packages=find_packages(exclude=["tests*"]), - install_requires=install_requires, - extras_require={ - "dev": ["pytest", "pytest-cov", "coverage"], - }, - entry_points={"console_scripts": ["pydantic2ts = pydantic2ts.cli.script:main"]}, - classifiers=classifiers, -) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c2a51b5 --- /dev/null +++ b/uv.lock @@ -0,0 +1,365 @@ +version = 1 +requires-python = ">=3.8" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "coverage" +version = "7.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/61/eb7ce5ed62bacf21beca4937a90fe32545c91a3c8a42a30c6616d48fc70d/coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", size = 206690 }, + { url = "https://files.pythonhosted.org/packages/7d/73/041928e434442bd3afde5584bdc3f932fb4562b1597629f537387cec6f3d/coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", size = 207127 }, + { url = "https://files.pythonhosted.org/packages/c7/c8/6ca52b5147828e45ad0242388477fdb90df2c6cbb9a441701a12b3c71bc8/coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", size = 235654 }, + { url = "https://files.pythonhosted.org/packages/d5/da/9ac2b62557f4340270942011d6efeab9833648380109e897d48ab7c1035d/coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc", size = 233598 }, + { url = "https://files.pythonhosted.org/packages/53/23/9e2c114d0178abc42b6d8d5281f651a8e6519abfa0ef460a00a91f80879d/coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", size = 234732 }, + { url = "https://files.pythonhosted.org/packages/0f/7e/a0230756fb133343a52716e8b855045f13342b70e48e8ad41d8a0d60ab98/coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", size = 233816 }, + { url = "https://files.pythonhosted.org/packages/28/7c/3753c8b40d232b1e5eeaed798c875537cf3cb183fb5041017c1fdb7ec14e/coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", size = 232325 }, + { url = "https://files.pythonhosted.org/packages/57/e3/818a2b2af5b7573b4b82cf3e9f137ab158c90ea750a8f053716a32f20f06/coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", size = 233418 }, + { url = "https://files.pythonhosted.org/packages/c8/fb/4532b0b0cefb3f06d201648715e03b0feb822907edab3935112b61b885e2/coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", size = 209343 }, + { url = "https://files.pythonhosted.org/packages/5a/25/af337cc7421eca1c187cc9c315f0a755d48e755d2853715bfe8c418a45fa/coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", size = 210136 }, + { url = "https://files.pythonhosted.org/packages/ad/5f/67af7d60d7e8ce61a4e2ddcd1bd5fb787180c8d0ae0fbd073f903b3dd95d/coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", size = 206796 }, + { url = "https://files.pythonhosted.org/packages/e1/0e/e52332389e057daa2e03be1fbfef25bb4d626b37d12ed42ae6281d0a274c/coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", size = 207244 }, + { url = "https://files.pythonhosted.org/packages/aa/cd/766b45fb6e090f20f8927d9c7cb34237d41c73a939358bc881883fd3a40d/coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", size = 239279 }, + { url = "https://files.pythonhosted.org/packages/70/6c/a9ccd6fe50ddaf13442a1e2dd519ca805cbe0f1fcd377fba6d8339b98ccb/coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", size = 236859 }, + { url = "https://files.pythonhosted.org/packages/14/6f/8351b465febb4dbc1ca9929505202db909c5a635c6fdf33e089bbc3d7d85/coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", size = 238549 }, + { url = "https://files.pythonhosted.org/packages/68/3c/289b81fa18ad72138e6d78c4c11a82b5378a312c0e467e2f6b495c260907/coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", size = 237477 }, + { url = "https://files.pythonhosted.org/packages/ed/1c/aa1efa6459d822bd72c4abc0b9418cf268de3f60eeccd65dc4988553bd8d/coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", size = 236134 }, + { url = "https://files.pythonhosted.org/packages/fb/c8/521c698f2d2796565fe9c789c2ee1ccdae610b3aa20b9b2ef980cc253640/coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", size = 236910 }, + { url = "https://files.pythonhosted.org/packages/7d/30/033e663399ff17dca90d793ee8a2ea2890e7fdf085da58d82468b4220bf7/coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", size = 209348 }, + { url = "https://files.pythonhosted.org/packages/20/05/0d1ccbb52727ccdadaa3ff37e4d2dc1cd4d47f0c3df9eb58d9ec8508ca88/coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", size = 210230 }, + { url = "https://files.pythonhosted.org/packages/7e/d4/300fc921dff243cd518c7db3a4c614b7e4b2431b0d1145c1e274fd99bd70/coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", size = 206983 }, + { url = "https://files.pythonhosted.org/packages/e1/ab/6bf00de5327ecb8db205f9ae596885417a31535eeda6e7b99463108782e1/coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", size = 207221 }, + { url = "https://files.pythonhosted.org/packages/92/8f/2ead05e735022d1a7f3a0a683ac7f737de14850395a826192f0288703472/coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", size = 240342 }, + { url = "https://files.pythonhosted.org/packages/0f/ef/94043e478201ffa85b8ae2d2c79b4081e5a1b73438aafafccf3e9bafb6b5/coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", size = 237371 }, + { url = "https://files.pythonhosted.org/packages/1f/0f/c890339dd605f3ebc269543247bdd43b703cce6825b5ed42ff5f2d6122c7/coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", size = 239455 }, + { url = "https://files.pythonhosted.org/packages/d1/04/7fd7b39ec7372a04efb0f70c70e35857a99b6a9188b5205efb4c77d6a57a/coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", size = 238924 }, + { url = "https://files.pythonhosted.org/packages/ed/bf/73ce346a9d32a09cf369f14d2a06651329c984e106f5992c89579d25b27e/coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", size = 237252 }, + { url = "https://files.pythonhosted.org/packages/86/74/1dc7a20969725e917b1e07fe71a955eb34bc606b938316bcc799f228374b/coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", size = 238897 }, + { url = "https://files.pythonhosted.org/packages/b6/e9/d9cc3deceb361c491b81005c668578b0dfa51eed02cd081620e9a62f24ec/coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", size = 209606 }, + { url = "https://files.pythonhosted.org/packages/47/c8/5a2e41922ea6740f77d555c4d47544acd7dc3f251fe14199c09c0f5958d3/coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", size = 210373 }, + { url = "https://files.pythonhosted.org/packages/8c/f9/9aa4dfb751cb01c949c990d136a0f92027fbcc5781c6e921df1cb1563f20/coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", size = 207007 }, + { url = "https://files.pythonhosted.org/packages/b9/67/e1413d5a8591622a46dd04ff80873b04c849268831ed5c304c16433e7e30/coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", size = 207269 }, + { url = "https://files.pythonhosted.org/packages/14/5b/9dec847b305e44a5634d0fb8498d135ab1d88330482b74065fcec0622224/coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", size = 239886 }, + { url = "https://files.pythonhosted.org/packages/7b/b7/35760a67c168e29f454928f51f970342d23cf75a2bb0323e0f07334c85f3/coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", size = 237037 }, + { url = "https://files.pythonhosted.org/packages/f7/95/d2fd31f1d638df806cae59d7daea5abf2b15b5234016a5ebb502c2f3f7ee/coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", size = 239038 }, + { url = "https://files.pythonhosted.org/packages/6e/bd/110689ff5752b67924efd5e2aedf5190cbbe245fc81b8dec1abaffba619d/coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", size = 238690 }, + { url = "https://files.pythonhosted.org/packages/d3/a8/08d7b38e6ff8df52331c83130d0ab92d9c9a8b5462f9e99c9f051a4ae206/coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", size = 236765 }, + { url = "https://files.pythonhosted.org/packages/d6/6a/9cf96839d3147d55ae713eb2d877f4d777e7dc5ba2bce227167d0118dfe8/coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", size = 238611 }, + { url = "https://files.pythonhosted.org/packages/74/e4/7ff20d6a0b59eeaab40b3140a71e38cf52547ba21dbcf1d79c5a32bba61b/coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", size = 209671 }, + { url = "https://files.pythonhosted.org/packages/35/59/1812f08a85b57c9fdb6d0b383d779e47b6f643bc278ed682859512517e83/coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", size = 210368 }, + { url = "https://files.pythonhosted.org/packages/9c/15/08913be1c59d7562a3e39fce20661a98c0a3f59d5754312899acc6cb8a2d/coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", size = 207758 }, + { url = "https://files.pythonhosted.org/packages/c4/ae/b5d58dff26cade02ada6ca612a76447acd69dccdbb3a478e9e088eb3d4b9/coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", size = 208035 }, + { url = "https://files.pythonhosted.org/packages/b8/d7/62095e355ec0613b08dfb19206ce3033a0eedb6f4a67af5ed267a8800642/coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", size = 250839 }, + { url = "https://files.pythonhosted.org/packages/7c/1e/c2967cb7991b112ba3766df0d9c21de46b476d103e32bb401b1b2adf3380/coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", size = 246569 }, + { url = "https://files.pythonhosted.org/packages/8b/61/a7a6a55dd266007ed3b1df7a3386a0d760d014542d72f7c2c6938483b7bd/coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", size = 248927 }, + { url = "https://files.pythonhosted.org/packages/c8/fa/13a6f56d72b429f56ef612eb3bc5ce1b75b7ee12864b3bd12526ab794847/coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", size = 248401 }, + { url = "https://files.pythonhosted.org/packages/75/06/0429c652aa0fb761fc60e8c6b291338c9173c6aa0f4e40e1902345b42830/coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", size = 246301 }, + { url = "https://files.pythonhosted.org/packages/52/76/1766bb8b803a88f93c3a2d07e30ffa359467810e5cbc68e375ebe6906efb/coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", size = 247598 }, + { url = "https://files.pythonhosted.org/packages/66/8b/f54f8db2ae17188be9566e8166ac6df105c1c611e25da755738025708d54/coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", size = 210307 }, + { url = "https://files.pythonhosted.org/packages/9f/b0/e0dca6da9170aefc07515cce067b97178cefafb512d00a87a1c717d2efd5/coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", size = 211453 }, + { url = "https://files.pythonhosted.org/packages/81/d0/d9e3d554e38beea5a2e22178ddb16587dbcbe9a1ef3211f55733924bf7fa/coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0", size = 206674 }, + { url = "https://files.pythonhosted.org/packages/38/ea/cab2dc248d9f45b2b7f9f1f596a4d75a435cb364437c61b51d2eb33ceb0e/coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a", size = 207101 }, + { url = "https://files.pythonhosted.org/packages/ca/6f/f82f9a500c7c5722368978a5390c418d2a4d083ef955309a8748ecaa8920/coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b", size = 236554 }, + { url = "https://files.pythonhosted.org/packages/a6/94/d3055aa33d4e7e733d8fa309d9adf147b4b06a82c1346366fc15a2b1d5fa/coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3", size = 234440 }, + { url = "https://files.pythonhosted.org/packages/e4/6e/885bcd787d9dd674de4a7d8ec83faf729534c63d05d51d45d4fa168f7102/coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de", size = 235889 }, + { url = "https://files.pythonhosted.org/packages/f4/63/df50120a7744492710854860783d6819ff23e482dee15462c9a833cc428a/coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6", size = 235142 }, + { url = "https://files.pythonhosted.org/packages/3a/5d/9d0acfcded2b3e9ce1c7923ca52ccc00c78a74e112fc2aee661125b7843b/coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569", size = 233805 }, + { url = "https://files.pythonhosted.org/packages/c4/56/50abf070cb3cd9b1dd32f2c88f083aab561ecbffbcd783275cb51c17f11d/coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989", size = 234655 }, + { url = "https://files.pythonhosted.org/packages/25/ee/b4c246048b8485f85a2426ef4abab88e48c6e80c74e964bea5cd4cd4b115/coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7", size = 209296 }, + { url = "https://files.pythonhosted.org/packages/5c/1c/96cf86b70b69ea2b12924cdf7cabb8ad10e6130eab8d767a1099fbd2a44f/coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8", size = 210137 }, + { url = "https://files.pythonhosted.org/packages/19/d3/d54c5aa83268779d54c86deb39c1c4566e5d45c155369ca152765f8db413/coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", size = 206688 }, + { url = "https://files.pythonhosted.org/packages/a5/fe/137d5dca72e4a258b1bc17bb04f2e0196898fe495843402ce826a7419fe3/coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", size = 207120 }, + { url = "https://files.pythonhosted.org/packages/78/5b/a0a796983f3201ff5485323b225d7c8b74ce30c11f456017e23d8e8d1945/coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", size = 235249 }, + { url = "https://files.pythonhosted.org/packages/4e/e1/76089d6a5ef9d68f018f65411fcdaaeb0141b504587b901d74e8587606ad/coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", size = 233237 }, + { url = "https://files.pythonhosted.org/packages/9a/6f/eef79b779a540326fee9520e5542a8b428cc3bfa8b7c8f1022c1ee4fc66c/coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", size = 234311 }, + { url = "https://files.pythonhosted.org/packages/75/e1/656d65fb126c29a494ef964005702b012f3498db1a30dd562958e85a4049/coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", size = 233453 }, + { url = "https://files.pythonhosted.org/packages/68/6a/45f108f137941a4a1238c85f28fd9d048cc46b5466d6b8dda3aba1bb9d4f/coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", size = 231958 }, + { url = "https://files.pythonhosted.org/packages/9b/e7/47b809099168b8b8c72ae311efc3e88c8d8a1162b3ba4b8da3cfcdb85743/coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", size = 232938 }, + { url = "https://files.pythonhosted.org/packages/52/80/052222ba7058071f905435bad0ba392cc12006380731c37afaf3fe749b88/coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", size = 209352 }, + { url = "https://files.pythonhosted.org/packages/b8/d8/1b92e0b3adcf384e98770a00ca095da1b5f7b483e6563ae4eb5e935d24a1/coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", size = 210153 }, + { url = "https://files.pythonhosted.org/packages/a5/2b/0354ed096bca64dc8e32a7cbcae28b34cb5ad0b1fe2125d6d99583313ac0/coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", size = 198926 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pydantic" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/78/58c36d0cf331b659d0ccd99175e3523c457b4f8e67cb92a8fdc22ec1667c/pydantic-2.10.0.tar.gz", hash = "sha256:0aca0f045ff6e2f097f1fe89521115335f15049eeb8a7bef3dafe4b19a74e289", size = 781980 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/ee/255cbfdbf5c47650de70ac8a5425107511f505ed0366c29d537f7f1842e1/pydantic-2.10.0-py3-none-any.whl", hash = "sha256:5e7807ba9201bdf61b1b58aa6eb690916c40a47acfb114b1b4fef3e7fd5b30fc", size = 454346 }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/cd/8331ae216bcc5a3f2d4c6b941c9f63de647e2700d38133f4f7e0132a00c4/pydantic_core-2.27.0.tar.gz", hash = "sha256:f57783fbaf648205ac50ae7d646f27582fc706be3977e87c3c124e7a92407b10", size = 412675 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/97/8a42e9c17c305516c0d956a2887d616d3a1b0531b0053ac95a917e4a1ab7/pydantic_core-2.27.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:cd2ac6b919f7fed71b17fe0b4603c092a4c9b5bae414817c9c81d3c22d1e1bcc", size = 1893954 }, + { url = "https://files.pythonhosted.org/packages/5b/09/ff3ce866f769ebbae2abdcd742247dc2bd6967d646daf54a562ceee6abdb/pydantic_core-2.27.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e015833384ca3e1a0565a79f5d953b0629d9138021c27ad37c92a9fa1af7623c", size = 1807944 }, + { url = "https://files.pythonhosted.org/packages/88/d7/e04d06ca71a0bd7f4cac24e6aa562129969c91117e5fad2520ede865c8cb/pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db72e40628967f6dc572020d04b5f800d71264e0531c6da35097e73bdf38b003", size = 1829151 }, + { url = "https://files.pythonhosted.org/packages/14/24/90b0babb61b68ecc471ce5becad8f7fc5f7835c601774e5de577b051b7ad/pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df45c4073bed486ea2f18757057953afed8dd77add7276ff01bccb79982cf46c", size = 1849502 }, + { url = "https://files.pythonhosted.org/packages/fc/34/62612e655b4d693a6ec515fd0ddab4bfc0cc6759076e09c23fc6966bd07b/pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:836a4bfe0cc6d36dc9a9cc1a7b391265bf6ce9d1eb1eac62ac5139f5d8d9a6fa", size = 2035489 }, + { url = "https://files.pythonhosted.org/packages/12/7d/0ff62235adda41b87c495c1b95c84d4debfecb91cfd62e3100abad9754fa/pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bf1340ae507f6da6360b24179c2083857c8ca7644aab65807023cf35404ea8d", size = 2774949 }, + { url = "https://files.pythonhosted.org/packages/7f/ac/e1867e2b808a668f32ad9012eaeac0b0ee377eee8157ab93720f48ee609b/pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ab325fc86fbc077284c8d7f996d904d30e97904a87d6fb303dce6b3de7ebba9", size = 2130123 }, + { url = "https://files.pythonhosted.org/packages/2f/04/5006f2dbf655052826ac8d03d51b9a122de709fed76eb1040aa21772f530/pydantic_core-2.27.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1da0c98a85a6c6ed702d5556db3b09c91f9b0b78de37b7593e2de8d03238807a", size = 1981988 }, + { url = "https://files.pythonhosted.org/packages/80/8b/bdbe875c4758282402e3cc75fa6bf2f0c8ffac1874f384190034786d3cbc/pydantic_core-2.27.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7b0202ebf2268954090209a84f9897345719e46a57c5f2c9b7b250ca0a9d3e63", size = 1992043 }, + { url = "https://files.pythonhosted.org/packages/2f/2d/4e46981cfcf4ca4c2ff7734dec08162e398dc598c6c0687454b05a82dc2f/pydantic_core-2.27.0-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:35380671c3c921fe8adf31ad349dc6f7588b7e928dbe44e1093789734f607399", size = 2087309 }, + { url = "https://files.pythonhosted.org/packages/d2/43/56ef2e72360d909629a54198d2bc7ef60f19fde8ceb5c90d7749120d0b61/pydantic_core-2.27.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b4c19525c3538fbc0bbda6229f9682fb8199ce9ac37395880e6952798e00373", size = 2140517 }, + { url = "https://files.pythonhosted.org/packages/61/40/81e5d8f84ab070cf091d072bb61b6021ff79d7110b2d0145fe3171b6107b/pydantic_core-2.27.0-cp310-none-win32.whl", hash = "sha256:333c840a1303d1474f491e7be0b718226c730a39ead0f7dab2c7e6a2f3855555", size = 1814120 }, + { url = "https://files.pythonhosted.org/packages/05/64/e543d342b991d38426bcb841bc0b4b95b9bd2191367ba0cc75f258e3d583/pydantic_core-2.27.0-cp310-none-win_amd64.whl", hash = "sha256:99b2863c1365f43f74199c980a3d40f18a218fbe683dd64e470199db426c4d6a", size = 1972268 }, + { url = "https://files.pythonhosted.org/packages/85/ba/5ed9583a44d9fbd6fbc028df8e3eae574a3ef4761d7f56bb4e0eb428d5ce/pydantic_core-2.27.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4523c4009c3f39d948e01962223c9f5538602e7087a628479b723c939fab262d", size = 1891468 }, + { url = "https://files.pythonhosted.org/packages/50/1e/58baa0fde14aafccfcc09a8b45bdc11eb941b58a69536729d832e383bdbd/pydantic_core-2.27.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:84af1cf7bfdcbc6fcf5a5f70cc9896205e0350306e4dd73d54b6a18894f79386", size = 1807103 }, + { url = "https://files.pythonhosted.org/packages/7d/87/0422a653ddfcf68763eb56d6e4e2ad19df6d5e006f3f4b854fda06ce2ba3/pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e65466b31be1070b4a5b7dbfbd14b247884cb8e8b79c64fb0f36b472912dbaea", size = 1827446 }, + { url = "https://files.pythonhosted.org/packages/a4/48/8e431b7732695c93ded79214299a83ac04249d748243b8ba6644ab076574/pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a5c022bb0d453192426221605efc865373dde43b17822a264671c53b068ac20c", size = 1847798 }, + { url = "https://files.pythonhosted.org/packages/98/7d/e1f28e12a26035d7c8b7678830400e5b94129c9ccb74636235a2eeeee40f/pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6bb69bf3b6500f195c3deb69c1205ba8fc3cb21d1915f1f158a10d6b1ef29b6a", size = 2033797 }, + { url = "https://files.pythonhosted.org/packages/89/b4/ad5bc2b43b7ca8fd5f5068eca7f195565f53911d9ae69925f7f21859a929/pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0aa4d1b2eba9a325897308b3124014a142cdccb9f3e016f31d3ebee6b5ea5e75", size = 2767592 }, + { url = "https://files.pythonhosted.org/packages/3e/a6/7fb0725eaf1122518c018bfe38aaf4ad3d512e8598e2c08419b9a270f4bf/pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e96ca781e0c01e32115912ebdf7b3fb0780ce748b80d7d28a0802fa9fbaf44e", size = 2130244 }, + { url = "https://files.pythonhosted.org/packages/a1/2c/453e52a866947a153bb575bbbb6b14db344f07a73b2ad820ff8f40e9807b/pydantic_core-2.27.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b872c86d8d71827235c7077461c502feb2db3f87d9d6d5a9daa64287d75e4fa0", size = 1979626 }, + { url = "https://files.pythonhosted.org/packages/7a/43/1faa8601085dab2a37dfaca8d48605b76e38aeefcde58bf95534ab96b135/pydantic_core-2.27.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:82e1ad4ca170e8af4c928b67cff731b6296e6a0a0981b97b2eb7c275cc4e15bd", size = 1990741 }, + { url = "https://files.pythonhosted.org/packages/dd/ef/21f25f5964979b7e6f9102074083b5448c22c871da438d91db09601e6634/pydantic_core-2.27.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:eb40f828bc2f73f777d1eb8fee2e86cd9692a4518b63b6b5aa8af915dfd3207b", size = 2086325 }, + { url = "https://files.pythonhosted.org/packages/8a/f9/81e5f910571a20655dd7bf10e6d6db8c279e250bfbdb5ab1a09ce3e0eb82/pydantic_core-2.27.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9a8fbf506fde1529a1e3698198fe64bfbe2e0c09557bc6a7dcf872e7c01fec40", size = 2138839 }, + { url = "https://files.pythonhosted.org/packages/59/c4/27917b73d0631098b91f2ec303e1becb823fead0628ee9055fca78ec1e2e/pydantic_core-2.27.0-cp311-none-win32.whl", hash = "sha256:24f984fc7762ed5f806d9e8c4c77ea69fdb2afd987b4fd319ef06c87595a8c55", size = 1809514 }, + { url = "https://files.pythonhosted.org/packages/ea/48/a30c67d62b8f39095edc3dab6abe69225e8c57186f31cc59a1ab984ea8e6/pydantic_core-2.27.0-cp311-none-win_amd64.whl", hash = "sha256:68950bc08f9735306322bfc16a18391fcaac99ded2509e1cc41d03ccb6013cfe", size = 1971838 }, + { url = "https://files.pythonhosted.org/packages/4e/9e/3798b901cf331058bae0ba4712a52fb0106c39f913830aaf71f01fd10d45/pydantic_core-2.27.0-cp311-none-win_arm64.whl", hash = "sha256:3eb8849445c26b41c5a474061032c53e14fe92a11a5db969f722a2716cd12206", size = 1862174 }, + { url = "https://files.pythonhosted.org/packages/82/99/43149b127559f3152cd28cb7146592c6547cfe47d528761954e2e8fcabaf/pydantic_core-2.27.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8117839a9bdbba86e7f9df57018fe3b96cec934c3940b591b0fd3fbfb485864a", size = 1887064 }, + { url = "https://files.pythonhosted.org/packages/7e/dd/989570c76334aa55ccb4ee8b5e0e6881a513620c6172d93b2f3b77e10f81/pydantic_core-2.27.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a291d0b4243a259c8ea7e2b84eb9ccb76370e569298875a7c5e3e71baf49057a", size = 1804405 }, + { url = "https://files.pythonhosted.org/packages/3e/b5/bce1d6d6fb71d916c74bf988b7d0cd7fc0c23da5e08bc0d6d6e08c12bf36/pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84e35afd9e10b2698e6f2f32256678cb23ca6c1568d02628033a837638b3ed12", size = 1822595 }, + { url = "https://files.pythonhosted.org/packages/35/93/a6e5e04625ac8fcbed523d7b741e91cc3a37ed1e04e16f8f2f34269bbe53/pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:58ab0d979c969983cdb97374698d847a4acffb217d543e172838864636ef10d9", size = 1848701 }, + { url = "https://files.pythonhosted.org/packages/3a/74/56ead1436e3f6513b59b3a442272578a6ec09a39ab95abd5ee321bcc8c95/pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d06b667e53320332be2bf6f9461f4a9b78092a079b8ce8634c9afaa7e10cd9f", size = 2031878 }, + { url = "https://files.pythonhosted.org/packages/e1/4d/8905b2710ef653c0da27224bfb6a084b5873ad6fdb975dda837943e5639d/pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78f841523729e43e3928a364ec46e2e3f80e6625a4f62aca5c345f3f626c6e8a", size = 2673386 }, + { url = "https://files.pythonhosted.org/packages/1d/f0/abe1511f11756d12ce18d016f3555cb47211590e4849ee02e7adfdd1684e/pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:400bf470e4327e920883b51e255617dfe4496d4e80c3fea0b5a5d0bf2c404dd4", size = 2152867 }, + { url = "https://files.pythonhosted.org/packages/c7/90/1c588d4d93ce53e1f5ab0cea2d76151fcd36613446bf99b670d7da9ddf89/pydantic_core-2.27.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:951e71da6c89d354572098bada5ba5b5dc3a9390c933af8a614e37755d3d1840", size = 1986595 }, + { url = "https://files.pythonhosted.org/packages/a3/9c/27d06369f39375966836cde5c8aec0a66dc2f532c13d9aa1a6c370131fbd/pydantic_core-2.27.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2a51ce96224eadd1845150b204389623c8e129fde5a67a84b972bd83a85c6c40", size = 1995731 }, + { url = "https://files.pythonhosted.org/packages/26/4e/b039e52b7f4c51d9fae6715d5d2e47a57c369b8e0cb75838974a193aae40/pydantic_core-2.27.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:483c2213a609e7db2c592bbc015da58b6c75af7360ca3c981f178110d9787bcf", size = 2085771 }, + { url = "https://files.pythonhosted.org/packages/01/93/2796bd116a93e7e4e10baca4c55266c4d214b3b4e5ee7f0e9add69c184af/pydantic_core-2.27.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:359e7951f04ad35111b5ddce184db3391442345d0ab073aa63a95eb8af25a5ef", size = 2150452 }, + { url = "https://files.pythonhosted.org/packages/0f/93/e57562d6ea961557174c3afa481a73ce0e2d8b823e0eb2b320bfb00debbe/pydantic_core-2.27.0-cp312-none-win32.whl", hash = "sha256:ee7d9d5537daf6d5c74a83b38a638cc001b648096c1cae8ef695b0c919d9d379", size = 1830767 }, + { url = "https://files.pythonhosted.org/packages/44/00/4f121ca5dd06420813e7858395b5832603ed0074a5b74ef3104c8dbc2fd5/pydantic_core-2.27.0-cp312-none-win_amd64.whl", hash = "sha256:2be0ad541bb9f059954ccf8877a49ed73877f862529575ff3d54bf4223e4dd61", size = 1973909 }, + { url = "https://files.pythonhosted.org/packages/c3/c7/36f87c0dabbde9c0dd59b9024e4bf117a5122515c864ddbe685ed8301670/pydantic_core-2.27.0-cp312-none-win_arm64.whl", hash = "sha256:6e19401742ed7b69e51d8e4df3c03ad5ec65a83b36244479fd70edde2828a5d9", size = 1877037 }, + { url = "https://files.pythonhosted.org/packages/9d/b2/740159bdfe532d856e340510246aa1fd723b97cadf1a38153bdfb52efa28/pydantic_core-2.27.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5f2b19b8d6fca432cb3acf48cf5243a7bf512988029b6e6fd27e9e8c0a204d85", size = 1886935 }, + { url = "https://files.pythonhosted.org/packages/ca/2a/2f435d9fd591c912ca227f29c652a93775d35d54677b57c3157bbad823b5/pydantic_core-2.27.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c86679f443e7085ea55a7376462553996c688395d18ef3f0d3dbad7838f857a2", size = 1805318 }, + { url = "https://files.pythonhosted.org/packages/ba/f2/755b628009530b19464bb95c60f829b47a6ef7930f8ca1d87dac90fd2848/pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:510b11e9c3b1a852876d1ccd8d5903684336d635214148637ceb27366c75a467", size = 1822284 }, + { url = "https://files.pythonhosted.org/packages/3d/c2/a12744628b1b55c5384bd77657afa0780868484a92c37a189fb460d1cfe7/pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb704155e73b833801c247f39d562229c0303f54770ca14fb1c053acb376cf10", size = 1848522 }, + { url = "https://files.pythonhosted.org/packages/60/1d/dfcb8ab94a4637d4cf682550a2bf94695863988e7bcbd6f4d83c04178e17/pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ce048deb1e033e7a865ca384770bccc11d44179cf09e5193a535c4c2f497bdc", size = 2031678 }, + { url = "https://files.pythonhosted.org/packages/ee/c8/f9cbcab0275e031c4312223c75d999b61fba60995003cd89dc4866300059/pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58560828ee0951bb125c6f2862fbc37f039996d19ceb6d8ff1905abf7da0bf3d", size = 2672948 }, + { url = "https://files.pythonhosted.org/packages/41/f9/c613546237cf58ed7a7fa9158410c14d0e7e0cbbf95f83a905c9424bb074/pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abb4785894936d7682635726613c44578c420a096729f1978cd061a7e72d5275", size = 2152419 }, + { url = "https://files.pythonhosted.org/packages/49/71/b951b03a271678b1d1b79481dac38cf8bce8a4e178f36ada0e9aff65a679/pydantic_core-2.27.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2883b260f7a93235488699d39cbbd94fa7b175d3a8063fbfddd3e81ad9988cb2", size = 1986408 }, + { url = "https://files.pythonhosted.org/packages/9a/2c/07b0d5b5e1cdaa07b7c23e758354377d294ff0395116d39c9fa734e5d89e/pydantic_core-2.27.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c6fcb3fa3855d583aa57b94cf146f7781d5d5bc06cb95cb3afece33d31aac39b", size = 1995895 }, + { url = "https://files.pythonhosted.org/packages/63/09/c21e0d7438c7e742209cc8603607c8d389df96018396c8a2577f6e24c5c5/pydantic_core-2.27.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e851a051f7260e6d688267eb039c81f05f23a19431bd7dfa4bf5e3cb34c108cd", size = 2085914 }, + { url = "https://files.pythonhosted.org/packages/68/e4/5ed8f09d92655dcd0a86ee547e509adb3e396cef0a48f5c31e3b060bb9d0/pydantic_core-2.27.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:edb1bfd45227dec8d50bc7c7d86463cd8728bcc574f9b07de7369880de4626a3", size = 2150217 }, + { url = "https://files.pythonhosted.org/packages/cd/e6/a202f0e1b81c729130404e82d9de90dc4418ec01df35000d48d027c38501/pydantic_core-2.27.0-cp313-none-win32.whl", hash = "sha256:678f66462058dd978702db17eb6a3633d634f7aa0deaea61e0a674152766d3fc", size = 1830973 }, + { url = "https://files.pythonhosted.org/packages/06/3d/21ed0f308e6618ce6c5c6bfb9e71734a9a3256d5474a53c8e5aaaba498ca/pydantic_core-2.27.0-cp313-none-win_amd64.whl", hash = "sha256:d28ca7066d6cdd347a50d8b725dc10d9a1d6a1cce09836cf071ea6a2d4908be0", size = 1974853 }, + { url = "https://files.pythonhosted.org/packages/d7/18/e5744a132b81f98b9f92e15f33f03229a1d254ce7af942b1422ec2ac656f/pydantic_core-2.27.0-cp313-none-win_arm64.whl", hash = "sha256:6f4a53af9e81d757756508b57cae1cf28293f0f31b9fa2bfcb416cc7fb230f9d", size = 1877469 }, + { url = "https://files.pythonhosted.org/packages/e1/79/9ff7da9e775aa9bf42c9df93fc940d421216b22d255a6edbc11aa291d3f0/pydantic_core-2.27.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:e9f9feee7f334b72ceae46313333d002b56f325b5f04271b4ae2aadd9e993ae4", size = 1897587 }, + { url = "https://files.pythonhosted.org/packages/5d/62/fecc64300ea766b6b45de87663ff2adba63c6624a71ba8bc5a323e17ef5e/pydantic_core-2.27.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:225bfff5d425c34e1fd562cef52d673579d59b967d9de06178850c4802af9039", size = 1777716 }, + { url = "https://files.pythonhosted.org/packages/89/96/85e7daa1151104c24f4b007d32374c899c5e66ebbbf4da4debd1794e084f/pydantic_core-2.27.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c921ad596ff1a82f9c692b0758c944355abc9f0de97a4c13ca60ffc6d8dc15d4", size = 1831004 }, + { url = "https://files.pythonhosted.org/packages/80/31/a9c66908c95dd2a04d84baa98b46d8ea35abb13354d0a27ac47ffab6decf/pydantic_core-2.27.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6354e18a9be37bfa124d6b288a87fb30c673745806c92956f1a25e3ae6e76b96", size = 1850721 }, + { url = "https://files.pythonhosted.org/packages/48/a4/7bc31d7bc5dcbc6d7c8ab2ada38a99d2bd22e93b73e9a9a2a84626016740/pydantic_core-2.27.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ee4c2a75af9fe21269a4a0898c5425afb01af1f5d276063f57e2ae1bc64e191", size = 2037703 }, + { url = "https://files.pythonhosted.org/packages/5c/d8/8f68ab9d67c129dc046ad1aa105dc3a86c9ffb6c2243d44d7140381007ea/pydantic_core-2.27.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c91e3c04f5191fd3fb68764bddeaf02025492d5d9f23343b283870f6ace69708", size = 2771401 }, + { url = "https://files.pythonhosted.org/packages/8e/e1/bb637cf80583bf9058b8e5a7645cdc99a8adf3941a58329ced63f4c63843/pydantic_core-2.27.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a6ebfac28fd51890a61df36ef202adbd77d00ee5aca4a3dadb3d9ed49cfb929", size = 2133159 }, + { url = "https://files.pythonhosted.org/packages/50/82/c9b7dc0b081a3f26ee321f56b67e5725ec94128d92f1e08525080ba2f2df/pydantic_core-2.27.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:36aa167f69d8807ba7e341d67ea93e50fcaaf6bc433bb04939430fa3dab06f31", size = 1983746 }, + { url = "https://files.pythonhosted.org/packages/65/02/6b308344a5968a1b99959fb965e72525837f609adf2412d47769902b2db5/pydantic_core-2.27.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3e8d89c276234579cd3d095d5fa2a44eb10db9a218664a17b56363cddf226ff3", size = 1992306 }, + { url = "https://files.pythonhosted.org/packages/f2/d6/4f9c7059020863535810a027f993bb384da1f9af60b4d6364493661befb6/pydantic_core-2.27.0-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:5cc822ab90a70ea3a91e6aed3afac570b276b1278c6909b1d384f745bd09c714", size = 2088195 }, + { url = "https://files.pythonhosted.org/packages/80/1e/896a1472a6d7863144e0738181cfdad872c90b57d5c1a5ee073838d751c5/pydantic_core-2.27.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e15315691fe2253eb447503153acef4d7223dfe7e7702f9ed66539fcd0c43801", size = 2142683 }, + { url = "https://files.pythonhosted.org/packages/8b/fe/773312dae0be37017e91e2684834bc971aca8f8b6f44e5395c7e4814ae52/pydantic_core-2.27.0-cp38-none-win32.whl", hash = "sha256:dfa5f5c0a4c8fced1422dc2ca7eefd872d5d13eb33cf324361dbf1dbfba0a9fe", size = 1817110 }, + { url = "https://files.pythonhosted.org/packages/90/c1/219e5b3c4dd33d88dee17479b5a3aace3c9c66f26cb7317acc33d74ef02a/pydantic_core-2.27.0-cp38-none-win_amd64.whl", hash = "sha256:513cb14c0cc31a4dfd849a4674b20c46d87b364f997bbcb02282306f5e187abf", size = 1970874 }, + { url = "https://files.pythonhosted.org/packages/00/e4/4d6d9193a33c964920bf56fcbe11fa30511d3d900a81c740b0157579b122/pydantic_core-2.27.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:4148dc9184ab79e356dc00a4199dc0ee8647973332cb385fc29a7cced49b9f9c", size = 1894360 }, + { url = "https://files.pythonhosted.org/packages/f4/46/9d27771309609126678dee81e8e93188dbd0515a543b27e0a01a806c1893/pydantic_core-2.27.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5fc72fbfebbf42c0856a824b8b0dc2b5cd2e4a896050281a21cfa6fed8879cb1", size = 1773921 }, + { url = "https://files.pythonhosted.org/packages/a0/3a/3a6a4cee7bc11bcb3f8859a63c6b4d88b8df66ad7c9c9e6d667dd894b439/pydantic_core-2.27.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:185ef205256cd8b38431205698531026979db89a79587725c1e55c59101d64e9", size = 1829480 }, + { url = "https://files.pythonhosted.org/packages/2b/aa/ecf0fcee9031eef516cef2e336d403a61bd8df75ab17a856bc29f3eb07d4/pydantic_core-2.27.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:395e3e1148fa7809016231f8065f30bb0dc285a97b4dc4360cd86e17bab58af7", size = 1849759 }, + { url = "https://files.pythonhosted.org/packages/b6/17/8953bbbe7d3c015bdfa34171ba1738a43682d770e68c87171dd8887035c3/pydantic_core-2.27.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:33d14369739c5d07e2e7102cdb0081a1fa46ed03215e07f097b34e020b83b1ae", size = 2035679 }, + { url = "https://files.pythonhosted.org/packages/ec/19/514fdf2f684003961b6f34543f0bdf3be2e0f17b8b25cd8d44c343521148/pydantic_core-2.27.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7820bb0d65e3ce1e3e70b6708c2f66143f55912fa02f4b618d0f08b61575f12", size = 2773208 }, + { url = "https://files.pythonhosted.org/packages/9a/37/2cdd48b7367fbf0576d16402837212d2b1798aa4ea887f1795f8ddbace07/pydantic_core-2.27.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43b61989068de9ce62296cde02beffabcadb65672207fc51e7af76dca75e6636", size = 2130616 }, + { url = "https://files.pythonhosted.org/packages/3a/6c/fa100356e1c8f749797d88401a1d5ed8d458705d43e259931681b5b96ab4/pydantic_core-2.27.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:15e350efb67b855cd014c218716feea4986a149ed1f42a539edd271ee074a196", size = 1981857 }, + { url = "https://files.pythonhosted.org/packages/0f/3d/36c0c832c1fd1351c495bf1495b61b2e40248c54f7874e6df439e6ffb9a5/pydantic_core-2.27.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:433689845288f9a1ee5714444e65957be26d30915f7745091ede4a83cfb2d7bb", size = 1992515 }, + { url = "https://files.pythonhosted.org/packages/99/12/ee67e29369b368c404c6aead492e1528ec887609d388a7a30b675b969b82/pydantic_core-2.27.0-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:3fd8bc2690e7c39eecdf9071b6a889ce7b22b72073863940edc2a0a23750ca90", size = 2087604 }, + { url = "https://files.pythonhosted.org/packages/0e/6c/72ca869aabe190e4cd36b03226286e430a1076c367097c77cb0704b1cbb3/pydantic_core-2.27.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:884f1806609c2c66564082540cffc96868c5571c7c3cf3a783f63f2fb49bd3cd", size = 2141000 }, + { url = "https://files.pythonhosted.org/packages/5c/b8/e7499cfa6f1e46e92a645e74198b7bb9ce3d49e82f626a02726dc917fc74/pydantic_core-2.27.0-cp39-none-win32.whl", hash = "sha256:bf37b72834e7239cf84d4a0b2c050e7f9e48bced97bad9bdf98d26b8eb72e846", size = 1813857 }, + { url = "https://files.pythonhosted.org/packages/2e/27/81203aa6cbf68772afd9c3877ce2e35878f434e824aad4047e7cfd3bc14d/pydantic_core-2.27.0-cp39-none-win_amd64.whl", hash = "sha256:31a2cae5f059329f9cfe3d8d266d3da1543b60b60130d186d9b6a3c20a346361", size = 1974744 }, + { url = "https://files.pythonhosted.org/packages/d3/ad/c1dc814ab524cb247ceb6cb25236895a5cae996c438baf504db610fd6c92/pydantic_core-2.27.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4fb49cfdb53af5041aba909be00cccfb2c0d0a2e09281bf542371c5fd36ad04c", size = 1889233 }, + { url = "https://files.pythonhosted.org/packages/24/bb/069a9dd910e6c09aab90a118c08d3cb30dc5738550e9f2d21f3b086352c2/pydantic_core-2.27.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:49633583eb7dc5cba61aaf7cdb2e9e662323ad394e543ee77af265736bcd3eaa", size = 1768419 }, + { url = "https://files.pythonhosted.org/packages/cb/a1/f9b4e625ee8c7f683c8295c85d11f79a538eb53719f326646112a7800bda/pydantic_core-2.27.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:153017e3d6cd3ce979de06d84343ca424bb6092727375eba1968c8b4693c6ecb", size = 1822870 }, + { url = "https://files.pythonhosted.org/packages/12/07/04abaeeabf212650de3edc300b2ab89fb17da9bc4408ef4e01a62efc87dc/pydantic_core-2.27.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff63a92f6e249514ef35bc795de10745be0226eaea06eb48b4bbeaa0c8850a4a", size = 1977039 }, + { url = "https://files.pythonhosted.org/packages/0f/9d/99bbeb21d5be1d5affdc171e0e84603a757056f9f4293ef236e41af0a5bc/pydantic_core-2.27.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5982048129f40b082c2654de10c0f37c67a14f5ff9d37cf35be028ae982f26df", size = 1974317 }, + { url = "https://files.pythonhosted.org/packages/5f/78/815aa74db1591a9ad4086bc1bf98e2126686245a956d76cd4e72bf9841ad/pydantic_core-2.27.0-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:91bc66f878557313c2a6bcf396e7befcffe5ab4354cfe4427318968af31143c3", size = 1985101 }, + { url = "https://files.pythonhosted.org/packages/d9/a8/9c1557d5282108916448415e85f829b70ba99d97f03cee0e40a296e58a65/pydantic_core-2.27.0-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:68ef5377eb582fa4343c9d0b57a5b094046d447b4c73dd9fbd9ffb216f829e7d", size = 2073399 }, + { url = "https://files.pythonhosted.org/packages/ca/b0/5296273d652fa9aa140771b3f4bb574edd3cbf397090625b988f6a57b02b/pydantic_core-2.27.0-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:c5726eec789ee38f2c53b10b1821457b82274f81f4f746bb1e666d8741fcfadb", size = 2129499 }, + { url = "https://files.pythonhosted.org/packages/e9/fd/7f39ff702fdca954f26c84b40d9bf744733bb1a50ca6b7569822b9cbb7f4/pydantic_core-2.27.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c0c431e4be5c1a0c6654e0c31c661cd89e0ca956ef65305c3c3fd96f4e72ca39", size = 1997246 }, + { url = "https://files.pythonhosted.org/packages/bb/4f/76f1ac16a0c277a3a8be2b5b52b0a09929630e794fb1938c4cd85396c34f/pydantic_core-2.27.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:8e21d927469d04b39386255bf00d0feedead16f6253dcc85e9e10ddebc334084", size = 1889486 }, + { url = "https://files.pythonhosted.org/packages/f3/96/4ff5a8ec0c457afcd87334d4e2f6fd25df6642b4ff8bf587316dd6eccd59/pydantic_core-2.27.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:4b51f964fcbb02949fc546022e56cdb16cda457af485e9a3e8b78ac2ecf5d77e", size = 1768718 }, + { url = "https://files.pythonhosted.org/packages/52/21/e7bab7b9674d5b1a8cf06939929991753e4b814b01bae29321a8739990b3/pydantic_core-2.27.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a7fd4de38f7ff99a37e18fa0098c3140286451bc823d1746ba80cec5b433a1", size = 1823291 }, + { url = "https://files.pythonhosted.org/packages/1d/68/d1868a78ce0d776c3e04179fbfa6272e72d4363c49f9bdecfe4b2007dd75/pydantic_core-2.27.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fda87808429c520a002a85d6e7cdadbf58231d60e96260976c5b8f9a12a8e13", size = 1977040 }, + { url = "https://files.pythonhosted.org/packages/68/7b/2e361ff81f60c4c28f65b53670436849ec716366d4f1635ea243a31903a2/pydantic_core-2.27.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a150392102c402c538190730fda06f3bce654fc498865579a9f2c1d2b425833", size = 1973909 }, + { url = "https://files.pythonhosted.org/packages/a8/44/a4a3718f3b148526baccdb9a0bc8e6b7aa840c796e637805c04aaf1a74c3/pydantic_core-2.27.0-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c9ed88b398ba7e3bad7bd64d66cc01dcde9cfcb7ec629a6fd78a82fa0b559d78", size = 1985091 }, + { url = "https://files.pythonhosted.org/packages/3a/79/2cdf503e8aac926a99d64b2a02642ab1377146999f9a68536c54bd8b2c46/pydantic_core-2.27.0-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:9fe94d9d2a2b4edd7a4b22adcd45814b1b59b03feb00e56deb2e89747aec7bfe", size = 2073484 }, + { url = "https://files.pythonhosted.org/packages/e8/15/74c61b7ea348b252fe97a32e5b531fdde331710db80e9b0fae1302023414/pydantic_core-2.27.0-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d8b5ee4ae9170e2775d495b81f414cc20268041c42571530513496ba61e94ba3", size = 2129473 }, + { url = "https://files.pythonhosted.org/packages/57/81/0e9ebcc80b107e1dfacc677ad7c2ab0202cc0e10ba76b23afbb147ac32fb/pydantic_core-2.27.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d29e235ce13c91902ef3efc3d883a677655b3908b1cbc73dee816e5e1f8f7739", size = 1997389 }, +] + +[[package]] +name = "pydantic-to-typescript" +version = "2.0.0" +source = { editable = "." } +dependencies = [ + { name = "pydantic" }, +] + +[package.optional-dependencies] +dev = [ + { name = "coverage" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "coverage", marker = "extra == 'dev'" }, + { name = "pydantic" }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-cov", marker = "extra == 'dev'" }, + { name = "ruff", marker = "extra == 'dev'" }, +] + +[[package]] +name = "pytest" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, +] + +[[package]] +name = "pytest-cov" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990 }, +] + +[[package]] +name = "ruff" +version = "0.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/8b/bc4e0dfa1245b07cf14300e10319b98e958a53ff074c1dd86b35253a8c2a/ruff-0.7.4.tar.gz", hash = "sha256:cd12e35031f5af6b9b93715d8c4f40360070b2041f81273d0527683d5708fce2", size = 3275547 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/4b/f5094719e254829766b807dadb766841124daba75a37da83e292ae5ad12f/ruff-0.7.4-py3-none-linux_armv6l.whl", hash = "sha256:a4919925e7684a3f18e18243cd6bea7cfb8e968a6eaa8437971f681b7ec51478", size = 10447512 }, + { url = "https://files.pythonhosted.org/packages/9e/1d/3d2d2c9f601cf6044799c5349ff5267467224cefed9b35edf5f1f36486e9/ruff-0.7.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfb365c135b830778dda8c04fb7d4280ed0b984e1aec27f574445231e20d6c63", size = 10235436 }, + { url = "https://files.pythonhosted.org/packages/62/83/42a6ec6216ded30b354b13e0e9327ef75a3c147751aaf10443756cb690e9/ruff-0.7.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:63a569b36bc66fbadec5beaa539dd81e0527cb258b94e29e0531ce41bacc1f20", size = 9888936 }, + { url = "https://files.pythonhosted.org/packages/4d/26/e1e54893b13046a6ad05ee9b89ee6f71542ba250f72b4c7a7d17c3dbf73d/ruff-0.7.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d06218747d361d06fd2fdac734e7fa92df36df93035db3dc2ad7aa9852cb109", size = 10697353 }, + { url = "https://files.pythonhosted.org/packages/21/24/98d2e109c4efc02bfef144ec6ea2c3e1217e7ce0cfddda8361d268dfd499/ruff-0.7.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0cea28d0944f74ebc33e9f934238f15c758841f9f5edd180b5315c203293452", size = 10228078 }, + { url = "https://files.pythonhosted.org/packages/ad/b7/964c75be9bc2945fc3172241b371197bb6d948cc69e28bc4518448c368f3/ruff-0.7.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80094ecd4793c68b2571b128f91754d60f692d64bc0d7272ec9197fdd09bf9ea", size = 11264823 }, + { url = "https://files.pythonhosted.org/packages/12/8d/20abdbf705969914ce40988fe71a554a918deaab62c38ec07483e77866f6/ruff-0.7.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:997512325c6620d1c4c2b15db49ef59543ef9cd0f4aa8065ec2ae5103cedc7e7", size = 11951855 }, + { url = "https://files.pythonhosted.org/packages/b8/fc/6519ce58c57b4edafcdf40920b7273dfbba64fc6ebcaae7b88e4dc1bf0a8/ruff-0.7.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00b4cf3a6b5fad6d1a66e7574d78956bbd09abfd6c8a997798f01f5da3d46a05", size = 11516580 }, + { url = "https://files.pythonhosted.org/packages/37/1a/5ec1844e993e376a86eb2456496831ed91b4398c434d8244f89094758940/ruff-0.7.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7dbdc7d8274e1422722933d1edddfdc65b4336abf0b16dfcb9dedd6e6a517d06", size = 12692057 }, + { url = "https://files.pythonhosted.org/packages/50/90/76867152b0d3c05df29a74bb028413e90f704f0f6701c4801174ba47f959/ruff-0.7.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e92dfb5f00eaedb1501b2f906ccabfd67b2355bdf117fea9719fc99ac2145bc", size = 11085137 }, + { url = "https://files.pythonhosted.org/packages/c8/eb/0a7cb6059ac3555243bd026bb21785bbc812f7bbfa95a36c101bd72b47ae/ruff-0.7.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3bd726099f277d735dc38900b6a8d6cf070f80828877941983a57bca1cd92172", size = 10681243 }, + { url = "https://files.pythonhosted.org/packages/5e/76/2270719dbee0fd35780b05c08a07b7a726c3da9f67d9ae89ef21fc18e2e5/ruff-0.7.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2e32829c429dd081ee5ba39aef436603e5b22335c3d3fff013cd585806a6486a", size = 10319187 }, + { url = "https://files.pythonhosted.org/packages/9f/e5/39100f72f8ba70bec1bd329efc880dea8b6c1765ea1cb9d0c1c5f18b8d7f/ruff-0.7.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:662a63b4971807623f6f90c1fb664613f67cc182dc4d991471c23c541fee62dd", size = 10803715 }, + { url = "https://files.pythonhosted.org/packages/a5/89/40e904784f305fb56850063f70a998a64ebba68796d823dde67e89a24691/ruff-0.7.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:876f5e09eaae3eb76814c1d3b68879891d6fde4824c015d48e7a7da4cf066a3a", size = 11162912 }, + { url = "https://files.pythonhosted.org/packages/8d/1b/dd77503b3875c51e3dbc053fd8367b845ab8b01c9ca6d0c237082732856c/ruff-0.7.4-py3-none-win32.whl", hash = "sha256:75c53f54904be42dd52a548728a5b572344b50d9b2873d13a3f8c5e3b91f5cac", size = 8702767 }, + { url = "https://files.pythonhosted.org/packages/63/76/253ddc3e89e70165bba952ecca424b980b8d3c2598ceb4fc47904f424953/ruff-0.7.4-py3-none-win_amd64.whl", hash = "sha256:745775c7b39f914238ed1f1b0bebed0b9155a17cd8bc0b08d3c87e4703b990d6", size = 9497534 }, + { url = "https://files.pythonhosted.org/packages/aa/70/f8724f31abc0b329ca98b33d73c14020168babcf71b0cba3cded5d9d0e66/ruff-0.7.4-py3-none-win_arm64.whl", hash = "sha256:11bff065102c3ae9d3ea4dc9ecdfe5a5171349cdd0787c1fc64761212fc9cf1f", size = 8851590 }, +] + +[[package]] +name = "tomli" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/e4/1b6cbcc82d8832dd0ce34767d5c560df8a3547ad8cbc427f34601415930a/tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8", size = 16622 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/f7/4da0ffe1892122c9ea096c57f64c2753ae5dd3ce85488802d11b0992cc6d/tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391", size = 13750 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] From e99563b82da57c7a3921581ae80f8f95910150b5 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Thu, 21 Nov 2024 18:02:29 -0500 Subject: [PATCH 25/38] Fix github action for ruff lint --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 17febf4..3c8e0fe 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@v4 - uses: astral-sh/ruff-action@v1 with: - args: check pydantic2ts + src: ./pydantic2ts test: name: Run unit tests runs-on: ${{ matrix.os }} From d86452bac65bbcb3f97333e72916915b2ee2debe Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Thu, 21 Nov 2024 18:05:46 -0500 Subject: [PATCH 26/38] Fix github action for combining coverage data --- .github/workflows/cicd.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 3c8e0fe..c60c7cc 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -57,6 +57,7 @@ jobs: python -m pip install -U . python -m pytest --cov=pydantic2ts --cov-append - name: Combine coverage data + if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' }} run: | coverage combine coverage report From 585fbca4db6666d22f0fd0a9251820827925e247 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Thu, 21 Nov 2024 18:22:32 -0500 Subject: [PATCH 27/38] Fix github workflow for combining coverage data from multiple test runs --- .github/workflows/cicd.yml | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index c60c7cc..9efb4ef 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -49,23 +49,18 @@ jobs: python -m pip install -U . - name: Run tests against 'pydantic@latest' run: | - python -m pytest --cov=pydantic2ts --cov-append - - name: Run tests against 'pydantic==1.8.2' + pytest --cov=pydantic2ts + - name: (3.9 ubuntu) Run tests against 'pydantic==1.8.2' if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' }} run: | python -m pip install 'pydantic==1.8.2' python -m pip install -U . - python -m pytest --cov=pydantic2ts --cov-append - - name: Combine coverage data + pytest --cov=pydantic2ts --cov-append + - name: (3.9 ubuntu) Generate LCOV File if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' }} run: | - coverage combine - coverage report - - name: Generate LCOV File - if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' }} - run: | - coverage lcov -o coverage.lcov - - name: Coveralls + coverage lcov + - name: (3.9 ubuntu) Upload to Coveralls if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' }} uses: coverallsapp/github-action@master with: From 923c7ee41ed9f88228d1d21d186dd9c21c927eb7 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Thu, 21 Nov 2024 18:35:16 -0500 Subject: [PATCH 28/38] Exclude TYPE_CHECKING logic from coverage report --- pydantic2ts/cli/script.py | 2 +- pydantic2ts/pydantic_v1.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pydantic2ts/cli/script.py b/pydantic2ts/cli/script.py index b1ab52d..83af2a2 100644 --- a/pydantic2ts/cli/script.py +++ b/pydantic2ts/cli/script.py @@ -26,7 +26,7 @@ import pydantic2ts.pydantic_v1 as v1 import pydantic2ts.pydantic_v2 as v2 -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from pydantic.config import ConfigDict from pydantic.v1.config import BaseConfig from pydantic.v1.fields import ModelField diff --git a/pydantic2ts/pydantic_v1.py b/pydantic2ts/pydantic_v1.py index 467280e..88016cd 100644 --- a/pydantic2ts/pydantic_v1.py +++ b/pydantic2ts/pydantic_v1.py @@ -1,5 +1,5 @@ try: - from pydantic.v1 import BaseModel, create_model + from pydantic.v1 import BaseModel, create_model # type: ignore from pydantic.v1.generics import GenericModel enabled = True @@ -10,7 +10,7 @@ try: from pydantic.generics import GenericModel - except ImportError: + except ImportError: # pragma: no cover GenericModel = None __all__ = ("BaseModel", "GenericModel", "create_model", "enabled") From 1b82de001ec7bcc398b1c3676b01da07d680cede Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Thu, 21 Nov 2024 18:46:41 -0500 Subject: [PATCH 29/38] Update github action for this project --- action.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/action.yml b/action.yml index a0d5e97..1317a41 100644 --- a/action.yml +++ b/action.yml @@ -2,7 +2,6 @@ name: Pydantic to Typescript description: | Convert pydantic models into typescript definitions and ensure that your type definitions are in sync. author: Phillip Dupuis - inputs: python-module: required: true @@ -44,17 +43,17 @@ runs: using: composite steps: - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: ">=3.6 <=3.10" + python-version: "3.x" - name: Install pydantic-to-typescript shell: bash run: | python -m pip install -U pip wheel pydantic-to-typescript - - name: Set up Node.js 16 - uses: actions/setup-node@v3 + - name: Set up Node.js 20 + uses: actions/setup-node@v4 with: - node-version: 16 + node-version: 20 - name: Install json-schema-to-typescript shell: bash run: | From a78230a21f4eea0ad40b1ffae23d873cd3956e2c Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Thu, 21 Nov 2024 19:43:33 -0500 Subject: [PATCH 30/38] Rewrite tests to ensure that the typescript output is identical for both v1 and v2 of pydantic; if a project upgrades to pydantic v2, the typescript definitions should not change unnecessarily --- .../computed_fields/{v2 => }/output.ts | 0 .../{v2/input.py => v2_input.py} | 0 .../excluding_models/{v1 => }/output.ts | 0 .../{v1/input.py => v1_input.py} | 0 .../excluding_models/v2/output.ts | 12 ------ .../{v2/input.py => v2_input.py} | 3 +- .../extra_fields/{v2 => }/output.ts | 0 .../expected_results/extra_fields/v1/input.py | 12 ------ .../extra_fields/v1/output.ts | 14 ------- .../expected_results/extra_fields/v1_input.py | 29 ++++++++++++++ .../extra_fields/{v2/input.py => v2_input.py} | 0 .../generics/{v1 => }/output.ts | 0 .../generics/{v1/input.py => v1_input.py} | 0 tests/expected_results/generics/v2/output.ts | 39 ------------------- .../generics/{v2/input.py => v2_input.py} | 0 .../single_module/{v1 => }/output.ts | 0 .../{v1/input.py => v1_input.py} | 0 .../single_module/v2/output.ts | 20 ---------- .../{v2/input.py => v2_input.py} | 3 +- .../submodules/{v1 => }/output.ts | 0 .../{v1/animals => v1_animals}/__init__.py | 0 .../{v1/animals => v1_animals}/cats.py | 0 .../{v1/animals => v1_animals}/dogs.py | 0 .../submodules/{v1/input.py => v1_input.py} | 4 +- .../expected_results/submodules/v2/output.ts | 26 ------------- .../{v2/animals => v2_animals}/__init__.py | 0 .../{v2/animals => v2_animals}/cats.py | 4 +- .../{v2/animals => v2_animals}/dogs.py | 4 +- .../submodules/{v2/input.py => v2_input.py} | 4 +- tests/test_script.py | 19 +++++---- 30 files changed, 50 insertions(+), 143 deletions(-) rename tests/expected_results/computed_fields/{v2 => }/output.ts (100%) rename tests/expected_results/computed_fields/{v2/input.py => v2_input.py} (100%) rename tests/expected_results/excluding_models/{v1 => }/output.ts (100%) rename tests/expected_results/excluding_models/{v1/input.py => v1_input.py} (100%) delete mode 100644 tests/expected_results/excluding_models/v2/output.ts rename tests/expected_results/excluding_models/{v2/input.py => v2_input.py} (88%) rename tests/expected_results/extra_fields/{v2 => }/output.ts (100%) delete mode 100644 tests/expected_results/extra_fields/v1/input.py delete mode 100644 tests/expected_results/extra_fields/v1/output.ts create mode 100644 tests/expected_results/extra_fields/v1_input.py rename tests/expected_results/extra_fields/{v2/input.py => v2_input.py} (100%) rename tests/expected_results/generics/{v1 => }/output.ts (100%) rename tests/expected_results/generics/{v1/input.py => v1_input.py} (100%) delete mode 100644 tests/expected_results/generics/v2/output.ts rename tests/expected_results/generics/{v2/input.py => v2_input.py} (100%) rename tests/expected_results/single_module/{v1 => }/output.ts (100%) rename tests/expected_results/single_module/{v1/input.py => v1_input.py} (100%) delete mode 100644 tests/expected_results/single_module/v2/output.ts rename tests/expected_results/single_module/{v2/input.py => v2_input.py} (88%) rename tests/expected_results/submodules/{v1 => }/output.ts (100%) rename tests/expected_results/submodules/{v1/animals => v1_animals}/__init__.py (100%) rename tests/expected_results/submodules/{v1/animals => v1_animals}/cats.py (100%) rename tests/expected_results/submodules/{v1/animals => v1_animals}/dogs.py (100%) rename tests/expected_results/submodules/{v1/input.py => v1_input.py} (76%) delete mode 100644 tests/expected_results/submodules/v2/output.ts rename tests/expected_results/submodules/{v2/animals => v2_animals}/__init__.py (100%) rename tests/expected_results/submodules/{v2/animals => v2_animals}/cats.py (88%) rename tests/expected_results/submodules/{v2/animals => v2_animals}/dogs.py (89%) rename tests/expected_results/submodules/{v2/input.py => v2_input.py} (69%) diff --git a/tests/expected_results/computed_fields/v2/output.ts b/tests/expected_results/computed_fields/output.ts similarity index 100% rename from tests/expected_results/computed_fields/v2/output.ts rename to tests/expected_results/computed_fields/output.ts diff --git a/tests/expected_results/computed_fields/v2/input.py b/tests/expected_results/computed_fields/v2_input.py similarity index 100% rename from tests/expected_results/computed_fields/v2/input.py rename to tests/expected_results/computed_fields/v2_input.py diff --git a/tests/expected_results/excluding_models/v1/output.ts b/tests/expected_results/excluding_models/output.ts similarity index 100% rename from tests/expected_results/excluding_models/v1/output.ts rename to tests/expected_results/excluding_models/output.ts diff --git a/tests/expected_results/excluding_models/v1/input.py b/tests/expected_results/excluding_models/v1_input.py similarity index 100% rename from tests/expected_results/excluding_models/v1/input.py rename to tests/expected_results/excluding_models/v1_input.py diff --git a/tests/expected_results/excluding_models/v2/output.ts b/tests/expected_results/excluding_models/v2/output.ts deleted file mode 100644 index 24a09cf..0000000 --- a/tests/expected_results/excluding_models/v2/output.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* 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/excluding_models/v2/input.py b/tests/expected_results/excluding_models/v2_input.py similarity index 88% rename from tests/expected_results/excluding_models/v2/input.py rename to tests/expected_results/excluding_models/v2_input.py index 63b4359..b3ee887 100644 --- a/tests/expected_results/excluding_models/v2/input.py +++ b/tests/expected_results/excluding_models/v2_input.py @@ -1,5 +1,6 @@ +from typing import List, Optional + from pydantic import BaseModel -from typing import Optional, List class LoginCredentials(BaseModel): diff --git a/tests/expected_results/extra_fields/v2/output.ts b/tests/expected_results/extra_fields/output.ts similarity index 100% rename from tests/expected_results/extra_fields/v2/output.ts rename to tests/expected_results/extra_fields/output.ts diff --git a/tests/expected_results/extra_fields/v1/input.py b/tests/expected_results/extra_fields/v1/input.py deleted file mode 100644 index 5090f49..0000000 --- a/tests/expected_results/extra_fields/v1/input.py +++ /dev/null @@ -1,12 +0,0 @@ -try: - from pydantic.v1 import BaseModel, Extra -except ImportError: - 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 deleted file mode 100644 index fafbfbe..0000000 --- a/tests/expected_results/extra_fields/v1/output.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* 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/v1_input.py b/tests/expected_results/extra_fields/v1_input.py new file mode 100644 index 0000000..6395315 --- /dev/null +++ b/tests/expected_results/extra_fields/v1_input.py @@ -0,0 +1,29 @@ +try: + from pydantic.v1 import BaseConfig, BaseModel, Extra +except ImportError: + from pydantic import BaseConfig, BaseModel, Extra + + +class ModelExtraAllow(BaseModel): + a: str + + class Config(BaseConfig): + extra = Extra.allow + + +class ModelExtraForbid(BaseModel): + a: str + + class Config(BaseConfig): + extra = Extra.forbid + + +class ModelExtraIgnore(BaseModel): + a: str + + class Config(BaseConfig): + extra = Extra.ignore + + +class ModelExtraNone(BaseModel): + a: str diff --git a/tests/expected_results/extra_fields/v2/input.py b/tests/expected_results/extra_fields/v2_input.py similarity index 100% rename from tests/expected_results/extra_fields/v2/input.py rename to tests/expected_results/extra_fields/v2_input.py diff --git a/tests/expected_results/generics/v1/output.ts b/tests/expected_results/generics/output.ts similarity index 100% rename from tests/expected_results/generics/v1/output.ts rename to tests/expected_results/generics/output.ts diff --git a/tests/expected_results/generics/v1/input.py b/tests/expected_results/generics/v1_input.py similarity index 100% rename from tests/expected_results/generics/v1/input.py rename to tests/expected_results/generics/v1_input.py diff --git a/tests/expected_results/generics/v2/output.ts b/tests/expected_results/generics/v2/output.ts deleted file mode 100644 index eafac05..0000000 --- a/tests/expected_results/generics/v2/output.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* 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/generics/v2/input.py b/tests/expected_results/generics/v2_input.py similarity index 100% rename from tests/expected_results/generics/v2/input.py rename to tests/expected_results/generics/v2_input.py diff --git a/tests/expected_results/single_module/v1/output.ts b/tests/expected_results/single_module/output.ts similarity index 100% rename from tests/expected_results/single_module/v1/output.ts rename to tests/expected_results/single_module/output.ts diff --git a/tests/expected_results/single_module/v1/input.py b/tests/expected_results/single_module/v1_input.py similarity index 100% rename from tests/expected_results/single_module/v1/input.py rename to tests/expected_results/single_module/v1_input.py diff --git a/tests/expected_results/single_module/v2/output.ts b/tests/expected_results/single_module/v2/output.ts deleted file mode 100644 index 05bf779..0000000 --- a/tests/expected_results/single_module/v2/output.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* 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/single_module/v2/input.py b/tests/expected_results/single_module/v2_input.py similarity index 88% rename from tests/expected_results/single_module/v2/input.py rename to tests/expected_results/single_module/v2_input.py index 63b4359..b3ee887 100644 --- a/tests/expected_results/single_module/v2/input.py +++ b/tests/expected_results/single_module/v2_input.py @@ -1,5 +1,6 @@ +from typing import List, Optional + from pydantic import BaseModel -from typing import Optional, List class LoginCredentials(BaseModel): diff --git a/tests/expected_results/submodules/v1/output.ts b/tests/expected_results/submodules/output.ts similarity index 100% rename from tests/expected_results/submodules/v1/output.ts rename to tests/expected_results/submodules/output.ts diff --git a/tests/expected_results/submodules/v1/animals/__init__.py b/tests/expected_results/submodules/v1_animals/__init__.py similarity index 100% rename from tests/expected_results/submodules/v1/animals/__init__.py rename to tests/expected_results/submodules/v1_animals/__init__.py diff --git a/tests/expected_results/submodules/v1/animals/cats.py b/tests/expected_results/submodules/v1_animals/cats.py similarity index 100% rename from tests/expected_results/submodules/v1/animals/cats.py rename to tests/expected_results/submodules/v1_animals/cats.py diff --git a/tests/expected_results/submodules/v1/animals/dogs.py b/tests/expected_results/submodules/v1_animals/dogs.py similarity index 100% rename from tests/expected_results/submodules/v1/animals/dogs.py rename to tests/expected_results/submodules/v1_animals/dogs.py diff --git a/tests/expected_results/submodules/v1/input.py b/tests/expected_results/submodules/v1_input.py similarity index 76% rename from tests/expected_results/submodules/v1/input.py rename to tests/expected_results/submodules/v1_input.py index 6c872e3..faf40fb 100644 --- a/tests/expected_results/submodules/v1/input.py +++ b/tests/expected_results/submodules/v1_input.py @@ -5,8 +5,8 @@ except ImportError: from pydantic import BaseModel -from .animals.cats import Cat -from .animals.dogs import Dog +from .v1_animals.cats import Cat +from .v1_animals.dogs import Dog class AnimalShelter(BaseModel): diff --git a/tests/expected_results/submodules/v2/output.ts b/tests/expected_results/submodules/v2/output.ts deleted file mode 100644 index 3091266..0000000 --- a/tests/expected_results/submodules/v2/output.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* 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/expected_results/submodules/v2/animals/__init__.py b/tests/expected_results/submodules/v2_animals/__init__.py similarity index 100% rename from tests/expected_results/submodules/v2/animals/__init__.py rename to tests/expected_results/submodules/v2_animals/__init__.py diff --git a/tests/expected_results/submodules/v2/animals/cats.py b/tests/expected_results/submodules/v2_animals/cats.py similarity index 88% rename from tests/expected_results/submodules/v2/animals/cats.py rename to tests/expected_results/submodules/v2_animals/cats.py index 3db89d3..542db86 100644 --- a/tests/expected_results/submodules/v2/animals/cats.py +++ b/tests/expected_results/submodules/v2_animals/cats.py @@ -1,7 +1,7 @@ -from pydantic import BaseModel -from typing import Optional, Literal from enum import Enum +from pydantic import BaseModel + class CatBreed(str, Enum): domestic_shorthair = "domestic shorthair" diff --git a/tests/expected_results/submodules/v2/animals/dogs.py b/tests/expected_results/submodules/v2_animals/dogs.py similarity index 89% rename from tests/expected_results/submodules/v2/animals/dogs.py rename to tests/expected_results/submodules/v2_animals/dogs.py index 07ec007..8bb4f46 100644 --- a/tests/expected_results/submodules/v2/animals/dogs.py +++ b/tests/expected_results/submodules/v2_animals/dogs.py @@ -1,7 +1,7 @@ -from pydantic import BaseModel -from typing import Optional from enum import Enum +from pydantic import BaseModel + class DogBreed(str, Enum): mutt = "mutt" diff --git a/tests/expected_results/submodules/v2/input.py b/tests/expected_results/submodules/v2_input.py similarity index 69% rename from tests/expected_results/submodules/v2/input.py rename to tests/expected_results/submodules/v2_input.py index 672c90f..2cccb4c 100644 --- a/tests/expected_results/submodules/v2/input.py +++ b/tests/expected_results/submodules/v2_input.py @@ -2,8 +2,8 @@ from pydantic import BaseModel -from .animals.cats import Cat -from .animals.dogs import Dog +from .v2_animals.cats import Cat +from .v2_animals.dogs import Dog class AnimalShelter(BaseModel): diff --git a/tests/test_script.py b/tests/test_script.py index 0826773..ea2db6d 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -16,12 +16,12 @@ ) -def _get_input_module(test_name: str, pydantic_version: int) -> str: - return str(_RESULTS_DIRECTORY / test_name / f"v{pydantic_version}" / "input.py") +def _python_module_path(test_name: str, pydantic_version: int) -> str: + return str(_RESULTS_DIRECTORY / test_name / f"v{pydantic_version}_input.py") -def _get_expected_output(test_name: str, pydantic_version: int) -> str: - return (_RESULTS_DIRECTORY / test_name / f"v{pydantic_version}" / "output.ts").read_text() +def _expected_typescript_code(test_name: str) -> str: + return (_RESULTS_DIRECTORY / test_name / "output.ts").read_text() def _run_test( @@ -37,7 +37,7 @@ def _run_test( Execute pydantic2ts logic for converting pydantic models into tyepscript definitions. Compare the output with the expected output, verifying it is identical. """ - module_path = module_path or _get_input_module(test_name, pydantic_version) + module_path = module_path or _python_module_path(test_name, pydantic_version) output_path = str(tmp_path / f"{test_name}_v{pydantic_version}.ts") if call_from_python: @@ -48,7 +48,7 @@ def _run_test( cmd += f" --exclude {model_to_exclude}" subprocess.run(cmd, shell=True, check=True) - assert Path(output_path).read_text() == _get_expected_output(test_name, pydantic_version) + assert Path(output_path).read_text() == _expected_typescript_code(test_name) @pytest.mark.parametrize( @@ -108,9 +108,8 @@ def test_extra_fields(tmp_path: Path, pydantic_version: int, call_from_python: b def test_relative_filepath(tmp_path: Path): test_name = "single_module" pydantic_version = _PYDANTIC_VERSIONS[0] - relative_path = ( - Path(".") / "tests" / "expected_results" / test_name / f"v{pydantic_version}" / "input.py" - ) + absolute_path = _python_module_path(test_name, pydantic_version) + relative_path = Path(absolute_path).relative_to(Path.cwd()) _run_test( tmp_path, test_name, @@ -120,7 +119,7 @@ def test_relative_filepath(tmp_path: Path): def test_error_if_json2ts_not_installed(tmp_path: Path): - module_path = _get_input_module("single_module", _PYDANTIC_VERSIONS[0]) + module_path = _python_module_path("single_module", _PYDANTIC_VERSIONS[0]) output_path = str(tmp_path / "json2ts_test_output.ts") # If the json2ts command has no spaces and the executable cannot be found, From 4b3f0ae8aa06922213bf984beb69c243653f2898 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Thu, 21 Nov 2024 19:59:07 -0500 Subject: [PATCH 31/38] Make 'module' and 'output' args required --- pydantic2ts/cli/script.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pydantic2ts/cli/script.py b/pydantic2ts/cli/script.py index 83af2a2..1604fbe 100644 --- a/pydantic2ts/cli/script.py +++ b/pydantic2ts/cli/script.py @@ -362,10 +362,12 @@ def parse_cli_args(args: Optional[List[str]] = None) -> argparse.Namespace: "--module", help="name or filepath of the python module.\n" "Discoverable submodules will also be checked.", + required=True, ) parser.add_argument( "--output", help="name of the file the typescript definitions should be written to.", + required=True, ) parser.add_argument( "--exclude", From cfd0a19ccfdfdfccf3c517245abdda0c35ed69a0 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Thu, 21 Nov 2024 20:09:59 -0500 Subject: [PATCH 32/38] CICD: run tests using uv --- .github/workflows/cicd.yml | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 9efb4ef..e45213e 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -32,34 +32,31 @@ jobs: steps: - name: Check out repo uses: actions/checkout@v4 - - name: Set up Node.js 20 + - name: Install node.js 20 uses: actions/setup-node@v4 with: node-version: 20 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - name: Install json-schema-to-typescript run: | npm i -g json-schema-to-typescript - - name: Install python dependencies - run: | - python -m pip install -U pip wheel pytest pytest-cov coverage - python -m pip install -U . + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + version: "0.5.2" + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + - name: Install the project + run: uv sync --all-extras --dev - name: Run tests against 'pydantic@latest' - run: | - pytest --cov=pydantic2ts + run: uv run pytest --cov=pydantic2ts - name: (3.9 ubuntu) Run tests against 'pydantic==1.8.2' if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' }} run: | - python -m pip install 'pydantic==1.8.2' - python -m pip install -U . - pytest --cov=pydantic2ts --cov-append + uv pip install 'pydantic==1.8.2' + uv run pytest --cov=pydantic2ts --cov-append - name: (3.9 ubuntu) Generate LCOV File if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' }} - run: | - coverage lcov + run: uv run coverage lcov - name: (3.9 ubuntu) Upload to Coveralls if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' }} uses: coverallsapp/github-action@master From daf183577930b3fa5370e934b7b03e912865012b Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Thu, 21 Nov 2024 21:19:31 -0500 Subject: [PATCH 33/38] CICD: fix the step for running tests against pydantic@1.8.2 --- .github/workflows/cicd.yml | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index e45213e..5ba37ba 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -32,32 +32,28 @@ jobs: steps: - name: Check out repo uses: actions/checkout@v4 - - name: Install node.js 20 + - name: Install node uses: actions/setup-node@v4 with: node-version: 20 - name: Install json-schema-to-typescript - run: | - npm i -g json-schema-to-typescript + run: npm i -g json-schema-to-typescript - name: Install uv uses: astral-sh/setup-uv@v3 with: version: "0.5.2" - - name: Set up Python ${{ matrix.python-version }} - run: uv python install ${{ matrix.python-version }} - - name: Install the project - run: uv sync --all-extras --dev - name: Run tests against 'pydantic@latest' - run: uv run pytest --cov=pydantic2ts - - name: (3.9 ubuntu) Run tests against 'pydantic==1.8.2' + run: | + uv python install ${{ matrix.python-version }} + uv sync --all-extras --dev + uv run pytest --cov=pydantic2ts + - name: (ubuntu 3.9) Run tests against 'pydantic==1.8.2' and generate an LCOV file for Coveralls if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' }} run: | - uv pip install 'pydantic==1.8.2' + uv add 'pydantic==1.8.2' uv run pytest --cov=pydantic2ts --cov-append - - name: (3.9 ubuntu) Generate LCOV File - if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' }} - run: uv run coverage lcov - - name: (3.9 ubuntu) Upload to Coveralls + uv run coverage lcov + - name: (ubuntu 3.9) Upload to Coveralls if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' }} uses: coverallsapp/github-action@master with: From 1bdcf49deb5cdb34c23a9bae39a510c4b3cc9ef1 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Thu, 21 Nov 2024 21:31:12 -0500 Subject: [PATCH 34/38] 'clean_json_schema' will now also remove the 'str' docstring, in pydantic<2 this can unintentionally be applied to Literal types --- pydantic2ts/cli/script.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/pydantic2ts/cli/script.py b/pydantic2ts/cli/script.py index 1604fbe..76fc9a9 100644 --- a/pydantic2ts/cli/script.py +++ b/pydantic2ts/cli/script.py @@ -34,6 +34,9 @@ LOG = logging.getLogger("pydantic2ts") +_USELESS_ENUM_DESCRIPTION = "An enumeration." +_USELESS_STR_DESCRIPTION = inspect.getdoc(str) + def _import_module(path: str) -> ModuleType: """ @@ -159,15 +162,20 @@ def _clean_json_schema(schema: Dict[str, Any], model: Any = None) -> None: """ Clean up the resulting JSON schemas via the following steps: - 1) Get rid of the useless "An enumeration." description applied to Enums - which don't have a docstring. + 1) Get rid of descriptions that are auto-generated and just add noise: + - "An enumeration." for Enums + - `inspect.getdoc(str)` for Literal types 2) Remove titles from JSON schema properties. If we don't do this, each property will have its own interface in the resulting typescript file (which is a LOT of unnecessary noise). 3) If it's a V1 model, ensure that nullability is properly represented. https://github.com/pydantic/pydantic/issues/1270 """ - if "enum" in schema and schema.get("description") == "An enumeration.": + description = schema.get("description") + + if "enum" in schema and description == _USELESS_ENUM_DESCRIPTION: + del schema["description"] + elif description == _USELESS_STR_DESCRIPTION: del schema["description"] properties: Dict[str, Dict[str, Any]] = schema.get("properties", {}) From 5fbd3a05cdc1afdedf3b0ba620a97ea62d85b2e2 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Thu, 21 Nov 2024 21:40:06 -0500 Subject: [PATCH 35/38] Remove the '.python-version' file, it keeps causing uv to ignore the desired version --- .python-version | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .python-version diff --git a/.python-version b/.python-version deleted file mode 100644 index e4fba21..0000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 From 46176bdce594f84f9de3f0f5708c98469f0be046 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Thu, 21 Nov 2024 21:43:48 -0500 Subject: [PATCH 36/38] CICD: make 'test' stage require 'lint' passes, change coveralls action to v2 --- .github/workflows/cicd.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 5ba37ba..069b259 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -13,6 +13,7 @@ jobs: src: ./pydantic2ts test: name: Run unit tests + needs: lint runs-on: ${{ matrix.os }} strategy: matrix: @@ -55,17 +56,18 @@ jobs: uv run coverage lcov - name: (ubuntu 3.9) Upload to Coveralls if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' }} - uses: coverallsapp/github-action@master + uses: coverallsapp/github-action@2 with: github-token: ${{ secrets.GITHUB_TOKEN }} path-to-lcov: coverage.lcov deploy: name: Deploy to PyPi + needs: test runs-on: ubuntu-latest if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') - needs: test steps: - - uses: actions/checkout@v4 + - name: Check out repo + uses: actions/checkout@v4 - name: Set up Python 3.9 uses: actions/setup-python@v5 with: From ad3b57d23df8596ac42d1b86495240ee010c180e Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Thu, 21 Nov 2024 21:44:51 -0500 Subject: [PATCH 37/38] Typo in coveralls action, '2' should be 'v2' --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 069b259..9b836b9 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -56,7 +56,7 @@ jobs: uv run coverage lcov - name: (ubuntu 3.9) Upload to Coveralls if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' }} - uses: coverallsapp/github-action@2 + uses: coverallsapp/github-action@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} path-to-lcov: coverage.lcov From 168104ddb742c4c13f1b61c7fbcc4c83305d2e59 Mon Sep 17 00:00:00 2001 From: phillipdupuis Date: Thu, 21 Nov 2024 22:15:57 -0500 Subject: [PATCH 38/38] CICD: modernize step for publishing to pypi, use trusted publisher workflow rather than username/pass --- .github/workflows/cicd.yml | 48 +++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 9b836b9..ff2a7b9 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -60,26 +60,42 @@ jobs: with: github-token: ${{ secrets.GITHUB_TOKEN }} path-to-lcov: coverage.lcov - deploy: - name: Deploy to PyPi + build: + name: Build pydantic2ts for distribution + if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') needs: test runs-on: ubuntu-latest - if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') steps: - name: Check out repo uses: actions/checkout@v4 - - name: Set up Python 3.9 - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@v3 with: - python-version: 3.9 - - name: Install dependencies - run: | - python -m pip install -U pip wheel - - name: Build dist - run: | - python setup.py sdist bdist_wheel bdist_egg - - name: Publish package - uses: pypa/gh-action-pypi-publish@v1.5.0 + version: "0.5.2" + - name: Install python 3.9 + run: uv python install 3.9 + - name: Build pydantic2ts + run: uv build + - name: Store the distribution + uses: actions/upload-artifact@v4 + with: + name: pydantic2ts-dist + path: dist/ + publish-to-pypi: + name: Publish pydantic2ts to PyPI + if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/pydantic-to-typescript + permissions: + id-token: write + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 with: - user: __token__ - password: ${{ secrets.pypi_password }} + name: pydantic2ts-dist + path: dist/ + - name: Publish distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1