Skip to content

Commit 9935ce6

Browse files
authored
fix async113 false alarms (#377)
1 parent 8143be4 commit 9935ce6

File tree

6 files changed

+104
-16
lines changed

6 files changed

+104
-16
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+
25.5.1
8+
======
9+
- Fixed :ref:`ASYNC113 <async113>` false alarms if the ``start_soon`` calls are in a nursery cm that was closed before the yield point.
10+
711
25.4.4
812
======
913
- :ref:`ASYNC900 <async900>` now accepts and recommends :func:`trio.as_safe_channel`.

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.4.4
36+
rev: 25.5.1
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.4.4"
41+
__version__ = "25.5.1"
4242

4343

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

flake8_async/visitors/visitors.py

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import ast
6+
from collections import defaultdict
67
from typing import TYPE_CHECKING, Any, cast
78

89
from .flake8asyncvisitor import Flake8AsyncVisitor, Flake8AsyncVisitor_cst
@@ -181,20 +182,25 @@ def __init__(self, *args: Any, **kwargs: Any):
181182
self.asynccontextmanager = False
182183
self.aenter = False
183184

185+
self.potential_errors: dict[str, list[ast.Call]] = defaultdict(list)
186+
184187
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
185-
self.save_state(node, "aenter")
188+
self.save_state(node, "aenter", "asynccontextmanager", "potential_errors")
186189

187-
self.aenter = node.name == "__aenter__" or has_decorator(
188-
node, "asynccontextmanager"
189-
)
190+
self.aenter = node.name == "__aenter__"
191+
self.asynccontextmanager = has_decorator(node, "asynccontextmanager")
190192

191193
def visit_FunctionDef(self, node: ast.FunctionDef):
192-
self.save_state(node, "aenter")
194+
self.save_state(node, "aenter", "asynccontextmanager", "potential_errors")
193195
# sync function should never be named __aenter__ or have @asynccontextmanager
194-
self.aenter = False
196+
self.aenter = self.asynccontextmanager = False
195197

196198
def visit_Yield(self, node: ast.Yield):
197-
self.aenter = False
199+
for nodes in self.potential_errors.values():
200+
for n in nodes:
201+
self.error(n)
202+
self.potential_errors.clear()
203+
self.aenter = self.asynccontextmanager = False
198204

199205
def visit_Call(self, node: ast.Call) -> None:
200206
def is_startable(n: ast.expr, *startable_list: str) -> bool:
@@ -210,14 +216,14 @@ def is_startable(n: ast.expr, *startable_list: str) -> bool:
210216
return any(is_startable(nn, *startable_list) for nn in n.args)
211217
return False
212218

213-
def is_nursery_call(node: ast.expr):
219+
def is_nursery_call(node: ast.expr) -> str | None:
214220
if not isinstance(node, ast.Attribute) or node.attr not in (
215221
"start_soon",
216222
"create_task",
217223
):
218-
return False
224+
return None
219225
var = ast.unparse(node.value)
220-
return (
226+
if (
221227
("trio" in self.library and var.endswith("nursery"))
222228
or ("anyio" in self.library and var.endswith("task_group"))
223229
or (
@@ -228,11 +234,12 @@ def is_nursery_call(node: ast.expr):
228234
"asyncio.TaskGroup",
229235
)
230236
)
231-
)
237+
):
238+
return var
239+
return None
232240

233241
if (
234-
self.aenter
235-
and is_nursery_call(node.func)
242+
(var := is_nursery_call(node.func)) is not None
236243
and len(node.args) > 0
237244
and is_startable(
238245
node.args[0],
@@ -241,7 +248,24 @@ def is_nursery_call(node: ast.expr):
241248
*self.options.startable_in_context_manager,
242249
)
243250
):
244-
self.error(node)
251+
if self.aenter:
252+
self.error(node)
253+
elif self.asynccontextmanager:
254+
self.potential_errors[var].append(node)
255+
256+
def visit_AsyncWith(self, node: ast.AsyncWith | ast.With):
257+
# Entirely skip any nurseries that doesn't have any yields in them.
258+
# This fixes an otherwise very thorny false alarm.
259+
# In the worst case this does mean we iterate over the body twice, but might
260+
# actually be a performance gain on average due to setting `novisit`
261+
if not any(isinstance(n, ast.Yield) for b in node.body for n in ast.walk(b)):
262+
self.novisit = True
263+
return
264+
265+
# open_nursery/create_task_group only works with AsyncWith, but in case somebody
266+
# is doing something very weird we'll be conservative and possibly avoid
267+
# some potential false alarms
268+
visit_With = visit_AsyncWith
245269

246270

247271
# Checks that all async functions with a "task_status" parameter have a match in

tests/eval_files/async113.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,18 @@ async def foo():
2424
# we don't check for `async with`
2525
with trio.open_nursery() as bar: # type: ignore[attr-defined]
2626
bar.start_soon(my_startable) # ASYNC113: 8
27+
yield
28+
29+
30+
@asynccontextmanager
31+
async def foo2():
2732
async with trio.open_nursery() as bar:
2833
bar.start_soon(my_startable) # ASYNC113: 8
34+
yield
35+
2936

37+
@asynccontextmanager
38+
async def foo3():
3039
boo: trio.Nursery = ... # type: ignore
3140
boo.start_soon(my_startable) # ASYNC113: 4
3241

@@ -132,3 +141,52 @@ def non_async_func():
132141
bar.start_soon(my_startable)
133142

134143
yield
144+
145+
146+
@asynccontextmanager
147+
async def false_alarm():
148+
async with trio.open_nursery() as nursery:
149+
nursery.start_soon(my_startable)
150+
yield
151+
152+
153+
@asynccontextmanager
154+
async def should_error():
155+
async with trio.open_nursery() as nursery:
156+
nursery.start_soon(my_startable) # ASYNC113: 8
157+
# overrides the nursery variable
158+
async with trio.open_nursery() as nursery:
159+
nursery.start_soon(my_startable)
160+
yield
161+
162+
163+
@asynccontextmanager
164+
async def foo_sync_with_closed():
165+
# we don't check for `async with`
166+
with trio.open_nursery() as bar: # type: ignore[attr-defined]
167+
bar.start_soon(my_startable)
168+
yield
169+
170+
171+
# fixed by entirely skipping nurseries without yields in them
172+
class FalseAlarm:
173+
async def __aenter__(self):
174+
with trio.open_nursery() as nursery:
175+
nursery.start_soon(my_startable)
176+
177+
178+
@asynccontextmanager
179+
async def yield_before_start_soon():
180+
with trio.open_nursery() as bar:
181+
yield
182+
bar.start_soon(my_startable)
183+
184+
185+
# This was broken when visit_AsyncWith manually visited subnodes due to not
186+
# letting TypeTrackerVisitor interject.
187+
@asynccontextmanager
188+
async def nested():
189+
with trio.open_nursery() as foo:
190+
with trio.open_nursery() as bar:
191+
bar.start_soon(my_startable) # error: 12
192+
yield

tests/eval_files/async113_trio.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,10 @@ async def foo4(): ...
132132
@asynccontextmanager() # type: ignore[call-arg]
133133
async def foo_paren():
134134
nursery.start_soon(trio.run_process) # error: 4
135+
yield
135136

136137

137138
@asynccontextmanager(1, 2, 3) # type: ignore[call-arg]
138139
async def foo_params():
139140
nursery.start_soon(trio.run_process) # error: 4
141+
yield

0 commit comments

Comments
 (0)