Skip to content

Commit e198655

Browse files
jakkdlZac-HDpre-commit-ci[bot]
authored
add async120, await-in-except (#265)
* add async120, await-in-except * Fix false alarm on nested function definitions for async102 --------- Co-authored-by: Zac Hatfield-Dodds <zac.hatfield.dodds@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent fcd515a commit e198655

7 files changed

Lines changed: 212 additions & 13 deletions

File tree

docs/changelog.rst

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

55
*[CalVer, YY.month.patch](https://calver.org/)*
66

7+
24.6.1
8+
======
9+
- Add :ref:`ASYNC120 <async120>` await-in-except.
10+
- Fix false alarm with :ref:`ASYNC102 <async102>` with function definitions inside finally/except.
11+
712
24.5.6
813
======
914
- Make :ref:`ASYNC913 <async913>` disabled by default, as originally intended.

docs/rules.rst

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ ASYNC101 : yield-in-cancel-scope
2020
See `this thread <https://discuss.python.org/t/preventing-yield-inside-certain-context-managers/1091/23>`_ for discussion of a future PEP.
2121
This has substantial overlap with :ref:`ASYNC119 <ASYNC119>`, which will warn on almost all instances of ASYNC101, but ASYNC101 is about a conceptually different problem that will not get resolved by `PEP 533 <https://peps.python.org/pep-0533/>`_.
2222

23-
ASYNC102 : await-in-finally-or-cancelled
23+
_`ASYNC102` : await-in-finally-or-cancelled
2424
``await`` inside ``finally`` or :ref:`cancelled-catching <cancelled>` ``except:`` must have shielded :ref:`cancel scope <cancel_scope>` with timeout.
25+
If not, the async call will immediately raise a new cancellation, suppressing the cancellation that was caught.
26+
See :ref:`ASYNC120 <async120>` for the general case where other exceptions might get suppressed.
2527
This is currently not able to detect asyncio shields.
2628

2729
ASYNC103 : no-reraise-cancelled
@@ -75,6 +77,12 @@ _`ASYNC119` : yield-in-cm-in-async-gen
7577
``yield`` in context manager in async generator is unsafe, the cleanup may be delayed until ``await`` is no longer allowed.
7678
We strongly encourage you to read `PEP 533 <https://peps.python.org/pep-0533/>`_ and use `async with aclosing(...) <https://docs.python.org/3/library/contextlib.html#contextlib.aclosing>`_, or better yet avoid async generators entirely (see `ASYNC900`_ ) in favor of context managers which return an iterable :ref:`channel/stream/queue <channel_stream_queue>`.
7779

80+
_`ASYNC120` : await-in-except
81+
Dangerous :ref:`checkpoint` inside an ``except`` block.
82+
If this checkpoint is cancelled, the current active exception will be replaced by the ``Cancelled`` exception, and cannot be reraised later.
83+
This will not trigger when :ref:`ASYNC102 <ASYNC102>` does, and if you don't care about losing non-cancelled exceptions you could disable this rule.
84+
This is currently not able to detect asyncio shields.
85+
7886

7987
Blocking sync calls in async functions
8088
======================================

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__ = "24.5.6"
41+
__version__ = "24.6.1"
4242

4343

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

flake8_async/visitors/visitor102.py

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ class Visitor102(Flake8AsyncVisitor):
2323
"await inside {0.name} on line {0.lineno} must have shielded cancel "
2424
"scope with a timeout."
2525
),
26+
"ASYNC120": (
27+
"checkpoint inside {0.name} on line {0.lineno} will discard the active "
28+
"exception if cancelled."
29+
),
2630
}
2731

2832
class TrioScope:
@@ -52,6 +56,15 @@ def __init__(self, *args: Any, **kwargs: Any):
5256
self._trio_context_managers: list[Visitor102.TrioScope] = []
5357
self.cancelled_caught = False
5458

