From b3824967b7931d1c8dd23d33673cd52aa2668b77 Mon Sep 17 00:00:00 2001 From: Anshu Raj Bisoyi Date: Fri, 2 Oct 2026 23:36:01 +0530 Subject: [PATCH 1/4] Extend ASYNC401 to additional exception group assertions --- docs/changelog.rst | 4 +++ docs/rules.rst | 2 +- flake8_async/visitors/visitor4xx.py | 32 +++++++++++++++------- tests/eval_files/async401.py | 42 +++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8cf036e..675a9fc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,10 @@ Changelog `CalVer, YY.month.patch `_ +Future +====== +- Extend :ref:`ASYNC401 ` to cover parameterized exception-group types, ``pytest.RaisesExc``, bare groups inside ``pytest.RaisesGroup``, and ``pytest.mark.xfail(raises=...)``. `(issue #475) `_ + 26.8.1 ====== - Add :ref:`ASYNC128 ` task-status-never-started, warning about startable functions (i.e. with a ``task_status`` parameter) that never call ``task_status.started()``. `(issue #471) `_ diff --git a/docs/rules.rst b/docs/rules.rst index 2082bcc..c6211e1 100644 --- a/docs/rules.rst +++ b/docs/rules.rst @@ -233,7 +233,7 @@ _`ASYNC400` : except-star-invalid-attribute When converting a codebase to use `except* ` it's easy to miss that the caught exception(s) are wrapped in a group, so accessing attributes on the caught exception must now check the contained exceptions. This checks for any attribute access on a caught ``except*`` that's not a known valid attribute on `ExceptionGroup`. This can be safely disabled on a type-checked or coverage-covered code base. _`ASYNC401` : pytest-raises-exception-group - ``pytest.raises(ExceptionGroup)`` and ``pytest.raises(BaseExceptionGroup)`` usually hide the structure of exception groups. Prefer ``pytest.RaisesGroup``. + Bare ``ExceptionGroup`` or ``BaseExceptionGroup`` expectations hide the structure of exception groups. Prefer ``pytest.RaisesGroup`` with explicit expectations for the contained exceptions. This checks ``pytest.raises``, ``pytest.RaisesExc``, ``pytest.RaisesGroup``, and the ``raises`` argument of ``pytest.mark.xfail``, including parameterized exception-group types. Optional rules disabled by default ================================== diff --git a/flake8_async/visitors/visitor4xx.py b/flake8_async/visitors/visitor4xx.py index 90cdf56..752e43f 100644 --- a/flake8_async/visitors/visitor4xx.py +++ b/flake8_async/visitors/visitor4xx.py @@ -113,8 +113,7 @@ def visit_FunctionDef( class Visitor401(Flake8AsyncVisitor): error_codes: Mapping[str, str] = { "ASYNC401": ( - "Use `pytest.RaisesGroup` instead of `pytest.raises({})` when expecting" - " exception groups." + "Use `pytest.RaisesGroup` to check the structure of exception group {}." ) } @@ -125,7 +124,9 @@ def _exception_group_name(self, node: ast.expr) -> str | None: return name return None - canonical = self.canonical_name(node) + canonical = self.canonical_name( + node.value if isinstance(node, ast.Subscript) else node + ) if canonical in EXCGROUP_QUALNAMES: return ast.unparse(node) return None @@ -139,9 +140,22 @@ def _expected_exception_arg(self, node: ast.Call) -> ast.expr | None: return None def visit_Call(self, node: ast.Call): - if ( - self.canonical_name(node.func) == "pytest.raises" - and (expected_exception := self._expected_exception_arg(node)) is not None - and (exception_group := self._exception_group_name(expected_exception)) - ): - self.error(node, exception_group) + name = self.canonical_name(node.func) + if name == "pytest.mark.xfail": + expected_exceptions = [ + kw.value for kw in node.keywords if kw.arg == "raises" + ] + elif name == "pytest.RaisesGroup": + expected_exceptions = node.args + elif name in ("pytest.raises", "pytest.RaisesExc"): + expected_exception = self._expected_exception_arg(node) + expected_exceptions = ( + [] if expected_exception is None else [expected_exception] + ) + else: + return + + for expected_exception in expected_exceptions: + if exception_group := self._exception_group_name(expected_exception): + self.error(node, exception_group) + break diff --git a/tests/eval_files/async401.py b/tests/eval_files/async401.py index 0abaf85..ae028a0 100644 --- a/tests/eval_files/async401.py +++ b/tests/eval_files/async401.py @@ -4,6 +4,9 @@ import pytest from exceptiongroup import BaseExceptionGroup as BackportBaseExceptionGroup from exceptiongroup import ExceptionGroup as BackportExceptionGroup +from pytest import RaisesExc as raises_exc +from pytest import RaisesGroup as raises_group +from pytest import mark as pytest_mark from pytest import raises from pytest import raises as pytest_raises @@ -32,3 +35,42 @@ def raises(self, expected_exception): pytest.RaisesGroup(ValueError) raises(ValueError) not_pytest.raises(ExceptionGroup) + +pytest.raises(ExceptionGroup[Exception]) # error: 0, "ExceptionGroup[Exception]" +pytest.RaisesExc(ExceptionGroup) # error: 0, "ExceptionGroup" +pytest.RaisesExc( # error: 0, "BaseExceptionGroup" + expected_exception=BaseExceptionGroup +) +pytest.RaisesGroup(ExceptionGroup) # error: 0, "ExceptionGroup" +pytest.RaisesGroup(ValueError, BaseExceptionGroup) # error: 0, "BaseExceptionGroup" +pytest.mark.xfail(raises=ExceptionGroup) # error: 0, "ExceptionGroup" +pytest.mark.xfail( # error: 0, "ExceptionGroup" + reason="expected", raises=(ValueError, ExceptionGroup) +) + +pytest.RaisesExc(ValueError) +pytest.RaisesGroup(pytest.RaisesGroup(ValueError)) +pytest.mark.xfail(raises=ValueError) +pytest.mark.xfail(reason="expected") + +raises_exc(BackportExceptionGroup) # error: 0, "BackportExceptionGroup" +raises_group( # error: 0, "ExceptionGroup[Exception]" + ValueError, ExceptionGroup[Exception] +) +pytest_mark.xfail( # error: 0, "BackportBaseExceptionGroup" + raises=BackportBaseExceptionGroup +) +pytest.RaisesGroup(ExceptionGroup, BaseExceptionGroup) # error: 0, "ExceptionGroup" +pytest.RaisesGroup(pytest.RaisesGroup(ExceptionGroup)) # error: 19, "ExceptionGroup" +pytest.RaisesExc(builtins.BaseExceptionGroup) # type: ignore[attr-defined] # error: 0, "builtins.BaseExceptionGroup" + +pytest.raises((ValueError, TypeError)) +pytest.raises(match="message") +pytest.RaisesExc(match="message") +pytest.RaisesGroup() +pytest.RaisesGroup(ValueError, TypeError, match="message") +pytest.mark.xfail(ExceptionGroup) +pytest.mark.xfail(raises=pytest.RaisesGroup(ValueError)) +pytest.mark.xfail(**{"raises": ExceptionGroup}) +not_pytest.raises(ExceptionGroup[Exception]) +pytest.raises(list[ExceptionGroup]) From 516ea352aa967cd78b78cb4825f05365460fa308 Mon Sep 17 00:00:00 2001 From: anshurajbisoyi98-ctrl Date: Mon, 5 Oct 2026 18:46:18 +0530 Subject: [PATCH 2/4] Shorten ASYNC401 diagnostic --- flake8_async/visitors/visitor4xx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake8_async/visitors/visitor4xx.py b/flake8_async/visitors/visitor4xx.py index 752e43f..f439692 100644 --- a/flake8_async/visitors/visitor4xx.py +++ b/flake8_async/visitors/visitor4xx.py @@ -113,7 +113,7 @@ def visit_FunctionDef( class Visitor401(Flake8AsyncVisitor): error_codes: Mapping[str, str] = { "ASYNC401": ( - "Use `pytest.RaisesGroup` to check the structure of exception group {}." + "Use `pytest.RaisesGroup` instead of expecting {} directly." ) } From 7e9b82dff78cf8d8c41f70aef8b6acf9b9f7e267 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:16:41 +0000 Subject: [PATCH 3/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- flake8_async/visitors/visitor4xx.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/flake8_async/visitors/visitor4xx.py b/flake8_async/visitors/visitor4xx.py index f439692..b4e3124 100644 --- a/flake8_async/visitors/visitor4xx.py +++ b/flake8_async/visitors/visitor4xx.py @@ -112,9 +112,7 @@ def visit_FunctionDef( @error_class class Visitor401(Flake8AsyncVisitor): error_codes: Mapping[str, str] = { - "ASYNC401": ( - "Use `pytest.RaisesGroup` instead of expecting {} directly." - ) + "ASYNC401": "Use `pytest.RaisesGroup` instead of expecting {} directly." } def _exception_group_name(self, node: ast.expr) -> str | None: From e23fe264720cb4416190afd7c00cdbbd48813427 Mon Sep 17 00:00:00 2001 From: anshurajbisoyi98-ctrl Date: Mon, 5 Oct 2026 18:47:23 +0530 Subject: [PATCH 4/4] Shorten ASYNC401 docs --- docs/changelog.rst | 2 +- docs/rules.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 675a9fc..7e43094 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,7 +6,7 @@ Changelog Future ====== -- Extend :ref:`ASYNC401 ` to cover parameterized exception-group types, ``pytest.RaisesExc``, bare groups inside ``pytest.RaisesGroup``, and ``pytest.mark.xfail(raises=...)``. `(issue #475) `_ +- Extend :ref:`ASYNC401 ` to catch more exception-group assertions. `(issue #475) `_ 26.8.1 ====== diff --git a/docs/rules.rst b/docs/rules.rst index c6211e1..2befc33 100644 --- a/docs/rules.rst +++ b/docs/rules.rst @@ -233,7 +233,7 @@ _`ASYNC400` : except-star-invalid-attribute When converting a codebase to use `except* ` it's easy to miss that the caught exception(s) are wrapped in a group, so accessing attributes on the caught exception must now check the contained exceptions. This checks for any attribute access on a caught ``except*`` that's not a known valid attribute on `ExceptionGroup`. This can be safely disabled on a type-checked or coverage-covered code base. _`ASYNC401` : pytest-raises-exception-group - Bare ``ExceptionGroup`` or ``BaseExceptionGroup`` expectations hide the structure of exception groups. Prefer ``pytest.RaisesGroup`` with explicit expectations for the contained exceptions. This checks ``pytest.raises``, ``pytest.RaisesExc``, ``pytest.RaisesGroup``, and the ``raises`` argument of ``pytest.mark.xfail``, including parameterized exception-group types. + ``pytest.raises(ExceptionGroup)`` and ``pytest.raises(BaseExceptionGroup)`` usually hide the structure of exception groups. Prefer ``pytest.RaisesGroup``. Similar pytest assertions are checked too. Optional rules disabled by default ==================================