Skip to content

Commit

Permalink
✨ Replace constr by StringConstraints
Browse files Browse the repository at this point in the history
  • Loading branch information
Kludex committed Jul 12, 2023
1 parent 359ece9 commit 30e207c
Show file tree
Hide file tree
Showing 4 changed files with 136 additions and 1 deletion.
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Bump Pydantic is a tool to help you migrate your code from Pydantic V1 to V2.
- [BP005: Replace `GenericModel` by `BaseModel`](#bp005-replace-genericmodel-by-basemodel)
- [BP006: Replace `__root__` by `RootModel`](#bp006-replace-__root__-by-rootmodel)
- [BP007: Replace decorators](#bp007-replace-decorators)
- [BP008: Replace `constr(*args)` by `Annotated[str, StringConstraints(*args)]`](#bp008-replace-constrargs-by-annotatedstr-stringconstraintsargs)
- [License](#license)

---
Expand Down Expand Up @@ -258,8 +259,33 @@ class User(BaseModel):
return values
```

### BP008: Replace `constr(*args)` by `Annotated[str, StringConstraints(*args)]`

- ✅ Replace `constr(*args)` by `Annotated[str, StringConstraints(*args)]`.

The following code will be transformed:

```py
from pydantic import BaseModel, constr


class User(BaseModel):
name: constr(min_length=1)
```

Into:

```py
from pydantic import BaseModel, StringConstraints
from typing_extensions import Annotated


class User(BaseModel):
name: Annotated[str, StringConstraints(min_length=1)]
```

<!--
### BP008: Replace `pydantic.parse_obj_as` by `pydantic.TypeAdapter`
### BP009: Replace `pydantic.parse_obj_as` by `pydantic.TypeAdapter`
- ✅ Replace `pydantic.parse_obj_as(T, obj)` to `pydantic.TypeAdapter(T).validate_python(obj)`.
Expand Down
6 changes: 6 additions & 0 deletions bump_pydantic/codemods/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from libcst.codemod.visitors import AddImportsVisitor, RemoveImportsVisitor

from bump_pydantic.codemods.add_default_none import AddDefaultNoneCommand
from bump_pydantic.codemods.con_func import ConFuncCallCommand
from bump_pydantic.codemods.field import FieldCodemod
from bump_pydantic.codemods.replace_config import ReplaceConfigCodemod
from bump_pydantic.codemods.replace_generic_model import ReplaceGenericModelCommand
Expand All @@ -28,6 +29,8 @@ class Rule(str, Enum):
"""Replace `BaseModel.__root__ = T` with `RootModel[T]`."""
BP007 = "BP007"
"""Replace `@validator` with `@field_validator`."""
BP008 = "BP008"
"""Replace `constr(<args>)` with `Annotated[str, StringConstraints(<args>)`."""


def gather_codemods(disabled: List[Rule]) -> List[Type[ContextAwareTransformer]]:
Expand All @@ -54,6 +57,9 @@ def gather_codemods(disabled: List[Rule]) -> List[Type[ContextAwareTransformer]]
if Rule.BP007 not in disabled:
codemods.append(ValidatorCodemod)

if Rule.BP008 not in disabled:
codemods.append(ConFuncCallCommand)

# Those codemods need to be the last ones.
codemods.extend([RemoveImportsVisitor, AddImportsVisitor])
return codemods
60 changes: 60 additions & 0 deletions bump_pydantic/codemods/con_func.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import libcst as cst
from libcst import matchers as m
from libcst.codemod import CodemodContext, VisitorBasedCodemodCommand
from libcst.codemod.visitors import AddImportsVisitor, RemoveImportsVisitor

CONSTR_CALL = m.Call(func=m.Name("constr") | m.Attribute(value=m.Name("pydantic"), attr=m.Name("constr")))
ANN_ASSIGN_CONSTR_CALL = m.AnnAssign(annotation=m.Annotation(annotation=CONSTR_CALL))


class ConFuncCallCommand(VisitorBasedCodemodCommand):
def __init__(self, context: CodemodContext) -> None:
super().__init__(context)

@m.leave(ANN_ASSIGN_CONSTR_CALL)
def leave_ann_assign_constr_call(self, original_node: cst.AnnAssign, updated_node: cst.AnnAssign) -> cst.AnnAssign:
AddImportsVisitor.add_needed_import(context=self.context, module="typing_extensions", obj="Annotated")
annotated = cst.Subscript(
value=cst.Name("Annotated"),
slice=[
cst.SubscriptElement(slice=cst.Index(value=cst.Name("str"))),
cst.SubscriptElement(slice=cst.Index(value=updated_node.annotation.annotation)),
],
)
annotation = cst.Annotation(annotation=annotated)
return updated_node.with_changes(annotation=annotation)

@m.leave(CONSTR_CALL)
def leave_constr_call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Call:
RemoveImportsVisitor.remove_unused_import(context=self.context, module="pydantic", obj="constr")
AddImportsVisitor.add_needed_import(context=self.context, module="pydantic", obj="StringConstraints")
return updated_node.with_changes(func=cst.Name("StringConstraints"))


if __name__ == "__main__":
import textwrap

from rich.console import Console

console = Console()

source = textwrap.dedent(
"""
from pydantic import BaseModel, constr
class A(BaseModel):
a: constr(max_length=10)
b: Annotated[str, StringConstraints(max_length=10)]
"""
)
console.print(source)
console.print("=" * 80)

mod = cst.parse_module(source)
context = CodemodContext(filename="main.py")
wrapper = cst.MetadataWrapper(mod)
command = ConFuncCallCommand(context=context)
console.print(mod)

mod = wrapper.visit(command)
print(mod.code)
43 changes: 43 additions & 0 deletions tests/unit/test_con_func.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from libcst.codemod import CodemodTest

from bump_pydantic.codemods.con_func import ConFuncCallCommand


class TestFieldCommand(CodemodTest):
TRANSFORM = ConFuncCallCommand

maxDiff = None

def test_constr_to_annotated(self) -> None:
before = """
from pydantic import BaseModel, constr
class Potato(BaseModel):
potato: constr(min_length=1, max_length=10)
"""
after = """
from pydantic import StringConstraints, BaseModel
from typing_extensions import Annotated
class Potato(BaseModel):
potato: Annotated[str, StringConstraints(min_length=1, max_length=10)]
"""
self.assertCodemod(before, after)

def test_pydantic_constr_to_annotated(self) -> None:
before = """
import pydantic
from pydantic import BaseModel
class Potato(BaseModel):
potato: pydantic.constr(min_length=1, max_length=10)
"""
after = """
import pydantic
from pydantic import StringConstraints, BaseModel
from typing_extensions import Annotated
class Potato(BaseModel):
potato: Annotated[str, StringConstraints(min_length=1, max_length=10)]
"""
self.assertCodemod(before, after)

0 comments on commit 30e207c

Please sign in to comment.