Skip to content

Commit 0748ce7

Browse files
authored
various small fixes (#264)
* doc updates. don't trigger on open_nursery in 102 (it didn't work anyway). async112 error message now specifies if its nursery or taskgroup. * update tests * help repro coverage bug * Revert "help repro coverage bug" This reverts commit 2cf2519. * updates after review. add test cases. type tracker can now handle attribute targets.
1 parent 315cd5f commit 0748ce7

13 files changed

Lines changed: 173 additions & 39 deletions

docs/rules.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ ASYNC112 : useless-nursery
5656
_`ASYNC113` : start-soon-in-aenter
5757
Using :meth:`~trio.Nursery.start_soon`/:meth:`~anyio.abc.TaskGroup.start_soon` in ``__aenter__`` doesn't wait for the task to begin.
5858
Consider replacing with :meth:`~trio.Nursery.start`/:meth:`~anyio.abc.TaskGroup.start`.
59+
This will only warn about functions listed in :ref:`ASYNC114 <async114>` or known from Trio.
60+
If you're starting a function that does not define `task_status`, then neither will trigger.
5961

6062
_`ASYNC114` : startable-not-in-config
6163
Startable function (i.e. has a ``task_status`` keyword parameter) not in :ref:`--startable-in-context-manager <--startable-in-context-manager>` parameter list, please add it so ASYNC113 can catch errors when using it.

docs/usage.rst

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,11 @@ Example
265265
``startable-in-context-manager``
266266
--------------------------------
267267

268-
Comma-separated list of methods which should be used with :meth:`trio.Nursery.start`/:meth:`anyio.abc.TaskGroup.start` when opening a context manager,
269-
in addition to the default :func:`trio.run_process`, :func:`trio.serve_tcp`, :func:`trio.serve_ssl_over_tcp`, and :func:`trio.serve_listeners`.
268+
Comma-separated list of functions which should be used with :meth:`trio.Nursery.start`/:meth:`anyio.abc.TaskGroup.start` when opening a context manager.
269+
We then add known functions from Trio to this list, namely :func:`trio.run_process`, :func:`trio.serve_tcp`, :func:`trio.serve_ssl_over_tcp`, :func:`trio.serve_listeners`, and :meth:`trio.DTLSEndpoint.serve`.
270+
AnyIO does not have any functions in its API that defines ``task_status``.
271+
asyncio does not have an equivalent of :meth:`~trio.Nursery.start`, nor ``task_status``, but you could still add functions to this list that you want to be extra careful about when opening in an `asyncio.TaskGroup` in an ``__aenter__``
272+
270273
Names must be valid identifiers as per :meth:`str.isidentifier`.
271274
Used by :ref:`ASYNC113 <async113>`, and :ref:`ASYNC114 <async114>` will warn when encountering methods not in the list.
272275

flake8_async/visitors/visitor102.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,7 @@ def visit_With(self, node: ast.With | ast.AsyncWith):
8787

8888
# Check for a `with trio.<scope_creator>`
8989
for item in node.items:
90-
call = get_matching_call(
91-
item.context_expr, "open_nursery", *cancel_scope_names
92-
)
90+
call = get_matching_call(item.context_expr, *cancel_scope_names)
9391
if call is None:
9492
continue
9593

flake8_async/visitors/visitor_utility.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,10 @@ def visit_ClassDef(self, node: ast.ClassDef):
6464
self.save_state(node, "variables", copy=True)
6565

6666
def visit_AnnAssign(self, node: ast.AnnAssign):
67-
if not isinstance(node.target, ast.Name):
68-
return
69-
target = node.target.id
67+
if not isinstance(node.target, (ast.Name, ast.Attribute)):
68+
# target can technically be a subscript
69+
return # pragma: no cover
70+
target = ast.unparse(node.target)
7071
typename = ast.unparse(node.annotation)
7172
self.variables[target] = typename
7273

@@ -87,6 +88,8 @@ def visit_Assign(self, node: ast.Assign):
8788
self.variables[node.targets[0].id] = value
8889

