-
-
Notifications
You must be signed in to change notification settings - Fork 3k
[attrs plugin] Support kw_only=True #6107
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
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,7 +9,7 @@ | |
Context, Argument, Var, ARG_OPT, ARG_POS, TypeInfo, AssignmentStmt, | ||
TupleExpr, ListExpr, NameExpr, CallExpr, RefExpr, FuncBase, | ||
is_class_var, TempNode, Decorator, MemberExpr, Expression, | ||
SymbolTableNode, MDEF, JsonDict, OverloadedFuncDef | ||
SymbolTableNode, MDEF, JsonDict, OverloadedFuncDef, ARG_NAMED_OPT, ARG_NAMED | ||
) | ||
from mypy.plugins.common import ( | ||
_get_argument, _get_bool_argument, _get_decorator_bool_argument, add_method | ||
|
@@ -56,12 +56,13 @@ class Attribute: | |
"""The value of an attr.ib() call.""" | ||
|
||
def __init__(self, name: str, info: TypeInfo, | ||
has_default: bool, init: bool, converter: Converter, | ||
has_default: bool, init: bool, kw_only: bool, converter: Converter, | ||
context: Context) -> None: | ||
self.name = name | ||
self.info = info | ||
self.has_default = has_default | ||
self.init = init | ||
self.kw_only = kw_only | ||
self.converter = converter | ||
self.context = context | ||
|
||
|
@@ -132,17 +133,23 @@ def argument(self, ctx: 'mypy.plugin.ClassDefContext') -> Argument: | |
# Convert type not set to Any. | ||
init_type = AnyType(TypeOfAny.unannotated) | ||
|
||
if self.kw_only: | ||
arg_kind = ARG_NAMED_OPT if self.has_default else ARG_NAMED | ||
else: | ||
arg_kind = ARG_OPT if self.has_default else ARG_POS | ||
|
||
# Attrs removes leading underscores when creating the __init__ arguments. | ||
return Argument(Var(self.name.lstrip("_"), init_type), init_type, | ||
None, | ||
ARG_OPT if self.has_default else ARG_POS) | ||
arg_kind) | ||
|
||
def serialize(self) -> JsonDict: | ||
"""Serialize this object so it can be saved and restored.""" | ||
return { | ||
'name': self.name, | ||
'has_default': self.has_default, | ||
'init': self.init, | ||
'kw_only': self.kw_only, | ||
'converter_name': self.converter.name, | ||
'converter_is_attr_converters_optional': self.converter.is_attr_converters_optional, | ||
'context_line': self.context.line, | ||
|
@@ -157,6 +164,7 @@ def deserialize(cls, info: TypeInfo, data: JsonDict) -> 'Attribute': | |
info, | ||
data['has_default'], | ||
data['init'], | ||
data['kw_only'], | ||
Converter(data['converter_name'], data['converter_is_attr_converters_optional']), | ||
Context(line=data['context_line'], column=data['context_column']) | ||
) | ||
|
@@ -181,6 +189,7 @@ def attr_class_maker_callback(ctx: 'mypy.plugin.ClassDefContext', | |
frozen = _get_frozen(ctx) | ||
cmp = _get_decorator_bool_argument(ctx, 'cmp', True) | ||
auto_attribs = _get_decorator_bool_argument(ctx, 'auto_attribs', auto_attribs_default) | ||
kw_only = _get_decorator_bool_argument(ctx, 'kw_only', False) | ||
|
||
if ctx.api.options.python_version[0] < 3: | ||
if auto_attribs: | ||
|
@@ -190,8 +199,11 @@ def attr_class_maker_callback(ctx: 'mypy.plugin.ClassDefContext', | |
# Note: This will not catch subclassing old-style classes. | ||
ctx.api.fail("attrs only works with new-style classes", info.defn) | ||
return | ||
if kw_only: | ||
ctx.api.fail("kw_only is not supported in Python 2", ctx.reason) | ||
return | ||
|
||
attributes = _analyze_class(ctx, auto_attribs) | ||
attributes = _analyze_class(ctx, auto_attribs, kw_only) | ||
|
||
# Save the attributes so that subclasses can reuse them. | ||
ctx.cls.info.metadata['attrs'] = { | ||
|
@@ -219,13 +231,15 @@ def _get_frozen(ctx: 'mypy.plugin.ClassDefContext') -> bool: | |
return False | ||
|
||
|
||
def _analyze_class(ctx: 'mypy.plugin.ClassDefContext', auto_attribs: bool) -> List[Attribute]: | ||
def _analyze_class(ctx: 'mypy.plugin.ClassDefContext', | ||
auto_attribs: bool, | ||
kw_only: bool) -> List[Attribute]: | ||
"""Analyze the class body of an attr maker, its parents, and return the Attributes found.""" | ||
own_attrs = OrderedDict() # type: OrderedDict[str, Attribute] | ||
# Walk the body looking for assignments and decorators. | ||
for stmt in ctx.cls.defs.body: | ||
if isinstance(stmt, AssignmentStmt): | ||
for attr in _attributes_from_assignment(ctx, stmt, auto_attribs): | ||
for attr in _attributes_from_assignment(ctx, stmt, auto_attribs, kw_only): | ||
# When attrs are defined twice in the same body we want to use the 2nd definition | ||
# in the 2nd location. So remove it from the OrderedDict. | ||
# Unless it's auto_attribs in which case we want the 2nd definition in the | ||
|
@@ -264,19 +278,34 @@ def _analyze_class(ctx: 'mypy.plugin.ClassDefContext', auto_attribs: bool) -> Li | |
# Check the init args for correct default-ness. Note: This has to be done after all the | ||
# attributes for all classes have been read, because subclasses can override parents. | ||
last_default = False | ||
last_kw_only = False | ||
|
||
for attribute in attributes: | ||
if attribute.init: | ||
if not attribute.has_default and last_default: | ||
ctx.api.fail( | ||
"Non-default attributes not allowed after default attributes.", | ||
attribute.context) | ||
last_default |= attribute.has_default | ||
if not attribute.init: | ||
continue | ||
|
||
if attribute.kw_only: | ||
# Keyword-only attributes don't care whether they are default or not. | ||
last_kw_only = True | ||
continue | ||
|
||
if not attribute.has_default and last_default: | ||
ctx.api.fail( | ||
"Non-default attributes not allowed after default attributes.", | ||
attribute.context) | ||
if last_kw_only: | ||
ctx.api.fail( | ||
"Non keyword-only attributes are not allowed after a keyword-only attribute.", | ||
attribute.context | ||
) | ||
last_default |= attribute.has_default | ||
|
||
return attributes | ||
|
||
|
||
def _attributes_from_assignment(ctx: 'mypy.plugin.ClassDefContext', | ||
stmt: AssignmentStmt, auto_attribs: bool) -> Iterable[Attribute]: | ||
stmt: AssignmentStmt, auto_attribs: bool, | ||
kw_only: bool) -> Iterable[Attribute]: | ||
"""Return Attribute objects that are created by this assignment. | ||
|
||
The assignments can look like this: | ||
|
@@ -300,11 +329,11 @@ def _attributes_from_assignment(ctx: 'mypy.plugin.ClassDefContext', | |
if (isinstance(rvalue, CallExpr) | ||
and isinstance(rvalue.callee, RefExpr) | ||
and rvalue.callee.fullname in attr_attrib_makers): | ||
attr = _attribute_from_attrib_maker(ctx, auto_attribs, lhs, rvalue, stmt) | ||
attr = _attribute_from_attrib_maker(ctx, auto_attribs, kw_only, lhs, rvalue, stmt) | ||
if attr: | ||
yield attr | ||
elif auto_attribs and stmt.type and stmt.new_syntax and not is_class_var(lhs): | ||
yield _attribute_from_auto_attrib(ctx, lhs, rvalue, stmt) | ||
yield _attribute_from_auto_attrib(ctx, kw_only, lhs, rvalue, stmt) | ||
|
||
|
||
def _cleanup_decorator(stmt: Decorator, attr_map: Dict[str, Attribute]) -> None: | ||
|
@@ -337,17 +366,19 @@ def _cleanup_decorator(stmt: Decorator, attr_map: Dict[str, Attribute]) -> None: | |
|
||
|
||
def _attribute_from_auto_attrib(ctx: 'mypy.plugin.ClassDefContext', | ||
kw_only: bool, | ||
lhs: NameExpr, | ||
rvalue: Expression, | ||
stmt: AssignmentStmt) -> Attribute: | ||
"""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, Converter(), stmt) | ||
return Attribute(lhs.name, ctx.cls.info, has_rhs, True, kw_only, Converter(), stmt) | ||
|
||
|
||
def _attribute_from_attrib_maker(ctx: 'mypy.plugin.ClassDefContext', | ||
auto_attribs: bool, | ||
kw_only: bool, | ||
lhs: NameExpr, | ||
rvalue: CallExpr, | ||
stmt: AssignmentStmt) -> Optional[Attribute]: | ||
|
@@ -367,6 +398,11 @@ def _attribute_from_attrib_maker(ctx: 'mypy.plugin.ClassDefContext', | |
|
||
# Read all the arguments from the call. | ||
init = _get_bool_argument(ctx, rvalue, 'init', True) | ||
kw_only |= _get_bool_argument(ctx, rvalue, 'kw_only', False) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I noticed this (a bit counterintuitive) behavior: import attr
@attr.s(kw_only=True)
class A:
a = attr.ib(kw_only=False)
A(1) # Error The logic here captures it correctly, but I would add a short comment that this is intentional. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I filed an issue in attrs to see if this is expected: python-attrs/attrs#481 |
||
if kw_only and ctx.api.options.python_version[0] < 3: | ||
ctx.api.fail("kw_only is not supported in Python 2", stmt) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would avoid repeating the same error message, and instead define a constant. |
||
return None | ||
|
||
# TODO: Check for attr.NOTHING | ||
attr_has_default = bool(_get_argument(rvalue, 'default')) | ||
attr_has_factory = bool(_get_argument(rvalue, 'factory')) | ||
|
@@ -400,7 +436,7 @@ def _attribute_from_attrib_maker(ctx: 'mypy.plugin.ClassDefContext', | |
converter = convert | ||
converter_info = _parse_converter(ctx, converter) | ||
|
||
return Attribute(lhs.name, ctx.cls.info, attr_has_default, init, converter_info, stmt) | ||
return Attribute(lhs.name, ctx.cls.info, attr_has_default, init, kw_only, converter_info, stmt) | ||
|
||
|
||
def _parse_converter(ctx: 'mypy.plugin.ClassDefContext', | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -1001,6 +1001,37 @@ class A: | |
== | ||
main:2: error: Unsupported left operand type for < ("B") | ||
|
||
[case testAttrsUpdateKwOnly] | ||
[file a.py] | ||
import attr | ||
@attr.s(kw_only=True) | ||
class A: | ||
a = attr.ib(15) # type: int | ||
|
||
[file b.py] | ||
from a import A | ||
import attr | ||
@attr.s(kw_only=True) | ||
class B(A): | ||
b = attr.ib("16") # type: str | ||
|
||
B(b="foo", a=7) | ||
|
||
[file b.py.2] | ||
from a import A | ||
import attr | ||
@attr.s() | ||
class B(A): | ||
b = attr.ib("16") # type: str | ||
|
||
B(b="foo", a=7) | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This empty line is not needed. |
||
[builtins fixtures/attr.pyi] | ||
[out] | ||
== | ||
b.py:5: error: Non keyword-only attributes are not allowed after a keyword-only attribute. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test doesn't test much of incremental logic. I would also add a test where base class in |
||
|
||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We prefer single empty line between tests. |
||
[case testAddBaseClassMethodCausingInvalidOverride] | ||
import m | ||
class B(m.A): | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -21,6 +21,7 @@ def attrib(default: None = ..., | |
type: None = ..., | ||
converter: None = ..., | ||
factory: None = ..., | ||
kw_only: bool = ..., | ||
) -> Any: ... | ||
# This form catches an explicit None or no default and infers the type from the other arguments. | ||
@overload | ||
|
@@ -35,6 +36,7 @@ def attrib(default: None = ..., | |
type: Optional[Type[_T]] = ..., | ||
converter: Optional[_ConverterType[_T]] = ..., | ||
factory: Optional[Callable[[], _T]] = ..., | ||
kw_only: bool = ..., | ||
) -> _T: ... | ||
# This form catches an explicit default argument. | ||
@overload | ||
|
@@ -49,6 +51,7 @@ def attrib(default: _T, | |
type: Optional[Type[_T]] = ..., | ||
converter: Optional[_ConverterType[_T]] = ..., | ||
factory: Optional[Callable[[], _T]] = ..., | ||
kw_only: bool = ..., | ||
) -> _T: ... | ||
# This form covers type=non-Type: e.g. forward references (str), Any | ||
@overload | ||
|
@@ -63,6 +66,7 @@ def attrib(default: Optional[_T] = ..., | |
type: object = ..., | ||
converter: Optional[_ConverterType[_T]] = ..., | ||
factory: Optional[Callable[[], _T]] = ..., | ||
kw_only: bool = ..., | ||
) -> Any: ... | ||
|
||
@overload | ||
|
@@ -75,8 +79,12 @@ def attrs(maybe_cls: _C, | |
init: bool = ..., | ||
slots: bool = ..., | ||
frozen: bool = ..., | ||
weakref_slot: bool = ..., | ||
str: bool = ..., | ||
auto_attribs: bool = ...) -> _C: ... | ||
auto_attribs: bool = ..., | ||
kw_only: bool = ..., | ||
cache_hash: bool = ..., | ||
) -> _C: ... | ||
@overload | ||
def attrs(maybe_cls: None = ..., | ||
these: Optional[Mapping[str, Any]] = ..., | ||
|
@@ -87,8 +95,12 @@ def attrs(maybe_cls: None = ..., | |
init: bool = ..., | ||
slots: bool = ..., | ||
frozen: bool = ..., | ||
weakref_slot: bool = ..., | ||
str: bool = ..., | ||
auto_attribs: bool = ...) -> Callable[[_C], _C]: ... | ||
auto_attribs: bool = ..., | ||
kw_only: bool = ..., | ||
cache_hash: bool = ..., | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I noticed that some of these are missing in typeshed. Could you please make a typeshed update PR? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now that |
||
) -> Callable[[_C], _C]: ... | ||
|
||
|
||
# aliases | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you please document
kw_only
(andauto_attribs
) in the docstring?