[RFC] kw_only python 2 backport (#700)

* Added kw_only support for py2

* Docs update

* Added changelog

* Better exception message, moved code to function

* Moved py2-only functions under if PY2:

* Tested fancy error message for unexpected kw-only argument

* Tested fancy error message for unexpected kw-only argument

* Fixed line length

* Added versionchanged

* Updated docs

* Moved functions back under if PY2 - seems codecov doesn't like them in module scope

* Blacked

* Fixed changelog.d

* Removed redundant brackets in test

* Added assertion to the _unpack_kw_only_lines_py2 - hope it will increase code coverage

* List comprehension -> for loop

* lines.extend? I do not like for-loops

* Fix code

* Fixed style/added comment

* Fixed docs (removed python2 mention)

* Fix lint

* Better docstring

* Rewritten docstring and added example code

* Added quotes

Co-authored-by: Hynek Schlawack <hs@ox.cx>
This commit is contained in:
Andrei Bodrov 2020-10-19 12:00:00 +03:00 committed by GitHub
parent 0e6c74ac17
commit bc527b9f29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 98 additions and 38 deletions

View File

@ -0,0 +1 @@
``kw_only=True`` now works on Python 2.

View File

@ -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 <https://docs.python.org/3/glossary.html#keyword-only-parameter>`_ attributes:
You can also add `keyword-only <https://docs.python.org/3/glossary.html#keyword-only-parameter>`_ attributes:
.. doctest::

View File

@ -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 _attrs_to_init_script(
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}):

View File

@ -716,7 +716,6 @@ class TestAttributes(object):
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 @@ class TestKeywordOnlyAttributes(object):
"""
@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 @@ class TestKeywordOnlyAttributes(object):
"""
@attr.s
class C:
class C(object):
x = attr.ib(init=False, default=0, kw_only=True)
y = attr.ib()
@ -763,15 +762,34 @@ class TestKeywordOnlyAttributes(object):
"""
@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 @@ class TestKeywordOnlyAttributes(object):
"""
@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 @@ class TestKeywordOnlyAttributes(object):
"""
@attr.s
class Base:
class Base(object):
x = attr.ib(default=0)
@attr.s
@ -830,7 +848,7 @@ class TestKeywordOnlyAttributes(object):
"""
@attr.s(kw_only=True)
class C:
class C(object):
x = attr.ib()
y = attr.ib(kw_only=True)
@ -849,7 +867,7 @@ class TestKeywordOnlyAttributes(object):
"""
@attr.s
class Base:
class Base(object):
x = attr.ib(default=0)
@attr.s(kw_only=True)
@ -872,7 +890,7 @@ class TestKeywordOnlyAttributes(object):
"""
@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 @@ class TestKeywordOnlyAttributes(object):
"""
@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``.