Skip to content

Commit 08685b2

Browse files
committed
Support classmethod and staticmethod as attrs converters
1 parent 938dbe2 commit 08685b2

2 files changed

Lines changed: 130 additions & 0 deletions

File tree

mypy/plugins/attrs.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
from mypy.server.trigger import make_wildcard_trigger
5858
from mypy.state import state
5959
from mypy.typeops import (
60+
bind_self,
6061
get_type_vars,
6162
make_simplified_union,
6263
map_type_from_supertype,
@@ -751,6 +752,27 @@ def _parse_converter(
751752
)
752753
else:
753754
converter_type = None
755+
elif (
756+
isinstance(converter_expr, MemberExpr)
757+
and isinstance(converter_expr.expr, RefExpr)
758+
and isinstance(converter_expr.expr.node, TypeInfo)
759+
):
760+
# The converter is a member accessed through a type node.
761+
sym = converter_expr.expr.node.get(converter_expr.name)
762+
if sym is not None and isinstance(sym.node, Decorator) and not sym.node.decorators:
763+
func = sym.node.func
764+
if func.is_class:
765+
if func.type is None:
766+
converter_info.init_type = AnyType(TypeOfAny.unannotated)
767+
return converter_info
768+
if isinstance(func.type, FunctionLike):
769+
converter_type = bind_self(func.type, is_classmethod=True)
770+
elif func.is_static:
771+
if func.type is None:
772+
converter_info.init_type = AnyType(TypeOfAny.unannotated)
773+
return converter_info
774+
if isinstance(func.type, FunctionLike):
775+
converter_type = func.type
754776

755777
if isinstance(converter_expr, LambdaExpr):
756778
# TODO: should we send a fail if converter_expr.min_args > 1?

test-data/unit/check-plugin-attrs.test

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -942,6 +942,114 @@ class C:
942942
reveal_type(C) # N: Revealed type is "def (x: Any, y: Any, z: Any) -> __main__.C"
943943
[builtins fixtures/list.pyi]
944944

945+
[case testAttrsUsingClassmethodConverter]
946+
import attr
947+
948+
class MyClass:
949+
@classmethod
950+
def my_class_method(cls, value: int) -> str:
951+
return "..."
952+
953+
@attr.s
954+
class Foo:
955+
bar: str = attr.ib(converter=MyClass.my_class_method)
956+
957+
# The classmethod's `cls` argument is dropped, so __init__ takes the int.
958+
reveal_type(Foo) # N: Revealed type is "def (bar: builtins.int) -> __main__.Foo"
959+
reveal_type(Foo(1).bar) # N: Revealed type is "builtins.str"
960+
[builtins fixtures/classmethod.pyi]
961+
962+
[case testAttrsUsingStaticmethodConverter]
963+
import attr
964+
965+
class MyClass:
966+
@staticmethod
967+
def my_static_method(value: bytes) -> str:
968+
return "..."
969+
970+
@attr.s
971+
class Foo:
972+
bar: str = attr.ib(converter=MyClass.my_static_method)
973+
974+
reveal_type(Foo) # N: Revealed type is "def (bar: builtins.bytes) -> __main__.Foo"
975+
reveal_type(Foo(b"x").bar) # N: Revealed type is "builtins.str"
976+
[builtins fixtures/classmethod.pyi]
977+
978+
[case testAttrsUsingDecoratedClassmethodConverterIsUnsupported]
979+
# A classmethod/staticmethod wrapped in another decorator may have its signature
980+
# changed by that decorator, so we can't trust the underlying function type and
981+
# treat it as unsupported instead of inferring a wrong __init__ type.
982+
import attr
983+
from typing import Any, Callable, TypeVar
984+
985+
F = TypeVar("F", bound=Callable[..., Any])
986+
987+
def deco(f: F) -> F:
988+
return f
989+
990+
class MyClass:
991+
@classmethod
992+
@deco
993+
def my_class_method(cls, value: int) -> str:
994+
return "..."
995+
996+
@attr.s
997+
class Foo:
998+
bar: str = attr.ib(converter=MyClass.my_class_method) # E: Unsupported converter, only named functions, types and lambdas are currently supported
999+
[builtins fixtures/classmethod.pyi]
1000+
1001+
[case testAttrsUsingUnannotatedClassmethodConverter]
1002+
import attr
1003+
1004+
class MyClass:
1005+
@classmethod
1006+
def my_class_method(cls, value):
1007+
return value
1008+
1009+
@staticmethod
1010+
def my_static_method(value):
1011+
return value
1012+
1013+
@attr.s
1014+
class Foo:
1015+
a: str = attr.ib(converter=MyClass.my_class_method)
1016+
b: str = attr.ib(converter=MyClass.my_static_method)
1017+
1018+
# Unannotated converters make the corresponding __init__ argument Any.
1019+
reveal_type(Foo) # N: Revealed type is "def (a: Any, b: Any) -> __main__.Foo"
1020+
[builtins fixtures/classmethod.pyi]
1021+
1022+
[case testAttrsUsingClassmethodPropertyConverterIsUnsupported]
1023+
# The exotic @classmethod @property combo is stripped to an empty decorators list,
1024+
# but yields a no-argument getter, so the converter type can't be determined.
1025+
import attr
1026+
1027+
class M:
1028+
@classmethod # E: Only instance methods can be decorated with @property
1029+
@property
1030+
def cp(cls) -> str:
1031+
return "..."
1032+
1033+
@attr.s
1034+
class Foo:
1035+
x: str = attr.ib(converter=M.cp) # E: Cannot determine __init__ type from converter \
1036+
# E: Argument "converter" has incompatible type "Callable[[], str]"; expected "Callable[[Any], Never] | None"
1037+
[builtins fixtures/property.pyi]
1038+
1039+
[case testAttrsUsingPropertyConverterIsUnsupported]
1040+
# A property is stripped to an empty decorators list but is neither classmethod
1041+
# nor staticmethod, so it must not be accepted as a converter.
1042+
import attr
1043+
1044+
class M:
1045+
@property
1046+
def p(self): ...
1047+
1048+
@attr.s
1049+
class Foo:
1050+
x: str = attr.ib(converter=M.p) # E: Unsupported converter, only named functions, types and lambdas are currently supported
1051+
[builtins fixtures/property.pyi]
1052+
9451053
[case testAttrsUsingConverterAndSubclass]
9461054
import attr
9471055

0 commit comments

Comments
 (0)