Skip to content

Commit 2b0f543

Browse files
authored
Merge pull request #60 from jakkdl/prefer_start_in_cm
Fixes #57
2 parents 0ae2d9f + 7db69c4 commit 2b0f543

File tree

6 files changed

+239
-15
lines changed

6 files changed

+239
-15
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# Changelog
22
*[CalVer, YY.month.patch](https://calver.org/)*
33

4+
## 22.11.3
5+
- Add TRIO113, prefer `await nursery.start(...)` to `nursery.start_soon()` for compatible functions when opening a context manager
6+
47
## 22.11.2
58
- TRIO105 now also checks that you `await`ed `nursery.start()`.
69

README.md

+22-3
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,34 @@ pip install flake8-trio
3535
- **TRIO110**: `while <condition>: await trio.sleep()` should be replaced by a `trio.Event`.
3636
- **TRIO111**: Variable, from context manager opened inside nursery, passed to `start[_soon]` might be invalidly accesed while in use, due to context manager closing before the nursery. This is usually a bug, and nurseries should generally be the inner-most context manager.
3737
- **TRIO112**: nursery body with only a call to `nursery.start[_soon]` and not passing itself as a parameter can be replaced with a regular function call.
38+
- **TRIO113**: using `nursery.start_soon` in `__aenter__` doesn't wait for the task to begin. Consider replacing with `nursery.start`.
3839

3940

4041
## Configuration
41-
`no-checkpoint-warning-decorators`: Specify a list of decorators to disable checkpointing checks for, turning off TRIO107 and TRIO108 warnings for functions decorated with any decorator matching any in the list. Matching is done with [fnmatch](https://docs.python.org/3/library/fnmatch.html). Defaults to disabling for `asynccontextmanager`.
42+
### `no-checkpoint-warning-decorators`
43+
Specify a list of decorators to disable checkpointing checks for, turning off TRIO107 and TRIO108 warnings for functions decorated with any decorator matching any in the list. Matching is done with [fnmatch](https://docs.python.org/3/library/fnmatch.html). Defaults to disabling for `asynccontextmanager`.
4244

4345
Decorators-to-match must be identifiers or dotted names only (not PEP-614 expressions), and will match against the name only - e.g. `foo.bar` matches `foo.bar`, `foo.bar()`, and `foo.bar(args, here)`, etc.
4446

45-
For example:
47+
[You can configure `flake8` with command-line options](https://flake8.pycqa.org/en/latest/user/configuration.html),
48+
but we prefer using a config file. For example:
4649
```
4750
[flake8]
48-
no-checkpoint-warning-decorators = mydecorator, mydecoratorpackage.checkpointing_decorators.*, ign*, *.ignore
51+
no-checkpoint-warning-decorators =
52+
mydecorator,
53+
mydecoratorpackage.checkpointing_decorators.*,
54+
ign*,
55+
*.ignore,
56+
```
57+
58+
59+
### `startable-in-context-manager`
60+
Comma-separated list of methods which should be used with `.start()` when opening a context manager,
61+
in addition to the default `trio.run_process`, `trio.serve_tcp`, `trio.serve_ssl_over_tcp`, and
62+
`trio.serve_listeners`. Uses fnmatch, like `no-checkpoint-warning-decorators`. For example:
63+
```
64+
[flake8]
65+
startable-methods-in-context-manager =
66+
mylib.myfun,
67+
myfun2,mypackage.myfunlib.*,
4968
```

flake8_trio.py

+93-10
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from flake8.options.manager import OptionManager
3030

3131
# CalVer: YY.month.patch, e.g. first release of July 2022 == "22.7.1"
32-
__version__ = "22.11.2"
32+
__version__ = "22.11.3"
3333

3434

3535
Error_codes = {
@@ -65,6 +65,7 @@
6565
"Nurseries should generally be the inner-most context manager."
6666
),
6767
"TRIO112": "Redundant nursery {}, consider replacing with directly awaiting the function call",
68+
"TRIO113": "Dangerous `.start_soon()`, process might not run before `__aenter__` exits. Consider replacing with `.start()`.",
6869
}
6970

7071

@@ -126,6 +127,10 @@ def __lt__(self, other: Any) -> bool:
126127
def __eq__(self, other: Any) -> bool:
127128
return isinstance(other, Error) and self.cmp() == other.cmp()
128129

130+
def __repr__(self) -> str:
131+
trailer = "".join(f", {x!r}" for x in self.args)
132+
return f"<{self.code} error at {self.line}:{self.col}{trailer}>"
133+
129134

130135
checkpoint_node_types = (ast.Await, ast.AsyncFor, ast.AsyncWith)
131136
cancel_scope_names = (
@@ -202,8 +207,9 @@ def has_decorator(decorator_list: List[ast.expr], *names: str):
202207
return False
203208

204209

205-
# matches the full decorator name against fnmatch pattern
206-
def fnmatch_decorator(decorator_list: List[ast.expr], *patterns: str):
210+
# matches the fully qualified name against fnmatch pattern
211+
# used to match decorators and methods to user-supplied patterns
212+
def fnmatch_qualified_name(name_list: List[ast.expr], *patterns: str):
207213
def construct_name(expr: ast.expr) -> Optional[str]:
208214
if isinstance(expr, ast.Call):
209215
expr = expr.func
@@ -216,12 +222,13 @@ def construct_name(expr: ast.expr) -> Optional[str]:
216222
# See https://peps.python.org/pep-0614/ - we don't handle everything
217223
return None # pragma: no cover # impossible on Python 3.8
218224

219-
for decorator in decorator_list:
220-
qualified_decorator_name = construct_name(decorator)
221-
if qualified_decorator_name is None:
225+
for name in name_list:
226+
qualified_name = construct_name(name)
227+
if qualified_name is None:
222228
continue # pragma: no cover # impossible on Python 3.8
223229
for pattern in patterns:
224-
if fnmatch(qualified_decorator_name, pattern.lstrip("@")):
230+
# strip leading "@"s for when we're working with decorators
231+
if fnmatch(qualified_name, pattern.lstrip("@")):
225232
return True
226233
return False
227234

@@ -803,6 +810,16 @@ def iter_guaranteed_once(iterable: ast.expr) -> bool:
803810
)
804811

805812

813+
def is_nursery_call(node: ast.AST, name: str) -> bool: # pragma: no cover
814+
assert name in ("start", "start_soon")
815+
if isinstance(node, ast.Attribute):
816+
if isinstance(node.value, ast.Name):
817+
return node.attr == name and node.value.id.endswith("nursery")
818+
if isinstance(node.value, ast.Attribute):
819+
return node.attr == name and node.value.attr.endswith("nursery")
820+
return False
821+
822+
806823
class Visitor105(Flake8TrioVisitor):
807824
def __init__(self, *args: Any, **kwargs: Any):
808825
super().__init__(*args, **kwargs)
@@ -820,13 +837,14 @@ def visit_Call(self, node: ast.Call):
820837
and isinstance(node.func.value, ast.Name)
821838
and (
822839
(node.func.value.id == "trio" and node.func.attr in trio_async_funcs)
823-
or (node.func.value.id == "nursery" and node.func.attr == "start")
840+
or is_nursery_call(node.func, "start")
824841
)
825842
and (
826843
len(self.node_stack) < 2
827844
or not isinstance(self.node_stack[-2], ast.Await)
828845
)
829846
):
847+
assert isinstance(node.func, ast.Attribute)
830848
self.error("TRIO105", node, node.func.attr)
831849
self.generic_visit(node)
832850

@@ -873,7 +891,7 @@ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
873891
self.set_state(self.default, copy=True)
874892

875893
# disable checks in asynccontextmanagers by saying the function isn't async
876-
self.async_function = not fnmatch_decorator(
894+
self.async_function = not fnmatch_qualified_name(
877895
node.decorator_list, *self.options.no_checkpoint_warning_decorators
878896
)
879897

@@ -1149,10 +1167,60 @@ def visit_BoolOp(self, node: ast.BoolOp):
11491167
self.uncheckpointed_statements = worst_case_shortcut
11501168

11511169

1170+
def _get_identifier(node: ast.expr) -> str:
1171+
if isinstance(node, ast.Name):
1172+
return node.id
1173+
if isinstance(node, ast.Attribute):
1174+
return node.attr
1175+
return "" # pragma: no cover
1176+
1177+
1178+
STARTABLE_CALLS = (
1179+
"trio.run_process",
1180+
"trio.serve_ssl_over_tcp",
1181+
"trio.serve_tcp",
1182+
"trio.serve_listeners",
1183+
)
1184+
1185+
1186+
class Visitor113(Flake8TrioVisitor):
1187+
def __init__(self, *args: Any, **kwargs: Any):
1188+
super().__init__(*args, **kwargs)
1189+
self.async_function = False
1190+
self.asynccontextmanager = False
1191+
self.aenter = False
1192+
1193+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
1194+
outer = self.aenter
1195+
1196+
self.aenter = node.name == "__aenter__" or any(
1197+
_get_identifier(d) == "asynccontextmanager" for d in node.decorator_list
1198+
)
1199+
1200+
self.generic_visit(node)
1201+
self.aenter = outer
1202+
1203+
def visit_Yield(self, node: ast.Yield):
1204+
self.aenter = False
1205+
1206+
def visit_Call(self, node: ast.Call) -> None:
1207+
if (
1208+
self.aenter
1209+
and is_nursery_call(node.func, "start_soon")
1210+
and len(node.args) > 0
1211+
and fnmatch_qualified_name(
1212+
node.args[:1],
1213+
*STARTABLE_CALLS,
1214+
*self.options.startable_in_context_manager,
1215+
)
1216+
):
1217+
self.error("TRIO113", node)
1218+
1219+
11521220
class Plugin:
11531221
name = __name__
11541222
version = __version__
1155-
options: Namespace = Namespace(no_checkpoint_warning_decorators=[])
1223+
options: Namespace = Namespace()
11561224

11571225
def __init__(self, tree: ast.AST):
11581226
self._tree = tree
@@ -1183,6 +1251,21 @@ def add_options(option_manager: OptionManager):
11831251
"mydecorator,mypackage.mydecorators.*``"
11841252
),
11851253
)
1254+
option_manager.add_option(
1255+
"--startable-in-context-manager",
1256+
default="",
1257+
parse_from_config=True,
1258+
required=False,
1259+
comma_separated_list=True,
1260+
help=(
1261+
"Comma-separated list of method calls to enable TRIO113"
1262+
" warnings for."
1263+
" Use if you want to override the default methods the check is enabled for."
1264+
" Methods can be dotted or not, as well as support * as a wildcard."
1265+
" For example, ``--startable-in-context-manager=worker_serve,"
1266+
"mypackage.sub.*``"
1267+
),
1268+
)
11861269

11871270
@staticmethod
11881271
def parse_options(options: Namespace):

tests/test_decorator.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import pytest
77
from flake8.main.application import Application
88

9-
from flake8_trio import Error_codes, Plugin, Statement, fnmatch_decorator
9+
from flake8_trio import Error_codes, Plugin, Statement, fnmatch_qualified_name
1010

1111

1212
def dec_list(*decorators: str) -> ast.Module:
@@ -21,7 +21,7 @@ def dec_list(*decorators: str) -> ast.Module:
2121
def wrap(decorators: Tuple[str, ...], decs2: str) -> bool:
2222
tree = dec_list(*decorators)
2323
assert isinstance(tree.body[0], ast.AsyncFunctionDef)
24-
return fnmatch_decorator(tree.body[0].decorator_list, decs2)
24+
return fnmatch_qualified_name(tree.body[0].decorator_list, decs2)
2525

2626

2727
def test_basic():
@@ -87,6 +87,7 @@ def test_pep614():
8787
def test_plugin():
8888
tree = dec_list("app.route")
8989
plugin = Plugin(tree)
90+
plugin.options = Namespace(no_checkpoint_warning_decorators=[])
9091
assert tuple(plugin.run())
9192

9293
plugin.options = Namespace(no_checkpoint_warning_decorators=["app.route"])

tests/test_flake8_trio.py

+30
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from typing import DefaultDict, Iterable, List, Sequence, Tuple, Type
1212

1313
import pytest
14+
from flake8.options.manager import OptionManager
1415

1516
# import trio # type: ignore
1617
from hypothesis import HealthCheck, given, settings
@@ -157,6 +158,15 @@ def read_file(test_file: str):
157158

158159

159160
def assert_expected_errors(plugin: Plugin, include: Iterable[str], *expected: Error):
161+
# initialize default option values
162+
om = OptionManager(
163+
version="",
164+
plugin_versions="",
165+
parents=[],
166+
formatter_names=["default"], # type: ignore
167+
)
168+
plugin.add_options(om)
169+
plugin.parse_options(om.parse_args(args=[""]))
160170

161171
errors = sorted(e for e in plugin.run() if e.code in include)
162172
expected_ = sorted(expected)
@@ -353,6 +363,26 @@ def test_107_permutations():
353363
assert not errors, "# false alarm:\n" + function_str
354364

355365

366+
def test_113_options():
367+
# check that no errors are given by default
368+
plugin = read_file("trio113.py")
369+
om = OptionManager(
370+
version="",
371+
plugin_versions="",
372+
parents=[],
373+
formatter_names=["default"], # type: ignore
374+
)
375+
plugin.add_options(om)
376+
plugin.parse_options(om.parse_args(args=["--startable-in-context-manager=''"]))
377+
default = {repr(e) for e in plugin.run() if e.code == "TRIO113"}
378+
379+
# and that the expected errors are given if we empty it and then extend it
380+
arg = "--startable-in-context-manager=custom_startable_function"
381+
plugin.parse_options(om.parse_args(args=[arg]))
382+
errors = {repr(e) for e in plugin.run() if e.code == "TRIO113"} - default
383+
assert errors == {repr(Error("TRIO113", 58, 8))}
384+
385+
356386
@pytest.mark.fuzz
357387
class TestFuzz(unittest.TestCase):
358388
@settings(max_examples=1_000, suppress_health_check=[HealthCheck.too_slow])

tests/trio113.py

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import contextlib
2+
import contextlib as arbitrary_import_alias_for_contextlib
3+
import os
4+
from contextlib import asynccontextmanager
5+
6+
import trio
7+
8+
9+
@asynccontextmanager
10+
async def run_sampling_profiler():
11+
async with trio.open_nursery() as nursery:
12+
nursery.start_soon( # error: 8
13+
trio.run_process, ["start-sampling-profiler", str(os.getpid())]
14+
)
15+
yield
16+
17+
nursery.start_soon( # safe, it's after the first yield
18+
trio.run_process, ["start-sampling-profiler", str(os.getpid())]
19+
)
20+
21+
22+
async def not_cm():
23+
async with trio.open_nursery() as nursery:
24+
nursery.start_soon(
25+
trio.run_process, ["start-sampling-profiler", str(os.getpid())]
26+
)
27+
yield
28+
29+
30+
class foo:
31+
async def __aenter__(self):
32+
async with trio.open_nursery() as nursery:
33+
nursery.start_soon( # error: 12
34+
trio.run_process, ["start-sampling-profiler", str(os.getpid())]
35+
)
36+
yield
37+
38+
39+
nursery = anything = custom_startable_function = trio # type: ignore
40+
41+
42+
class foo2:
43+
_nursery = nursery
44+
45+
async def __aenter__(self):
46+
nursery.start_soon(trio.run_process) # error: 8
47+
nursery.start_soon(trio.serve_ssl_over_tcp) # error: 8
48+
nursery.start_soon(trio.serve_tcp) # error: 8
49+
nursery.start_soon(trio.serve_listeners) # error: 8
50+
51+
# Where does `nursery` come from in __aenter__? Probably an attribute:
52+
self._nursery.start_soon(trio.run_process) # error: 8
53+
self._nursery.start_soon(trio.serve_ssl_over_tcp) # error: 8
54+
self._nursery.start_soon(trio.serve_tcp) # error: 8
55+
self._nursery.start_soon(trio.serve_listeners) # error: 8
56+
57+
# We have a test that sets `startable-in-context-manager` to error here
58+
nursery.start_soon(custom_startable_function)
59+
60+
61+
class foo3:
62+
async def __aenter__(_a_parameter_not_named_self):
63+
nursery.start_soon(trio.run_process) # error: 8
64+
65+
66+
# might be monkeypatched onto an instance, count this as an error too
67+
async def __aenter__():
68+
nursery.start_soon(trio.run_process) # error: 4
69+
nursery.start_soon() # broken code, but our analysis shouldn't crash
70+
nursery.cancel_scope.cancel()
71+
72+
73+
# this only takes a single parameter ... right? :P
74+
class foo4:
75+
async def __aenter__(self, *, args="foo"):
76+
nursery.start_soon(trio.run_process) # error: 8
77+
78+
79+
@contextlib.asynccontextmanager
80+
async def contextlib_acm():
81+
nursery.start_soon(trio.run_process) # error: 4
82+
yield
83+
84+
85+
@arbitrary_import_alias_for_contextlib.asynccontextmanager
86+
async def contextlib_import_alias_acm():
87+
nursery.start_soon(trio.run_process) # error: 4
88+
yield

0 commit comments

Comments
 (0)