From 8613af97bb9b81526ca1e8385f1f48916f607588 Mon Sep 17 00:00:00 2001 From: Karthikeyan Singaravelan Date: Tue, 18 May 2021 10:32:06 +0530 Subject: [PATCH] Add __match_args__ to support match case destructuring in Python 3.10 (#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 --- .pre-commit-config.yaml | 7 +- changelog.d/815.changes.rst | 3 + conftest.py | 2 + docs/api.rst | 2 +- pyproject.toml | 4 ++ src/attr/__init__.pyi | 4 ++ src/attr/_make.py | 18 +++++ src/attr/_next_gen.py | 2 + tests/test_make.py | 118 +++++++++++++++++++++++++++++++++ tests/test_pattern_matching.py | 98 +++++++++++++++++++++++++++ tests/typing_example.py | 7 ++ tox.ini | 6 +- 12 files changed, 265 insertions(+), 6 deletions(-) create mode 100644 changelog.d/815.changes.rst create mode 100644 tests/test_pattern_matching.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cb59b3a36..6d042f496 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 000000000..a8f16b907 --- /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 `_. +This can be controlled by ``match_args`` argument to the class decorators. diff --git a/conftest.py b/conftest.py index a2c8d59f2..85659a8a2 100644 --- a/conftest.py +++ b/conftest.py @@ -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( [ diff --git a/docs/api.rst b/docs/api.rst index 3df314504..3fd71d651 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 14f65a366..93145c9e3 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 3503b073b..7221836c1 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 f14369a99..82b0f7667 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -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( @@ -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 + `_ (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 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 diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py index fab0af966..1d8acac36 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 do_it(cls, auto_attribs): getstate_setstate=getstate_setstate, on_setattr=on_setattr, field_transformer=field_transformer, + match_args=match_args, ) def wrap(cls): diff --git a/tests/test_make.py b/tests/test_make.py index e54b0bb05..c1d893e3d 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -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",) diff --git a/tests/test_pattern_matching.py b/tests/test_pattern_matching.py new file mode 100644 index 000000000..6a1cb84dc --- /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 13b5638db..2edbce216 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() diff --git a/tox.ini b/tox.ini index a9f67b296..d796d0383 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