8990
def visit_With(self, node: ast.With | ast.AsyncWith):
91+
# TODO: it's actually the return type of
92+
# `ast.unparse(item.context_expr.func).__[a]enter__()` that should be used
9093
if len(node.items) != 1:
9194
return
9295
item = node.items[0]

flake8_async/visitors/visitors.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ def visit_While(self, node: ast.While):
9595
class Visitor112(Flake8AsyncVisitor):
9696
error_codes: Mapping[str, str] = {
9797
"ASYNC112": (
98-
"Redundant nursery {}, consider replacing with directly awaiting "
98+
"Redundant {1} {0}, consider replacing with directly awaiting "
9999
"the function call."
100100
),
101101
}
@@ -113,19 +113,22 @@ def visit_With(self, node: ast.With | ast.AsyncWith):
113113
continue
114114
var_name = item.optional_vars.id
115115

116-
# check for trio.open_nursery and anyio.create_task_group
117-
nursery = get_matching_call(
118-
item.context_expr, "open_nursery", base="trio"
119-
) or get_matching_call(item.context_expr, "create_task_group", base="anyio")
120116
start_methods: tuple[str, ...] = ("start", "start_soon")
121-
if nursery is None:
122-
# check for asyncio.TaskGroup
123-
nursery = get_matching_call(
124-
item.context_expr, "TaskGroup", base="asyncio"
125-
)
126-
if nursery is None:
127-
continue
117+
# check for trio.open_nursery and anyio.create_task_group
118+
if get_matching_call(item.context_expr, "open_nursery", base="trio"):
119+
nursery_type = "nursery"
120+
121+
elif get_matching_call(
122+
item.context_expr, "create_task_group", base="anyio"
123+
):
124+
nursery_type = "taskgroup"
125+
# check for asyncio.TaskGroup
126+
elif get_matching_call(item.context_expr, "TaskGroup", base="asyncio"):
127+
nursery_type = "taskgroup"
128128
start_methods = ("create_task",)
129+
else:
130+
# incorrectly marked as not covered on py39
131+
continue # pragma: no cover # https://github.com/nedbat/coveragepy/issues/198
129132

130133
body_call = node.body[0].value
131134
if isinstance(body_call, ast.Await):
@@ -142,7 +145,7 @@ def visit_With(self, node: ast.With | ast.AsyncWith):
142145
for n in self.walk(*body_call.args, *body_call.keywords)
143146
)
144147
):
145-
self.error(item.context_expr, var_name)
148+
self.error(item.context_expr, var_name, nursery_type)
146149

147150
visit_AsyncWith = visit_With
148151

@@ -168,8 +171,11 @@ class Visitor113(Flake8AsyncVisitor):
168171

169172
def __init__(self, *args: Any, **kwargs: Any):
170173
super().__init__(*args, **kwargs)
174+
# this is not entirely correct, it's trio.open_nursery.__aenter__()->trio.Nursery
175+
# but VisitorTypeTracker currently does not work like that.
171176
self.typed_calls["trio.open_nursery"] = "trio.Nursery"
172177
self.typed_calls["anyio.create_task_group"] = "anyio.TaskGroup"
178+
self.typed_calls["asyncio.TaskGroup"] = "asyncio.TaskGroup"
173179

174180
self.async_function = False
175181
self.asynccontextmanager = False
@@ -196,7 +202,10 @@ def is_startable(n: ast.expr, *startable_list: str) -> bool:
196202
return False
197203

