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

Add __match_args__ to support match case destructuring in Python 3.10 #815

Merged
merged 20 commits into from May 18, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 5 additions & 2 deletions .pre-commit-config.yaml
Expand Up @@ -4,6 +4,7 @@ repos:
rev: 20.8b1
hooks:
- id: black
exclude: tests/test_pattern_matching.py
language_version: python3.8

- repo: https://github.com/PyCQA/isort
Expand All @@ -16,19 +17,21 @@ repos:
rev: 3.8.4
hooks:
- id: flake8
language_version: python3.8
language_version: python3.10

- repo: https://github.com/econchick/interrogate
rev: 1.3.2
hooks:
- id: interrogate
exclude: tests/test_pattern_matching.py
args: [tests]
language_version: python3.8
language_version: python3.10

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: debug-statements
language_version: python3.10
- id: check-toml
3 changes: 3 additions & 0 deletions changelog.d/815.changes.rst
@@ -0,0 +1,3 @@
``__match_args__`` are now generated to support Python 3.10's
`Structural Pattern Matching <https://docs.python.org/3.10/whatsnew/3.10.html#pep-634-structural-pattern-matching>`_.
This can be controlled by ``match_args`` argument to the class decorators.
2 changes: 2 additions & 0 deletions conftest.py
Expand Up @@ -23,6 +23,8 @@ def pytest_configure(config):
"tests/test_next_gen.py",
]
)
if sys.version_info[:2] < (3, 10):
collect_ignore.extend(["tests/test_pattern_matching.py"])
if sys.version_info[:2] >= (3, 10):
collect_ignore.extend(
[
Expand Down
2 changes: 1 addition & 1 deletion docs/api.rst
Expand Up @@ -28,7 +28,7 @@ Core

.. autodata:: attr.NOTHING

.. autofunction:: attr.s(these=None, repr_ns=None, repr=None, cmp=None, hash=None, init=None, slots=False, frozen=False, weakref_slot=True, str=False, auto_attribs=False, kw_only=False, cache_hash=False, auto_exc=False, eq=None, order=None, auto_detect=False, collect_by_mro=False, getstate_setstate=None, on_setattr=None, field_transformer=None)
.. autofunction:: attr.s(these=None, repr_ns=None, repr=None, cmp=None, hash=None, init=None, slots=False, frozen=False, weakref_slot=True, str=False, auto_attribs=False, kw_only=False, cache_hash=False, auto_exc=False, eq=None, order=None, auto_detect=False, collect_by_mro=False, getstate_setstate=None, on_setattr=None, field_transformer=None, match_args=True)

.. note::

Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Expand Up @@ -22,6 +22,10 @@ exclude_lines = [

[tool.black]
line-length = 79
extend-exclude = '''
# Exclude pattern matching test till black gains Python 3.10 support
.*test_pattern_matching.*
'''


[tool.interrogate]
Expand Down
4 changes: 4 additions & 0 deletions src/attr/__init__.pyi
Expand Up @@ -315,6 +315,7 @@ def attrs(
getstate_setstate: Optional[bool] = ...,
on_setattr: Optional[_OnSetAttrArgType] = ...,
field_transformer: Optional[_FieldTransformer] = ...,
match_args: bool = ...,
) -> _C: ...
@overload
@__dataclass_transform__(order_default=True, field_descriptors=(attrib, field))
Expand All @@ -341,6 +342,7 @@ def attrs(
getstate_setstate: Optional[bool] = ...,
on_setattr: Optional[_OnSetAttrArgType] = ...,
field_transformer: Optional[_FieldTransformer] = ...,
match_args: bool = ...,
) -> Callable[[_C], _C]: ...
@overload
@__dataclass_transform__(field_descriptors=(attrib, field))
Expand All @@ -365,6 +367,7 @@ def define(
getstate_setstate: Optional[bool] = ...,
on_setattr: Optional[_OnSetAttrArgType] = ...,
field_transformer: Optional[_FieldTransformer] = ...,
match_args: bool = ...,
) -> _C: ...
@overload
@__dataclass_transform__(field_descriptors=(attrib, field))
Expand All @@ -389,6 +392,7 @@ def define(
getstate_setstate: Optional[bool] = ...,
on_setattr: Optional[_OnSetAttrArgType] = ...,
field_transformer: Optional[_FieldTransformer] = ...,
match_args: bool = ...,
) -> Callable[[_C], _C]: ...

mutable = define
Expand Down
18 changes: 18 additions & 0 deletions src/attr/_make.py
Expand Up @@ -973,6 +973,13 @@ def add_init(self):

return self

def add_match_args(self):
self._cls_dict["__match_args__"] = tuple(
field.name
for field in self._attrs
if field.init and not field.kw_only
)

def add_attrs_init(self):
self._cls_dict["__attrs_init__"] = self._add_method_dunders(
_make_init(
Expand Down Expand Up @@ -1198,6 +1205,7 @@ def attrs(
getstate_setstate=None,
on_setattr=None,
field_transformer=None,
match_args=True,
):
r"""
A class decorator that adds `dunder
Expand Down Expand Up @@ -1413,6 +1421,12 @@ def attrs(
this, e.g., to automatically add converters or validators to
fields based on their types. See `transform-fields` for more details.

:param bool match_args:
If `True`, set ``__match_args__`` on the class to support `PEP 634
<https://www.python.org/dev/peps/pep-0634/>`_ (Structural Pattern
Matching). It is a tuple of all positional-only ``__init__`` parameter
names.

.. versionadded:: 16.0.0 *slots*
.. versionadded:: 16.1.0 *frozen*
.. versionadded:: 16.3.0 *str*
Expand Down Expand Up @@ -1446,6 +1460,7 @@ def attrs(
``init=False`` injects ``__attrs_init__``
.. versionchanged:: 21.1.0 Support for ``__attrs_pre_init__``
.. versionchanged:: 21.1.0 *cmp* undeprecated
.. versionadded:: 21.3.0 *match_args*
"""
if auto_detect and PY2:
raise PythonTooOldError(
Expand Down Expand Up @@ -1562,6 +1577,9 @@ def wrap(cls):
" init must be True."
)

if match_args and not _has_own_attribute(cls, "__match_args__"):
builder.add_match_args()

return builder.build_class()

# maybe_cls's type depends on the usage of the decorator. It's a class
Expand Down
2 changes: 2 additions & 0 deletions src/attr/_next_gen.py
Expand Up @@ -32,6 +32,7 @@ def define(
getstate_setstate=None,
on_setattr=None,
field_transformer=None,
match_args=True,
):
r"""
The only behavioral differences are the handling of the *auto_attribs*
Expand Down Expand Up @@ -72,6 +73,7 @@ def do_it(cls, auto_attribs):
getstate_setstate=getstate_setstate,
on_setattr=on_setattr,
field_transformer=field_transformer,
match_args=match_args,
)

def wrap(cls):
Expand Down
118 changes: 118 additions & 0 deletions tests/test_make.py
Expand Up @@ -2327,3 +2327,121 @@ def __setstate__(self, state):

assert True is i.called
assert None is getattr(C(), "__getstate__", None)


class TestMatchArgs(object):
"""
Tests for match_args and __match_args__ generation.
"""

def test_match_args(self):
"""
__match_args__ generation
"""

@attr.s()
class C(object):
a = attr.ib()

assert C.__match_args__ == ("a",)

def test_explicit_match_args(self):
"""
__match_args__ manually set is not overriden.
"""

ma = ()

@attr.s()
class C(object):
a = attr.ib()
__match_args__ = ma

assert C(42).__match_args__ is ma

@pytest.mark.parametrize("match_args", [True, False])
def test_match_args_attr_set(self, match_args):
"""
__match_args__ being set depending on match_args.
"""

@attr.s(match_args=match_args)
class C(object):
a = attr.ib()

if match_args:
assert hasattr(C, "__match_args__")
else:
assert not hasattr(C, "__match_args__")

def test_match_args_kw_only(self):
"""
kw_only being set doesn't generate __match_args__
kw_only field is not included in __match_args__
"""

@attr.s()
class C(object):
a = attr.ib(kw_only=True)
b = attr.ib()

assert C.__match_args__ == ("b",)

@attr.s(match_args=True, kw_only=True)
class C(object):
a = attr.ib()
b = attr.ib()

assert C.__match_args__ == ()

def test_match_args_argument(self):
"""
match_args being False with inheritance.
"""

@attr.s(match_args=False)
class X(object):
a = attr.ib()

assert "__match_args__" not in X.__dict__

@attr.s(match_args=False)
class Y(object):
a = attr.ib()
__match_args__ = ("b",)

assert Y.__match_args__ == ("b",)

@attr.s(match_args=False)
class Z(Y):
z = attr.ib()

assert Z.__match_args__ == ("b",)

@attr.s()
class A(object):
a = attr.ib()
z = attr.ib()

@attr.s(match_args=False)
class B(A):
b = attr.ib()

assert B.__match_args__ == ("a", "z")

def test_make_class(self):
"""
match_args generation with make_class.
"""

C1 = make_class("C1", ["a", "b"])
assert C1.__match_args__ == ("a", "b")

C1 = make_class("C1", ["a", "b"], match_args=False)
assert not hasattr(C1, "__match_args__")

C1 = make_class("C1", ["a", "b"], kw_only=True)
assert C1.__match_args__ == ()

C1 = make_class("C1", {"a": attr.ib(kw_only=True), "b": attr.ib()})
assert C1.__match_args__ == ("b",)
98 changes: 98 additions & 0 deletions tests/test_pattern_matching.py
@@ -0,0 +1,98 @@
# flake8: noqa
# Python 3.10 issue in flake8 : https://github.com/PyCQA/pyflakes/issues/634
import pytest

import attr

from attr._make import make_class


class TestPatternMatching(object):
"""
Pattern matching syntax test cases.
"""

def test_simple_match_case(self):
"""
Simple match case statement
"""

@attr.s()
class C(object):
a = attr.ib()

assert C.__match_args__ == ("a",)

matched = False
c = C(a=1)
match c:
case C(a):
matched = True

assert matched

def test_explicit_match_args(self):
"""
Manually set empty __match_args__ will not match.
"""

ma = ()

@attr.s()
class C(object):
a = attr.ib()
__match_args__ = ma

c = C(a=1)

msg = r"C\(\) accepts 0 positional sub-patterns \(1 given\)"
with pytest.raises(TypeError, match=msg):
match c:
case C(a):
pass

def test_match_args_kw_only(self):
"""
kw_only being set doesn't generate __match_args__
kw_only field is not included in __match_args__
"""

@attr.s()
class C(object):
a = attr.ib(kw_only=True)
b = attr.ib()

assert C.__match_args__ == ("b",)

c = C(a=1, b=1)
msg = r"C\(\) accepts 1 positional sub-pattern \(2 given\)"
with pytest.raises(TypeError, match=msg):
match c:
case C(a, b):
pass

found = False
match c:
case C(b, a=a):
found = True

assert found

@attr.s(match_args=True, kw_only=True)
class C(object):
a = attr.ib()
b = attr.ib()

c = C(a=1, b=1)
msg = r"C\(\) accepts 0 positional sub-patterns \(2 given\)"
with pytest.raises(TypeError, match=msg):
match c:
case C(a, b):
pass

found = False
match c:
case C(a=a, b=b):
found = True

assert found
7 changes: 7 additions & 0 deletions tests/typing_example.py
Expand Up @@ -257,3 +257,10 @@ class FactoryTest:
a: List[int] = attr.ib(default=attr.Factory(list))
b: List[Any] = attr.ib(default=attr.Factory(list, False))
c: List[int] = attr.ib(default=attr.Factory((lambda s: s.a), True))


# Check match_args stub
@attr.s(match_args=False)
class MatchArgs:
a: int = attr.ib()
b: int = attr.ib()