Skip to content

Commit 5039c0f

Browse files
t4lzTal ZwickJelleZijlstra
authored
Support unannotated converters for attr.ib (#12815)
### Description Fixes #6172 If an unannotated converter function or a lambda expression is passed as a converter to `attr.ib()`, instead of giving an error, just take the type of the respective argument of the generated `__init__()` function to be `Any`, as suggested by @JelleZijlstra and @cgebbe. ## Test Plan Add two tests: one that tests the example from the issue of an unannotated function, and one that tests an example with a lambda expression as a converter. Co-authored-by: t4lz <[email protected]> Co-authored-by: Tal Zwick <[email protected]> Co-authored-by: Jelle Zijlstra <[email protected]>
1 parent eb1b1e0 commit 5039c0f

File tree

2 files changed

+134
-92
lines changed

2 files changed

+134
-92
lines changed

mypy/plugins/attrs.py

Lines changed: 101 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
TupleExpr, ListExpr, NameExpr, CallExpr, RefExpr, FuncDef,
1313
is_class_var, TempNode, Decorator, MemberExpr, Expression,
1414
SymbolTableNode, MDEF, JsonDict, OverloadedFuncDef, ARG_NAMED_OPT, ARG_NAMED,
15-
TypeVarExpr, PlaceholderNode
15+
TypeVarExpr, PlaceholderNode, LambdaExpr
1616
)
1717
from mypy.plugin import SemanticAnalyzerPluginInterface
1818
from mypy.plugins.common import (
@@ -60,19 +60,16 @@ class Converter:
6060
"""Holds information about a `converter=` argument"""
6161

6262
def __init__(self,
63-
type: Optional[Type] = None,
64-
is_attr_converters_optional: bool = False,
65-
is_invalid_converter: bool = False) -> None:
66-
self.type = type
67-
self.is_attr_converters_optional = is_attr_converters_optional
68-
self.is_invalid_converter = is_invalid_converter
63+
init_type: Optional[Type] = None,
64+
) -> None:
65+
self.init_type = init_type
6966

7067

7168
class Attribute:
7269
"""The value of an attr.ib() call."""
7370

7471
def __init__(self, name: str, info: TypeInfo,
75-
has_default: bool, init: bool, kw_only: bool, converter: Converter,
72+
has_default: bool, init: bool, kw_only: bool, converter: Optional[Converter],
7673
context: Context,
7774
init_type: Optional[Type]) -> None:
7875
self.name = name
@@ -88,54 +85,35 @@ def argument(self, ctx: 'mypy.plugin.ClassDefContext') -> Argument:
8885
"""Return this attribute as an argument to __init__."""
8986
assert self.init
9087

91-
init_type = self.init_type or self.info[self.name].type
92-
93-
if self.converter.type and not self.converter.is_invalid_converter:
94-
# When a converter is set the init_type is overridden by the first argument
95-
# of the converter method.
96-
converter_type = self.converter.type
97-
init_type = None
98-
converter_type = get_proper_type(converter_type)
99-
if isinstance(converter_type, CallableType) and converter_type.arg_types:
100-
init_type = converter_type.arg_types[0]
101-
elif isinstance(converter_type, Overloaded):
102-
types: List[Type] = []
103-
for item in converter_type.items:
104-
# Walk the overloads looking for methods that can accept one argument.
105-
num_arg_types = len(item.arg_types)
106-
if not num_arg_types:
107-
continue
108-
if num_arg_types > 1 and any(kind == ARG_POS for kind in item.arg_kinds[1:]):
109-
continue
110-
types.append(item.arg_types[0])
111-
# Make a union of all the valid types.
112-
if types:
113-
init_type = make_simplified_union(types)
114-
115-
if self.converter.is_attr_converters_optional and init_type:
116-
# If the converter was attr.converter.optional(type) then add None to
117-
# the allowed init_type.
118-
init_type = UnionType.make_union([init_type, NoneType()])
119-
120-
if not init_type:
88+
init_type: Optional[Type] = None
89+
if self.converter:
90+
if self.converter.init_type:
91+
init_type = self.converter.init_type
92+
else:
12193
ctx.api.fail("Cannot determine __init__ type from converter", self.context)
12294
init_type = AnyType(TypeOfAny.from_error)
123-
elif self.converter.is_invalid_converter:
124-
# This means we had a converter but it's not of a type we can infer.
125-
init_type = AnyType(TypeOfAny.from_error)
95+
else: # There is no converter, the init type is the normal type.
96+
init_type = self.init_type or self.info[self.name].type
12697