59+
# list of dangerous awaits inside a non-critical except handler,
60+
# which will emit errors upon reaching a raise.
61+
# Entries are only added to the list inside except handlers,
62+
# so with the `save_state` in visit_ExceptHandler any entries not followed
63+
# by a raise will be thrown out when exiting the except handler.
64+
self._potential_120: list[
65+
tuple[ast.Await | ast.AsyncFor | ast.AsyncWith, Statement]
66+
] = []
67+
5568
# if we're inside a finally or critical except, and we're not inside a scope that
5669
# doesn't have both a timeout and shield
5770
def async_call_checker(
@@ -60,7 +73,16 @@ def async_call_checker(
6073
if self._critical_scope is not None and not any(
6174
cm.has_timeout and cm.shielded for cm in self._trio_context_managers
6275
):
63-
self.error(node, self._critical_scope)
76+
# non-critical exception handlers have the statement name set to "except"
77+
if self._critical_scope.name == "except":
78+
self._potential_120.append((node, self._critical_scope))
79+
else:
80+
self.error(node, self._critical_scope, error_code="ASYNC102")
81+
82+
def visit_Raise(self, node: ast.Raise):
83+
for err_node, scope in self._potential_120:
84+
self.error(err_node, scope, error_code="ASYNC120")
85+
self._potential_120.clear()
6486

6587
def is_safe_aclose_call(self, node: ast.Await) -> bool:
6688
return (
@@ -120,16 +142,21 @@ def visit_Try(self, node: ast.Try):
120142
self.visit_nodes(node.finalbody)
121143

122144
def visit_ExceptHandler(self, node: ast.ExceptHandler):
123-
if self.cancelled_caught:
124-
return
125-
res = critical_except(node)
126-
if res is None:
145+
# if we're inside a critical scope, a nested except should never override that
146+
if self._critical_scope is not None and self._critical_scope.name != "except":
127147
return
128148

129-
self.save_state(node, "_critical_scope", "_trio_context_managers")
130-
self.cancelled_caught = True
149+
self.save_state(
150+
node, "_critical_scope", "_trio_context_managers", "_potential_120"
151+
)
131152
self._trio_context_managers = []
132-
self._critical_scope = res
153+
self._potential_120 = []
154+
155+
if self.cancelled_caught or (res := critical_except(node)) is None:
156+
self._critical_scope = Statement("except", node.lineno, node.col_offset)
157+
else:
158+
self._critical_scope = res
159+
self.cancelled_caught = True
133160

134161
def visit_Assign(self, node: ast.Assign):
135162
# checks for <scopename>.shield = [True/False]
@@ -145,3 +172,24 @@ def visit_Assign(self, node: ast.Assign):
145172
and isinstance(node.value, ast.Constant)
146173
):
147174
last_scope.shielded = node.value.value
175+
176+
def visit_FunctionDef(
177+
self, node: ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda
178+
):
179+
self.save_state(
180+
node,
181+
"_critical_scope",
182+
"_trio_context_managers",
183+
"_potential_120",
184+
"cancelled_caught",
185+
)
186+
self._critical_scope = None
187+
self._trio_context_managers = []
188+
self.cancelled_caught = False
189+
190+
self._potential_120 = []
191+
192+
visit_AsyncFunctionDef = visit_FunctionDef
193+
# lambda can't contain await, try, except, raise, with, or assignments.
194+
# You also can't do assignment expressions with attributes. So we don't need to
195+
# do any special handling for them.

tests/eval_files/async102.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# type: ignore
2+
# ARG --enable=ASYNC102,ASYNC120
23
# NOASYNCIO # TODO: support asyncio shields
34
from contextlib import asynccontextmanager
45

@@ -180,7 +181,7 @@ async def foo4():
180181
try:
181182
...
182183
except ValueError:
183-
await foo() # safe
184+
await foo()
184185
except BaseException:
185186
await foo() # error: 8, Statement("BaseException", lineno-1)
186187
except:
@@ -247,7 +248,7 @@ async def foo_nested_excepts():
247248
try:
248249
await foo() # error: 12, Statement("BaseException", lineno-3)
249250
except BaseException:
250-
await foo() # error: 12, Statement("BaseException", lineno-1)
251+
await foo() # error: 12, Statement("BaseException", lineno-5)
251252
except:
252253
# unsafe, because we're waiting inside the parent baseexception
253254
await foo() # error: 12, Statement("BaseException", lineno-8)
@@ -275,3 +276,13 @@ async def foo_nested_async_for():
275276
j
276277
) in trio.bypasslinters:
277278
...
279+
280+
281+
# nested funcdef (previously false alarm)
282+
async def foo_nested_funcdef():
283+
try:
284+
...
285+
except:
286+
287+
async def foobar():
288+
await foo()

tests/eval_files/async102_aclose.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
# type: ignore
2+
# ARG --enable=ASYNC102,ASYNC120
23

3-
# exclude finally: await x.aclose() from async102, with trio/anyio
4+
# exclude finally: await x.aclose() from async102 and async120, with trio/anyio
5+
6+
# These magical markers are the ones that ensure trio & anyio don't raise errors:
47
# ANYIO_NO_ERROR
58
# TRIO_NO_ERROR
9+
610
# See also async102_aclose_args.py - which makes sure trio/anyio raises errors if there
711
# are arguments to aclose().
812

tests/eval_files/async120.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# ARG --enable=ASYNC102,ASYNC120
2+
# NOASYNCIO # TODO: support asyncio shields (?)
3+
4+
import trio
5+
6+
7+
def condition() -> bool:
8+
return False
9+
10+
11+
async def foo():
12+
try:
13+
...
14+
except ValueError:
15+
await foo() # ASYNC120: 8, Stmt("except", lineno-1)
16+
if condition():
17+
raise
18+
await foo()
19+
20+
try:
21+
...
22+
except ValueError:
23+
await foo() # ASYNC120: 8, Stmt("except", lineno-1)
24+
raise
25+
26+
# don't error if the raise is in a separate excepthandler
27+
try:
28+
...
29+
except ValueError:
30+
await foo()
31+
except TypeError:
32+
raise
33+
34+
# does not support conditional branches
35+
try:
36+
...
37+
except ValueError:
38+
if ...:
39+
await foo() # ASYNC120: 12, Stmt("except", lineno-2)
40+
else:
41+
raise
42+
43+
# don't trigger on cases of ASYNC102 (?)
44+
try:
45+
...
46+
except:
47+
await foo() # ASYNC102: 8, Stmt("bare except", lineno-1)
48+
raise
49+
50+
# shielded awaits with timeouts don't trigger 120
51+
try:
52+
...
53+
except:
54+
with trio.fail_after(10) as cs:
55+
cs.shield = True
56+
await foo()
57+
raise
58+
59+
try:
60+
...
61+
except:
62+
with trio.fail_after(10) as cs:
63+
cs.shield = True
64+
await foo()
65+
raise
66+
67+
# ************************
68+
# Weird nesting edge cases
69+
# ************************
70+
71+
# nested excepthandlers should not trigger 120 on awaits in
72+
# their parent scope
73+
try:
74+
...
75+
except ValueError:
76+
await foo()
77+
try:
78+
...
79+
except TypeError:
80+
raise
81+
82+
# but the other way around probably should(?)
83+
try:
84+
...
85+
except ValueError:
86+
try:
87+
...
88+
except TypeError:
89+
await foo()
90+
raise
91+
92+
# but only when they're properly nested, this should not give 120
93+
try:
94+
...
95+
except TypeError:
96+
await foo()
97+
if condition():
98+
raise
99+
100+
try:
101+
...
102+
except ValueError:
103+
await foo() # ASYNC120: 8, Statement("except", lineno-1)
104+
try:
105+
await foo() # ASYNC120: 12, Statement("except", lineno-3)
106+
except BaseException:
107+
await foo() # ASYNC102: 12, Statement("BaseException", lineno-1)
108+
except:
109+
await foo()
110+
await foo() # ASYNC120: 8, Statement("except", lineno-8)
111+
raise
112+
113+
114+
# nested funcdef
115+
async def foo_nested_funcdef():
116+
try:
117+
...
118+
except ValueError:
119+
120+
async def foobar():
121+
await foo()
122+
123+
raise

0 commit comments

Comments
 (0)