Skip to content

Commit efe8aee

Browse files
committed
Added support for keyword-only attributes. Closes #106, and closes #38
1 parent 3d3d49b commit efe8aee

File tree

5 files changed

+201
-22
lines changed

5 files changed

+201
-22
lines changed

changelog.d/281.change.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Added ``kwonly`` argument to ``attr.ib`` and corresponding ``kwonly`` attribute to ``attr.Attribute``.
2+
This change makes it possible to have a generated ``__init__`` with keyword-only arguments on Python 3.

docs/examples.rst

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,55 @@ Therefore ``@attr.s`` comes with the ``repr_ns`` option to set it manually:
141141
``repr_ns`` works on both Python 2 and 3.
142142
On Python 3 it overrides the implicit detection.
143143

144+
Keyword-only Attributes
145+
~~~~~~~~~~~~~~~~~~~~~~~
146+
147+
When using ``attrs`` on Python 3, you can also add `keyword-only <https://docs.python.org/3/glossary.html#keyword-only-parameter>`_ attributes:
148+
149+
.. doctest::
150+
151+
>>> @attr.s
152+
... class A:
153+
... a = attr.ib(kwonly=True)
154+
>>> A()
155+
Traceback (most recent call last):
156+
...
157+
TypeError: A() missing 1 required keyword-only argument: 'a'
158+
>>> A(a=1)
159+
A(a=1)
160+
161+
If you create an attribute with ``init=False``, ``kwonly`` argument is simply ignored.
162+
163+
Keyword-only attributes allow subclasses to add attributes without default values, even if the base class defines attributes with default values:
164+
165+
.. doctest::
166+
167+
>>> @attr.s
168+
... class A:
169+
... a = attr.ib(default=0)
170+
>>> @attr.s
171+
... class B(A):
172+
... b = attr.ib(kwonly=True)
173+
>>> B(b=1)
174+
B(a=0, b=1)
175+
>>> B()
176+
Traceback (most recent call last):
177+
...
178+
TypeError: B() missing 1 required keyword-only argument: 'b'
179+
180+
If you omit ``kwonly`` or specify ``kwonly=False``, then you'll get an error:
181+
182+
.. doctest::
183+
184+
>>> @attr.s
185+
... class A:
186+
... a = attr.ib(default=0)
187+
>>> @attr.s
188+
... class B(Base):
189+
... b = attr.ib()
190+
Traceback (most recent call last):
191+
...
192+
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)
144193

145194
.. _asdict:
146195

src/attr/_make.py

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def __hash__(self):
6161

6262
def attrib(default=NOTHING, validator=None,
6363
repr=True, cmp=True, hash=None, init=True,
64-
convert=None, metadata={}, type=None):
64+
convert=None, metadata={}, type=None, kwonly=False):
6565
"""
6666
Create a new attribute on a class.
6767
@@ -130,11 +130,15 @@ def attrib(default=NOTHING, validator=None,
130130
This argument is provided for backward compatibility.
131131
Regardless of the approach used, the type will be stored on
132132
``Attribute.type``.
133+
:param kwonly: Make this attribute keyword-only (Python 3+)
134+
in the generated ``__init__`` (if ``init`` is ``False``, this
135+
parameter is simply ignored).
133136
134137
.. versionchanged:: 17.1.0 *validator* can be a ``list`` now.
135138
.. versionchanged:: 17.1.0
136139
*hash* is ``None`` and therefore mirrors *cmp* by default .
137140
.. versionadded:: 17.3.0 *type*
141+
.. versionadded:: 17.3.0 *kwonly*
138142
"""
139143
if hash is not None and hash is not True and hash is not False:
140144
raise TypeError(
@@ -150,6 +154,7 @@ def attrib(default=NOTHING, validator=None,
150154
convert=convert,
151155
metadata=metadata,
152156
type=type,
157+
kwonly=kwonly,
153158
)
154159

155160

@@ -257,17 +262,31 @@ def _transform_attrs(cls, these):
257262
)
258263

259264
had_default = False
265+
was_kwonly = False
260266
for a in attrs:
261-
if had_default is True and a.default is NOTHING and a.init is True:
267+
if (was_kwonly is False and had_default is True and
268+
a.default is NOTHING and a.init is True and
269+
a.kwonly is False):
262270
raise ValueError(
263271
"No mandatory attributes allowed after an attribute with a "
264272
"default value or factory. Attribute in question: {a!r}"
265273
.format(a=a)
266274
)
267-
elif had_default is False and \
268-
a.default is not NOTHING and \
269-
a.init is not False:
275+
elif (had_default is False and
276+
a.default is not NOTHING and
277+
a.init is not False and
278+
# Keyword-only attributes can be specified after keyword-only
279+
# attributes with default values.
280+
a.kwonly is False):
270281
had_default = True
282+
if was_kwonly is True and a.kwonly is False:
283+
raise ValueError(
284+
"Non keyword-only attributes are not allowed after a "
285+
"keyword-only attribute. Attribute in question: {a!r}"
286+
.format(a=a)
287+
)
288+
if was_kwonly is False and a.init is True and a.kwonly is True:
289+
was_kwonly = True
271290

