From 08685b2185b07e61a8c63dc0f87946416cfe09e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Mond=C3=A9jar?= Date: Wed, 27 May 2026 22:12:21 +0200 Subject: [PATCH] Support classmethod and staticmethod as attrs converters --- mypy/plugins/attrs.py | 22 +++++ test-data/unit/check-plugin-attrs.test | 108 +++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py index 1593c73cd2bfe..9f51ba22012ac 100644 --- a/mypy/plugins/attrs.py +++ b/mypy/plugins/attrs.py @@ -57,6 +57,7 @@ from mypy.server.trigger import make_wildcard_trigger from mypy.state import state from mypy.typeops import ( + bind_self, get_type_vars, make_simplified_union, map_type_from_supertype, @@ -751,6 +752,27 @@ def _parse_converter( ) else: converter_type = None + elif ( + isinstance(converter_expr, MemberExpr) + and isinstance(converter_expr.expr, RefExpr) + and isinstance(converter_expr.expr.node, TypeInfo) + ): + # The converter is a member accessed through a type node. + sym = converter_expr.expr.node.get(converter_expr.name) + if sym is not None and isinstance(sym.node, Decorator) and not sym.node.decorators: + func = sym.node.func + if func.is_class: + if func.type is None: + converter_info.init_type = AnyType(TypeOfAny.unannotated) + return converter_info + if isinstance(func.type, FunctionLike): + converter_type = bind_self(func.type, is_classmethod=True) + elif func.is_static: + if func.type is None: + converter_info.init_type = AnyType(TypeOfAny.unannotated) + return converter_info + if isinstance(func.type, FunctionLike): + converter_type = func.type if isinstance(converter_expr, LambdaExpr): # TODO: should we send a fail if converter_expr.min_args > 1? diff --git a/test-data/unit/check-plugin-attrs.test b/test-data/unit/check-plugin-attrs.test index 5e6dd4d83ce02..a8d2a128aa03a 100644 --- a/test-data/unit/check-plugin-attrs.test +++ b/test-data/unit/check-plugin-attrs.test @@ -942,6 +942,114 @@ class C: reveal_type(C) # N: Revealed type is "def (x: Any, y: Any, z: Any) -> __main__.C" [builtins fixtures/list.pyi] +[case testAttrsUsingClassmethodConverter] +import attr + +class MyClass: + @classmethod + def my_class_method(cls, value: int) -> str: + return "..." + +@attr.s +class Foo: + bar: str = attr.ib(converter=MyClass.my_class_method) + +# The classmethod's `cls` argument is dropped, so __init__ takes the int. +reveal_type(Foo) # N: Revealed type is "def (bar: builtins.int) -> __main__.Foo" +reveal_type(Foo(1).bar) # N: Revealed type is "builtins.str" +[builtins fixtures/classmethod.pyi] + +[case testAttrsUsingStaticmethodConverter] +import attr + +class MyClass: + @staticmethod + def my_static_method(value: bytes) -> str: + return "..." + +@attr.s +class Foo: + bar: str = attr.ib(converter=MyClass.my_static_method) + +reveal_type(Foo) # N: Revealed type is "def (bar: builtins.bytes) -> __main__.Foo" +reveal_type(Foo(b"x").bar) # N: Revealed type is "builtins.str" +[builtins fixtures/classmethod.pyi] + +[case testAttrsUsingDecoratedClassmethodConverterIsUnsupported] +# A classmethod/staticmethod wrapped in another decorator may have its signature +# changed by that decorator, so we can't trust the underlying function type and +# treat it as unsupported instead of inferring a wrong __init__ type. +import attr +from typing import Any, Callable, TypeVar + +F = TypeVar("F", bound=Callable[..., Any]) + +def deco(f: F) -> F: + return f + +class MyClass: + @classmethod + @deco + def my_class_method(cls, value: int) -> str: + return "..." + +@attr.s +class Foo: + bar: str = attr.ib(converter=MyClass.my_class_method) # E: Unsupported converter, only named functions, types and lambdas are currently supported +[builtins fixtures/classmethod.pyi] + +[case testAttrsUsingUnannotatedClassmethodConverter] +import attr + +class MyClass: + @classmethod + def my_class_method(cls, value): + return value + + @staticmethod + def my_static_method(value): + return value + +@attr.s +class Foo: + a: str = attr.ib(converter=MyClass.my_class_method) + b: str = attr.ib(converter=MyClass.my_static_method) + +# Unannotated converters make the corresponding __init__ argument Any. +reveal_type(Foo) # N: Revealed type is "def (a: Any, b: Any) -> __main__.Foo" +[builtins fixtures/classmethod.pyi] + +[case testAttrsUsingClassmethodPropertyConverterIsUnsupported] +# The exotic @classmethod @property combo is stripped to an empty decorators list, +# but yields a no-argument getter, so the converter type can't be determined. +import attr + +class M: + @classmethod # E: Only instance methods can be decorated with @property + @property + def cp(cls) -> str: + return "..." + +@attr.s +class Foo: + x: str = attr.ib(converter=M.cp) # E: Cannot determine __init__ type from converter \ + # E: Argument "converter" has incompatible type "Callable[[], str]"; expected "Callable[[Any], Never] | None" +[builtins fixtures/property.pyi] + +[case testAttrsUsingPropertyConverterIsUnsupported] +# A property is stripped to an empty decorators list but is neither classmethod +# nor staticmethod, so it must not be accepted as a converter. +import attr + +class M: + @property + def p(self): ... + +@attr.s +class Foo: + x: str = attr.ib(converter=M.p) # E: Unsupported converter, only named functions, types and lambdas are currently supported +[builtins fixtures/property.pyi] + [case testAttrsUsingConverterAndSubclass] import attr