diff --git a/project/add_none.py b/project/add_none.py deleted file mode 100644 index b344fbb..0000000 --- a/project/add_none.py +++ /dev/null @@ -1,11 +0,0 @@ -from typing import Any, Dict, Optional, Union - -from pydantic import BaseModel - - -class A(BaseModel): - a: int | None - b: Optional[int] - c: Union[int, None] - d: Any - e: Dict[str, str] diff --git a/project/config_to_model.py b/project/config_to_model.py deleted file mode 100644 index e3658d5..0000000 --- a/project/config_to_model.py +++ /dev/null @@ -1,17 +0,0 @@ -from pydantic import BaseModel - - -class A(BaseModel): - class Config: - orm_mode = True - validate_all = True - - -class BaseConfig: - orm_mode = True - validate_all = True - - -class B(BaseModel): - class Config(BaseConfig): - ... diff --git a/project/rename_method.py b/project/rename_method.py deleted file mode 100644 index d969fc9..0000000 --- a/project/rename_method.py +++ /dev/null @@ -1,4 +0,0 @@ -from project.add_none import A - -a = A(a=1, b=2, c=3, d=4, e={"ha": "ha"}) -a.dict() diff --git a/project/replace_generic.py b/project/replace_generic.py deleted file mode 100644 index 5625f21..0000000 --- a/project/replace_generic.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import Generic, TypeVar - -from pydantic.generics import GenericModel - -T = TypeVar("T") - - -class User(GenericModel, Generic[T]): - name: str diff --git a/project/settings.py b/project/settings.py deleted file mode 100644 index d6064ec..0000000 --- a/project/settings.py +++ /dev/null @@ -1,5 +0,0 @@ -from pydantic import BaseSettings - - -class Settings(BaseSettings): - a: int diff --git a/project/__init__.py b/tests/integration/__init__.py similarity index 100% rename from project/__init__.py rename to tests/integration/__init__.py diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py new file mode 100644 index 0000000..62ce026 --- /dev/null +++ b/tests/integration/test_cli.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import difflib +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from bump_pydantic.main import app + + +class Folder: + def __init__(self, name: str, *files: Folder | File) -> None: + self.name = name + self._files = files + + @property + def files(self) -> list[Folder | File]: + return sorted(self._files, key=lambda f: f.name) + + def create_structure(self, root: Path) -> None: + path = root / self.name + path.mkdir() + + for file in self.files: + if isinstance(file, Folder): + file.create_structure(path) + else: + (path / file.name).write_text(file.content) + + @classmethod + def from_structure(cls, root: Path) -> Folder: + name = root.name + files: list[File | Folder] = [] + + for path in root.iterdir(): + if path.is_dir(): + files.append(cls.from_structure(path)) + else: + files.append(File(path.name, path.read_text().splitlines())) + + return Folder(name, *files) + + def __eq__(self, __value: object) -> bool: + if isinstance(__value, File): + return False + + if not isinstance(__value, Folder): + return NotImplemented + + if self.name != __value.name: + return False + + if len(self.files) != len(__value.files): + return False + + for self_file, other_file in zip(self.files, __value.files): + if self_file != other_file: + return False + + return True + + +class File: + def __init__(self, name: str, content: list[str] | None = None) -> None: + self.name = name + self.content = "\n".join(content or []) + + def __eq__(self, __value: object) -> bool: + if not isinstance(__value, File): + return NotImplemented + + if self.name != __value.name: + return False + + return self.content == __value.content + + +@pytest.fixture() +def before() -> Folder: + return Folder( + "project", + File("__init__.py"), + File( + "settings.py", + content=[ + "from pydantic import BaseSettings", + "", + "", + "class Settings(BaseSettings):", + " a: int", + ], + ), + File( + "add_none.py", + content=[ + "from typing import Any, Dict, Optional, Union", + "", + "from pydantic import BaseModel", + "", + "", + "class A(BaseModel):", + " a: int | None", + " b: Optional[int]", + " c: Union[int, None]", + " d: Any", + " e: Dict[str, str]", + ], + ), + File( + "config_to_model.py", + content=[ + "from pydantic import BaseModel", + "", + "", + "class A(BaseModel):", + " class Config:", + " orm_mode = True", + " validate_all = True", + "", + "", + "class BaseConfig:", + " orm_mode = True", + " validate_all = True", + "", + "", + "class B(BaseModel):", + " class Config(BaseConfig):", + " ...", + ], + ), + # File( + # "rename_method.py", + # content=[ + # "from project.add_none import A", + # "", + # 'a = A(a=1, b=2, c=3, d=4, e={"ha": "ha"})', + # "a.dict()", + # ], + # ), + File( + "replace_generic.py", + content=[ + "from typing import Generic, TypeVar", + "", + "from pydantic.generics import GenericModel", + "", + "T = TypeVar('T')", + "", + "", + "class User(GenericModel, Generic[T]):", + " name: str", + ], + ), + ) + + +@pytest.fixture() +def expected() -> Folder: + return Folder( + "project", + File("__init__.py"), + File( + "settings.py", + content=[ + "from pydantic_settings import BaseSettings", + "", + "", + "class Settings(BaseSettings):", + " a: int", + ], + ), + File( + "add_none.py", + content=[ + "from typing import Any, Dict, Optional, Union", + "", + "from pydantic import BaseModel", + "", + "", + "class A(BaseModel):", + " a: int | None = None", + " b: Optional[int] = None", + " c: Union[int, None] = None", + " d: Any = None", + " e: Dict[str, str]", + ], + ), + File( + "config_to_model.py", + content=[ + "from pydantic import ConfigDict, BaseModel", + "", + "", + "class A(BaseModel):", + " model_config = ConfigDict(orm_mode=True, validate_all=True)", + "", + "", + "class BaseConfig:", + " orm_mode = True", + " validate_all = True", + "", + "", + "class B(BaseModel):", + " # TODO[pydantic]: The `Config` class inherits from another class, please create the `model_config` manually.", # noqa: E501 + " # Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-config for more information.", + " class Config(BaseConfig):", + " ...", + ], + ), + # File( + # "rename_method.py", + # content=[ + # "from project.add_none import A", + # "", + # 'a = A(a=1, b=2, c=3, d=4, e={"ha": "ha"})', + # "a.dict()", + # ], + # ), + File( + "replace_generic.py", + content=[ + "from typing import Generic, TypeVar", + "from pydantic import BaseModel", + "", + "T = TypeVar('T')", + "", + "", + "class User(BaseModel, Generic[T]):", + " name: str", + ], + ), + ) + + +def find_issue(current: Folder, expected: Folder) -> str: + for current_file, expected_file in zip(current.files, expected.files): + if current_file != expected_file: + if current_file.name != expected_file.name: + return f"Files have different names: {current_file.name} != {expected_file.name}" + if isinstance(current_file, Folder) or isinstance(expected_file, Folder): + return f"One of the files is a folder: {current_file.name} != {expected_file.name}" + return "\n".join( + difflib.unified_diff( + current_file.content.splitlines(), + expected_file.content.splitlines(), + fromfile=current_file.name, + tofile=expected_file.name, + ) + ) + return "Unknown" + + +def test_command_line(tmp_path: Path, before: Folder, expected: Folder) -> None: + runner = CliRunner() + + with runner.isolated_filesystem(temp_dir=tmp_path) as td: + before.create_structure(root=Path(td)) + + result = runner.invoke(app, [before.name]) + assert result.exit_code == 0, result.output + assert result.output == "Refactored 4 files.\n" + + after = Folder.from_structure(Path(td) / before.name) + + assert after == expected, find_issue(after, expected) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_add_default_none.py b/tests/unit/test_add_default_none.py similarity index 100% rename from tests/test_add_default_none.py rename to tests/unit/test_add_default_none.py diff --git a/tests/test_class_def_visitor.py b/tests/unit/test_class_def_visitor.py similarity index 100% rename from tests/test_class_def_visitor.py rename to tests/unit/test_class_def_visitor.py diff --git a/tests/test_generic_model.py b/tests/unit/test_generic_model.py similarity index 100% rename from tests/test_generic_model.py rename to tests/unit/test_generic_model.py diff --git a/tests/test_replace_config.py b/tests/unit/test_replace_config.py similarity index 100% rename from tests/test_replace_config.py rename to tests/unit/test_replace_config.py diff --git a/tests/test_replace_imports.py b/tests/unit/test_replace_imports.py similarity index 100% rename from tests/test_replace_imports.py rename to tests/unit/test_replace_imports.py