272291
return _Attributes((attrs, super_attrs))
273292

@@ -913,6 +932,7 @@ def fmt_setter_with_converter(attr_name, value_var):
913932
}
914933

915934
args = []
935+
kwonly_args = []
916936
attrs_to_validate = []
917937

918938
# This is a dictionary of names to validator and converter callables.
@@ -960,19 +980,25 @@ def fmt_setter_with_converter(attr_name, value_var):
960980
.format(attr_name=attr_name)
961981
))
962982
elif a.default is not NOTHING and not has_factory:
963-
args.append(
964-
"{arg_name}=attr_dict['{attr_name}'].default".format(
965-
arg_name=arg_name,
966-
attr_name=attr_name,
967-
)
983+
arg = "{arg_name}=attr_dict['{attr_name}'].default".format(
984+
arg_name=arg_name,
985+
attr_name=attr_name,
968986
)
987+
if a.kwonly:
988+
kwonly_args.append(arg)
989+
else:
990+
args.append(arg)
969991
if a.convert is not None:
970992
lines.append(fmt_setter_with_converter(attr_name, arg_name))
971993
names_for_globals[_init_convert_pat.format(a.name)] = a.convert
972994
else:
973995
lines.append(fmt_setter(attr_name, arg_name))
974996
elif has_factory:
975-
args.append("{arg_name}=NOTHING".format(arg_name=arg_name))
997+
arg = "{arg_name}=NOTHING".format(arg_name=arg_name)
998+
if a.kwonly:
999+
kwonly_args.append(arg)
1000+
else:
1001+
args.append(arg)
9761002
lines.append("if {arg_name} is not NOTHING:"
9771003
.format(arg_name=arg_name))
9781004
init_factory_name = _init_factory_pat.format(a.name)
@@ -994,7 +1020,10 @@ def fmt_setter_with_converter(attr_name, value_var):
9941020
))
9951021
names_for_globals[init_factory_name] = a.default.factory
9961022
else:
997-
args.append(arg_name)
1023+
if a.kwonly:
1024+
kwonly_args.append(arg_name)
1025+
else:
1026+
args.append(arg_name)
9981027
if a.convert is not None:
9991028
lines.append(fmt_setter_with_converter(attr_name, arg_name))
10001029
names_for_globals[_init_convert_pat.format(a.name)] = a.convert
@@ -1014,11 +1043,17 @@ def fmt_setter_with_converter(attr_name, value_var):
10141043
if post_init:
10151044
lines.append("self.__attrs_post_init__()")
10161045

1046+
args = ", ".join(args)
1047+
if kwonly_args:
1048+
args += "{leading_comma}*, {kwonly_args}".format(
1049+
leading_comma=", " if args else "",
1050+
kwonly_args=", ".join(kwonly_args)
1051+
)
10171052
return """\
10181053
def __init__(self, {args}):
10191054
{lines}
10201055
""".format(
1021-
args=", ".join(args),
1056+
args=args,
10221057
lines="\n ".join(lines) if lines else "pass",
10231058
), names_for_globals
10241059

@@ -1033,11 +1068,11 @@ class Attribute(object):
10331068
"""
10341069
__slots__ = (
10351070
"name", "default", "validator", "repr", "cmp", "hash", "init",
1036-
"convert", "metadata", "type"
1071+
"convert", "metadata", "type", "kwonly"
10371072
)
10381073

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

@@ -1052,6 +1087,7 @@ def __init__(self, name, default, validator, repr, cmp, hash, init,
10521087
bound_setattr("metadata", (metadata_proxy(metadata) if metadata
10531088
else _empty_metadata_singleton))
10541089
bound_setattr("type", type)
1090+
bound_setattr("kwonly", kwonly)
10551091

10561092
def __setattr__(self, name, value):
10571093
raise FrozenInstanceError()
@@ -1117,20 +1153,20 @@ class _CountingAttr(object):
11171153
likely the result of a bug like a forgotten `@attr.s` decorator.
11181154
"""
11191155
__slots__ = ("counter", "_default", "repr", "cmp", "hash", "init",
1120-
"metadata", "_validator", "convert", "type")
1156+
"metadata", "_validator", "convert", "type", "kwonly")
11211157
__attrs_attrs__ = tuple(
11221158
Attribute(name=name, default=NOTHING, validator=None,
1123-
repr=True, cmp=True, hash=True, init=True)
1159+
repr=True, cmp=True, hash=True, init=True, kwonly=False)
11241160
for name
11251161
in ("counter", "_default", "repr", "cmp", "hash", "init",)
11261162
) + (
11271163
Attribute(name="metadata", default=None, validator=None,
1128-
repr=True, cmp=True, hash=False, init=True),
1164+
repr=True, cmp=True, hash=False, init=True, kwonly=False),
11291165
)
11301166
cls_counter = 0
11311167

