Skip to content

Commit 7326ac7

Browse files
authored
Add ASYNC401 for pytest.raises(ExceptionGroup) (#466)
* Add ASYNC401 pytest RaisesGroup rule * Address ASYNC401 review feedback --------- Co-authored-by: paranoa233 <paranoa233@users.noreply.github.com>
1 parent 83f512b commit 7326ac7

5 files changed

Lines changed: 91 additions & 0 deletions

File tree

docs/changelog.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ Changelog
44

55
`CalVer, YY.month.patch <https://calver.org/>`_
66

7+
27.7.1
8+
======
9+
- Add :ref:`ASYNC401 <async401>` pytest-raises-exception-group, recommending ``pytest.RaisesGroup`` over ``pytest.raises(ExceptionGroup)``. `(issue #430) <https://github.com/python-trio/flake8-async/issues/430>`_
10+
711
26.6.1
812
======
913
- Add :ref:`ASYNC127 <async127>` unmaintained-httpx: use ``httpx2`` instead of ``httpx``, which is no longer maintained, to get security updates. `(issue #460) <https://github.com/python-trio/flake8-async/issues/460>`_

docs/rules.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,9 @@ ExceptionGroup rules
220220
_`ASYNC400` : except-star-invalid-attribute
221221
When converting a codebase to use `except* <except_star>` 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.
222222

223+
_`ASYNC401` : pytest-raises-exception-group
224+
``pytest.raises(ExceptionGroup)`` and ``pytest.raises(BaseExceptionGroup)`` usually hide the structure of exception groups. Prefer ``pytest.RaisesGroup``.
225+
223226
Optional rules disabled by default
224227
==================================
225228

flake8_async/visitors/visitor4xx.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""4XX error classes, which handle exception groups.
22
33
ASYNC400 except-star-invalid-attribute checks for invalid attribute access on except*
4+
ASYNC401 pytest-raises-exception-group checks for pytest.raises(ExceptionGroup)
45
"""
56

67
from __future__ import annotations
@@ -96,3 +97,51 @@ def visit_FunctionDef(
9697

9798
visit_AsyncFunctionDef = visit_FunctionDef
9899
visit_Lambda = visit_FunctionDef
100+
101+
102+
EXCGROUP_QUALNAMES = (
103+
"ExceptionGroup",
104+
"BaseExceptionGroup",
105+
"builtins.ExceptionGroup",
106+
"builtins.BaseExceptionGroup",
107+
"exceptiongroup.ExceptionGroup",
108+
"exceptiongroup.BaseExceptionGroup",
109+
)
110+
111+
112+
@error_class
113+
class Visitor401(Flake8AsyncVisitor):
114+
error_codes: Mapping[str, str] = {
115+
"ASYNC401": (
116+
"Use `pytest.RaisesGroup` instead of `pytest.raises({})` when expecting"
117+
" exception groups."
118+
)
119+
}
120+
121+
def _exception_group_name(self, node: ast.expr) -> str | None:
122+
if isinstance(node, ast.Tuple):
123+
for elt in node.elts:
124+
if name := self._exception_group_name(elt):
125+
return name
126+
return None
127+
128+
canonical = self.canonical_name(node)
129+
if canonical in EXCGROUP_QUALNAMES:
130+
return ast.unparse(node)
131+
return None
132+
133+
def _expected_exception_arg(self, node: ast.Call) -> ast.expr | None:
134+
if node.args:
135+
return node.args[0]
136+
for kw in node.keywords:
137+
if kw.arg == "expected_exception":
138+
return kw.value
139+
return None
140+
141+
def visit_Call(self, node: ast.Call):
142+
if (
143+
self.canonical_name(node.func) == "pytest.raises"
144+
and (expected_exception := self._expected_exception_arg(node)) is not None
145+
and (exception_group := self._exception_group_name(expected_exception))
146+
):
147+
self.error(node, exception_group)

tests/eval_files/async401.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import builtins
2+
import sys
3+
4+
import pytest
5+
from exceptiongroup import BaseExceptionGroup as BackportBaseExceptionGroup
6+
from exceptiongroup import ExceptionGroup as BackportExceptionGroup
7+
from pytest import raises
8+
from pytest import raises as pytest_raises
9+
10+
if sys.version_info < (3, 11):
11+
from exceptiongroup import BaseExceptionGroup, ExceptionGroup
12+
13+
14+
class _NotPytest:
15+
def raises(self, expected_exception):
16+
pass
17+
18+
19+
not_pytest = _NotPytest()
20+
21+
pytest.raises(ExceptionGroup) # error: 0, "ExceptionGroup"
22+
pytest.raises(BaseExceptionGroup) # error: 0, "BaseExceptionGroup"
23+
pytest.raises(expected_exception=ExceptionGroup) # error: 0, "ExceptionGroup"
24+
pytest.raises((ValueError, ExceptionGroup)) # error: 0, "ExceptionGroup"
25+
pytest.raises(builtins.ExceptionGroup) # type: ignore[attr-defined] # error: 0, "builtins.ExceptionGroup"
26+
pytest.raises(BackportExceptionGroup) # error: 0, "BackportExceptionGroup"
27+
pytest.raises(BackportBaseExceptionGroup) # error: 0, "BackportBaseExceptionGroup"
28+
raises(ExceptionGroup) # error: 0, "ExceptionGroup"
29+
pytest_raises(ExceptionGroup) # error: 0, "ExceptionGroup"
30+
31+
pytest.raises(ValueError)
32+
pytest.RaisesGroup(ValueError)
33+
raises(ValueError)
34+
not_pytest.raises(ExceptionGroup)

tests/test_flake8_async.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,7 @@ def _parse_eval_file(
543543
"ASYNC127",
544544
"ASYNC300",
545545
"ASYNC400",
546+
"ASYNC401",
546547
"ASYNC912",
547548
}
548549

0 commit comments

Comments
 (0)