diff --git a/changelog.d/281.change.rst b/changelog.d/281.change.rst new file mode 100644 index 000000000..964e1c8ae --- /dev/null +++ b/changelog.d/281.change.rst @@ -0,0 +1,2 @@ +Added ``kw_only`` argument to ``attr.ib`` and corresponding ``kw_only`` attribute to ``attr.Attribute``. +This change makes it possible to have a generated ``__init__`` with keyword-only arguments on Python 3. diff --git a/docs/api.rst b/docs/api.rst index 6144ab9d1..5bd979ec0 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -90,7 +90,7 @@ Core ... class C(object): ... x = attr.ib() >>> attr.fields(C).x - Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None) + Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None, kw_only=False) .. autofunction:: attr.make_class @@ -202,9 +202,9 @@ Helpers ... x = attr.ib() ... y = attr.ib() >>> attr.fields(C) - (Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None), Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None)) + (Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None, kw_only=False), Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None, kw_only=False)) >>> attr.fields(C)[1] - Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None) + Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None, kw_only=False) >>> attr.fields(C).y is attr.fields(C)[1] True @@ -299,7 +299,7 @@ See :ref:`asdict` for examples. >>> attr.validate(i) Traceback (most recent call last): ... - TypeError: ("'x' must be (got '1' that is a ).", Attribute(name='x', default=NOTHING, validator=>, repr=True, cmp=True, hash=None, init=True, type=None), , '1') + TypeError: ("'x' must be (got '1' that is a ).", Attribute(name='x', default=NOTHING, validator=>, repr=True, cmp=True, hash=None, init=True, type=None, kw_only=False), , '1') Validators can be globally disabled if you want to run them only in development and tests but not in production because you fear their performance impact: @@ -332,11 +332,11 @@ Validators >>> C("42") Traceback (most recent call last): ... - TypeError: ("'x' must be (got '42' that is a ).", Attribute(name='x', default=NOTHING, validator=>, type=None), , '42') + TypeError: ("'x' must be (got '42' that is a ).", Attribute(name='x', default=NOTHING, validator=>, type=None, kw_only=False), , '42') >>> C(None) Traceback (most recent call last): ... - TypeError: ("'x' must be (got None that is a ).", Attribute(name='x', default=NOTHING, validator=>, repr=True, cmp=True, hash=None, init=True, type=None), , None) + TypeError: ("'x' must be (got None that is a ).", Attribute(name='x', default=NOTHING, validator=>, repr=True, cmp=True, hash=None, init=True, type=None, kw_only=False), , None) .. autofunction:: attr.validators.in_ @@ -388,7 +388,7 @@ Validators >>> C("42") Traceback (most recent call last): ... - TypeError: ("'x' must be (got '42' that is a ).", Attribute(name='x', default=NOTHING, validator=>, type=None), , '42') + TypeError: ("'x' must be (got '42' that is a ).", Attribute(name='x', default=NOTHING, validator=>, type=None, kw_only=False), , '42') >>> C(None) C(x=None) diff --git a/docs/examples.rst b/docs/examples.rst index 885b368fc..5a6fe803d 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -141,6 +141,55 @@ Therefore ``@attr.s`` comes with the ``repr_ns`` option to set it manually: ``repr_ns`` works on both Python 2 and 3. On Python 3 it overrides the implicit detection. +Keyword-only Attributes +~~~~~~~~~~~~~~~~~~~~~~~ + +When using ``attrs`` on Python 3, you can also add `keyword-only `_ attributes: + +.. doctest:: + + >>> @attr.s + ... class A: + ... a = attr.ib(kw_only=True) + >>> A() + Traceback (most recent call last): + ... + TypeError: A() missing 1 required keyword-only argument: 'a' + >>> A(a=1) + A(a=1) + +If you create an attribute with ``init=False``, ``kw_only`` argument is simply ignored. + +Keyword-only attributes allow subclasses to add attributes without default values, even if the base class defines attributes with default values: + +.. doctest:: + + >>> @attr.s + ... class A: + ... a = attr.ib(default=0) + >>> @attr.s + ... class B(A): + ... b = attr.ib(kw_only=True) + >>> B(b=1) + B(a=0, b=1) + >>> B() + Traceback (most recent call last): + ... + TypeError: B() missing 1 required keyword-only argument: 'b' + +If you omit ``kw_only`` or specify ``kw_only=False``, then you'll get an error: + +.. doctest:: + + >>> @attr.s + ... class A: + ... a = attr.ib(default=0) + >>> @attr.s + ... class B(A): + ... b = attr.ib() + Traceback (most recent call last): + ... + ValueError: No mandatory attributes allowed after an attribute with a default value or factory. Attribute in question: Attribute(name='b', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None, kw_only=False) .. _asdict: @@ -368,7 +417,7 @@ This example also shows of some syntactic sugar for using the :func:`attr.valida >>> C("42") Traceback (most recent call last): ... - TypeError: ("'x' must be (got '42' that is a ).", Attribute(name='x', default=NOTHING, factory=NOTHING, validator=>, type=None), , '42') + TypeError: ("'x' must be (got '42' that is a ).", Attribute(name='x', default=NOTHING, factory=NOTHING, validator=>, type=None, kw_only=False), , '42') Of course you can mix and match the two approaches at your convenience: @@ -386,7 +435,7 @@ Of course you can mix and match the two approaches at your convenience: >>> C("128") Traceback (most recent call last): ... - TypeError: ("'x' must be (got '128' that is a ).", Attribute(name='x', default=NOTHING, validator=[>, ], repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({}), type=None), , '128') + TypeError: ("'x' must be (got '128' that is a ).", Attribute(name='x', default=NOTHING, validator=[>, ], repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({}), type=None, kw_only=False), , '128') >>> C(256) Traceback (most recent call last): ... @@ -401,7 +450,7 @@ And finally you can disable validators globally: >>> C("128") Traceback (most recent call last): ... - TypeError: ("'x' must be (got '128' that is a ).", Attribute(name='x', default=NOTHING, validator=[>, ], repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({}), type=None), , '128') + TypeError: ("'x' must be (got '128' that is a ).", Attribute(name='x', default=NOTHING, validator=[>, ], repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({}), type=None, kw_only=False), , '128') Conversion diff --git a/docs/extending.rst b/docs/extending.rst index d460ee9b8..132a4457a 100644 --- a/docs/extending.rst +++ b/docs/extending.rst @@ -17,7 +17,7 @@ So it is fairly simple to build your own decorators on top of ``attrs``: ... @attr.s ... class C(object): ... a = attr.ib() - (Attribute(name='a', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None),) + (Attribute(name='a', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None, kw_only=False),) .. warning:: diff --git a/src/attr/_make.py b/src/attr/_make.py index eaac0843a..f04ef7d75 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -61,7 +61,7 @@ def __hash__(self): def attrib(default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, - convert=None, metadata={}, type=None): + convert=None, metadata={}, type=None, kw_only=False): """ Create a new attribute on a class. @@ -130,11 +130,15 @@ def attrib(default=NOTHING, validator=None, This argument is provided for backward compatibility. Regardless of the approach used, the type will be stored on ``Attribute.type``. + :param kw_only: Make this attribute keyword-only (Python 3+) + in the generated ``__init__`` (if ``init`` is ``False``, this + parameter is simply ignored). .. versionchanged:: 17.1.0 *validator* can be a ``list`` now. .. versionchanged:: 17.1.0 *hash* is ``None`` and therefore mirrors *cmp* by default . .. versionadded:: 17.3.0 *type* + .. versionadded:: 17.3.0 *kw_only* """ if hash is not None and hash is not True and hash is not False: raise TypeError( @@ -150,6 +154,7 @@ def attrib(default=NOTHING, validator=None, convert=convert, metadata=metadata, type=type, + kw_only=kw_only, ) @@ -257,17 +262,31 @@ def _transform_attrs(cls, these): ) had_default = False + was_kw_only = False for a in attrs: - if had_default is True and a.default is NOTHING and a.init is True: + if (was_kw_only is False and had_default is True and + a.default is NOTHING and a.init is True and + a.kw_only is False): raise ValueError( "No mandatory attributes allowed after an attribute with a " "default value or factory. Attribute in question: {a!r}" .format(a=a) ) - elif had_default is False and \ - a.default is not NOTHING and \ - a.init is not False: + elif (had_default is False and + a.default is not NOTHING and + a.init is not False and + # Keyword-only attributes without defaults can be specified + # after keyword-only attributes with defaults. + a.kw_only is False): had_default = True + if was_kw_only is True and a.kw_only is False: + raise ValueError( + "Non keyword-only attributes are not allowed after a " + "keyword-only attribute. Attribute in question: {a!r}" + .format(a=a) + ) + if was_kw_only is False and a.init is True and a.kw_only is True: + was_kw_only = True return _Attributes((attrs, super_attrs)) @@ -913,6 +932,7 @@ def fmt_setter_with_converter(attr_name, value_var): } args = [] + kw_only_args = [] attrs_to_validate = [] # This is a dictionary of names to validator and converter callables. @@ -960,19 +980,25 @@ def fmt_setter_with_converter(attr_name, value_var): .format(attr_name=attr_name) )) elif a.default is not NOTHING and not has_factory: - args.append( - "{arg_name}=attr_dict['{attr_name}'].default".format( - arg_name=arg_name, - attr_name=attr_name, - ) + arg = "{arg_name}=attr_dict['{attr_name}'].default".format( + arg_name=arg_name, + attr_name=attr_name, ) + if a.kw_only: + kw_only_args.append(arg) + else: + args.append(arg) if a.convert is not None: lines.append(fmt_setter_with_converter(attr_name, arg_name)) names_for_globals[_init_convert_pat.format(a.name)] = a.convert else: lines.append(fmt_setter(attr_name, arg_name)) elif has_factory: - args.append("{arg_name}=NOTHING".format(arg_name=arg_name)) + arg = "{arg_name}=NOTHING".format(arg_name=arg_name) + if a.kw_only: + kw_only_args.append(arg) + else: + args.append(arg) lines.append("if {arg_name} is not NOTHING:" .format(arg_name=arg_name)) init_factory_name = _init_factory_pat.format(a.name) @@ -994,7 +1020,10 @@ def fmt_setter_with_converter(attr_name, value_var): )) names_for_globals[init_factory_name] = a.default.factory else: - args.append(arg_name) + if a.kw_only: + kw_only_args.append(arg_name) + else: + args.append(arg_name) if a.convert is not None: lines.append(fmt_setter_with_converter(attr_name, arg_name)) names_for_globals[_init_convert_pat.format(a.name)] = a.convert @@ -1014,11 +1043,17 @@ def fmt_setter_with_converter(attr_name, value_var): if post_init: lines.append("self.__attrs_post_init__()") + args = ", ".join(args) + if kw_only_args: + args += "{leading_comma}*, {kw_only_args}".format( + leading_comma=", " if args else "", + kw_only_args=", ".join(kw_only_args) + ) return """\ def __init__(self, {args}): {lines} """.format( - args=", ".join(args), + args=args, lines="\n ".join(lines) if lines else "pass", ), names_for_globals @@ -1033,11 +1068,11 @@ class Attribute(object): """ __slots__ = ( "name", "default", "validator", "repr", "cmp", "hash", "init", - "convert", "metadata", "type" + "convert", "metadata", "type", "kw_only" ) def __init__(self, name, default, validator, repr, cmp, hash, init, - convert=None, metadata=None, type=None): + convert=None, metadata=None, type=None, kw_only=False): # Cache this descriptor here to speed things up later. bound_setattr = _obj_setattr.__get__(self, Attribute) @@ -1052,6 +1087,7 @@ def __init__(self, name, default, validator, repr, cmp, hash, init, bound_setattr("metadata", (metadata_proxy(metadata) if metadata else _empty_metadata_singleton)) bound_setattr("type", type) + bound_setattr("kw_only", kw_only) def __setattr__(self, name, value): raise FrozenInstanceError() @@ -1117,20 +1153,20 @@ class _CountingAttr(object): likely the result of a bug like a forgotten `@attr.s` decorator. """ __slots__ = ("counter", "_default", "repr", "cmp", "hash", "init", - "metadata", "_validator", "convert", "type") + "metadata", "_validator", "convert", "type", "kw_only") __attrs_attrs__ = tuple( Attribute(name=name, default=NOTHING, validator=None, - repr=True, cmp=True, hash=True, init=True) + repr=True, cmp=True, hash=True, init=True, kw_only=False) for name in ("counter", "_default", "repr", "cmp", "hash", "init",) ) + ( Attribute(name="metadata", default=None, validator=None, - repr=True, cmp=True, hash=False, init=True), + repr=True, cmp=True, hash=False, init=True, kw_only=False), ) cls_counter = 0 def __init__(self, default, validator, repr, cmp, hash, init, convert, - metadata, type): + metadata, type, kw_only): _CountingAttr.cls_counter += 1 self.counter = _CountingAttr.cls_counter self._default = default @@ -1146,6 +1182,7 @@ def __init__(self, default, validator, repr, cmp, hash, init, convert, self.convert = convert self.metadata = metadata self.type = type + self.kw_only = kw_only def validator(self, meth): """ diff --git a/tests/test_make.py b/tests/test_make.py index 5eb6f13a4..0436c3668 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -193,7 +193,7 @@ class C(object): "default value or factory. Attribute in question: Attribute" "(name='y', default=NOTHING, validator=None, repr=True, " "cmp=True, hash=None, init=True, convert=None, " - "metadata=mappingproxy({}), type=None)", + "metadata=mappingproxy({}), type=None, kw_only=False)", ) == e.value.args def test_these(self): @@ -407,6 +407,97 @@ class C(object): assert not isinstance(x, _CountingAttr) +@pytest.mark.skipif(PY2, reason="keyword-only arguments is PY3-only.") +class TestKeywordOnlyAttributes(object): + """ + Tests for keyword-only attributes. + """ + + def test_adds_keyword_only_arguments(self): + """ + Attributes can be added as keyword-only. + """ + @attr.s + class C(object): + a = attr.ib() + b = attr.ib(default=2, kw_only=True) + c = attr.ib(kw_only=True) + d = attr.ib(default=attr.Factory(lambda: 4), kw_only=True) + + c = C(1, c=3) + + assert c.a == 1 + assert c.b == 2 + assert c.c == 3 + assert c.d == 4 + + def test_ignores_kw_only_when_init_is_false(self): + """ + Specifying ``kw_only=True`` when ``init=False`` is essentially a no-op. + """ + @attr.s + class C(object): + x = attr.ib(init=False, default=0, kw_only=True) + y = attr.ib() + + c = C(1) + assert c.x == 0 + assert c.y == 1 + + def test_keyword_only_attributes_presence(self): + """ + Raises `TypeError` when keyword-only arguments are + not specified. + """ + @attr.s + class C(object): + x = attr.ib(kw_only=True) + + with pytest.raises(TypeError) as e: + C() + + assert ( + "missing 1 required keyword-only argument: 'x'" + ) in e.value.args[0] + + def test_conflicting_keyword_only_attributes(self): + """ + Raises `ValueError` if keyword-only attributes are followed by + regular (non keyword-only) attributes. + """ + class C(object): + x = attr.ib(kw_only=True) + y = attr.ib() + + with pytest.raises(ValueError) as e: + _transform_attrs(C, None) + assert ( + "Non keyword-only attributes are not allowed after a " + "keyword-only attribute. Attribute in question: Attribute" + "(name='y', default=NOTHING, validator=None, repr=True, " + "cmp=True, hash=None, init=True, convert=None, " + "metadata=mappingproxy({}), type=None, kw_only=False)", + ) == e.value.args + + def test_keyword_only_attributes_allow_subclassing(self): + """ + Subclass can define keyword-only attributed without defaults, + when the base class has attributes with defaults. + """ + @attr.s + class Base(object): + x = attr.ib(default=0) + + @attr.s + class C(Base): + y = attr.ib(kw_only=True) + + c = C(y=1) + + assert c.x == 0 + assert c.y == 1 + + @attr.s class GC(object): @attr.s diff --git a/tests/utils.py b/tests/utils.py index 36b624981..05485c4a8 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -30,13 +30,13 @@ def simple_class(cmp=False, repr=False, hash=False, str=False, slots=False, def simple_attr(name, default=NOTHING, validator=None, repr=True, - cmp=True, hash=None, init=True): + cmp=True, hash=None, init=True, kw_only=False): """ Return an attribute with a name and no other bells and whistles. """ return Attribute( name=name, default=default, validator=validator, repr=repr, - cmp=cmp, hash=hash, init=init + cmp=cmp, hash=hash, init=init, kw_only=kw_only, )