Added support for keyword-only arguments on Python 3+ [rebase] (#411)
* Added support for keyword-only attributes. Closes #106, and closes #38
(Rebases #281)
Co-authored-by: Alex Ford <fordas@uw.edu>
* Add `attr.s`-level `kw_only` flag.
Add `kw_only` flag to `attr.s` decorator, indicating that all class
attributes should be keyword-only in __init__.
Minor updates to internal interface of `Attribute` to support
evolution of attributes to `kw_only` in class factory.
Expand examples with `attr.s` level kw_only.
* Add `kw_only` to type stubs.
* Update changelog for rebased PR.
Hear ye, hear ye. A duplicate PR is born.
* Tidy docs from review.
* Tidy code from review.
* Add explicit tests of PY2 kw_only SyntaxError behavior.
* Add `PythonToOldError`, raise for kw_only on PY2.
* `Attribute._evolve` to `Attribute._assoc`.
diff --git a/changelog.d/281.change.rst b/changelog.d/281.change.rst
new file mode 100644
index 0000000..e942d18
--- /dev/null
+++ b/changelog.d/281.change.rst
@@ -0,0 +1,2 @@
+Added ``kw_only`` arguments to ``attr.ib`` and ``attr.s```, and a corresponding ``kw_only`` attribute to ``attr.Attribute``.
+This change makes it possible to have a generated ``__init__`` with keyword-only arguments on Python 3, relaxing the required ordering of default and non-default valued attributes.
diff --git a/changelog.d/411.change.rst b/changelog.d/411.change.rst
new file mode 100644
index 0000000..e942d18
--- /dev/null
+++ b/changelog.d/411.change.rst
@@ -0,0 +1,2 @@
+Added ``kw_only`` arguments to ``attr.ib`` and ``attr.s```, and a corresponding ``kw_only`` attribute to ``attr.Attribute``.
+This change makes it possible to have a generated ``__init__`` with keyword-only arguments on Python 3, relaxing the required ordering of default and non-default valued attributes.
diff --git a/docs/api.rst b/docs/api.rst
index 665fe9f..ba3bc18 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -90,7 +90,7 @@
... 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, metadata=mappingproxy({}), type=None, converter=None)
+ Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False)
.. autofunction:: attr.make_class
@@ -161,9 +161,9 @@
... x = attr.ib()
... y = attr.ib()
>>> attr.fields(C)
- (Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None), Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None))
+ (Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False))
>>> attr.fields(C)[1]
- Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None)
+ Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False)
>>> attr.fields(C).y is attr.fields(C)[1]
True
@@ -178,9 +178,9 @@
... x = attr.ib()
... y = attr.ib()
>>> attr.fields_dict(C)
- {'x': Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None), 'y': Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None)}
+ {'x': Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), 'y': Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False)}
>>> attr.fields_dict(C)['y']
- Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None)
+ Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False)
>>> attr.fields_dict(C)['y'] is attr.fields(C).y
True
@@ -275,7 +275,7 @@
>>> attr.validate(i)
Traceback (most recent call last):
...
- TypeError: ("'x' must be <type 'int'> (got '1' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, repr=True, cmp=True, hash=None, init=True, type=None), <type 'int'>, '1')
+ TypeError: ("'x' must be <type 'int'> (got '1' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, repr=True, cmp=True, hash=None, init=True, type=None, kw_only=False), <type 'int'>, '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:
@@ -308,11 +308,11 @@
>>> C("42")
Traceback (most recent call last):
...
- TypeError: ("'x' must be <type 'int'> (got '42' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, type=None), <type 'int'>, '42')
+ TypeError: ("'x' must be <type 'int'> (got '42' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, type=None, kw_only=False), <type 'int'>, '42')
>>> C(None)
Traceback (most recent call last):
...
- TypeError: ("'x' must be <type 'int'> (got None that is a <type 'NoneType'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, repr=True, cmp=True, hash=None, init=True, type=None), <type 'int'>, None)
+ TypeError: ("'x' must be <type 'int'> (got None that is a <type 'NoneType'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, repr=True, cmp=True, hash=None, init=True, type=None, kw_only=False), <type 'int'>, None)
.. autofunction:: attr.validators.in_
@@ -364,7 +364,7 @@
>>> C("42")
Traceback (most recent call last):
...
- TypeError: ("'x' must be <type 'int'> (got '42' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, type=None), <type 'int'>, '42')
+ TypeError: ("'x' must be <type 'int'> (got '42' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, type=None, kw_only=False), <type 'int'>, '42')
>>> C(None)
C(x=None)
diff --git a/docs/examples.rst b/docs/examples.rst
index dfd6bf9..656d969 100644
--- a/docs/examples.rst
+++ b/docs/examples.rst
@@ -145,6 +145,73 @@
On Python 3 it overrides the implicit detection.
+Keyword-only Attributes
+~~~~~~~~~~~~~~~~~~~~~~~
+
+When using ``attrs`` on Python 3, you can also add `keyword-only <https://docs.python.org/3/glossary.html#keyword-only-parameter>`_ 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)
+
+``kw_only`` may also be specified at via ``attr.s``, and will apply to all attributes:
+
+.. doctest::
+
+ >>> @attr.s(kw_only=True)
+ ... class A:
+ ... a = attr.ib()
+ ... b = attr.ib()
+ >>> A(1, 2)
+ Traceback (most recent call last):
+ ...
+ TypeError: __init__() takes 1 positional argument but 3 were given
+ >>> A(a=1, b=2)
+ A(a=1, b=2)
+
+
+
+If you create an attribute with ``init=False``, the ``kw_only`` argument is 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 don't set ``kw_only=True``, then there's is no valid attribute ordering and 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:
Converting to Collections Types
@@ -352,7 +419,7 @@
>>> C("128")
Traceback (most recent call last):
...
- TypeError: ("'x' must be <class 'int'> (got '128' that is a <class 'str'>).", Attribute(name='x', default=NOTHING, validator=[<instance_of validator for type <class 'int'>>, <function fits_byte at 0x10fd7a0d0>], repr=True, cmp=True, hash=True, init=True, metadata=mappingproxy({}), type=None, converter=one), <class 'int'>, '128')
+ TypeError: ("'x' must be <class 'int'> (got '128' that is a <class 'str'>).", Attribute(name='x', default=NOTHING, validator=[<instance_of validator for type <class 'int'>>, <function fits_byte at 0x10fd7a0d0>], repr=True, cmp=True, hash=True, init=True, metadata=mappingproxy({}), type=None, converter=one, kw_only=False), <class 'int'>, '128')
>>> C(256)
Traceback (most recent call last):
...
@@ -371,7 +438,7 @@
>>> C("42")
Traceback (most recent call last):
...
- TypeError: ("'x' must be <type 'int'> (got '42' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, factory=NOTHING, validator=<instance_of validator for type <type 'int'>>, type=None), <type 'int'>, '42')
+ TypeError: ("'x' must be <type 'int'> (got '42' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, factory=NOTHING, validator=<instance_of validator for type <type 'int'>>, type=None, kw_only=False), <type 'int'>, '42')
Check out :ref:`validators` for more details.
diff --git a/docs/extending.rst b/docs/extending.rst
index 77f3f64..11f2a74 100644
--- a/docs/extending.rst
+++ b/docs/extending.rst
@@ -17,7 +17,7 @@
... @attr.s
... class C(object):
... a = attr.ib()
- (Attribute(name='a', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None),)
+ (Attribute(name='a', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False),)
.. warning::
diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi
index c8b4c65..34376a2 100644
--- a/src/attr/__init__.pyi
+++ b/src/attr/__init__.pyi
@@ -56,6 +56,7 @@
converter: Optional[_ConverterType[_T]]
metadata: Dict[Any, Any]
type: Optional[Type[_T]]
+ kw_only: bool
def __lt__(self, x: Attribute) -> bool: ...
def __le__(self, x: Attribute) -> bool: ...
def __gt__(self, x: Attribute) -> bool: ...
@@ -99,6 +100,7 @@
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.
@@ -115,6 +117,7 @@
type: Optional[Type[_T]] = ...,
converter: Optional[_ConverterType[_T]] = ...,
factory: Optional[Callable[[], _T]] = ...,
+ kw_only: bool = ...,
) -> _T: ...
# This form catches an explicit default argument.
@@ -131,6 +134,7 @@
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
@@ -147,6 +151,7 @@
type: object = ...,
converter: Optional[_ConverterType[_T]] = ...,
factory: Optional[Callable[[], _T]] = ...,
+ kw_only: bool = ...,
) -> Any: ...
@overload
def attrs(
@@ -161,6 +166,7 @@
frozen: bool = ...,
str: bool = ...,
auto_attribs: bool = ...,
+ kw_only: bool = ...,
) -> _C: ...
@overload
def attrs(
@@ -175,6 +181,7 @@
frozen: bool = ...,
str: bool = ...,
auto_attribs: bool = ...,
+ kw_only: bool = ...,
) -> Callable[[_C], _C]: ...
# TODO: add support for returning NamedTuple from the mypy plugin
@@ -200,6 +207,7 @@
frozen: bool = ...,
str: bool = ...,
auto_attribs: bool = ...,
+ kw_only: bool = ...,
) -> type: ...
# _funcs --
diff --git a/src/attr/_make.py b/src/attr/_make.py
index a8d9c70..71c0f23 100644
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -1,5 +1,6 @@
from __future__ import absolute_import, division, print_function
+import copy
import hashlib
import linecache
import sys
@@ -21,6 +22,7 @@
DefaultAlreadySetError,
FrozenInstanceError,
NotAnAttrsClassError,
+ PythonTooOldError,
UnannotatedAttributeError,
)
@@ -79,6 +81,7 @@
type=None,
converter=None,
factory=None,
+ kw_only=False,
):
"""
Create a new attribute on a class.
@@ -151,6 +154,9 @@
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 ignored).
.. versionadded:: 15.2.0 *convert*
.. versionadded:: 16.3.0 *metadata*
@@ -163,6 +169,7 @@
*convert* to achieve consistency with other noun-based arguments.
.. versionadded:: 18.1.0
``factory=f`` is syntactic sugar for ``default=attr.Factory(f)``.
+ .. versionadded:: 18.2.0 *kw_only*
"""
if hash is not None and hash is not True and hash is not False:
raise TypeError(
@@ -206,6 +213,7 @@
converter=converter,
metadata=metadata,
type=type,
+ kw_only=kw_only,
)
@@ -285,7 +293,7 @@
return e[1].counter
-def _transform_attrs(cls, these, auto_attribs):
+def _transform_attrs(cls, these, auto_attribs, kw_only):
"""
Transform all `_CountingAttr`s on a class into `Attribute`s.
@@ -368,19 +376,22 @@
AttrsClass = _make_attr_tuple_class(cls.__name__, attr_names)
- attrs = AttrsClass(
- super_attrs
- + [
- Attribute.from_counting_attr(
- name=attr_name, ca=ca, type=anns.get(attr_name)
- )
- for attr_name, ca in ca_list
- ]
- )
+ if kw_only:
+ own_attrs = [a._assoc(kw_only=True) for a in own_attrs]
+ super_attrs = [a._assoc(kw_only=True) for a in super_attrs]
+
+ attrs = AttrsClass(super_attrs + own_attrs)
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: %r" % (a,)
@@ -389,8 +400,21 @@
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, super_attr_map))
@@ -427,9 +451,9 @@
"_super_attr_map",
)
- def __init__(self, cls, these, slots, frozen, auto_attribs):
+ def __init__(self, cls, these, slots, frozen, auto_attribs, kw_only):
attrs, super_attrs, super_map = _transform_attrs(
- cls, these, auto_attribs
+ cls, these, auto_attribs, kw_only
)
self._cls = cls
@@ -639,6 +663,7 @@
frozen=False,
str=False,
auto_attribs=False,
+ kw_only=False,
):
r"""
A class decorator that adds `dunder
@@ -736,6 +761,10 @@
Attributes annotated as :data:`typing.ClassVar` are **ignored**.
.. _`PEP 526`: https://www.python.org/dev/peps/pep-0526/
+ :param bool kw_only: Make all attributes keyword-only (Python 3+)
+ in the generated ``__init__`` (if ``init`` is ``False``, this
+ parameter is ignored).
+
.. versionadded:: 16.0.0 *slots*
.. versionadded:: 16.1.0 *frozen*
@@ -752,13 +781,16 @@
:class:`DeprecationWarning` if the classes compared are subclasses of
each other. ``__eq`` and ``__ne__`` never tried to compared subclasses
to each other.
+ .. versionadded:: 18.2.0 *kw_only*
"""
def wrap(cls):
if getattr(cls, "__class__", None) is None:
raise TypeError("attrs only works with new-style classes.")
- builder = _ClassBuilder(cls, these, slots, frozen, auto_attribs)
+ builder = _ClassBuilder(
+ cls, these, slots, frozen, auto_attribs, kw_only
+ )
if repr is True:
builder.add_repr(repr_ns)
@@ -1298,6 +1330,7 @@
}
args = []
+ kw_only_args = []
attrs_to_validate = []
# This is a dictionary of names to validator and converter callables.
@@ -1357,11 +1390,13 @@
)
)
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.converter is not None:
lines.append(fmt_setter_with_converter(attr_name, arg_name))
names_for_globals[
@@ -1370,7 +1405,11 @@
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)
)
@@ -1402,7 +1441,10 @@
)
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.converter is not None:
lines.append(fmt_setter_with_converter(attr_name, arg_name))
names_for_globals[
@@ -1428,13 +1470,23 @@
if post_init:
lines.append("self.__attrs_post_init__()")
+ args = ", ".join(args)
+ if kw_only_args:
+ if PY2:
+ raise PythonTooOldError(
+ "Keyword-only arguments only work on Python 3 and later."
+ )
+
+ 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),
- lines="\n ".join(lines) if lines else "pass",
+ args=args, lines="\n ".join(lines) if lines else "pass"
),
names_for_globals,
annotations,
@@ -1463,6 +1515,7 @@
"metadata",
"type",
"converter",
+ "kw_only",
)
def __init__(
@@ -1478,6 +1531,7 @@
metadata=None,
type=None,
converter=None,
+ kw_only=False,
):
# Cache this descriptor here to speed things up later.
bound_setattr = _obj_setattr.__get__(self, Attribute)
@@ -1515,6 +1569,7 @@
),
)
bound_setattr("type", type)
+ bound_setattr("kw_only", kw_only)
def __setattr__(self, name, value):
raise FrozenInstanceError()
@@ -1558,6 +1613,17 @@
**inst_dict
)
+ # Don't use attr.assoc since fields(Attribute) doesn't work
+ def _assoc(self, **changes):
+ """
+ Copy *self* and apply *changes*.
+ """
+ new = copy.copy(self)
+
+ new._setattrs(changes.items())
+
+ return new
+
# Don't use _add_pickle since fields(Attribute) doesn't work
def __getstate__(self):
"""
@@ -1572,8 +1638,11 @@
"""
Play nice with pickle.
"""
+ self._setattrs(zip(self.__slots__, state))
+
+ def _setattrs(self, name_values_pairs):
bound_setattr = _obj_setattr.__get__(self, Attribute)
- for name, value in zip(self.__slots__, state):
+ for name, value in name_values_pairs:
if name != "metadata":
bound_setattr(name, value)
else:
@@ -1625,6 +1694,7 @@
"_validator",
"converter",
"type",
+ "kw_only",
)
__attrs_attrs__ = tuple(
Attribute(
@@ -1635,6 +1705,7 @@
cmp=True,
hash=True,
init=True,
+ kw_only=False,
)
for name in ("counter", "_default", "repr", "cmp", "hash", "init")
) + (
@@ -1646,6 +1717,7 @@
cmp=True,
hash=False,
init=True,
+ kw_only=False,
),
)
cls_counter = 0
@@ -1661,6 +1733,7 @@
converter,
metadata,
type,
+ kw_only,
):
_CountingAttr.cls_counter += 1
self.counter = _CountingAttr.cls_counter
@@ -1677,6 +1750,7 @@
self.converter = converter
self.metadata = metadata
self.type = type
+ self.kw_only = kw_only
def validator(self, meth):
"""
diff --git a/src/attr/exceptions.py b/src/attr/exceptions.py
index 1a3229f..b12e41e 100644
--- a/src/attr/exceptions.py
+++ b/src/attr/exceptions.py
@@ -47,3 +47,11 @@
.. versionadded:: 17.3.0
"""
+
+
+class PythonTooOldError(RuntimeError):
+ """
+ An ``attrs`` feature requiring a more recent python version has been used.
+
+ .. versionadded:: 18.2.0
+ """
diff --git a/tests/test_annotations.py b/tests/test_annotations.py
index 34807a4..fee45d1 100644
--- a/tests/test_annotations.py
+++ b/tests/test_annotations.py
@@ -229,3 +229,24 @@
"foo": "typing.Any",
"return": None,
}
+
+ def test_keyword_only_auto_attribs(self):
+ """
+ `kw_only` propagates to attributes defined via `auto_attribs`.
+ """
+
+ @attr.s(auto_attribs=True, kw_only=True)
+ class C:
+ x: int
+ y: int
+
+ with pytest.raises(TypeError):
+ C(0, 1)
+
+ with pytest.raises(TypeError):
+ C(x=0)
+
+ c = C(x=0, y=1)
+
+ assert c.x == 0
+ assert c.y == 1
diff --git a/tests/test_make.py b/tests/test_make.py
index 20d13b6..0302cb5 100644
--- a/tests/test_make.py
+++ b/tests/test_make.py
@@ -35,7 +35,11 @@
make_class,
validate,
)
-from attr.exceptions import DefaultAlreadySetError, NotAnAttrsClassError
+from attr.exceptions import (
+ DefaultAlreadySetError,
+ NotAnAttrsClassError,
+ PythonTooOldError,
+)
from .strategies import (
gen_attr_names,
@@ -229,7 +233,7 @@
Doesn't attach __attrs_attrs__ to the class anymore.
"""
C = make_tc()
- _transform_attrs(C, None, False)
+ _transform_attrs(C, None, False, False)
assert None is getattr(C, "__attrs_attrs__", None)
@@ -238,7 +242,7 @@
Transforms every `_CountingAttr` and leaves others (a) be.
"""
C = make_tc()
- attrs, _, _ = _transform_attrs(C, None, False)
+ attrs, _, _ = _transform_attrs(C, None, False, False)
assert ["z", "y", "x"] == [a.name for a in attrs]
@@ -251,14 +255,16 @@
class C(object):
pass
- assert _Attributes(((), [], {})) == _transform_attrs(C, None, False)
+ assert _Attributes(((), [], {})) == _transform_attrs(
+ C, None, False, False
+ )
def test_transforms_to_attribute(self):
"""
All `_CountingAttr`s are transformed into `Attribute`s.
"""
C = make_tc()
- attrs, super_attrs, _ = _transform_attrs(C, None, False)
+ attrs, super_attrs, _ = _transform_attrs(C, None, False, False)
assert [] == super_attrs
assert 3 == len(attrs)
@@ -275,15 +281,46 @@
y = attr.ib()
with pytest.raises(ValueError) as e:
- _transform_attrs(C, None, False)
+ _transform_attrs(C, None, False, False)
assert (
"No mandatory attributes allowed after an attribute with a "
"default value or factory. Attribute in question: Attribute"
"(name='y', default=NOTHING, validator=None, repr=True, "
"cmp=True, hash=None, init=True, metadata=mappingproxy({}), "
- "type=None, converter=None)",
+ "type=None, converter=None, kw_only=False)",
) == e.value.args
+ def test_kw_only(self):
+ """
+ Converts all attributes, including superclass attributes, if `kw_only`
+ is provided. Therefore, `kw_only` allows attributes with defaults to
+ preceed mandatory attributes.
+
+ Updates in the subclass *don't* affect the superclass attributes.
+ """
+
+ @attr.s
+ class B(object):
+ b = attr.ib()
+
+ for b_a in B.__attrs_attrs__:
+ assert b_a.kw_only is False
+
+ class C(B):
+ x = attr.ib(default=None)
+ y = attr.ib()
+
+ attrs, super_attrs, _ = _transform_attrs(C, None, False, True)
+
+ assert len(attrs) == 3
+ assert len(super_attrs) == 1
+
+ for a in attrs:
+ assert a.kw_only is True
+
+ for b_a in B.__attrs_attrs__:
+ assert b_a.kw_only is False
+
def test_these(self):
"""
If these is passed, use it and ignore body and super classes.
@@ -295,7 +332,9 @@
class C(Base):
y = attr.ib()
- attrs, super_attrs, _ = _transform_attrs(C, {"x": attr.ib()}, False)
+ attrs, super_attrs, _ = _transform_attrs(
+ C, {"x": attr.ib()}, False, False
+ )
assert [] == super_attrs
assert (simple_attr("x"),) == attrs
@@ -594,6 +633,182 @@
x = attr.ib(factory=Factory(list))
+@pytest.mark.skipif(PY2, reason="keyword-only arguments are 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, False, False)
+
+ 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, metadata=mappingproxy({}), "
+ "type=None, converter=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
+
+ def test_keyword_only_class_level(self):
+ """
+ `kw_only` can be provided at the attr.s level, converting all
+ attributes to `kw_only.`
+ """
+
+ @attr.s(kw_only=True)
+ class C:
+ x = attr.ib()
+ y = attr.ib(kw_only=True)
+
+ with pytest.raises(TypeError):
+ C(0, y=1)
+
+ c = C(x=0, y=1)
+
+ assert c.x == 0
+ assert c.y == 1
+
+ def test_keyword_only_class_level_subclassing(self):
+ """
+ Subclass `kw_only` propagates to attrs inherited from the base,
+ allowing non-default following default.
+ """
+
+ @attr.s
+ class Base(object):
+ x = attr.ib(default=0)
+
+ @attr.s(kw_only=True)
+ class C(Base):
+ y = attr.ib()
+
+ with pytest.raises(TypeError):
+ C(1)
+
+ c = C(x=0, y=1)
+
+ assert c.x == 0
+ assert c.y == 1
+
+
+@pytest.mark.skipif(not PY2, reason="PY2-specific keyword-only error behavior")
+class TestKeywordOnlyAttributesOnPy2(object):
+ """
+ Tests for keyword-only attribute behavior on py2.
+ """
+
+ def test_syntax_error(self):
+ """
+ Keyword-only attributes raise Syntax error on ``__init__`` generation.
+ """
+
+ with pytest.raises(PythonTooOldError):
+
+ @attr.s(kw_only=True)
+ class ClassLevel(object):
+ a = attr.ib()
+
+ with pytest.raises(PythonTooOldError):
+
+ @attr.s()
+ class AttrLevel(object):
+ a = attr.ib(kw_only=True)
+
+ def test_no_init(self):
+ """
+ Keyworld-only is a no-op, not any error, if ``init=false``.
+ """
+
+ @attr.s(kw_only=True, init=False)
+ class ClassLevel(object):
+ a = attr.ib()
+
+ @attr.s(init=False)
+ class AttrLevel(object):
+ a = attr.ib(kw_only=True)
+
+
@attr.s
class GC(object):
@attr.s
@@ -1153,7 +1368,7 @@
class C(object):
pass
- b = _ClassBuilder(C, None, True, True, False)
+ b = _ClassBuilder(C, None, True, True, False, False)
assert "<_ClassBuilder(cls=C)>" == repr(b)
@@ -1165,7 +1380,7 @@
class C(object):
x = attr.ib()
- b = _ClassBuilder(C, None, True, True, False)
+ b = _ClassBuilder(C, None, True, True, False, False)
cls = (
b.add_cmp()
@@ -1222,7 +1437,12 @@
pass
b = _ClassBuilder(
- C, these=None, slots=False, frozen=False, auto_attribs=False
+ C,
+ these=None,
+ slots=False,
+ frozen=False,
+ auto_attribs=False,
+ kw_only=False,
)
b._cls = {} # no __module__; no __qualname__
diff --git a/tests/utils.py b/tests/utils.py
index baf7331..230726c 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -36,6 +36,7 @@
hash=None,
init=True,
converter=None,
+ kw_only=False,
):
"""
Return an attribute with a name and no other bells and whistles.
@@ -49,6 +50,7 @@
hash=hash,
init=init,
converter=converter,
+ kw_only=False,
)