Skip to content

Commit

Permalink
✨ Support GenericModel to BaseModel replacement (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
Kludex committed Jun 19, 2023
1 parent a1f512a commit 5770c89
Show file tree
Hide file tree
Showing 8 changed files with 215 additions and 10 deletions.
2 changes: 0 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ jobs:

steps:
- uses: actions/checkout@v2

- name: set up python
uses: actions/setup-python@v4
with:
Expand All @@ -38,7 +37,6 @@ jobs:

steps:
- uses: actions/checkout@v2

- name: set up python
uses: actions/setup-python@v4
with:
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,32 @@ class User(BaseModel):
```

#### BP004: Replace `BaseModel` methods


#### BP005: Replace `GenericModel` by `BaseModel`

- ✅ Replace `GenericModel` by `BaseModel`.

The following code will be transformed:

```py
from typing import Generic, TypeVar
from pydantic.generics import GenericModel

T = TypeVar('T')

class User(GenericModel, Generic[T]):
name: str
```

Into:

```py
from typing import Generic, TypeVar
from pydantic.generics import GenericModel

T = TypeVar('T')

class User(BaseModel, Generic[T]):
name: str
```
6 changes: 5 additions & 1 deletion bump_pydantic/codemods/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from typing import List, Type

from libcst.codemod import ContextAwareTransformer
from libcst.codemod.visitors import AddImportsVisitor
from libcst.codemod.visitors import AddImportsVisitor, RemoveImportsVisitor

from bump_pydantic.codemods.add_default_none import AddDefaultNoneCommand
from bump_pydantic.codemods.replace_config import ReplaceConfigCodemod
from bump_pydantic.codemods.replace_generic_model import ReplaceGenericModelCommand
from bump_pydantic.codemods.replace_imports import ReplaceImportsCodemod


Expand All @@ -13,6 +14,9 @@ def gather_codemods() -> List[Type[ContextAwareTransformer]]:
AddDefaultNoneCommand,
ReplaceConfigCodemod,
ReplaceImportsCodemod,
ReplaceGenericModelCommand,
# RemoveImportsVisitor needs to be the second to last.
RemoveImportsVisitor,
# AddImportsVisitor needs to be the last.
AddImportsVisitor,
]
63 changes: 63 additions & 0 deletions bump_pydantic/codemods/replace_generic_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from __future__ import annotations

import libcst as cst
import libcst.matchers as m
from libcst.codemod import VisitorBasedCodemodCommand
from libcst.codemod.visitors import AddImportsVisitor, RemoveImportsVisitor

GENERIC_MODEL_ARG = m.Arg(value=m.Name("GenericModel")) | m.Arg(
value=m.Attribute(value=m.Name("generics"), attr=m.Name("GenericModel"))
)


class ReplaceGenericModelCommand(VisitorBasedCodemodCommand):
@m.leave(m.ClassDef(bases=[m.ZeroOrMore(), GENERIC_MODEL_ARG, m.ZeroOrMore()]))
def leave_generic_model(self, original_node: cst.ClassDef, updated_node: cst.ClassDef) -> cst.ClassDef:
RemoveImportsVisitor.remove_unused_import(context=self.context, module="pydantic.generics", obj="GenericModel")
RemoveImportsVisitor.remove_unused_import(context=self.context, module="pydantic", obj="generics")
AddImportsVisitor.add_needed_import(context=self.context, module="pydantic", obj="BaseModel")
return updated_node.with_changes(
bases=[
base if not m.matches(base, GENERIC_MODEL_ARG) else cst.Arg(value=cst.Name("BaseModel"))
for base in updated_node.bases
]
)


if __name__ == "__main__":
import textwrap

from rich.console import Console

console = Console()

source = textwrap.dedent(
"""
from typing import Generic, TypeVar
from pydantic.generics import GenericModel
T = TypeVar("T")
class Potato(GenericModel, Generic[T]):
...
"""
)
console.print(source)
# console.print("=" * 80)

# mod = cst.parse_module(source)
# context = CodemodContext(filename="main.py")

# wrapper = cst.MetadataWrapper(mod)
# command = ReplaceGenericModelCommand(context=context)
# mod = wrapper.visit(command)

# wrapper = cst.MetadataWrapper(mod)
# command = RemoveImportsVisitor(context=context) # type: ignore[assignment]
# mod = wrapper.visit(command)

# wrapper = cst.MetadataWrapper(mod)
# command = AddImportsVisitor(context=context) # type: ignore[assignment]
# mod = wrapper.visit(command)
# console.print(mod.code)
1 change: 0 additions & 1 deletion bump_pydantic/codemods/replace_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,6 @@ class Potato(BaseSettings):
context = CodemodContext(filename="main.py")
wrapper = cst.MetadataWrapper(mod)
command = ReplaceImportsCodemod(context=context)
console.print(mod)

mod = wrapper.visit(command)
wrapper = cst.MetadataWrapper(mod)
Expand Down
9 changes: 9 additions & 0 deletions project/replace_generic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from typing import Generic, TypeVar

from pydantic.generics import GenericModel

T = TypeVar("T")


class User(GenericModel, Generic[T]):
name: str
13 changes: 7 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,15 @@ isort = { known-first-party = ['bump_pydantic', 'tests'] }
target-version = 'py38'

[tool.coverage.run]
source_pkgs = ["bump_pydantic", "tests"]
source_pkgs = ["bump_pydantic"]
branch = true

[tool.coverage.report]
show_missing = true
skip_covered = true
exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]

[tool.coverage.paths]
source = ["bump_pydantic/"]
detached = true
exclude_lines = [
"pragma: nocover",
"pragma: no cover",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]
102 changes: 102 additions & 0 deletions tests/test_generic_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from libcst.codemod import CodemodTest

from bump_pydantic.codemods.replace_generic_model import ReplaceGenericModelCommand


class TestReplaceGenericModelCommand(CodemodTest):
TRANSFORM = ReplaceGenericModelCommand

def test_noop(self) -> None:
code = """
from typing import Generic, TypeVar
T = TypeVar("T")
class Potato(Generic[T]):
...
"""
self.assertCodemod(code, code)

def test_generic_model(self) -> None:
before = """
from typing import TypeVar
from pydantic.generics import GenericModel
T = TypeVar("T")
class Potato(GenericModel, Generic[T]):
...
"""
after = """
from typing import TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class Potato(BaseModel, Generic[T]):
...
"""
self.assertCodemod(before, after)

def test_generic_model_multiple_bases(self) -> None:
before = """
from typing import TypeVar
from pydantic.generics import GenericModel
T = TypeVar("T")
class Potato(GenericModel, Generic[T], object):
...
"""
after = """
from typing import TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class Potato(BaseModel, Generic[T], object):
...
"""
self.assertCodemod(before, after)

def test_generic_model_second_base(self) -> None:
before = """
from typing import TypeVar
from pydantic.generics import GenericModel
T = TypeVar("T")
class Potato(object, GenericModel, Generic[T]):
...
"""
after = """
from typing import TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class Potato(object, BaseModel, Generic[T]):
...
"""
self.assertCodemod(before, after)

def test_generic_model_from_pydantic_import_generics(self) -> None:
before = """
from typing import TypeVar
from pydantic import generics
T = TypeVar("T")
class Potato(generics.GenericModel, Generic[T]):
...
"""
after = """
from typing import TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class Potato(BaseModel, Generic[T]):
...
"""
self.assertCodemod(before, after)

0 comments on commit 5770c89

Please sign in to comment.