Skip to content

Commit 4e4c1d1

Browse files
committed
Add ASYNC401 pytest RaisesGroup rule
1 parent b32a38c commit 4e4c1d1

5 files changed

Lines changed: 91 additions & 1 deletion

File tree

docs/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Changelog
66

77
26.6.1
88
======
9+
- Add :ref:`ASYNC401 <async401>` pytest-raises-exception-group, an opt-in rule recommending ``pytest.RaisesGroup`` over ``pytest.raises(ExceptionGroup)``. `(issue #430) <https://github.com/python-trio/flake8-async/issues/430>`_
910
- 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>`_
1011
- :ref:`ASYNC210 <async210>`, :ref:`ASYNC211 <async211>` and :ref:`ASYNC212 <async212>` now also detect blocking calls made through ``httpx2``, and their messages recommend ``httpx2.AsyncClient`` instead of ``httpx.AsyncClient``.
1112

docs/rules.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,10 @@ 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+
This rule is disabled by default because some uses of ``pytest.raises`` with exception groups are valid.
226+
223227
Optional rules disabled by default
224228
==================================
225229

flake8_async/visitors/visitor4xx.py

Lines changed: 51 additions & 1 deletion
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
@@ -9,7 +10,7 @@
910
from typing import TYPE_CHECKING, Any
1011

1112
from .flake8asyncvisitor import Flake8AsyncVisitor
12-
from .helpers import error_class
13+
from .helpers import disabled_by_default, error_class
1314

1415
if TYPE_CHECKING:
1516
from collections.abc import Mapping
@@ -96,3 +97,52 @@ 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+
@disabled_by_default
114+
class Visitor401(Flake8AsyncVisitor):
115+
error_codes: Mapping[str, str] = {
116+
"ASYNC401": (
117+
"Use `pytest.RaisesGroup` instead of `pytest.raises({})` when expecting"
118+
" exception groups."
119+
)
120+
}
121+
122+
def _exception_group_name(self, node: ast.expr) -> str | None:
123+
if isinstance(node, ast.Tuple):
124+
for elt in node.elts:
125+
if name := self._exception_group_name(elt):
126+
return name
127+
return None
128+
129+
canonical = self.canonical_name(node)
130+
if canonical in EXCGROUP_QUALNAMES:
131+
return ast.unparse(node)
132+
return None
133+
134+
def _expected_exception_arg(self, node: ast.Call) -> ast.expr | None:
135+
if node.args:
136+
return node.args[0]
137+
for kw in node.keywords:
138+
if kw.arg == "expected_exception":
139+
return kw.value
140+
return None
141+
142+
def visit_Call(self, node: ast.Call):
143+
if (
144+
self.canonical_name(node.func) == "pytest.raises"
145+
and (expected_exception := self._expected_exception_arg(node)) is not None
146+
and (exception_group := self._exception_group_name(expected_exception))
147+
):
148+
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)