Skip to content

Commit 93913ef

Browse files
authored
add async400 exceptiongroup-invalid-access (#379)
1 parent 00d63c3 commit 93913ef

File tree

6 files changed

+206
-2
lines changed

6 files changed

+206
-2
lines changed

docs/changelog.rst

Lines changed: 6 additions & 2 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+
FUTURE
8+
======
9+
- Add :ref:`ASYNC400 <async400>` except-star-invalid-attribute.
10+
711
25.5.1
812
======
913
- Fixed :ref:`ASYNC113 <async113>` false alarms if the ``start_soon`` calls are in a nursery cm that was closed before the yield point.
@@ -19,7 +23,7 @@ Changelog
1923

2024
25.4.2
2125
======
22-
- Add :ref:`ASYNC125 <async125>` constant-absolute-deadline
26+
- Add :ref:`ASYNC125 <async125>` constant-absolute-deadline.
2327

2428
25.4.1
2529
======
@@ -31,7 +35,7 @@ Changelog
3135

3236
25.2.3
3337
=======
34-
- No longer require ``flake8`` for installation... so if you require support for config files you must install ``flake8-async[flake8]``
38+
- No longer require ``flake8`` for installation... so if you require support for config files you must install ``flake8-async[flake8]``.
3539

3640
25.2.2
3741
=======

docs/rules.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@ _`ASYNC300` : create-task-no-reference
182182
Note that this rule won't check whether the variable the result is saved in is susceptible to being garbage-collected itself. See the asyncio documentation for best practices.
183183
You might consider instead using a :ref:`TaskGroup <taskgroup_nursery>` and calling :meth:`asyncio.TaskGroup.create_task` to avoid this problem, and gain the advantages of structured concurrency with e.g. better cancellation semantics.
184184

185+
ExceptionGroup rules
186+
====================
187+
188+
_`ASYNC400` : except-star-invalid-attribute
189+
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.
185190

186191
Optional rules disabled by default
187192
==================================

flake8_async/visitors/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
# This has to be done at the end to avoid circular imports
3030
from . import (
3131
visitor2xx,
32+
visitor4xx,
3233
visitor91x,
3334
visitor101,
3435
visitor102_120,

flake8_async/visitors/visitor4xx.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""4XX error classes, which handle exception groups.
2+
3+
ASYNC400 except-star-invalid-attribute checks for invalid attribute access on except*
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import ast
9+
from typing import TYPE_CHECKING, Any
10+
11+
from .flake8asyncvisitor import Flake8AsyncVisitor
12+
from .helpers import error_class
13+
14+
if TYPE_CHECKING:
15+
from collections.abc import Mapping
16+
17+
EXCGROUP_ATTRS = (
18+
# from ExceptionGroup
19+
"message",
20+
"exceptions",
21+
"subgroup",
22+
"split",
23+
"derive",
24+
# from BaseException
25+
"args",
26+
"with_traceback",
27+
"add_note",
28+
# in the backport
29+
"_is_protocol",
30+
)
31+
32+
33+
@error_class
34+
class Visitor4xx(Flake8AsyncVisitor):
35+
36+
error_codes: Mapping[str, str] = {
37+
"ASYNC400": (
38+
"Accessing attribute {} on ExceptionGroup as if it was a bare Exception."
39+
)
40+
}
41+
42+
def __init__(self, *args: Any, **kwargs: Any):
43+
super().__init__(*args, **kwargs)
44+
self.exception_groups: list[str] = []
45+
self.trystar = False
46+
47+
def visit_TryStar(self, node: ast.TryStar): # type: ignore[name-defined]
48+
self.save_state(node, "trystar")
49+
self.trystar = True
50+
51+
def visit_Try(self, node: ast.Try):
52+
self.save_state(node, "trystar")
53+
self.trystar = False
54+
55+
def visit_ExceptHandler(self, node: ast.ExceptHandler):
56+
if not self.trystar or node.name is None:
57+
return
58+
self.save_state(node, "exception_groups", copy=True)
59+
self.exception_groups.append(node.name)
60+
self.visit_nodes(node.body)
61+
62+
def visit_Attribute(self, node: ast.Attribute):
63+
if (
64+
isinstance(node.value, ast.Name)
65+
and node.value.id in self.exception_groups
66+
and node.attr not in EXCGROUP_ATTRS
67+
and not (node.attr.startswith("__") and node.attr.endswith("__"))
68+
):
69+
self.error(node, node.attr)
70+
71+
def _clear_if_name(self, node: ast.AST | None):
72+
if isinstance(node, ast.Name) and node.id in self.exception_groups:
73+
self.exception_groups.remove(node.id)
74+
75+
def _walk_and_clear(self, node: ast.AST | None):
76+
if node is None:
77+
return
78+
for n in ast.walk(node):
79+
self._clear_if_name(n)
80+
81+
def visit_Assign(self, node: ast.Assign):
82+
for t in node.targets:
83+
self._walk_and_clear(t)
84+
85+
def visit_AnnAssign(self, node: ast.AnnAssign):
86+
self._clear_if_name(node.target)
87+
88+
def visit_withitem(self, node: ast.withitem):
89+
self._walk_and_clear(node.optional_vars)
90+
91+
def visit_FunctionDef(
92+
self, node: ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda
93+
):
94+
self.save_state(node, "exception_groups", "trystar", copy=False)
95+
self.exception_groups = []
96+
97+
visit_AsyncFunctionDef = visit_FunctionDef
98+
visit_Lambda = visit_FunctionDef

tests/eval_files/async400_py311.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
try:
2+
...
3+
except* ValueError as e:
4+
e.anything # error: 4, "anything"
5+
e.foo() # error: 4, "foo"
6+
e.bar.zee # error: 4, "bar"
7+
8+
# from ExceptionGroup
9+
e.message
10+
e.exceptions
11+
e.subgroup
12+
e.split
13+
e.derive
14+
15+
# from BaseException
16+
e.args
17+
e.with_traceback
18+
e.add_note
19+
20+
# ignore anything that looks like a dunder
21+
e.__foo__
22+
e.__bar__
23+
24+
e.anything # safe
25+
26+
# assigning to the variable clears it
27+
try:
28+
...
29+
except* ValueError as e:
30+
e = e.exceptions[0]
31+
e.ignore # safe
32+
except* ValueError as e:
33+
e, f = 1, 2
34+
e.anything # safe
35+
except* TypeError as e:
36+
(e, f) = (1, 2)
37+
e.anything # safe
38+
except* ValueError as e:
39+
with blah as e:
40+
e.anything
41+
e.anything
42+
except* ValueError as e:
43+
e: int = 1
44+
e.real
45+
except* ValueError as e:
46+
with blah as (e, f):
47+
e.anything
48+
49+
# check state saving
50+
try:
51+
...
52+
except* ValueError as e:
53+
...
54+
except* BaseException:
55+
e.error # safe
56+
57+
try:
58+
...
59+
except* ValueError as e:
60+
try:
61+
...
62+
except* TypeError as e:
63+
...
64+
e.anything # error: 4, "anything"
65+
66+
try:
67+
...
68+
except* ValueError as e:
69+
70+
def foo():
71+
# possibly problematic, but we minimize false alarms
72+
e.anything
73+
74+
e.anything # error: 4, "anything"
75+
76+
def foo(e):
77+
# this one is more clear it should be treated as safe
78+
e.anything
79+
80+
e.anything # error: 4, "anything"
81+
82+
lambda e: e.anything
83+
84+
e.anything # error: 4, "anything"

tests/test_flake8_async.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
from flake8_async import Plugin
2727
from flake8_async.base import Error, Statement
2828
from flake8_async.visitors import ERROR_CLASSES, ERROR_CLASSES_CST
29+
from flake8_async.visitors.visitor4xx import EXCGROUP_ATTRS
30+
31+
if sys.version_info < (3, 11):
32+
from exceptiongroup import ExceptionGroup
2933

3034
if TYPE_CHECKING:
3135
from collections.abc import Iterable, Sequence
@@ -512,6 +516,7 @@ def _parse_eval_file(
512516
"ASYNC123",
513517
"ASYNC125",
514518
"ASYNC300",
519+
"ASYNC400",
515520
"ASYNC912",
516521
}
517522

@@ -845,6 +850,13 @@ async def foo():
845850
assert not errors, "# false alarm:\n" + function_str
846851

847852

853+
def test_async400_excgroup_attributes():
854+
for attr in dir(ExceptionGroup):
855+
if attr.startswith("__") and attr.endswith("__"):
856+
continue
857+
assert attr in EXCGROUP_ATTRS
858+
859+
848860
# from https://docs.python.org/3/library/itertools.html#itertools-recipes
849861
def consume(iterator: Iterable[Any]):
850862
deque(iterator, maxlen=0)

0 commit comments

Comments
 (0)