Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Attrs] Add support for attr.converters.optional #5411

Merged
merged 2 commits into from Aug 2, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
64 changes: 46 additions & 18 deletions mypy/plugins/attrs.py
Expand Up @@ -37,31 +37,41 @@
}


class Converter:
"""Holds information about a `converter=` argument"""

def __init__(self,
name: Optional[str] = None,
is_attr_converters_optional: bool = False) -> None:
self.name = name
self.is_attr_converters_optional = is_attr_converters_optional


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

def __init__(self, name: str, info: TypeInfo,
has_default: bool, init: bool, converter_name: Optional[str],
has_default: bool, init: bool, converter: Converter,
context: Context) -> None:
self.name = name
self.info = info
self.has_default = has_default
self.init = init
self.converter_name = converter_name
self.converter = converter
self.context = context

def argument(self, ctx: 'mypy.plugin.ClassDefContext') -> Argument:
"""Return this attribute as an argument to __init__."""
assert self.init
init_type = self.info[self.name].type

if self.converter_name:
if self.converter.name:
# When a converter is set the init_type is overridden by the first argument
# of the converter method.
converter = lookup_qualified_stnode(ctx.api.modules, self.converter_name, True)
converter = lookup_qualified_stnode(ctx.api.modules, self.converter.name, True)
if not converter:
# The converter may be a local variable. Check there too.
converter = ctx.api.lookup_qualified(self.converter_name, self.info, True)
converter = ctx.api.lookup_qualified(self.converter.name, self.info, True)

# Get the type of the converter.
converter_type = None
Expand Down Expand Up @@ -90,10 +100,16 @@ def argument(self, ctx: 'mypy.plugin.ClassDefContext') -> Argument:
if types:
args = UnionType.make_simplified_union(types)
init_type = ctx.api.anal_type(args)

if self.converter.is_attr_converters_optional and init_type:
# If the converter was attr.converter.optional(type) then add None to
# the allowed init_type.
init_type = UnionType.make_simplified_union([init_type, NoneTyp()])

if not init_type:
ctx.api.fail("Cannot determine __init__ type from converter", self.context)
init_type = AnyType(TypeOfAny.from_error)
elif self.converter_name == '':
elif self.converter.name == '':
# This means we had a converter but it's not of a type we can infer.
# Error was shown in _get_converter_name
init_type = AnyType(TypeOfAny.from_error)
Expand Down Expand Up @@ -122,7 +138,8 @@ def serialize(self) -> JsonDict:
'name': self.name,
'has_default': self.has_default,
'init': self.init,
'converter_name': self.converter_name,
'converter_name': self.converter.name,
'converter_is_attr_converters_optional': self.converter.is_attr_converters_optional,
'context_line': self.context.line,
'context_column': self.context.column,
}
Expand All @@ -135,7 +152,7 @@ def deserialize(cls, info: TypeInfo, data: JsonDict) -> 'Attribute':
info,
data['has_default'],
data['init'],
data['converter_name'],
Converter(data['converter_name'], data['converter_is_attr_converters_optional']),
Context(line=data['context_line'], column=data['context_column'])
)

Expand Down Expand Up @@ -318,7 +335,7 @@ def _attribute_from_auto_attrib(ctx: 'mypy.plugin.ClassDefContext',
"""Return an Attribute for a new type assignment."""
# `x: int` (without equal sign) assigns rvalue to TempNode(AnyType())
has_rhs = not isinstance(rvalue, TempNode)
return Attribute(lhs.name, ctx.cls.info, has_rhs, True, None, stmt)
return Attribute(lhs.name, ctx.cls.info, has_rhs, True, Converter(), stmt)


def _attribute_from_attrib_maker(ctx: 'mypy.plugin.ClassDefContext',
Expand Down Expand Up @@ -373,31 +390,42 @@ def _attribute_from_attrib_maker(ctx: 'mypy.plugin.ClassDefContext',
elif convert:
ctx.api.fail("convert is deprecated, use converter", rvalue)
converter = convert
converter_name = _get_converter_name(ctx, converter)
converter_info = _parse_converter(ctx, converter)

return Attribute(lhs.name, ctx.cls.info, attr_has_default, init, converter_name, stmt)
return Attribute(lhs.name, ctx.cls.info, attr_has_default, init, converter_info, stmt)


def _get_converter_name(ctx: 'mypy.plugin.ClassDefContext',
converter: Optional[Expression]) -> Optional[str]:
"""Return the full name of the converter if it exists and is a simple function."""
def _parse_converter(ctx: 'mypy.plugin.ClassDefContext',
converter: Optional[Expression]) -> Converter:
"""Return the Converter object from an Expression."""
# TODO: Support complex converters, e.g. lambdas, calls, etc.
if converter:
if isinstance(converter, RefExpr) and converter.node:
if (isinstance(converter.node, FuncBase)
and converter.node.type
and isinstance(converter.node.type, FunctionLike)):
return converter.node.fullname()
return Converter(converter.node.fullname())
elif isinstance(converter.node, TypeInfo):
return converter.node.fullname()
return Converter(converter.node.fullname())

if (isinstance(converter, CallExpr)
and isinstance(converter.callee, RefExpr)
and converter.callee.fullname == "attr.converters.optional"
and converter.args
and converter.args[0]):
# Special handling for attr.converters.optional(type)
# We extract the type and add make the init_args Optional in Attribute.argument
argument = _parse_converter(ctx, converter.args[0])
argument.is_attr_converters_optional = True
return argument

# Signal that we have an unsupported converter.
ctx.api.fail(
"Unsupported converter, only named functions and types are currently supported",
converter
)
return ''
return None
return Converter('')
return Converter(None)


def _parse_assignments(
Expand Down
20 changes: 20 additions & 0 deletions test-data/unit/check-attr.test
Expand Up @@ -889,3 +889,23 @@ class C:
e = attr.ib() # E: Non-default attributes not allowed after default attributes.

[builtins fixtures/bool.pyi]

[case testAttrsOptionalConverter]
# flags: --strict-optional
import attr
from attr.converters import optional
from typing import Optional

def converter(s:int) -> str:
return 'hello'


@attr.s
class A:
y: Optional[int] = attr.ib(converter=optional(int))
z: Optional[str] = attr.ib(converter=optional(converter))


A(None, None)

[builtins fixtures/attr.pyi]