198204
def is_nursery_call(node: ast.expr):
199-
if not isinstance(node, ast.Attribute) or node.attr != "start_soon":
205+
if not isinstance(node, ast.Attribute) or node.attr not in (
206+
"start_soon",
207+
"create_task",
208+
):
200209
return False
201210
var = ast.unparse(node.value)
202211
return ("trio" in self.library and var.endswith("nursery")) or (

tests/eval_files/async102.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,19 @@ async def foo():
109109
pass
110110
finally:
111111
myvar = True
112-
with trio.open_nursery(10) as s:
113-
s.shield = myvar
114-
await foo() # safe in theory, error: 12, Statement("try/finally", lineno-6)
112+
with trio.CancelScope(deadline=10) as cs:
113+
cs.shield = myvar
114+
# safe in theory, but we don't track variable values
115+
await foo() # error: 12, Statement("try/finally", lineno-7)
116+
try:
117+
pass
118+
finally:
119+
# false alarm, open_nursery does not block/checkpoint on entry.
120+
async with trio.open_nursery() as nursery: # error: 8, Statement("try/finally", lineno-4)
121+
nursery.cancel_scope.deadline = trio.current_time() + 10
122+
nursery.cancel_scope.shield = True
123+
# false alarm, we currently don't handle nursery.cancel_scope.[deadline/shield]
124+
await foo() # error: 12, Statement("try/finally", lineno-8)
115125
try:
116126
pass
117127
finally:

tests/eval_files/async112.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,34 +9,34 @@
99
import trio as noterror
1010

1111
# error
12-
with trio.open_nursery() as n: # error: 5, "n"
12+
with trio.open_nursery() as n: # error: 5, "n", "nursery"
1313
n.start(...)
1414

15-
with trio.open_nursery(...) as nurse: # error: 5, "nurse"
15+
with trio.open_nursery(...) as nurse: # error: 5, "nurse", "nursery"
1616
nurse.start_soon(...)
1717

18-
with trio.open_nursery() as n: # error: 5, "n"
18+
with trio.open_nursery() as n: # error: 5, "n", "nursery"
1919
n.start_soon(n=7)
2020

2121

2222
async def foo():
23-
async with trio.open_nursery() as n: # error: 15, "n"
23+
async with trio.open_nursery() as n: # error: 15, "n", "nursery"
2424
n.start(...)
2525

2626

2727
# weird ones with multiple `withitem`s
2828
# but if split among several `with` they'd all be treated as error (or ASYNC111), so
2929
# treating as error for now.
30-
with trio.open_nursery() as n, trio.open("") as n: # error: 5, "n"
30+
with trio.open_nursery() as n, trio.open("") as n: # error: 5, "n", "nursery"
3131
n.start(...)
3232

33-
with open("") as o, trio.open_nursery() as n: # error: 20, "n"
33+
with open("") as o, trio.open_nursery() as n: # error: 20, "n", "nursery"
3434
n.start(o)
3535

36-
with trio.open_nursery() as n, trio.open_nursery() as nurse: # error: 31, "nurse"
36+
with trio.open_nursery() as n, trio.open_nursery() as nurse: # error: 31, "nurse", "nursery"
3737
nurse.start(n.start(...))
3838

39-
with trio.open_nursery() as n, trio.open_nursery() as n: # error: 5, "n" # error: 31, "n"
39+
with trio.open_nursery() as n, trio.open_nursery() as n: # error: 5, "n", "nursery" # error: 31, "n", "nursery"
4040
n.start(...)
4141

4242
# safe if passing variable as parameter
@@ -83,7 +83,7 @@ async def foo():
8383

8484
# body is a call to await n.start
8585
async def foo_1():
86-
with trio.open_nursery(...) as n: # error: 9, "n"
86+
with trio.open_nursery(...) as n: # error: 9, "n", "nursery"
8787
await n.start(...)
8888

8989

tests/eval_files/async112_anyio.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ async def bar(*args): ...
1111

1212

1313
async def foo():
14-
async with anyio.create_task_group() as tg: # error: 15, "tg"
14+
async with anyio.create_task_group() as tg: # error: 15, "tg", "taskgroup"
1515
await tg.start_soon(bar())
1616

1717
async with anyio.create_task_group() as tg:
1818
await tg.start(bar(tg))
1919

20-
async with anyio.create_task_group() as tg: # error: 15, "tg"
20+
async with anyio.create_task_group() as tg: # error: 15, "tg", "taskgroup"
2121
tg.start_soon(bar())
2222

2323
async with anyio.create_task_group() as tg:

tests/eval_files/async112_asyncio.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ async def bar(*args): ...
1313

1414

1515
async def foo():
16-
async with asyncio.TaskGroup() as tg: # error: 15, "tg"
16+
async with asyncio.TaskGroup() as tg: # error: 15, "tg", "taskgroup"
1717
tg.create_task(bar())
1818

1919
async with asyncio.TaskGroup() as tg:

tests/eval_files/async113.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# mypy: disable-error-code="arg-type,attr-defined"
2+
# ARG --startable-in-context-manager=my_startable
3+
from __future__ import annotations
24
from contextlib import asynccontextmanager
35

46
import anyio
@@ -22,6 +24,84 @@ async def foo():
2224
boo.start_soon(trio.run_process) # ASYNC113: 4
2325

2426
boo_anyio: anyio.TaskGroup = ... # type: ignore
27+
# false alarm - anyio.run_process is not startable
28+
# (nor is asyncio.run_process)
2529
boo_anyio.start_soon(anyio.run_process) # ASYNC113: 4
2630

2731
yield
32+
33+
34+
async def my_startable(task_status: trio.TaskStatus[object] = trio.TASK_STATUS_IGNORED):
35+
task_status.started()
36+
await trio.lowlevel.checkpoint()
37+
38+
39+
# name of variable being [xxx.]nursery triggers it
40+
class MyCm_named_variable:
41+
def __init__(self):
42+
self.nursery_manager = trio.open_nursery()
43+
self.nursery = None
44+
45+
async def __aenter__(self):
46+
self.nursery = await self.nursery_manager.__aenter__()
47+
self.nursery.start_soon(my_startable) # ASYNC113: 8
48+
49+
async def __aexit__(self, *args):
50+
assert self.nursery is not None
51+
await self.nursery_manager.__aexit__(*args)
52+
53+
54+
# call chain is not tracked
55+
# trio.open_nursery -> NurseryManager
56+
# NurseryManager.__aenter__ -> nursery
57+
class MyCm_calls:
58+
async def __aenter__(self):
59+
self.nursery_manager = trio.open_nursery()
60+
self.moo = None
61+
self.moo = await self.nursery_manager.__aenter__()
62+
self.moo.start_soon(my_startable)
63+
64+
async def __aexit__(self, *args):
65+
assert self.moo is not None
66+
await self.nursery_manager.__aexit__(*args)
67+
68+
69+
# types of class variables are not tracked across functions
70+
class MyCm_typehint_class_variable:
71+
def __init__(self):
72+
self.nursery_manager = trio.open_nursery()
73+
self.moo: trio.Nursery = None # type: ignore
74+
75+
async def __aenter__(self):
76+
self.moo = await self.nursery_manager.__aenter__()
77+
self.moo.start_soon(my_startable)
78+
79+
async def __aexit__(self, *args):
80+
assert self.moo is not None
81+
await self.nursery_manager.__aexit__(*args)
82+
83+
84+
# type hint with __or__ is not picked up
85+
class MyCm_typehint:
86+
async def __aenter__(self):
87+
self.nursery_manager = trio.open_nursery()
88+
self.moo: trio.Nursery | None = None
89+
self.moo = await self.nursery_manager.__aenter__()
90+
self.moo.start_soon(my_startable)
91+
92+
async def __aexit__(self, *args):
93+
assert self.moo is not None
94+
await self.nursery_manager.__aexit__(*args)
95+
96+
97+
# only if the type hint is exactly trio.Nursery
98+
class MyCm_typehint_explicit:
99+
async def __aenter__(self):
100+
self.nursery_manager = trio.open_nursery()
101+
self.moo: trio.Nursery = None # type: ignore
102+
self.moo = await self.nursery_manager.__aenter__()
103+
self.moo.start_soon(my_startable) # ASYNC113: 8
104+
105+
async def __aexit__(self, *args):
106+
assert self.moo is not None
107+
await self.nursery_manager.__aexit__(*args)

0 commit comments

Comments
 (0)