From 404ae62c555c5717ca807d60cef61ecb7443a7b8 Mon Sep 17 00:00:00 2001 From: David Robertson Date: Sun, 22 May 2022 02:06:36 +0100 Subject: [PATCH 1/3] Teach mypy plugin that validator methods are classmethods For your consideration: a patch which implements the suggestion I made here: https://github.com/samuelcolvin/pydantic/discussions/4101 Briefly: pydantic automatically wraps validator methods using `@classmethod`. Hence the first argument to a user-provided validator should be `cls`. But mypy doesn't know this: it analyses validator methods like any other regular method, believing the first parameter `cls` to be a model instance (usually denoted self). This means that if one annotates `cls` as `Type[...]` then mypy believes raises an error: error: The erased type of self "Type[temp.Model]" is not a supertype of its class "temp.Model" I concede that this is an extremely niche thing. The only tangible end-user benefit I can think of is that it'll stop you from calling instance methods in a validator. ---- I haven't written a mypy plugin before, so this is a bit of a hack-until-it-works. But it was more straightforward than I expected to get something working! --- pydantic/mypy.py | 19 +++++++++++++++++++ tests/mypy/modules/plugin_success.py | 10 +++++++++- tests/mypy/outputs/plugin_success.txt | 3 ++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/pydantic/mypy.py b/pydantic/mypy.py index d89fe04367..164e57e685 100644 --- a/pydantic/mypy.py +++ b/pydantic/mypy.py @@ -185,6 +185,7 @@ def transform(self) -> None: ctx = self._ctx info = self._ctx.cls.info + self.adjust_validator_signatures() config = self.collect_config() fields = self.collect_fields(config) for field in fields: @@ -200,6 +201,24 @@ def transform(self) -> None: 'config': config.set_values_dict(), } + def adjust_validator_signatures(self) -> None: + """When we decorate a function `f` with `pydantic.validator(...), mypy sees + `f` as a regular method taking a `self` instance, even though pydantic + internally wraps `f` with `classmethod` if necessary. + + Teach mypy this by marking any function whose outermost decorator is a + `validator()` call as a classmethod. + """ + for name, sym in self._ctx.cls.info.names.items(): + if isinstance(sym.node, Decorator): + first_dec = sym.node.original_decorators[0] + if ( + isinstance(first_dec, CallExpr) + and isinstance(first_dec.callee, NameExpr) + and first_dec.callee.fullname == 'pydantic.class_validators.validator' + ): + sym.node.func.is_class = True + def collect_config(self) -> 'ModelConfigData': """ Collects the values of the config attributes that are used by the plugin, accounting for parent classes. diff --git a/tests/mypy/modules/plugin_success.py b/tests/mypy/modules/plugin_success.py index 217eebcd32..5a22e422c0 100644 --- a/tests/mypy/modules/plugin_success.py +++ b/tests/mypy/modules/plugin_success.py @@ -1,4 +1,4 @@ -from typing import ClassVar, Optional, Union +from typing import ClassVar, Optional, Type, Union from pydantic import BaseModel, BaseSettings, Field, create_model, validator from pydantic.dataclasses import dataclass @@ -181,3 +181,11 @@ class ModelWithAllowReuseValidator(BaseModel): model_with_allow_reuse_validator = ModelWithAllowReuseValidator(name='xyz') + + +class ModelWithAnnotatedValidator(BaseModel): + name: str + + @validator('name') + def noop_validator_with_annotations(cls: Type['ModelWithAnnotatedValidator'], name: str) -> str: + return name diff --git a/tests/mypy/outputs/plugin_success.txt b/tests/mypy/outputs/plugin_success.txt index 26ee50c39e..67bd057c36 100644 --- a/tests/mypy/outputs/plugin_success.txt +++ b/tests/mypy/outputs/plugin_success.txt @@ -1,3 +1,4 @@ 121: error: Unexpected keyword argument "name" for "AddProject" [call-arg] 121: error: Unexpected keyword argument "slug" for "AddProject" [call-arg] -121: error: Unexpected keyword argument "description" for "AddProject" [call-arg] \ No newline at end of file +121: error: Unexpected keyword argument "description" for "AddProject" [call-arg] +190: error: The erased type of self "Type[plugin_success.ModelWithAnnotatedValidator]" is not a supertype of its class "plugin_success.ModelWithAnnotatedValidator" [misc] \ No newline at end of file From faf92516b1788325d042453940f78120f74d3fe3 Mon Sep 17 00:00:00 2001 From: David Robertson Date: Sun, 22 May 2022 02:38:05 +0100 Subject: [PATCH 2/3] changelog --- changes/4102-DMRobertson.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/4102-DMRobertson.md diff --git a/changes/4102-DMRobertson.md b/changes/4102-DMRobertson.md new file mode 100644 index 0000000000..aef7bcec25 --- /dev/null +++ b/changes/4102-DMRobertson.md @@ -0,0 +1 @@ +Teach the mypy plugin that methods decorated by `@validator` are classmethods. \ No newline at end of file From 375ee997359f240b5981ce2211d72736013f9b82 Mon Sep 17 00:00:00 2001 From: David Robertson Date: Mon, 8 Aug 2022 21:11:59 +0100 Subject: [PATCH 3/3] Add failure test --- tests/mypy/modules/plugin_fail.py | 16 +++++++++++++++- tests/mypy/modules/plugin_success.py | 4 ++-- tests/mypy/outputs/plugin-fail-strict.txt | 1 + tests/mypy/outputs/plugin-fail.txt | 1 + tests/mypy/outputs/plugin_success.txt | 1 - 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/mypy/modules/plugin_fail.py b/tests/mypy/modules/plugin_fail.py index 079246f0bd..91fcbae062 100644 --- a/tests/mypy/modules/plugin_fail.py +++ b/tests/mypy/modules/plugin_fail.py @@ -1,6 +1,6 @@ from typing import Any, Generic, List, Optional, Set, TypeVar, Union -from pydantic import BaseModel, BaseSettings, Extra, Field +from pydantic import BaseModel, BaseSettings, Extra, Field, validator from pydantic.dataclasses import dataclass from pydantic.generics import GenericModel @@ -248,3 +248,17 @@ class FieldDefaultTestingModel(BaseModel): # Default and default factory m: int = Field(default=1, default_factory=list) + + +class ModelWithAnnotatedValidator(BaseModel): + name: str + + @validator('name') + def noop_validator_with_annotations(self, name: str) -> str: + # This is a mistake: the first argument to a validator is the class itself, + # like a classmethod. + self.instance_method() + return name + + def instance_method(self) -> None: + ... diff --git a/tests/mypy/modules/plugin_success.py b/tests/mypy/modules/plugin_success.py index 9c3793ea06..1d3054b7a8 100644 --- a/tests/mypy/modules/plugin_success.py +++ b/tests/mypy/modules/plugin_success.py @@ -1,4 +1,4 @@ -from typing import ClassVar, Generic, List, Optional, Type, TypeVar, Union +from typing import ClassVar, Generic, List, Optional, TypeVar, Union from pydantic import BaseModel, BaseSettings, Field, create_model, validator from pydantic.dataclasses import dataclass @@ -199,7 +199,7 @@ class ModelWithAnnotatedValidator(BaseModel): name: str @validator('name') - def noop_validator_with_annotations(cls: Type['ModelWithAnnotatedValidator'], name: str) -> str: + def noop_validator_with_annotations(cls, name: str) -> str: return name diff --git a/tests/mypy/outputs/plugin-fail-strict.txt b/tests/mypy/outputs/plugin-fail-strict.txt index 222c65523b..db93869290 100644 --- a/tests/mypy/outputs/plugin-fail-strict.txt +++ b/tests/mypy/outputs/plugin-fail-strict.txt @@ -41,3 +41,4 @@ 246: error: Incompatible types in assignment (expression has type "List[_T]", variable has type "List[int]") [assignment] 247: error: Argument "default_factory" to "Field" has incompatible type "int"; expected "Optional[Callable[[], Any]]" [arg-type] 250: error: Field default and default_factory cannot be specified together [pydantic-field] +260: error: Missing positional argument "self" in call to "instance_method" of "ModelWithAnnotatedValidator" [call-arg] diff --git a/tests/mypy/outputs/plugin-fail.txt b/tests/mypy/outputs/plugin-fail.txt index afad4692eb..ff93213837 100644 --- a/tests/mypy/outputs/plugin-fail.txt +++ b/tests/mypy/outputs/plugin-fail.txt @@ -30,3 +30,4 @@ 246: error: Incompatible types in assignment (expression has type "List[_T]", variable has type "List[int]") [assignment] 247: error: Argument "default_factory" to "Field" has incompatible type "int"; expected "Optional[Callable[[], Any]]" [arg-type] 250: error: Field default and default_factory cannot be specified together [pydantic-field] +260: error: Missing positional argument "self" in call to "instance_method" of "ModelWithAnnotatedValidator" [call-arg] diff --git a/tests/mypy/outputs/plugin_success.txt b/tests/mypy/outputs/plugin_success.txt index e2a3be48d5..5b782e4396 100644 --- a/tests/mypy/outputs/plugin_success.txt +++ b/tests/mypy/outputs/plugin_success.txt @@ -1,4 +1,3 @@ 122: error: Unexpected keyword argument "name" for "AddProject" [call-arg] 122: error: Unexpected keyword argument "slug" for "AddProject" [call-arg] 122: error: Unexpected keyword argument "description" for "AddProject" [call-arg] -202: error: The erased type of self "Type[plugin_success.ModelWithAnnotatedValidator]" is not a supertype of its class "plugin_success.ModelWithAnnotatedValidator" [misc]