Skip to content

Commit ef1ca84

Browse files
committed
Support more callable converters in the attrs plugin
The attrs plugin only recognized converters that were named functions, types, or lambdas. Extend it to also accept: - Calls returning a callable, e.g. `converter=make_converter(arg)` — the long-standing request in #15736. Includes chained calls and overloaded callees (such as `attrs.converters.pipe`/`default_if_none`). - Variables annotated with a callable type. The init parameter type is still derived from the first positional parameter of the wrapped callable, so existing diagnostics for converters with the wrong signature continue to apply. Fixes #15736. This was authored with the help of Claude Code.
1 parent 9b4e31c commit ef1ca84

2 files changed

Lines changed: 117 additions & 0 deletions

File tree

mypy/plugins/attrs.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,11 @@ def _parse_converter(
734734
converter_type = converter_expr.node.type
735735
elif isinstance(converter_expr.node, TypeInfo):
736736
converter_type = type_object_type(converter_expr.node)
737+
elif isinstance(converter_expr.node, Var) and converter_expr.node.type:
738+
# The converter is a variable annotated with a callable type.
739+
var_type = get_proper_type(converter_expr.node.type)
740+
if isinstance(var_type, FunctionLike):
741+
converter_type = var_type
737742
elif (
738743
isinstance(converter_expr, IndexExpr)
739744
and isinstance(converter_expr.analyzed, TypeApplication)
@@ -751,6 +756,10 @@ def _parse_converter(
751756
)
752757
else:
753758
converter_type = None
759+
elif isinstance(converter_expr, CallExpr):
760+
# The converter is the result of a call, e.g. `converter=make_converter(arg)`.
761+
# Use the return type of the callee as the converter type.
762+
converter_type = _callable_return_type(converter_expr)
754763

755764
if isinstance(converter_expr, LambdaExpr):
756765
# TODO: should we send a fail if converter_expr.min_args > 1?
@@ -794,6 +803,44 @@ def _parse_converter(
794803
return converter_info
795804

796805

806+
def _callable_return_type(call: CallExpr) -> Type | None:
807+
"""Return the return type of `call` if it is statically known to be callable.
808+
809+
This is used to support converters created by higher-order functions, e.g.
810+
`converter=make_converter(arg)`. We don't perform full type inference at the
811+
call site; we just look at the statically declared return type of the callee.
812+
Generic returns are returned as-is and may contain unresolved type variables.
813+
"""
814+
callee = call.callee
815+
callee_type: Type | None = None
816+
if isinstance(callee, RefExpr) and callee.node:
817+
if isinstance(callee.node, (FuncDef, OverloadedFuncDef)):
818+
callee_type = callee.node.type
819+
elif isinstance(callee.node, Var):
820+
callee_type = callee.node.type
821+
elif isinstance(callee, CallExpr):
822+
# Chained calls like `factory()(arg)`.
823+
callee_type = _callable_return_type(callee)
824+
if callee_type is None:
825+
return None
826+
callee_type = get_proper_type(callee_type)
827+
if isinstance(callee_type, CallableType):
828+
ret = get_proper_type(callee_type.ret_type)
829+
if isinstance(ret, FunctionLike):
830+
return ret
831+
elif isinstance(callee_type, Overloaded):
832+
# Without type inference at the call site we can't pick the correct
833+
# overload. As a heuristic, take the first overload whose return type is
834+
# itself a callable. This matches helpers like `attrs.converters.pipe`
835+
# and `attrs.converters.default_if_none`, whose first overload is the
836+
# most specific callable form.
837+
for item in callee_type.items:
838+
ret = get_proper_type(item.ret_type)
839+
if isinstance(ret, FunctionLike):
840+
return ret
841+
return None
842+
843+
797844
def is_valid_overloaded_converter(defn: OverloadedFuncDef) -> bool:
798845
return all(
799846
(not isinstance(item, Decorator) or isinstance(item.func.type, FunctionLike))

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -942,6 +942,76 @@ 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 testAttrsUsingHigherOrderConverter]
946+
# Regression test for https://github.com/python/mypy/issues/15736
947+
from typing import Any, Callable
948+
from attrs import define, field
949+
950+
def make_converter(_length: int) -> Callable[[str], str]:
951+
def converter(val: str) -> str:
952+
return val
953+
return converter
954+
955+
def make_untyped_converter(_length: int) -> Callable[[Any], Any]:
956+
def f(val: Any) -> Any:
957+
return val
958+
return f
959+
960+
@define
961+
class C:
962+
a: str = field(converter=make_converter(40))
963+
b: str = field(converter=make_untyped_converter(40))
964+
965+
reveal_type(C) # N: Revealed type is "def (a: builtins.str, b: Any) -> __main__.C"
966+
reveal_type(C("hi", 5).a) # N: Revealed type is "builtins.str"
967+
[builtins fixtures/list.pyi]
968+
969+
[case testAttrsUsingCallableVariableConverter]
970+
from typing import Callable
971+
from attrs import define, field
972+
973+
def to_str(x: int) -> str:
974+
return ""
975+
my_converter: Callable[[int], str] = to_str
976+
977+
@define
978+
class C:
979+
x: str = field(converter=my_converter)
980+
981+
reveal_type(C) # N: Revealed type is "def (x: builtins.int) -> __main__.C"
982+
reveal_type(C(15).x) # N: Revealed type is "builtins.str"
983+
[builtins fixtures/list.pyi]
984+
985+
[case testAttrsUsingHigherOrderConverterChainedCall]
986+
from typing import Callable
987+
from attrs import define, field
988+
989+
def outer() -> Callable[[int], Callable[[str], str]]:
990+
def middle(_n: int) -> Callable[[str], str]:
991+
def inner(v: str) -> str:
992+
return v
993+
return inner
994+
return middle
995+
996+
@define
997+
class C:
998+
x: str = field(converter=outer()(40))
999+
1000+
reveal_type(C) # N: Revealed type is "def (x: builtins.str) -> __main__.C"
1001+
[builtins fixtures/list.pyi]
1002+
1003+
[case testAttrsUsingDefaultIfNoneConverter]
1004+
from typing import Optional
1005+
from attrs import define, field
1006+
from attrs.converters import default_if_none
1007+
1008+
@define
1009+
class C:
1010+
x: int = field(default=None, converter=default_if_none(0))
1011+
1012+
reveal_type(C) # N: Revealed type is "def (x: Any =) -> __main__.C"
1013+
[builtins fixtures/plugin_attrs.pyi]
1014+
9451015
[case testAttrsUsingConverterAndSubclass]
9461016
import attr
9471017

0 commit comments

Comments
 (0)