diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py index 47832ff654e9..db222bd8be73 100644 --- a/mypy/plugins/attrs.py +++ b/mypy/plugins/attrs.py @@ -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 @@ -25,6 +25,7 @@ if MYPY: from typing_extensions import Final +KW_ONLY_PYTHON_2_UNSUPPORTED = "kw_only is not supported in Python 2" # The names of the different functions that create classes or arguments. attr_class_makers = { @@ -56,12 +57,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,10 +134,15 @@ 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.""" @@ -143,6 +150,7 @@ def serialize(self) -> JsonDict: '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 +165,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 +190,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 +200,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_PYTHON_2_UNSUPPORTED, 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 +232,19 @@ def _get_frozen(ctx: 'mypy.plugin.ClassDefContext') -> bool: return False -def _analyze_class(ctx: 'mypy.plugin.ClassDefContext', auto_attribs: bool) -> List[Attribute]: - """Analyze the class body of an attr maker, its parents, and return the Attributes found.""" +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. + + auto_attribs=True means we'll generate attributes from type annotations also. + kw_only=True means that all attributes created here will be keyword only args in __init__. + """ 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 +283,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 +334,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 +371,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 +403,13 @@ def _attribute_from_attrib_maker(ctx: 'mypy.plugin.ClassDefContext', # Read all the arguments from the call. init = _get_bool_argument(ctx, rvalue, 'init', True) + # Note: If the class decorator says kw_only=True the attribute is ignored. + # See https://github.com/python-attrs/attrs/issues/481 for explanation. + kw_only |= _get_bool_argument(ctx, rvalue, 'kw_only', False) + if kw_only and ctx.api.options.python_version[0] < 3: + ctx.api.fail(KW_ONLY_PYTHON_2_UNSUPPORTED, stmt) + 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 +443,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', diff --git a/test-data/unit/check-attr.test b/test-data/unit/check-attr.test index 7f1c4fd889a7..c1b0990eb884 100644 --- a/test-data/unit/check-attr.test +++ b/test-data/unit/check-attr.test @@ -934,3 +934,89 @@ T = TypeVar("T", bytes, str) class A(Generic[T]): v: T [builtins fixtures/attr.pyi] + +[case testAttrsKwOnlyAttrib] +import attr +@attr.s +class A: + a = attr.ib(kw_only=True) +A() # E: Missing named argument "a" for "A" +A(15) # E: Too many positional arguments for "A" +A(a=15) +[builtins fixtures/attr.pyi] + +[case testAttrsKwOnlyClass] +import attr +@attr.s(kw_only=True, auto_attribs=True) +class A: + a: int + b: bool +A() # E: Missing named argument "a" for "A" # E: Missing named argument "b" for "A" +A(b=True, a=15) +[builtins fixtures/attr.pyi] + +[case testAttrsKwOnlyClassNoInit] +import attr +@attr.s(kw_only=True) +class B: + a = attr.ib(init=False) + b = attr.ib() +B(b=True) +[builtins fixtures/attr.pyi] + +[case testAttrsKwOnlyWithDefault] +import attr +@attr.s +class C: + a = attr.ib(0) + b = attr.ib(kw_only=True) + c = attr.ib(16, kw_only=True) +C(b=17) +[builtins fixtures/attr.pyi] + +[case testAttrsKwOnlyClassWithMixedDefaults] +import attr +@attr.s(kw_only=True) +class D: + a = attr.ib(10) + b = attr.ib() + c = attr.ib(15) +D(b=17) +[builtins fixtures/attr.pyi] + + +[case testAttrsKwOnlySubclass] +import attr +@attr.s +class A2: + a = attr.ib(default=0) +@attr.s +class B2(A2): + b = attr.ib(kw_only=True) +B2(b=1) +[builtins fixtures/attr.pyi] + +[case testAttrsNonKwOnlyAfterKwOnly] +import attr +@attr.s(kw_only=True) +class A: + a = attr.ib(default=0) +@attr.s +class B(A): + b = attr.ib() # E: Non keyword-only attributes are not allowed after a keyword-only attribute. +@attr.s +class C: + a = attr.ib(kw_only=True) + b = attr.ib(15) # E: Non keyword-only attributes are not allowed after a keyword-only attribute. +[builtins fixtures/attr.pyi] + +[case testAttrsKwOnlyPy2] +# flags: --py2 +import attr +@attr.s(kw_only=True) # E: kw_only is not supported in Python 2 +class A(object): + x = attr.ib() +@attr.s +class B(object): + x = attr.ib(kw_only=True) # E: kw_only is not supported in Python 2 +[builtins_py2 fixtures/bool.pyi] diff --git a/test-data/unit/fine-grained.test b/test-data/unit/fine-grained.test index 8ac461bc2d59..164954544484 100644 --- a/test-data/unit/fine-grained.test +++ b/test-data/unit/fine-grained.test @@ -1001,6 +1001,56 @@ 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 + +[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) +[builtins fixtures/attr.pyi] +[out] +== +b.py:5: error: Non keyword-only attributes are not allowed after a keyword-only attribute. + +[case testAttrsUpdateBaseKwOnly] +from b import B +B(5) +[file a.py] +import attr +@attr.s() +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 + +[file a.py.2] +import attr +@attr.s(kw_only=True) +class A: + a = attr.ib(15) # type: int +[builtins fixtures/attr.pyi] +[out] +== +main:2: error: Too many positional arguments for "B" + [case testAddBaseClassMethodCausingInvalidOverride] import m class B(m.A): diff --git a/test-data/unit/lib-stub/attr.pyi b/test-data/unit/lib-stub/attr.pyi index cc4047ccfb94..f27709f2dccd 100644 --- a/test-data/unit/lib-stub/attr.pyi +++ b/test-data/unit/lib-stub/attr.pyi @@ -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 = ..., + ) -> Callable[[_C], _C]: ... # aliases