Skip to content

Commit

Permalink
Add __match_args__ to support match case destructuring in Python 3.10 (
Browse files Browse the repository at this point in the history
…#815)

* Add support to generate __match_args__ for Python 3.10.

* Add versionadded directive.

* Update stubs.

* Update changelog and add a test to typing examples.

* Fix error regarding new-style classes in Python 2.

* Fix lint error regarding line length.

* Fix lint error regarding trailing whitespace.

* Add docstrings for interrogate.

* Use _has_own_attribute instead of cls.__dict__ contains check.

* Update docs as per review comments.

* Revert mistaken changelog update.

* Add Python 3.10 pattern matching syntax test cases.

* Update define signature with match_args.

* Fix conftest formatting.

* Fix isort formatting.

* Bump to Python 3.10 to parse syntax.

* Bump basepython of lint to Python 3.10 for parsing.

* Move lint to py310

Co-authored-by: Hynek Schlawack <hs@ox.cx>
  • Loading branch information
tirkarthi and hynek committed May 18, 2021
1 parent 20c2d4f commit 8613af9
Show file tree
Hide file tree
Showing 12 changed files with 265 additions and 6 deletions.
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()

0 comments on commit 8613af9

Please sign in to comment.