Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

kw_only python 2 backport #700

Merged
merged 27 commits into from
Oct 19, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c9ed44a
Added kw_only support for py2
Drino Oct 12, 2020
c938678
Docs update
Drino Oct 12, 2020
ca337e9
Added changelog
Drino Oct 15, 2020
f636e14
Better exception message, moved code to function
Drino Oct 15, 2020
e12ad75
Moved py2-only functions under if PY2:
Drino Oct 15, 2020
c6e58aa
Tested fancy error message for unexpected kw-only argument
Drino Oct 15, 2020
0a45340
Tested fancy error message for unexpected kw-only argument
Drino Oct 15, 2020
d052808
Fixed line length
Drino Oct 15, 2020
356a256
Added versionchanged
Drino Oct 15, 2020
430ea73
Updated docs
Drino Oct 15, 2020
c228a64
Moved functions back under if PY2 - seems codecov doesn't like them i…
Drino Oct 15, 2020
ca9600d
Blacked
Drino Oct 15, 2020
2e65ef7
Merge branch 'master' into py2_kw_only
Drino Oct 15, 2020
2dd691f
Fixed changelog.d
Drino Oct 15, 2020
edd79f2
Removed redundant brackets in test
Drino Oct 15, 2020
b03f015
Added assertion to the _unpack_kw_only_lines_py2 - hope it will incre…
Drino Oct 15, 2020
6e15d38
List comprehension -> for loop
Drino Oct 15, 2020
42d5e42
lines.extend? I do not like for-loops
Drino Oct 15, 2020
69c6de6
Fix code
Drino Oct 15, 2020
c0e0e06
Fixed style/added comment
Drino Oct 16, 2020
f775c7a
Fixed docs (removed python2 mention)
Drino Oct 16, 2020
b5d04ef
Fix lint
Drino Oct 16, 2020
80cbb00
Better docstring
Drino Oct 16, 2020
e2ad8af
Rewritten docstring and added example code
Drino Oct 16, 2020
89cfd5f
Added quotes
Drino Oct 16, 2020
a1b9b7b
Merge branch 'master' into py2_kw_only
Drino Oct 16, 2020
9828189
Merge branch 'master' into py2_kw_only
hynek Oct 19, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/700.change.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``kw_only=True`` now works on Python 2.
2 changes: 1 addition & 1 deletion docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::

Expand Down
72 changes: 65 additions & 7 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Drino marked this conversation as resolved.
Show resolved Hide resolved

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,
Expand Down Expand Up @@ -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}):
Expand Down
61 changes: 31 additions & 30 deletions tests/test_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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()

Expand All @@ -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):
"""
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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``.
Expand Down