11321168
def __init__(self, default, validator, repr, cmp, hash, init, convert,
1133-
metadata, type):
1169+
metadata, type, kwonly):
11341170
_CountingAttr.cls_counter += 1
11351171
self.counter = _CountingAttr.cls_counter
11361172
self._default = default
@@ -1146,6 +1182,7 @@ def __init__(self, default, validator, repr, cmp, hash, init, convert,
11461182
self.convert = convert
11471183
self.metadata = metadata
11481184
self.type = type
1185+
self.kwonly = kwonly
11491186

11501187
def validator(self, meth):
11511188
"""

tests/test_make.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ class C(object):
193193
"default value or factory. Attribute in question: Attribute"
194194
"(name='y', default=NOTHING, validator=None, repr=True, "
195195
"cmp=True, hash=None, init=True, convert=None, "
196-
"metadata=mappingproxy({}), type=None)",
196+
"metadata=mappingproxy({}), type=None, kwonly=False)",
197197
) == e.value.args
198198

199199
def test_these(self):
@@ -407,6 +407,97 @@ class C(object):
407407
assert not isinstance(x, _CountingAttr)
408408

409409

410+
@pytest.mark.skipif(PY2, reason="keyword-only arguments is PY3-only.")
411+
class TestKeywordOnlyAttributes(object):
412+
"""
413+
Tests for keyword-only attributes.
414+
"""
415+
416+
def test_adds_keyword_only_arguments(self):
417+
"""
418+
Attributes can be added as keyword-only.
419+
"""
420+
@attr.s
421+
class C(object):
422+
a = attr.ib()
423+
b = attr.ib(default=2, kwonly=True)
424+
c = attr.ib(kwonly=True)
425+
d = attr.ib(default=attr.Factory(lambda: 4), kwonly=True)
426+
427+
c = C(1, c=3)
428+
429+
assert c.a == 1
430+
assert c.b == 2
431+
assert c.c == 3
432+
assert c.d == 4
433+
434+
def test_ignores_kwonly_when_init_is_false(self):
435+
"""
436+
Specifying ``kwonly=True`` when ``init=False`` is essentially a no-op.
437+
"""
438+
@attr.s
439+
class C(object):
440+
x = attr.ib(init=False, default=0, kwonly=True)
441+
y = attr.ib()
442+
443+
c = C(1)
444+
assert c.x == 0
445+
assert c.y == 1
446+
447+
def test_keyword_only_attributes_presence(self):
448+
"""
449+
Raises `TypeError` when keyword-only arguments are
450+
not specified.
451+
"""
452+
@attr.s
453+
class C(object):
454+
x = attr.ib(kwonly=True)
455+
456+
with pytest.raises(TypeError) as e:
457+
C()
458+
459+
assert (
460+
"missing 1 required keyword-only argument: 'x'"
461+
) in e.value.args[0]
462+
463+
def test_conflicting_keyword_only_attributes(self):
464+
"""
465+
Raises `ValueError` if keyword-only attributes are followed by
466+
regular (non keyword-only) attributes.
467+
"""
468+
class C(object):
469+
x = attr.ib(kwonly=True)
470+
y = attr.ib()
471+
472+
with pytest.raises(ValueError) as e:
473+
_transform_attrs(C, None)
474+
assert (
475+
"Non keyword-only attributes are not allowed after a "
476+
"keyword-only attribute. Attribute in question: Attribute"
477+
"(name='y', default=NOTHING, validator=None, repr=True, "
478+
"cmp=True, hash=None, init=True, convert=None, "
479+
"metadata=mappingproxy({}), type=None, kwonly=False)",
480+
) == e.value.args
481+
482+
def test_keyword_only_attributes_allow_subclassing(self):
483+
"""
484+
Subclass can define keyword-only attributed without defaults,
485+
when the base class has attributes with defaults.
486+
"""
487+
@attr.s
488+
class Base(object):
489+
x = attr.ib(default=0)
490+
491+
@attr.s
492+
class C(Base):
493+
y = attr.ib(kwonly=True)
494+
495+
c = C(y=1)
496+
497+
assert c.x == 0
498+
assert c.y == 1
499+
500+
410501
@attr.s
411502
class GC(object):
412503
@attr.s

tests/utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,13 @@ def simple_class(cmp=False, repr=False, hash=False, str=False, slots=False,
3030

3131

3232
def simple_attr(name, default=NOTHING, validator=None, repr=True,
33-
cmp=True, hash=None, init=True):
33+
cmp=True, hash=None, init=True, kwonly=False):
3434
"""
3535
Return an attribute with a name and no other bells and whistles.
3636
"""
3737
return Attribute(
3838
name=name, default=default, validator=validator, repr=repr,
39-
cmp=cmp, hash=hash, init=init
39+
cmp=cmp, hash=hash, init=init, kwonly=kwonly,
4040
)
4141

4242

0 commit comments

Comments
 (0)