From 11d8589423ac82f64eb4007bdad824f4a6217434 Mon Sep 17 00:00:00 2001 From: David Robertson Date: Tue, 9 Aug 2022 17:04:05 +0100 Subject: [PATCH] Teach mypy plugin that validator methods are classmethods (#4102) * 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! * changelog * Add failure test --- changes/4102-DMRobertson.md | 1 + pydantic/mypy.py | 19 +++++++++++++++++++ tests/mypy/modules/plugin_fail.py | 16 +++++++++++++++- tests/mypy/modules/plugin_success.py | 8 ++++++++ tests/mypy/outputs/plugin-fail-strict.txt | 1 + tests/mypy/outputs/plugin-fail.txt | 1 + 6 files changed, 45 insertions(+), 1 deletion(-) 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 diff --git a/pydantic/mypy.py b/pydantic/mypy.py index eca465af95..48923856c4 100644 --- a/pydantic/mypy.py +++ b/pydantic/mypy.py @@ -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: @@ -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. 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 a7920ea3e6..1d3054b7a8 100644 --- a/tests/mypy/modules/plugin_success.py +++ b/tests/mypy/modules/plugin_success.py @@ -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: ... 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]