From 4e4c1d16764558c5cb4e30488725873c21666a59 Mon Sep 17 00:00:00 2001 From: paranoa233 Date: Wed, 8 Jul 2026 23:26:36 +0800 Subject: [PATCH] Add ASYNC401 pytest RaisesGroup rule --- docs/changelog.rst | 1 + docs/rules.rst | 4 +++ flake8_async/visitors/visitor4xx.py | 52 ++++++++++++++++++++++++++++- tests/eval_files/async401.py | 34 +++++++++++++++++++ tests/test_flake8_async.py | 1 + 5 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 tests/eval_files/async401.py diff --git a/docs/changelog.rst b/docs/changelog.rst index 92e9b47..9edab99 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,6 +6,7 @@ Changelog 26.6.1 ====== +- Add :ref:`ASYNC401 ` pytest-raises-exception-group, an opt-in rule recommending ``pytest.RaisesGroup`` over ``pytest.raises(ExceptionGroup)``. `(issue #430) `_ - Add :ref:`ASYNC127 ` unmaintained-httpx: use ``httpx2`` instead of ``httpx``, which is no longer maintained, to get security updates. `(issue #460) `_ - :ref:`ASYNC210 `, :ref:`ASYNC211 ` and :ref:`ASYNC212 ` now also detect blocking calls made through ``httpx2``, and their messages recommend ``httpx2.AsyncClient`` instead of ``httpx.AsyncClient``. diff --git a/docs/rules.rst b/docs/rules.rst index 47d7f6d..fad12b5 100644 --- a/docs/rules.rst +++ b/docs/rules.rst @@ -220,6 +220,10 @@ ExceptionGroup rules _`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``. + This rule is disabled by default because some uses of ``pytest.raises`` with exception groups are valid. + Optional rules disabled by default ================================== diff --git a/flake8_async/visitors/visitor4xx.py b/flake8_async/visitors/visitor4xx.py index 602b9c1..8c242df 100644 --- a/flake8_async/visitors/visitor4xx.py +++ b/flake8_async/visitors/visitor4xx.py @@ -1,6 +1,7 @@ """4XX error classes, which handle exception groups. ASYNC400 except-star-invalid-attribute checks for invalid attribute access on except* +ASYNC401 pytest-raises-exception-group checks for pytest.raises(ExceptionGroup) """ from __future__ import annotations @@ -9,7 +10,7 @@ from typing import TYPE_CHECKING, Any from .flake8asyncvisitor import Flake8AsyncVisitor -from .helpers import error_class +from .helpers import disabled_by_default, error_class if TYPE_CHECKING: from collections.abc import Mapping @@ -96,3 +97,52 @@ def visit_FunctionDef( visit_AsyncFunctionDef = visit_FunctionDef visit_Lambda = visit_FunctionDef + + +EXCGROUP_QUALNAMES = ( + "ExceptionGroup", + "BaseExceptionGroup", + "builtins.ExceptionGroup", + "builtins.BaseExceptionGroup", + "exceptiongroup.ExceptionGroup", + "exceptiongroup.BaseExceptionGroup", +) + + +@error_class +@disabled_by_default +class Visitor401(Flake8AsyncVisitor): + error_codes: Mapping[str, str] = { + "ASYNC401": ( + "Use `pytest.RaisesGroup` instead of `pytest.raises({})` when expecting" + " exception groups." + ) + } + + def _exception_group_name(self, node: ast.expr) -> str | None: + if isinstance(node, ast.Tuple): + for elt in node.elts: + if name := self._exception_group_name(elt): + return name + return None + + canonical = self.canonical_name(node) + if canonical in EXCGROUP_QUALNAMES: + return ast.unparse(node) + return None + + def _expected_exception_arg(self, node: ast.Call) -> ast.expr | None: + if node.args: + return node.args[0] + for kw in node.keywords: + if kw.arg == "expected_exception": + return kw.value + 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) diff --git a/tests/eval_files/async401.py b/tests/eval_files/async401.py new file mode 100644 index 0000000..0abaf85 --- /dev/null +++ b/tests/eval_files/async401.py @@ -0,0 +1,34 @@ +import builtins +import sys + +import pytest +from exceptiongroup import BaseExceptionGroup as BackportBaseExceptionGroup +from exceptiongroup import ExceptionGroup as BackportExceptionGroup +from pytest import raises +from pytest import raises as pytest_raises + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup, ExceptionGroup + + +class _NotPytest: + def raises(self, expected_exception): + pass + + +not_pytest = _NotPytest() + +pytest.raises(ExceptionGroup) # error: 0, "ExceptionGroup" +pytest.raises(BaseExceptionGroup) # error: 0, "BaseExceptionGroup" +pytest.raises(expected_exception=ExceptionGroup) # error: 0, "ExceptionGroup" +pytest.raises((ValueError, ExceptionGroup)) # error: 0, "ExceptionGroup" +pytest.raises(builtins.ExceptionGroup) # type: ignore[attr-defined] # error: 0, "builtins.ExceptionGroup" +pytest.raises(BackportExceptionGroup) # error: 0, "BackportExceptionGroup" +pytest.raises(BackportBaseExceptionGroup) # error: 0, "BackportBaseExceptionGroup" +raises(ExceptionGroup) # error: 0, "ExceptionGroup" +pytest_raises(ExceptionGroup) # error: 0, "ExceptionGroup" + +pytest.raises(ValueError) +pytest.RaisesGroup(ValueError) +raises(ValueError) +not_pytest.raises(ExceptionGroup) diff --git a/tests/test_flake8_async.py b/tests/test_flake8_async.py index 15321af..f0dabd5 100644 --- a/tests/test_flake8_async.py +++ b/tests/test_flake8_async.py @@ -543,6 +543,7 @@ def _parse_eval_file( "ASYNC127", "ASYNC300", "ASYNC400", + "ASYNC401", "ASYNC912", }