98+
unannotated = False
12799
if init_type is None:
128-
if ctx.api.options.disallow_untyped_defs:
129-
# This is a compromise. If you don't have a type here then the
130-
# __init__ will be untyped. But since the __init__ is added it's
131-
# pointing at the decorator. So instead we also show the error in the
132-
# assignment, which is where you would fix the issue.
133-
node = self.info[self.name].node
134-
assert node is not None
135-
ctx.api.msg.need_annotation_for_var(node, self.context)
136-
100+
unannotated = True
137101
# Convert type not set to Any.
138102
init_type = AnyType(TypeOfAny.unannotated)
103+
else:
104+
proper_type = get_proper_type(init_type)
105+
if isinstance(proper_type, AnyType):
106+
if proper_type.type_of_any == TypeOfAny.unannotated:
107+
unannotated = True
108+
109+
if unannotated and ctx.api.options.disallow_untyped_defs:
110+
# This is a compromise. If you don't have a type here then the
111+
# __init__ will be untyped. But since the __init__ is added it's
112+
# pointing at the decorator. So instead we also show the error in the
113+
# assignment, which is where you would fix the issue.
114+
node = self.info[self.name].node
115+
assert node is not None
116+
ctx.api.msg.need_annotation_for_var(node, self.context)
139117

140118
if self.kw_only:
141119
arg_kind = ARG_NAMED_OPT if self.has_default else ARG_NAMED
@@ -154,9 +132,9 @@ def serialize(self) -> JsonDict:
154132
'has_default': self.has_default,
155133
'init': self.init,
156134
'kw_only': self.kw_only,
157-
'converter_type': self.converter.type.serialize() if self.converter.type else None,
158-
'converter_is_attr_converters_optional': self.converter.is_attr_converters_optional,
159-
'converter_is_invalid_converter': self.converter.is_invalid_converter,
135+
'has_converter': self.converter is not None,
136+
'converter_init_type': self.converter.init_type.serialize()
137+
if self.converter and self.converter.init_type else None,
160138
'context_line': self.context.line,
161139
'context_column': self.context.column,
162140
'init_type': self.init_type.serialize() if self.init_type else None,
@@ -169,17 +147,16 @@ def deserialize(cls, info: TypeInfo,
169147
"""Return the Attribute that was serialized."""
170148
raw_init_type = data['init_type']
171149
init_type = deserialize_and_fixup_type(raw_init_type, api) if raw_init_type else None
150+
raw_converter_init_type = data['converter_init_type']
151+
converter_init_type = (deserialize_and_fixup_type(raw_converter_init_type, api)
152+
if raw_converter_init_type else None)
172153

173-
converter_type = None
174-
if data['converter_type']:
175-
converter_type = deserialize_and_fixup_type(data['converter_type'], api)
176154
return Attribute(data['name'],
177155
info,
178156
data['has_default'],
179157
data['init'],
180158
data['kw_only'],
181-
Converter(converter_type, data['converter_is_attr_converters_optional'],
182-
data['converter_is_invalid_converter']),
159+
Converter(converter_init_type) if data['has_converter'] else None,
183160
Context(line=data['context_line'], column=data['context_column']),
184161
init_type)
185162

@@ -542,7 +519,7 @@ def _attribute_from_auto_attrib(ctx: 'mypy.plugin.ClassDefContext',
542519
has_rhs = not isinstance(rvalue, TempNode)
543520
sym = ctx.cls.info.names.get(name)
544521
init_type = sym.type if sym else None
545-
return Attribute(name, ctx.cls.info, has_rhs, True, kw_only, Converter(), stmt, init_type)
522+
return Attribute(name, ctx.cls.info, has_rhs, True, kw_only, None, stmt, init_type)
546523

547524

548525
def _attribute_from_attrib_maker(ctx: 'mypy.plugin.ClassDefContext',
@@ -613,40 +590,76 @@ def _attribute_from_attrib_maker(ctx: 'mypy.plugin.ClassDefContext',
613590

614591

615592
def _parse_converter(ctx: 'mypy.plugin.ClassDefContext',
616-
converter: Optional[Expression]) -> Converter:
593+
converter_expr: Optional[Expression]) -> Optional[Converter]:
617594
"""Return the Converter object from an Expression."""
618595
# TODO: Support complex converters, e.g. lambdas, calls, etc.
619-
if converter:
620-
if isinstance(converter, RefExpr) and converter.node:
621-
if (isinstance(converter.node, FuncDef)
622-
and converter.node.type
623-
and isinstance(converter.node.type, FunctionLike)):
624-
return Converter(converter.node.type)
625-
elif (isinstance(converter.node, OverloadedFuncDef)
626-
and is_valid_overloaded_converter(converter.node)):
627-
return Converter(converter.node.type)
628-
elif isinstance(converter.node, TypeInfo):
629-
from mypy.checkmember import type_object_type # To avoid import cycle.
630-
return Converter(type_object_type(converter.node, ctx.api.named_type))
631-
632-
if (isinstance(converter, CallExpr)
633-
and isinstance(converter.callee, RefExpr)
634-
and converter.callee.fullname in attr_optional_converters
635-
and converter.args
636-
and converter.args[0]):
637-
# Special handling for attr.converters.optional(type)
638-
# We extract the type and add make the init_args Optional in Attribute.argument
639-
argument = _parse_converter(ctx, converter.args[0])
640-
argument.is_attr_converters_optional = True
641-
return argument
642-
596+
if not converter_expr:
597+
return None
598+
converter_info = Converter()
599+
if (isinstance(converter_expr, CallExpr)
600+
and isinstance(converter_expr.callee, RefExpr)
601+
and converter_expr.callee.fullname in attr_optional_converters
602+
and converter_expr.args
603+
and converter_expr.args[0]):
604+
# Special handling for attr.converters.optional(type)
605+
# We extract the type and add make the init_args Optional in Attribute.argument
606+
converter_expr = converter_expr.args[0]
607+
is_attr_converters_optional = True
608+
else:
609+
is_attr_converters_optional = False
610+
611+
converter_type: Optional[Type] = None
612+
if isinstance(converter_expr, RefExpr) and converter_expr.node:
613+
if isinstance(converter_expr.node, FuncDef):
614+
if converter_expr.node.type and isinstance(converter_expr.node.type, FunctionLike):
615+
converter_type = converter_expr.node.type
616+
else: # The converter is an unannotated function.
617+
converter_info.init_type = AnyType(TypeOfAny.unannotated)
618+
return converter_info
619+
elif (isinstance(converter_expr.node, OverloadedFuncDef)
620+
and is_valid_overloaded_converter(converter_expr.node)):
621+
converter_type = converter_expr.node.type
622+
elif isinstance(converter_expr.node, TypeInfo):
623+
from mypy.checkmember import type_object_type # To avoid import cycle.
624+
converter_type = type_object_type(converter_expr.node, ctx.api.named_type)
625+
if isinstance(converter_expr, LambdaExpr):
626+
# TODO: should we send a fail if converter_expr.min_args > 1?
627+
converter_info.init_type = AnyType(TypeOfAny.unannotated)
628+
return converter_info
629+
630+
if not converter_type:
643631
# Signal that we have an unsupported converter.
644632
ctx.api.fail(
645-
"Unsupported converter, only named functions and types are currently supported",
646-
converter
633+
"Unsupported converter, only named functions, types and lambdas are currently "
634+
"supported",
635+
converter_expr
647636
)
648-
return Converter(None, is_invalid_converter=True)
649-
return Converter(None)
637+
converter_info.init_type = AnyType(TypeOfAny.from_error)
638+
return converter_info
639+
640+
converter_type = get_proper_type(converter_type)
641+
if isinstance(converter_type, CallableType) and converter_type.arg_types:
642+
converter_info.init_type = converter_type.arg_types[0]
643+
elif isinstance(converter_type, Overloaded):
644+
types: List[Type] = []
645+
for item in converter_type.items:
646+
# Walk the overloads looking for methods that can accept one argument.
647+
num_arg_types = len(item.arg_types)
648+
if not num_arg_types:
649+
continue
650+
if num_arg_types > 1 and any(kind == ARG_POS for kind in item.arg_kinds[1:]):
651+
continue
652+
types.append(item.arg_types[0])
653+
# Make a union of all the valid types.
654+
if types:
655+
converter_info.init_type = make_simplified_union(types)
656+
657+
if is_attr_converters_optional and converter_info.init_type:
658+
# If the converter was attr.converter.optional(type) then add None to
659+
# the allowed init_type.
660+
converter_info.init_type = UnionType.make_union([converter_info.init_type, NoneType()])
661+
662+
return converter_info
650663

651664

652665
def is_valid_overloaded_converter(defn: OverloadedFuncDef) -> bool:

test-data/unit/check-attr.test

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -878,9 +878,9 @@ def factory(default: int):
878878
...
879879
@attr.s
880880
class C:
881-
x: str = attr.ib(converter=thing.do_it) # E: Unsupported converter, only named functions and types are currently supported
882-
y: str = attr.ib(converter=lambda x: x) # E: Unsupported converter, only named functions and types are currently supported
883-
z: str = attr.ib(converter=factory(8)) # E: Unsupported converter, only named functions and types are currently supported
881+
x: str = attr.ib(converter=thing.do_it) # E: Unsupported converter, only named functions, types and lambdas are currently supported
882+
y: str = attr.ib(converter=lambda x: x)
883+
z: str = attr.ib(converter=factory(8)) # E: Unsupported converter, only named functions, types and lambdas are currently supported
884884
reveal_type(C) # N: Revealed type is "def (x: Any, y: Any, z: Any) -> __main__.C"
885885
[builtins fixtures/list.pyi]
886886

@@ -1731,10 +1731,39 @@ class C:
17311731
name: Union[str, None] = attr.ib(default=None)
17321732
options: Mapping[str, Mapping[str, Any]] = attr.ib(
17331733
default=None, converter=default_if_none(factory=dict) \
1734-
# E: Unsupported converter, only named functions and types are currently supported
1734+
# E: Unsupported converter, only named functions, types and lambdas are currently supported
17351735
)
17361736
[builtins fixtures/dict.pyi]
17371737

1738+
[case testAttrsUnannotatedConverter]
1739+
import attr
1740+
1741+
def foo(value):
1742+
return value.split()
1743+
1744+
@attr.s
1745+
class Bar:
1746+
field = attr.ib(default=None, converter=foo)
1747+
1748+
reveal_type(Bar) # N: Revealed type is "def (field: Any =) -> __main__.Bar"
1749+
bar = Bar("Hello")
1750+
reveal_type(bar.field) # N: Revealed type is "Any"
1751+
1752+
[builtins fixtures/tuple.pyi]
1753+
1754+
[case testAttrsLambdaConverter]
1755+
import attr
1756+
1757+
@attr.s
1758+
class Bar:
1759+
name: str = attr.ib(converter=lambda s: s.lower())
1760+
1761+
reveal_type(Bar) # N: Revealed type is "def (name: Any) -> __main__.Bar"
1762+
bar = Bar("Hello")
1763+
reveal_type(bar.name) # N: Revealed type is "builtins.str"
1764+
1765+
[builtins fixtures/tuple.pyi]
1766+
17381767
[case testAttrsNestedClass]
17391768
from typing import List
17401769
import attr

0 commit comments

Comments
 (0)