Skip to content

Commit c695f61

Browse files
Zac-HDclaude
andauthored
Add ASYNC128 task-status-never-started (#474)
Warns about startable functions (i.e. taking a `task_status` parameter, or a parameter annotated as `TaskStatus`) that never call `task_status.started()`, which makes `nursery.start()` / `TaskGroup.start()` calls on them fail or block forever. The check is intentionally strict, per the issue: passing `task_status` on to a helper function or aliasing it does not count, and any direct call counts regardless of reachability. Stub bodies (overloads, protocols, abstract methods) are excluded. Closes #471 Claude-Session: https://claude.ai/code/session_01EJGxyMtQSge3dVQpw19rZX Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6392dec commit c695f61

6 files changed

Lines changed: 243 additions & 3 deletions

File tree

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+
26.8.1
8+
======
9+
- Add :ref:`ASYNC128 <async128>` task-status-never-started, warning about startable functions (i.e. with a ``task_status`` parameter) that never call ``task_status.started()``. `(issue #471) <https://github.com/python-trio/flake8-async/issues/471>`_
10+
711
26.7.1
812
======
913
- Add :ref:`ASYNC401 <async401>` pytest-raises-exception-group, recommending ``pytest.RaisesGroup`` over ``pytest.raises(ExceptionGroup)``. `(issue #430) <https://github.com/python-trio/flake8-async/issues/430>`_

docs/rules.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,18 @@ _`ASYNC127`: unmaintained-httpx
141141
migration is usually just a matter of replacing ``httpx`` with ``httpx2`` in
142142
imports. This rule triggers on any import of ``httpx``.
143143

144+
_`ASYNC128`: task-status-never-started
145+
A startable function - one taking a ``task_status`` keyword parameter, or with a
146+
parameter annotated as ``TaskStatus`` - never calls ``task_status.started()``.
147+
:meth:`trio.Nursery.start`/:meth:`anyio.abc.TaskGroup.start` wait for the callee
148+
to call ``task_status.started()``: if it returns without doing so they raise
149+
``RuntimeError``, and if it never returns they block until cancelled.
150+
This check is intentionally strict: passing ``task_status`` on to a helper
151+
function or assigning it to another variable does not count, only a direct call
152+
in the function body, or in a nested function where the parameter isn't shadowed.
153+
Functions with stub bodies (only ``pass``, ``...``, string constants, and/or
154+
``raise``) are ignored, e.g. overloads, protocols, and abstract methods.
155+
144156
Blocking sync calls in async functions
145157
======================================
146158

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: 26.7.1
36+
rev: 26.8.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__ = "26.7.1"
41+
__version__ = "26.8.1"
4242

4343

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

flake8_async/visitors/visitors.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
)
1717

1818
if TYPE_CHECKING:
19-
from collections.abc import Mapping
19+
from collections.abc import Iterable, Mapping
2020

2121
import libcst as cst
2222

@@ -617,6 +617,59 @@ def visit_ImportFrom(self, node: ast.ImportFrom):
617617
self.error(node)
618618

619619

