Skip to content

Commit 3b35d1a

Browse files
authored
add async121 control-flow-in-taskgroup (#282)
* add async121 control-flow-in-taskgroup * bump __version__ * aaand fix the version # in the changelog * reword the description after feedback from oremanj * enable rule for asyncio, add more details to rule explanation. Extend tests to be more thorough with state management. * also check AsyncFor, restructure tests
1 parent 1740b0a commit 3b35d1a

File tree

8 files changed

+215
-1
lines changed

8 files changed

+215
-1
lines changed

docs/changelog.rst

Lines changed: 4 additions & 0 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+
24.9.1
8+
======
9+
- Add :ref:`ASYNC121 <async121>` control-flow-in-taskgroup
10+
711
24.8.1
812
======
913
- Add config option ``transform-async-generator-decorators``, to list decorators which

docs/rules.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ _`ASYNC120` : await-in-except
8383
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.
8484
This is currently not able to detect asyncio shields.
8585

86+
_`ASYNC121`: control-flow-in-taskgroup
87+
`return`, `continue`, and `break` inside a :ref:`taskgroup_nursery` can lead to counterintuitive behaviour. Refactor the code to instead cancel the :ref:`cancel_scope` inside the TaskGroup/Nursery and place the statement outside of the TaskGroup/Nursery block. In asyncio a user might expect the statement to have an immediate effect, but it will wait for all tasks to finish before having an effect. See `Trio issue #1493 <https://github.com/python-trio/trio/issues/1493>` for further issues specific to trio/anyio.
88+
8689

8790
Blocking sync calls in async functions
8891
======================================

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.8.1"
41+
__version__ = "24.9.1"
4242

4343

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

flake8_async/visitors/visitors.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,59 @@ def visit_Yield(self, node: ast.Yield):
350350
visit_Lambda = visit_AsyncFunctionDef
351351

352352

353+
@error_class
354+
class Visitor121(Flake8AsyncVisitor):
355+
error_codes: Mapping[str, str] = {
356+
"ASYNC121": (
357+
"{0} in a {1} block behaves counterintuitively in several"
358+
" situations. Refactor to have the {0} outside."
359+
)
360+
}
361+
362+
def __init__(self, *args: Any, **kwargs: Any):
363+
super().__init__(*args, **kwargs)
364+
self.unsafe_stack: list[str] = []
365+
366+
def visit_AsyncWith(self, node: ast.AsyncWith):
367+
self.save_state(node, "unsafe_stack", copy=True)
368+
369+
for item in node.items:
370+
if get_matching_call(item.context_expr, "open_nursery", base="trio"):
371+
self.unsafe_stack.append("nursery")
372+
elif get_matching_call(
373+
item.context_expr, "create_task_group", base="anyio"
374+
) or get_matching_call(item.context_expr, "TaskGroup", base="asyncio"):
375+
self.unsafe_stack.append("task group")
376+
377+
def visit_While(self, node: ast.While | ast.For | ast.AsyncFor):
378+
self.save_state(node, "unsafe_stack", copy=True)
379+
self.unsafe_stack.append("loop")
380+
381+
visit_For = visit_While
382+
visit_AsyncFor = visit_While
383+
384+
def check_loop_flow(self, node: ast.Continue | ast.Break, statement: str) -> None:
385+
# self.unsafe_stack should never be empty, but no reason not to avoid a crash
386+
# for invalid code.
387+
if self.unsafe_stack and self.unsafe_stack[-1] != "loop":
388+
self.error(node, statement, self.unsafe_stack[-1])
389+
390+
def visit_Continue(self, node: ast.Continue) -> None:
391+
self.check_loop_flow(node, "continue")
392+
393+
def visit_Break(self, node: ast.Break) -> None:
394+
self.check_loop_flow(node, "break")
395+
396+
def visit_Return(self, node: ast.Return) -> None:
397+
for unsafe_cm in "nursery", "task group":
398+
if unsafe_cm in self.unsafe_stack:
399+
self.error(node, "return", unsafe_cm)
400+
401+
def visit_FunctionDef(self, node: ast.FunctionDef):
402+
self.save_state(node, "unsafe_stack", copy=True)
403+
self.unsafe_stack = []
404+
405+
353406
@error_class_cst
354407
class Visitor300(Flake8AsyncVisitor_cst):
355408
error_codes: Mapping[str, str] = {

tests/eval_files/async121.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# ASYNCIO_NO_ERROR # checked in async121_asyncio.py
2+
# ANYIO_NO_ERROR # checked in async121_anyio.py
3+
4+
import trio
5+
from typing import Any
6+
7+
8+
# To avoid mypy unreachable-statement we wrap control flow calls in if statements
9+
# they should have zero effect on the visitor logic.
10+
def condition() -> bool:
11+
return False
12+
13+
14+
def bar() -> Any: ...
15+
16+
17+
async def foo_return():
18+
async with trio.open_nursery():
19+
if condition():
20+
return # ASYNC121: 12, "return", "nursery"
21+
while condition():
22+
return # ASYNC121: 12, "return", "nursery"
23+
24+
return # safe
25+
26+
27+
async def foo_return_nested():
28+
async with trio.open_nursery():
29+
30+
def bar():
31+
return # safe
32+
33+
34+
async def foo_while_safe():
35+
async with trio.open_nursery():
36+
while True:
37+
if condition():
38+
break # safe
39+
if condition():
40+
continue # safe
41+
continue # safe
42+
43+
44+
async def foo_while_unsafe():
45+
while True:
46+
async with trio.open_nursery():
47+
if condition():
48+
continue # ASYNC121: 16, "continue", "nursery"
49+
if condition():
50+
break # ASYNC121: 16, "break", "nursery"
51+
if condition():
52+
continue # safe
53+
break # safe
54+
55+
56+
async def foo_for_safe():
57+
async with trio.open_nursery():
58+
for _ in range(5):
59+
if condition():
60+
continue # safe
61+
if condition():
62+
break # safe
63+
64+
65+
async def foo_for_unsafe():
66+
for _ in range(5):
67+
async with trio.open_nursery():
68+
if condition():
69+
continue # ASYNC121: 16, "continue", "nursery"
70+
if condition():
71+
break # ASYNC121: 16, "break", "nursery"
72+
continue # safe
73+
74+
75+
async def foo_async_for_safe():
76+
async with trio.open_nursery():
77+
async for _ in bar():
78+
if condition():
79+
continue # safe
80+
if condition():
81+
break # safe
82+
83+
84+
async def foo_async_for_unsafe():
85+
async for _ in bar():
86+
async with trio.open_nursery():
87+
if condition():
88+
continue # ASYNC121: 16, "continue", "nursery"
89+
if condition():
90+
break # ASYNC121: 16, "break", "nursery"
91+
continue # safe
92+
93+
94+
# nested nursery
95+
async def foo_nested_nursery():
96+
async with trio.open_nursery():
97+
if condition():
98+
return # ASYNC121: 12, "return", "nursery"
99+
async with trio.open_nursery():
100+
if condition():
101+
return # ASYNC121: 16, "return", "nursery"
102+
if condition():
103+
return # ASYNC121: 12, "return", "nursery"
104+
if condition():
105+
return # safe

tests/eval_files/async121_anyio.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# ASYNCIO_NO_ERROR # checked in async121_asyncio.py
2+
# TRIO_NO_ERROR # checked in async121.py
3+
# BASE_LIBRARY anyio
4+
5+
import anyio
6+
7+
8+
# To avoid mypy unreachable-statement we wrap control flow calls in if statements
9+
# they should have zero effect on the visitor logic.
10+
def condition() -> bool:
11+
return False
12+
13+
14+
# only tests that asyncio.TaskGroup is detected, main tests in async121.py
15+
async def foo_return():
16+
while True:
17+
async with anyio.create_task_group():
18+
if condition():
19+
continue # ASYNC121: 16, "continue", "task group"
20+
if condition():
21+
break # ASYNC121: 16, "break", "task group"
22+
return # ASYNC121: 12, "return", "task group"

tests/eval_files/async121_asyncio.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# ANYIO_NO_ERROR
2+
# TRIO_NO_ERROR # checked in async121.py
3+
# BASE_LIBRARY asyncio
4+
# TaskGroup was added in 3.11, we run type checking with 3.9
5+
# mypy: disable-error-code=attr-defined
6+
7+
import asyncio
8+
9+
10+
# To avoid mypy unreachable-statement we wrap control flow calls in if statements
11+
# they should have zero effect on the visitor logic.
12+
def condition() -> bool:
13+
return False
14+
15+
16+
# only tests that asyncio.TaskGroup is detected, main tests in async121.py
17+
async def foo_return():
18+
while True:
19+
async with asyncio.TaskGroup():
20+
if condition():
21+
continue # ASYNC121: 16, "continue", "task group"
22+
if condition():
23+
break # ASYNC121: 16, "break", "task group"
24+
return # ASYNC121: 12, "return", "task group"

tests/test_flake8_async.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,9 @@ def _parse_eval_file(
478478
"ASYNC116",
479479
"ASYNC117",
480480
"ASYNC118",
481+
# opening nurseries & taskgroups can only be done in async context, so ASYNC121
482+
# doesn't check for it
483+
"ASYNC121",
481484
"ASYNC300",
482485
"ASYNC912",
483486
}

0 commit comments

Comments
 (0)