Skip to content

Commit

Permalink
Teach mypy plugin that validator methods are classmethods (#4102)
Browse files Browse the repository at this point in the history
* Teach mypy plugin that validator methods are classmethods

For your consideration: a patch which implements the suggestion I made here:
#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!

* changelog

* Add failure test
  • Loading branch information
DMRobertson committed Aug 9, 2022
1 parent 6afc0c6 commit 11d8589
Show file tree
Hide file tree
Showing 6 changed files with 45 additions and 1 deletion.
1 change: 1 addition & 0 deletions changes/4102-DMRobertson.md
@@ -0,0 +1 @@
Teach the mypy plugin that methods decorated by `@validator` are classmethods.
19 changes: 19 additions & 0 deletions pydantic/mypy.py
Expand Up @@ -244,6 +244,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:
Expand All @@ -259,6 +260,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.
Expand Down
16 changes: 15 additions & 1 deletion 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

Expand Down Expand Up @@ -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:
...
8 changes: 8 additions & 0 deletions tests/mypy/modules/plugin_success.py
Expand Up @@ -195,6 +195,14 @@ class Response(GenericModel, Generic[T]):
response = Response[Model](data=model, error=None)


class ModelWithAnnotatedValidator(BaseModel):
name: str

@validator('name')
def noop_validator_with_annotations(cls, name: str) -> str:
return name


def _default_factory_str() -> str:
...

Expand Down
1 change: 1 addition & 0 deletions tests/mypy/outputs/plugin-fail-strict.txt
Expand Up @@ -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]
1 change: 1 addition & 0 deletions tests/mypy/outputs/plugin-fail.txt
Expand Up @@ -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]

0 comments on commit 11d8589

Please sign in to comment.