620+
@error_class
621+
class Visitor128(Flake8AsyncVisitor):
622+
error_codes: Mapping[str, str] = {
623+
"ASYNC128": (
624+
"Startable function {} never calls `{}.started()`, so `.start()`"
625+
" calls on it will fail, or block forever."
626+
),
627+
}
628+
629+
# Look for a `<name>.started()` call anywhere in the function body, including
630+
# in nested functions closing over the parameter. Nested functions that rebind
631+
# the name are startable functions of their own, checked separately.
632+
def _calls_started(self, node: ast.AST, name: str) -> bool:
633+
if isinstance(node, ast.Call) and ast.unparse(node.func) == f"{name}.started":
634+
return True
635+
children: Iterable[ast.AST] = ast.iter_child_nodes(node)
636+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
637+
a = node.args
638+
if any(
639+
p is not None and p.arg == name
640+
for p in (*a.posonlyargs, *a.args, *a.kwonlyargs, a.vararg, a.kwarg)
641+
):
642+
return False
643+
# only look in the body - decorators, defaults and annotations are
644+
# evaluated in the enclosing scope, but a call in them is nonsensical
645+
children = [node.body] if isinstance(node, ast.Lambda) else node.body
646+
return any(self._calls_started(child, name) for child in children)
647+
648+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
649+
args = node.args
650+
for arg in (*args.posonlyargs, *args.args, *args.kwonlyargs):
651+
# startable: a `task_status` parameter - unless positional-only, when it
652+
# can't be passed by keyword - or a `TaskStatus`-annotated parameter
653+
ann = arg.annotation
654+
if isinstance(ann, ast.Subscript): # strip generics: `TaskStatus[int]`
655+
ann = ann.value
656+
if not (
657+
(
658+
ann is not None
659+
and ast.unparse(ann).rsplit(".", 1)[-1] == "TaskStatus"
660+
)
661+
or (arg.arg == "task_status" and arg not in args.posonlyargs)
662+
):
663+
continue
664+
if not all( # stub bodies are fine: overloads, protocols, abstractmethods
665+
isinstance(stmt, (ast.Pass, ast.Raise))
666+
or (isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant))
667+
for stmt in node.body
668+
) and not any(self._calls_started(stmt, arg.arg) for stmt in node.body):
669+
self.error(node, node.name, arg.arg)
670+
return
671+
672+
620673
@error_class_cst
621674
class Visitor300(Flake8AsyncVisitor_cst):
622675
error_codes: Mapping[str, str] = {

tests/eval_files/async128.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Test file for ASYNC128 task-status-never-started."""
2+
3+
# ASYNC128 does not care about the imported library, so will raise errors regardless
4+
# of trio/anyio/asyncio
5+
6+
from typing import Any
7+
8+
import trio
9+
from trio import TaskStatus
10+
11+
12+
async def never_started( # error: 0, "never_started", "task_status"
13+
task_status=trio.TASK_STATUS_IGNORED,
14+
):
15+
await trio.sleep(1)
16+
17+
18+
async def started(task_status=trio.TASK_STATUS_IGNORED):
19+
task_status.started()
20+
21+
22+
async def started_with_value(task_status=trio.TASK_STATUS_IGNORED):
23+
task_status.started(7)
24+
25+
26+
async def no_task_status():
27+
await trio.sleep(1)
28+
29+
30+
# a conditional call counts - no attempt is made to check all code paths
31+
async def conditional_start(condition: bool, *, task_status):
32+
if condition:
33+
task_status.started()
34+
35+
36+
# ... even if the call can never actually execute
37+
async def unreachable_start(task_status):
38+
for _ in range(0):
39+
task_status.started()
40+
41+
42+
# annotated parameters trigger regardless of their name
43+
async def annotated(status: TaskStatus[int]): # error: 0, "annotated", "status"
44+
await trio.sleep(1)
45+
46+
47+
async def annotated_bare(status: TaskStatus): # error: 0, "annotated_bare", "status"
48+
await trio.sleep(1)
49+
50+
51+
async def annotated_qualified( # error: 0, "annotated_qualified", "status"
52+
status: trio.TaskStatus[int],
53+
):
54+
await trio.sleep(1)
55+
56+
57+
async def annotated_ok(status: TaskStatus[int]):
58+
status.started(5)
59+
60+
61+
# a `task_status` parameter that's positional-only can't be startable, but an
62+
# explicit annotation still counts
63+
async def posonly_ignored(task_status, /):
64+
await trio.sleep(1)
65+
66+
67+
async def posonly_annotated( # error: 0, "posonly_annotated", "status"
68+
status: TaskStatus[int], /
69+
):
70+
await trio.sleep(1)
71+
72+
73+
async def starargs_ignored(*task_status, **kwargs):
74+
await trio.sleep(1)
75+
76+
77+
# passing `task_status` to a helper does not count, even if the helper calls
78+
# `started()` for you. The check is intentionally strict, silence it with `noqa`
79+
# if you're intentionally proxying it.
80+
async def helper(fn: Any, task_status): # error: 0, "helper", "task_status"
81+
await fn(task_status=task_status)
82+
83+
84+
# aliasing does not count either
85+
async def aliased(task_status): # error: 0, "aliased", "task_status"
86+
ts = task_status
87+
ts.started()
88+
89+
90+
# accessing `.started` without calling it does not count
91+
async def not_called(task_status): # error: 0, "not_called", "task_status"
92+
task_status.started
93+
94+
95+
# the call must be on the parameter itself, not e.g. an attribute by the same name
96+
class AttributeStatus:
97+
task_status: TaskStatus[None]
98+
99+
async def relay(self, task_status): # error: 4, "relay", "task_status"
100+
self.task_status.started()
101+
102+
103+
# calls in nested functions closing over the parameter do count
104+
async def closure(task_status=trio.TASK_STATUS_IGNORED):
105+
def inner():
106+
task_status.started()
107+
108+
inner()
109+
110+
111+
async def lambda_closure(task_status=trio.TASK_STATUS_IGNORED):
112+
fn = lambda: task_status.started()
113+
fn()
114+
115+
116+
# ... but not if the nested function rebinds the name; it is instead
117+
# checked on its own
118+
async def shadowed(task_status): # error: 0, "shadowed", "task_status"
119+
async def inner(task_status=trio.TASK_STATUS_IGNORED):
120+
task_status.started()
121+
122+
await inner()
123+
124+
125+
async def shadowed_by_lambda( # error: 0, "shadowed_by_lambda", "task_status"
126+
task_status,
127+
):
128+
fn = lambda task_status: task_status.started()
129+
fn(None)
130+
131+
132+
async def shadowed_by_vararg( # error: 0, "shadowed_by_vararg", "task_status"
133+
task_status,
134+
):
135+
def inner(*task_status: Any):
136+
task_status[0].started()
137+
138+
inner(None)
139+
140+
141+
async def nested_never_started():
142+
async def inner( # error: 4, "inner", "task_status"
143+
task_status=trio.TASK_STATUS_IGNORED,
144+
):
145+
await trio.sleep(1)
146+
147+
await inner()
148+
149+
150+
# stub bodies don't error, e.g. overloads, protocols, and abstract methods
151+
class StartableProtocol:
152+
async def ellipsis_body(self, *, task_status: TaskStatus[None]): ...
153+
154+
async def pass_body(self, *, task_status: TaskStatus[None]):
155+
pass
156+
157+
async def docstring_body(self, *, task_status: TaskStatus[None]):
158+
"""It has a docstring."""
159+
160+
async def raise_body(self, *, task_status: TaskStatus[None]):
161+
raise NotImplementedError
162+
163+
async def method_never_started( # error: 4, "method_never_started", "task_status"
164+
self, task_status=trio.TASK_STATUS_IGNORED
165+
):
166+
await trio.sleep(1)
167+
168+
169+
# sync functions are not checked - they cannot be passed to `.start()`
170+
def sync_fn(task_status):
171+
return None

0 commit comments

Comments
 (0)