diff --git a/changelog.d/700.change.rst b/changelog.d/700.change.rst new file mode 100644 index 000000000..d4dc2dfd0 --- /dev/null +++ b/changelog.d/700.change.rst @@ -0,0 +1 @@ +``kw_only=True`` now works on Python 2. diff --git a/docs/examples.rst b/docs/examples.rst index 508aa9f6e..0fac312a0 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -146,7 +146,7 @@ On Python 3 it overrides the implicit detection. Keyword-only Attributes ~~~~~~~~~~~~~~~~~~~~~~~ -When using ``attrs`` on Python 3, you can also add `keyword-only `_ attributes: +You can also add `keyword-only `_ attributes: .. doctest:: diff --git a/src/attr/_make.py b/src/attr/_make.py index 7990dba82..84b63d55c 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -223,6 +223,7 @@ def attrib( .. deprecated:: 19.2.0 *cmp* Removal on or after 2021-06-01. .. versionadded:: 19.2.0 *eq* and *order* .. versionadded:: 20.1.0 *on_setattr* + .. versionchanged:: 20.3.0 *kw_only* backported to Python 2 """ eq, order = _determine_eq_order(cmp, eq, order, True) @@ -1924,6 +1925,63 @@ def _assign_with_converter(attr_name, value_var, has_on_setattr): ) +if PY2: + + def _unpack_kw_only_py2(attr_name, default=None): + """ + Unpack *attr_name* from _kw_only dict. + """ + if default is not None: + arg_default = ", %s" % default + else: + arg_default = "" + return "%s = _kw_only.pop('%s'%s)" % ( + attr_name, + attr_name, + arg_default, + ) + + def _unpack_kw_only_lines_py2(kw_only_args): + """ + Unpack all *kw_only_args* from _kw_only dict and handle errors. + + Given a list of strings "{attr_name}" and "{attr_name}={default}" + generates list of lines of code that pop attrs from _kw_only dict and + raise TypeError similar to builtin if required attr is missing or + extra key is passed. + + >>> print("\n".join(_unpack_kw_only_lines_py2(["a", "b=42"]))) + try: + a = _kw_only.pop('a') + b = _kw_only.pop('b', 42) + except KeyError as _key_error: + raise TypeError( + ... + if _kw_only: + raise TypeError( + ... + """ + lines = ["try:"] + lines.extend( + " " + _unpack_kw_only_py2(*arg.split("=")) + for arg in kw_only_args + ) + lines += """\ +except KeyError as _key_error: + raise TypeError( + '__init__() missing required keyword-only argument: %s' % _key_error + ) +if _kw_only: + raise TypeError( + '__init__() got an unexpected keyword argument %r' + % next(iter(_kw_only)) + ) +""".split( + "\n" + ) + return lines + + def _attrs_to_init_script( attrs, frozen, @@ -2178,14 +2236,14 @@ def fmt_setter_with_converter( args = ", ".join(args) if kw_only_args: if PY2: - raise PythonTooOldError( - "Keyword-only arguments only work on Python 3 and later." - ) + lines = _unpack_kw_only_lines_py2(kw_only_args) + lines - args += "{leading_comma}*, {kw_only_args}".format( - leading_comma=", " if args else "", - kw_only_args=", ".join(kw_only_args), - ) + args += "%s**_kw_only" % (", " if args else "",) # leading comma + else: + args += "%s*, %s" % ( + ", " if args else "", # leading comma + ", ".join(kw_only_args), # kw_only args + ) return ( """\ def __init__(self, {args}): diff --git a/tests/test_make.py b/tests/test_make.py index e475dc8fb..fad4ec7e2 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -716,7 +716,6 @@ class SubClass(BaseClass): assert hash(ba) == hash(sa) -@pytest.mark.skipif(PY2, reason="keyword-only arguments are PY3-only.") class TestKeywordOnlyAttributes(object): """ Tests for keyword-only attributes. @@ -728,7 +727,7 @@ def test_adds_keyword_only_arguments(self): """ @attr.s - class C: + class C(object): a = attr.ib() b = attr.ib(default=2, kw_only=True) c = attr.ib(kw_only=True) @@ -747,7 +746,7 @@ def test_ignores_kw_only_when_init_is_false(self): """ @attr.s - class C: + class C(object): x = attr.ib(init=False, default=0, kw_only=True) y = attr.ib() @@ -763,15 +762,34 @@ def test_keyword_only_attributes_presence(self): """ @attr.s - class C: + 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] + if PY2: + assert ( + "missing required keyword-only argument: 'x'" + ) in e.value.args[0] + else: + assert ( + "missing 1 required keyword-only argument: 'x'" + ) in e.value.args[0] + + def test_keyword_only_attributes_unexpected(self): + """ + Raises `TypeError` when unexpected keyword argument passed. + """ + + @attr.s + class C(object): + x = attr.ib(kw_only=True) + + with pytest.raises(TypeError) as e: + C(x=5, y=10) + + assert "got an unexpected keyword argument 'y'" in e.value.args[0] def test_keyword_only_attributes_can_come_in_any_order(self): """ @@ -782,7 +800,7 @@ def test_keyword_only_attributes_can_come_in_any_order(self): """ @attr.s - class C: + class C(object): a = attr.ib(kw_only=True) b = attr.ib(kw_only=True, default="b") c = attr.ib(kw_only=True) @@ -811,7 +829,7 @@ def test_keyword_only_attributes_allow_subclassing(self): """ @attr.s - class Base: + class Base(object): x = attr.ib(default=0) @attr.s @@ -830,7 +848,7 @@ def test_keyword_only_class_level(self): """ @attr.s(kw_only=True) - class C: + class C(object): x = attr.ib() y = attr.ib(kw_only=True) @@ -849,7 +867,7 @@ def test_keyword_only_class_level_subclassing(self): """ @attr.s - class Base: + class Base(object): x = attr.ib(default=0) @attr.s(kw_only=True) @@ -872,7 +890,7 @@ def test_init_false_attribute_after_keyword_attribute(self): """ @attr.s - class KwArgBeforeInitFalse: + class KwArgBeforeInitFalse(object): kwarg = attr.ib(kw_only=True) non_init_function_default = attr.ib(init=False) non_init_keyword_default = attr.ib( @@ -900,7 +918,7 @@ def test_init_false_attribute_after_keyword_attribute_with_inheritance( """ @attr.s - class KwArgBeforeInitFalseParent: + class KwArgBeforeInitFalseParent(object): kwarg = attr.ib(kw_only=True) @attr.s @@ -927,23 +945,6 @@ 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``.