Repository: python-attrs/attrs
Pull request #815 (2021-05-14 05:30:27)

Add __match_args__ to support match case destructuring in Python 3.10
# Pull Request Check List

This is just a friendly reminder about the most common mistakes.  Please make sure that you tick all boxes.  But please read our [contribution guide](https://www.attrs.org/en/latest/contributing.html) at least once, it will save you unnecessary review cycles!

If an item doesn't apply to your pull request, **check it anyway** to make it apparent that there's nothing left to do.  If your pull request is a documentation fix or a trivial typo, feel free to delete the whole thing.

- [x] Added **tests** for changed code.
- [ ] New features have been added to our [Hypothesis testing strategy](https://github.com/python-attrs/attrs/blob/main/tests/strategies.py).
- [x] Changes or additions to public APIs are reflected in our type stubs (files ending in ``.pyi``).
    - [x] ...and used in the stub test file `tests/typing_example.py`.
- [ ] Updated **documentation** for changed code.
    - [ ] New functions/classes have to be added to `docs/api.rst` by hand.
    - [x] Changes to the signature of `@attr.s()` have to be added by hand too.
    - [ ] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded).  Find the appropriate next version in our [``__init__.py``](https://github.com/python-attrs/attrs/blob/main/src/attr/__init__.py) file.
- [ ] Documentation in `.rst` files is written using [semantic newlines](https://rhodesmill.org/brandon/2012/one-sentence-per-line/).
- [ ] Changes (and possible deprecations) have news fragments in [`changelog.d`](https://github.com/python-attrs/attrs/blob/main/changelog.d).

If you have *any* questions to *any* of the points above, just **submit and ask**!  This checklist is here to *help* you, not to deter you from contributing!


tirkarthi: Sorry for the lint errors. Hopefully it's all fine now.
tirkarthi: Changes done :

* Updated docs. Removed "Test" from docstrings and updated a few. Feel free to makes changes.
* Added `test_pattern_matching.py` on Python 3.10.
* Disabled black since black doesn't support formatting pattern matching : https://github.com/psf/black/issues/2242
* Disabled flake8 since pyflakes has an issue with keyword argument matching : https://github.com/PyCQA/pyflakes/issues/634

> needs to be added to _next_gen.py

I am not sure about the changes to be made here since `match_args` is not a signature change in `define` and `field`
hynek: `define`, `mutable`, and `frozen` all wrap `attr.s`, so the argument needs to be added to them (and their type stubs) too?
tirkarthi: Thanks, added in https://github.com/python-attrs/attrs/pull/815/commits/0cd51b485e560d201b7703c37eeb1b1d854cd8b6
tirkarthi: It seems pre-commit  uses Python 3.8 and interrogate fails to parse the Python 3.10 syntax. Excluding the file in pyproject.toml for interrogate doesn't seem to ignore it. debug_statement action of pre-commit also uses the syntax and fails.

black seems not to exclude the files as per pre-commit pinned version. Upgrading black gives some more formatting changes on unrelated files as it seems black has changed the formatting. 
hynek: I think you'll have to block the match tests to black and interrogate in `.pre-commit-config.yaml`.
tirkarthi: As noted in https://stackoverflow.com/questions/61032281/exclude-some-files-on-running-black-using-pre-commit, it seems that pre-commit gets all python files and just passes it to the command and using `--exclude` for black doesn't help. I have bumped to Python 3.10 for interrogate and debug_statement. For black I have added exclude from pre-commit end. `tox -e lint` seems to pass.

```
tox -e lint
lint installed: appdirs==1.4.4,cfgv==3.2.0,distlib==0.3.1,filelock==3.0.12,identify==2.2.4,nodeenv==1.6.0,pre-commit==2.12.1,PyYAML==5.4.1,six==1.16.0,toml==0.10.2,virtualenv==20.4.6
lint run-test-pre: PYTHONHASHSEED='2125636111'
lint run-test: commands[0] | pre-commit run --all-files
black....................................................................Passed
isort....................................................................Passed
flake8...................................................................Passed
interrogate..............................................................Passed
Trim Trailing Whitespace.................................................Passed
Fix End of Files.........................................................Passed
Debug Statements (Python)................................................Passed
Check Toml...............................................................Passed
_______________________________________________________________________________ summary ________________________________________________________________________________
  lint: commands succeeded
  congratulations :)

```
tirkarthi: codecov gives me 403, I am not sure about the code coverage error.
hynek: codecov is probably failing, because python 3.8 fails and doesn't upload.

now we have the problem, that the linter _needs_ python 3.10 and that's not present in the 3.8 container

I guess there's two options:

1. block match from interrogate
2. move lint to py310

I guess you _could_ try option 2 (change the `[gh-actions]` section in tox.ini) but if that fails for other reasons (or you don't want to deal with it), just go with 1.
tirkarthi: Thanks, I guess option 2 makes more sense since eventually the python version has to be upgraded to support parsing. I have pushed commit to bump basepython to 3.10 in lint step.
tirkarthi: tox and gh-actions are slightly new to me so I got confused. I pushed the lint job from py38 to py310 and also made basepython 3.10 in lint step. I guess this should run link in python 3.10 container with python 3.10 as base. Please correct me if I am wrong.
hynek: Looks good, but I think you still add the tuple on Python <3.10? (On my phone for the rest of the day)
tirkarthi: Yes, I am adding `__match_args__` tuple always. I don't see any backwards compatibility issues with the same. I will be curious how dataclasses backport also deals with the change too. I can add a version check to add tuple only for Python 3.10+ if you want. In that case I guess match_args=True will always be there in signature and it's just that tuple generation becomes python 3.10+?
hynek: Yeah I don’t have any comparability problems in mind – it just seems wasteful to set an attribute on all classes even tho they can’t use it.

And yes, just add something to the tune of  “this argument is ignored on Python versions before 3.10” to the doc string and call it a day.

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index cb59b3a..6d042f4 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -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
@@ -16,14 +17,15 @@ 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
@@ -31,4 +33,5 @@ repos:
       - id: trailing-whitespace
       - id: end-of-file-fixer
       - id: debug-statements
+        language_version: python3.10
       - id: check-toml
diff --git a/changelog.d/815.changes.rst b/changelog.d/815.changes.rst
new file mode 100644
index 0000000..a8f16b9
--- /dev/null
+++ b/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.
diff --git a/docs/api.rst b/docs/api.rst
index 3df3145..3fd71d6 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -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::
 
diff --git a/pyproject.toml b/pyproject.toml
index 14f65a3..93145c9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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]
diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi
index 3503b07..7221836 100644
--- a/src/attr/__init__.pyi
+++ b/src/attr/__init__.pyi
@@ -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))
@@ -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))
@@ -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))
@@ -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
diff --git a/src/attr/_make.py b/src/attr/_make.py
index f14369a..82b0f76 100644
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -973,6 +973,13 @@ class _ClassBuilder(object):
 
         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(
@@ -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
@@ -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*
@@ -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(
@@ -1562,6 +1577,9 @@ def attrs(
                     " 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
diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py
index fab0af9..1d8acac 100644
--- a/src/attr/_next_gen.py
+++ b/src/attr/_next_gen.py
@@ -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*
@@ -72,6 +73,7 @@ def define(
             getstate_setstate=getstate_setstate,
             on_setattr=on_setattr,
             field_transformer=field_transformer,
+            match_args=match_args,
         )
 
     def wrap(cls):
diff --git a/tox.ini b/tox.ini
index a9f67b2..d796d03 100644
--- a/tox.ini
+++ b/tox.ini
@@ -14,9 +14,9 @@ python =
     3.5: py35
     3.6: py36
     3.7: py37, docs
-    3.8: py38, lint, manifest, typing, changelog
+    3.8: py38, manifest, typing, changelog
     3.9: py39, pyright
-    3.10: py310
+    3.10: py310, lint
     pypy2: pypy2
     pypy3: pypy3
 
@@ -71,7 +71,7 @@ commands =
 
 
 [testenv:lint]
-basepython = python3.8
+basepython = python3.10
 skip_install = true
 deps =
     pre-commit


Test patch:
diff --git a/conftest.py b/conftest.py
index a2c8d59..85659a8 100644
--- a/conftest.py
+++ b/conftest.py
@@ -23,6 +23,8 @@ if sys.version_info[:2] < (3, 6):
             "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(
         [
diff --git a/tests/test_make.py b/tests/test_make.py
index e54b0bb..c1d893e 100644
--- a/tests/test_make.py
+++ b/tests/test_make.py
@@ -2327,3 +2327,121 @@ class TestAutoDetect:
 
         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",)
diff --git a/tests/test_pattern_matching.py b/tests/test_pattern_matching.py
new file mode 100644
index 0000000..6a1cb84
--- /dev/null
+++ b/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
diff --git a/tests/typing_example.py b/tests/typing_example.py
index 13b5638..2edbce2 100644
--- a/tests/typing_example.py
+++ b/tests/typing_example.py
@@ -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()