Skip to content

Commit

Permalink
Added support for keyword-only attributes. Closes python-attrs#106, and
Browse files Browse the repository at this point in the history
  • Loading branch information
malinoff committed Nov 1, 2017
1 parent 3d3d49b commit f06cf91
Show file tree
Hide file tree
Showing 4 changed files with 199 additions and 20 deletions.
51 changes: 51 additions & 0 deletions docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,57 @@ 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 <https://docs.python.org/3/glossary.html#keyword-only-parameter>`_ attributes:

.. doctest::

>>> @attr.s
... class A:
... a = attr.ib(kwonly=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``, ``kwonly`` 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(kwonly=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 ``kwonly`` or specify ``kwonly=False``, then you'll get an error:

.. doctest::

>>> @attr.s
... class A:
... a = attr.ib(default=0)
>>> @attr.s
... class B(Base):
... 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, kwonly=False)

.. _asdict:

Expand Down
71 changes: 54 additions & 17 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, kwonly=False):
"""
Create a new attribute on a class.
Expand Down Expand Up @@ -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 kwonly: 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.4.0 *kwonly*
"""
if hash is not None and hash is not True and hash is not False:
raise TypeError(
Expand All @@ -150,6 +154,7 @@ def attrib(default=NOTHING, validator=None,
convert=convert,
metadata=metadata,
type=type,
kwonly=kwonly,
)


Expand Down Expand Up @@ -257,17 +262,31 @@ def _transform_attrs(cls, these):
)

had_default = False
was_kwonly = False
for a in attrs:
if had_default is True and a.default is NOTHING and a.init is True:
if (was_kwonly is False and had_default is True and
a.default is NOTHING and a.init is True and
a.kwonly 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 can be specified after keyword-only
# attributes with default values.
a.kwonly is False):
had_default = True
if was_kwonly is True and a.kwonly 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_kwonly is False and a.init is True and a.kwonly is True:
was_kwonly = True

return _Attributes((attrs, super_attrs))

Expand Down Expand Up @@ -913,6 +932,7 @@ def fmt_setter_with_converter(attr_name, value_var):
}

args = []
kwonly_args = []
attrs_to_validate = []

# This is a dictionary of names to validator and converter callables.
Expand Down Expand Up @@ -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.kwonly:
kwonly_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.kwonly:
kwonly_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)
Expand All @@ -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.kwonly:
kwonly_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
Expand All @@ -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 kwonly_args:
args += "{trailing_comma}*, {kwonly_args}".format(
trailing_comma=", " if args else "",
kwonly_args=", ".join(kwonly_args)
)
return """\
def __init__(self, {args}):
{lines}
""".format(
args=", ".join(args),
args=args,
lines="\n ".join(lines) if lines else "pass",
), names_for_globals

Expand All @@ -1033,11 +1068,11 @@ class Attribute(object):
"""
__slots__ = (
"name", "default", "validator", "repr", "cmp", "hash", "init",
"convert", "metadata", "type"
"convert", "metadata", "type", "kwonly"
)

def __init__(self, name, default, validator, repr, cmp, hash, init,
convert=None, metadata=None, type=None):
convert=None, metadata=None, type=None, kwonly=False):
# Cache this descriptor here to speed things up later.
bound_setattr = _obj_setattr.__get__(self, Attribute)

Expand All @@ -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("kwonly", kwonly)

def __setattr__(self, name, value):
raise FrozenInstanceError()
Expand Down Expand Up @@ -1117,7 +1153,7 @@ 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", "kwonly")
__attrs_attrs__ = tuple(
Attribute(name=name, default=NOTHING, validator=None,
repr=True, cmp=True, hash=True, init=True)
Expand All @@ -1130,7 +1166,7 @@ class _CountingAttr(object):
cls_counter = 0

def __init__(self, default, validator, repr, cmp, hash, init, convert,
metadata, type):
metadata, type, kwonly=False):
_CountingAttr.cls_counter += 1
self.counter = _CountingAttr.cls_counter
self._default = default
Expand All @@ -1146,6 +1182,7 @@ def __init__(self, default, validator, repr, cmp, hash, init, convert,
self.convert = convert
self.metadata = metadata
self.type = type
self.kwonly = kwonly

def validator(self, meth):
"""
Expand Down
93 changes: 92 additions & 1 deletion tests/test_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, kwonly=False)",
) == e.value.args

def test_these(self):
Expand Down Expand Up @@ -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, kwonly=True)
c = attr.ib(kwonly=True)
d = attr.ib(default=attr.Factory(lambda: 4), kwonly=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_kwonly_when_init_is_false(self):
"""
Specifying ``kwonly=True`` when ``init=False`` is essentially a no-op.
"""
@attr.s
class C(object):
x = attr.ib(init=False, default=0, kwonly=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(kwonly=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(kwonly=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, kwonly=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(kwonly=True)

c = C(y=1)

assert c.x == 0
assert c.y == 1


@attr.s
class GC(object):
@attr.s
Expand Down
4 changes: 2 additions & 2 deletions tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, kwonly=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, kwonly=kwonly,
)


Expand Down

0 comments on commit f06cf91

Please sign in to comment.