Skip to content

Commit 85fe612

Browse files
authored
Add ASYNC913, indefinite-loop-no-guaranteed-checkpoint. Fix async91x bugs (#255)
* Add ASYNC913, indefinite-loop-no-guaranteed-checkpoint. * Fix autofix bugs in async910 & 911.
1 parent a5a0268 commit 85fe612

20 files changed

+437
-16
lines changed

docs/changelog.rst

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

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

7+
24.5.4
8+
======
9+
- Add ASYNC913: Indefinite loop with no guaranteed checkpoint.
10+
- Fix bugs in ASYNC910 and ASYNC911 autofixing where they sometimes didn't add a library import.
11+
- Fix crash in ASYNC911 when trying to autofix a one-line ``while ...: yield``
12+
- Add :ref:`exception-suppress-context-managers`. Contextmanagers that may suppress exceptions.
13+
- ASYNC91x now treats checkpoints inside ``with contextlib.suppress`` as unreliable.
14+
715
24.5.3
816
======
917
- Rename config option ``trio200-blocking-calls`` to :ref:`async200-blocking-calls`.

docs/rules.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ _`ASYNC912` : cancel-scope-no-guaranteed-checkpoint
146146
Similar to `ASYNC100`_, but it does not warn on trivial cases where there is no checkpoint at all.
147147
It instead shares logic with `ASYNC910`_ and `ASYNC911`_ for parsing conditionals and branches.
148148

149+
_`ASYNC913` : indefinite-loop-no-guaranteed-checkpoint
150+
An indefinite loop (e.g. ``while True``) has no guaranteed :ref:`checkpoint <checkpoint>`. This could potentially cause a deadlock.
151+
149152
.. _autofix-support:
150153

151154
Autofix support
@@ -154,6 +157,7 @@ The following rules support :ref:`autofixing <autofix>`.
154157
- :ref:`ASYNC100 <ASYNC100>`
155158
- :ref:`ASYNC910 <ASYNC910>`
156159
- :ref:`ASYNC911 <ASYNC911>`
160+
- :ref:`ASYNC913 <ASYNC913>`
157161

158162
Removed rules
159163
================

docs/usage.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,8 @@ Example
232232
ign*,
233233
*.ignore,
234234
235+
.. _exception-suppress-context-managers:
236+
235237
``exception-suppress-context-managers``
236238
---------------------------------------
237239

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.3"
41+
__version__ = "24.5.4"
4242

4343

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

flake8_async/visitors/visitor91x.py

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from abc import ABC, abstractmethod
1111
from dataclasses import dataclass, field
12-
from typing import TYPE_CHECKING, Any
12+
from typing import TYPE_CHECKING, Any, cast
1313

1414
import libcst as cst
1515
import libcst.matchers as m
@@ -73,8 +73,8 @@ class LoopState:
7373
uncheckpointed_before_break: set[Statement] = field(default_factory=set)
7474

7575
artificial_errors: set[cst.Return | cst.Yield] = field(default_factory=set)
76-
nodes_needing_checkpoints: list[cst.Return | cst.Yield] = field(
77-
default_factory=list
76+
nodes_needing_checkpoints: list[cst.Return | cst.Yield | ArtificialStatement] = (
77+
field(default_factory=list)
7878
)
7979

8080
def copy(self):
@@ -215,8 +215,10 @@ def __init__(
215215
self,
216216
nodes_needing_checkpoint: Sequence[cst.Yield | cst.Return],
217217
library: tuple[str, ...],
218+
explicitly_imported: dict[str, bool],
218219
):
219220
super().__init__()
221+
self.explicitly_imported_library = explicitly_imported
220222
self.nodes_needing_checkpoint = nodes_needing_checkpoint
221223
self.__library = library
222224

@@ -262,6 +264,7 @@ class Visitor91X(Flake8AsyncVisitor_cst, CommonVisitors):
262264
"CancelScope with no guaranteed checkpoint. This makes it potentially "
263265
"impossible to cancel."
264266
),
267+
"ASYNC913": ("Indefinite loop with no guaranteed checkpoint."),
265268
"ASYNC100": (
266269
"{0}.{1} context contains no checkpoints, remove the context or add"
267270
" `await {0}.lowlevel.checkpoint()`."
@@ -382,6 +385,8 @@ def leave_FunctionDef(
382385
indentedblock = updated_node.body.with_changes(body=new_body)
383386
updated_node = updated_node.with_changes(body=indentedblock)
384387

388+
self.ensure_imported_library()
389+
385390
self.restore_state(original_node)
386391
return updated_node
387392

@@ -772,6 +777,18 @@ def leave_While_body(self, node: cst.For | cst.While):
772777
if not any_error:
773778
self.loop_state.nodes_needing_checkpoints = []
774779

780+
if (
781+
self.loop_state.infinite_loop
782+
and not self.loop_state.has_break
783+
and ARTIFICIAL_STATEMENT in self.uncheckpointed_statements
784+
and self.error(node, error_code="ASYNC913")
785+
):
786+
# We can override nodes_needing_checkpoints, as that's solely for checkpoints
787+
# that error because of the artificial statement injected at the start of
788+
# the loop. When inserting a checkpoint at the start of the loop, those
789+
# will be remedied
790+
self.loop_state.nodes_needing_checkpoints = [ARTIFICIAL_STATEMENT]
791+
775792
# replace artificial statements in else with prebody uncheckpointed statements
776793
# non-artificial stmts before continue/break/at body end will already be in them
777794
for stmts in (
@@ -832,15 +849,38 @@ def leave_While(
832849
| cst.FlattenSentinel[cst.For | cst.While]
833850
| cst.RemovalSentinel
834851
):
835-
if self.loop_state.nodes_needing_checkpoints:
852+
# don't bother autofixing same-line loops
853+
if isinstance(updated_node.body, cst.SimpleStatementSuite):
854+
self.restore_state(original_node)
855+
return updated_node
856+
857+
# ASYNC913, indefinite loop with no guaranteed checkpoint
858+
if self.loop_state.nodes_needing_checkpoints == [ARTIFICIAL_STATEMENT]:
859+
if self.should_autofix(original_node, code="ASYNC913"):
860+
# insert checkpoint at start of body
861+
new_body = list(updated_node.body.body)
862+
new_body.insert(0, self.checkpoint_statement())
863+
indentedblock = updated_node.body.with_changes(body=new_body)
864+
updated_node = updated_node.with_changes(body=indentedblock)
865+
866+
self.ensure_imported_library()
867+
elif self.loop_state.nodes_needing_checkpoints:
868+
assert ARTIFICIAL_STATEMENT not in self.loop_state.nodes_needing_checkpoints
836869
transformer = InsertCheckpointsInLoopBody(
837-
self.loop_state.nodes_needing_checkpoints, self.library
870+
cast(
871+
"list[cst.Yield | cst.Return]",
872+
self.loop_state.nodes_needing_checkpoints,
873+
),
874+
self.library,
875+
self.explicitly_imported_library,
838876
)
839877
# type of updated_node expanded to the return type
840878
updated_node = updated_node.visit(transformer) # type: ignore
841879

880+
# include any necessary import added
881+
self.add_import.update(transformer.add_import)
882+
842883
self.restore_state(original_node)
843-
# https://github.com/afonasev/flake8-return/issues/133
844884
return updated_node
845885

846886
leave_For = leave_While

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ remove-all-unused-imports = true
77
remove-duplicate-keys = true
88
remove-unused-variables = true
99

10+
[tool.black]
11+
# need to use force-exclude since pre-commit directly specifies files
12+
force-exclude = "tests/autofix_files/.*.py"
13+
1014
[tool.codespell]
1115
ignore-words-list = 'spawnve'
1216

@@ -16,7 +20,7 @@ profile = "black"
1620
py_version = "311"
1721
quiet = true
1822
skip_gitignore = true
19-
skip_glob = "tests/eval_files/*"
23+
skip_glob = "tests/*_files/*"
2024

2125
[tool.mypy]
2226
check_untyped_defs = true
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# ensure that import gets added when adding checkpoints at end of function body
2+
3+
# AUTOFIX
4+
# ASYNCIO_NO_AUTOFIX
5+
6+
7+
import trio
8+
def condition() -> bool:
9+
return False
10+
11+
12+
async def foo(): # ASYNC910: 0, "exit", Stmt("function definition", line)
13+
print()
14+
await trio.lowlevel.checkpoint()
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
+++
3+
@@ x,9 x,11 @@
4+
# ASYNCIO_NO_AUTOFIX
5+
6+
7+
+import trio
8+
def condition() -> bool:
9+
return False
10+
11+
12+
async def foo(): # ASYNC910: 0, "exit", Stmt("function definition", line)
13+
print()
14+
+ await trio.lowlevel.checkpoint()
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# ensure that import gets added when adding checkpoints in loop body
2+
3+
# AUTOFIX
4+
# ASYNCIO_NO_AUTOFIX
5+
6+
7+
import trio
8+
def condition() -> bool:
9+
return False
10+
11+
12+
async def foo():
13+
await foo()
14+
while condition():
15+
await trio.lowlevel.checkpoint()
16+
yield # ASYNC911: 8, "yield", Stmt("yield", lineno)
17+
await foo()
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
+++
3+
@@ x,6 x,7 @@
4+
# ASYNCIO_NO_AUTOFIX
5+
6+
7+
+import trio
8+
def condition() -> bool:
9+
return False
10+
11+
@@ x,5 x,6 @@
12+
async def foo():
13+
await foo()
14+
while condition():
15+
+ await trio.lowlevel.checkpoint()
16+
yield # ASYNC911: 8, "yield", Stmt("yield", lineno)
17+
await foo()

tests/autofix_files/async913.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# ARG --enable=ASYNC910,ASYNC911,ASYNC913
2+
# AUTOFIX
3+
# ASYNCIO_NO_AUTOFIX
4+
5+
6+
import trio
7+
def condition() -> bool:
8+
return False
9+
10+
11+
async def foo():
12+
while True: # ASYNC913: 4
13+
await trio.lowlevel.checkpoint()
14+
...
15+
16+
17+
async def foo2():
18+
while True:
19+
await foo()
20+
21+
22+
async def foo3():
23+
while True: # ASYNC913: 4
24+
await trio.lowlevel.checkpoint()
25+
if condition():
26+
await foo()
27+
28+
29+
# ASYNC913 does not trigger on loops with break, but those will generally be handled
30+
# by 910/911/912 if necessary
31+
async def foo_break(): # ASYNC910: 0, "exit", Statement("function definition", lineno)
32+
while True:
33+
if condition():
34+
break
35+
await trio.lowlevel.checkpoint()
36+
37+
38+
# the inner loop will suppress the error in the outer loop
39+
async def foo_nested():
40+
while True:
41+
while True: # ASYNC913: 8
42+
await trio.lowlevel.checkpoint()
43+
...
44+
45+
46+
async def foo_conditional_nested():
47+
while True: # ASYNC913: 4
48+
await trio.lowlevel.checkpoint()
49+
if condition():
50+
while True: # ASYNC913: 12
51+
await trio.lowlevel.checkpoint()
52+
...
53+
54+
55+
# various checks I added for my own sanity to ensure autofixes worked when multiple
56+
# codes simultaneously want to autofix.
57+
58+
59+
async def foo_indef_and_910():
60+
while True: # ASYNC913: 4
61+
await trio.lowlevel.checkpoint()
62+
if ...:
63+
await foo()
64+
return
65+
66+
67+
async def foo_indef_and_910_2():
68+
while True: # ASYNC913: 4
69+
await trio.lowlevel.checkpoint()
70+
if ...:
71+
return # ASYNC910: 12, "return", Stmt("function definition", line-3)
72+
73+
74+
async def foo_indef_and_911():
75+
await foo()
76+
while True: # ASYNC913: 4
77+
await trio.lowlevel.checkpoint()
78+
if condition():
79+
yield # ASYNC911: 12, "yield", Stmt("yield", line) # ASYNC911: 12, "yield", Stmt("yield", line+2)
80+
if condition():
81+
await trio.lowlevel.checkpoint()
82+
yield # ASYNC911: 12, "yield", Stmt("yield", line) # ASYNC911: 12, "yield", Stmt("yield", line-2) # ASYNC911: 12, "yield", Stmt("yield", line-2)
83+
84+
85+
async def foo_indef_and_911_2():
86+
await foo()
87+
while True: # ASYNC913: 4
88+
await trio.lowlevel.checkpoint()
89+
while condition():
90+
await trio.lowlevel.checkpoint()
91+
yield # ASYNC911: 12, "yield", Stmt("yield", line)

0 commit comments

Comments
 (0)