Skip to content

Commit e9b8048

Browse files
committed
add async400 exceptiongroup-invalid-access
1 parent 00d63c3 commit e9b8048

File tree

8 files changed

+195
-4
lines changed

8 files changed

+195
-4
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+
25.5.2
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
==================================

docs/usage.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ adding the following to your ``.pre-commit-config.yaml``:
3333
minimum_pre_commit_version: '2.9.0'
3434
repos:
3535
- repo: https://github.com/python-trio/flake8-async
36-
rev: 25.5.1
36+
rev: 25.5.2
3737
hooks:
3838
- id: flake8-async
3939
# args: ["--enable=ASYNC100,ASYNC112", "--disable=", "--autofix=ASYNC"]

flake8_async/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838

3939

4040
# CalVer: YY.month.patch, e.g. first release of July 2022 == "22.7.1"
41-
__version__ = "25.5.1"
41+
__version__ = "25.5.2"
4242

4343

4444
# taken from https://github.com/Zac-HD/shed

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,
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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_notes",
28+
)
29+
30+
31+
@error_class
32+
class Visitor4xx(Flake8AsyncVisitor):
33+
34+
error_codes: Mapping[str, str] = {
35+
"ASYNC400": (
36+
"Accessing attribute {} on ExceptionGroup as if it was a bare Exception"
37+
)
38+
}
39+
40+
def __init__(self, *args: Any, **kwargs: Any):
41+
super().__init__(*args, **kwargs)
42+
self.exception_groups: list[str] = []
43+
self.trystar = False
44+
45+
def visit_TryStar(self, node: ast.TryStar): # type: ignore[name-defined]
46+
self.save_state(node, "trystar")
47+
self.trystar = True
48+
49+
def visit_Try(self, node: ast.Try):
50+
self.save_state(node, "trystar")
51+
self.trystar = False
52+
53+
def visit_ExceptHandler(self, node: ast.ExceptHandler):
54+
if not self.trystar or node.name is None:
55+
return
56+
self.save_state(node, "exception_groups", copy=True)
57+
self.exception_groups.append(node.name)
58+
self.visit_nodes(node.body)
59+
60+
def visit_Attribute(self, node: ast.Attribute):
61+
if (
62+
isinstance(node.value, ast.Name)
63+
and node.value.id in self.exception_groups
64+
and node.attr not in EXCGROUP_ATTRS
65+
and not (node.attr.startswith("__") and node.attr.endswith("__"))
66+
):
67+
self.error(node, node.attr)
68+
69+
def _clear_if_name(self, node: ast.AST | None):
70+
if isinstance(node, ast.Name) and node.id in self.exception_groups:
71+
self.exception_groups.remove(node.id)
72+
73+
def _walk_and_clear(self, node: ast.AST | None):
74+
if node is None:
75+
return
76+
for n in ast.walk(node):
77+
self._clear_if_name(n)
78+
79+
def visit_Assign(self, node: ast.Assign):
80+
for t in node.targets:
81+
self._walk_and_clear(t)
82+
83+
def visit_AnnAssign(self, node: ast.AnnAssign):
84+
self._clear_if_name(node.target)
85+
86+
def visit_withitem(self, node: ast.withitem):
87+
self._walk_and_clear(node.optional_vars)
88+
89+
def visit_FunctionDef(
90+
self, node: ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda
91+
):
92+
self.save_state(node, "exception_groups", "trystar", copy=False)
93+
self.exception_groups = []
94+
95+
visit_AsyncFunctionDef = visit_FunctionDef
96+
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_notes
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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,7 @@ def _parse_eval_file(
512512
"ASYNC123",
513513
"ASYNC125",
514514
"ASYNC300",
515+
"ASYNC400",
515516
"ASYNC912",
516517
}
517518

0 commit comments

Comments
 (0)