Skip to content

Commit 5c6879a

Browse files
jakkdlZac-HD
andauthored
basic Asyncio support (#210)
* initial support for asyncio: 106, 200, 21x, and 9xx are working * partial support for 102, 103, 104 which now detect asyncio.exceptions.CancelledError as a critical exception * add --asyncio * update error descriptions in README to better reflect which errors work with which library, and other minor rewrites. * test improvements: Fix import/arg detection of asyncio. add asyncio102_asyncio. add async103_all_imported. Finally make test_anyio_from_config autodetect the correct line number. Fix BASE_LIBRARY marker being interpreted as a bool. Make #NOTRIO/#NOASYNCIO/#NOANYIO run the visitor but ignore the result, instead of skipping, to check it doesn't crash. Generalize error-message-library-check. * Replace all old URLs (ZacHD/flake8-trio and python-trio/flake8-trio) to point to python-trio/flake8-async --------- Co-authored-by: Zac Hatfield-Dodds <[email protected]>
1 parent eb2de73 commit 5c6879a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+390
-82
lines changed

.github/workflows/ci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ jobs:
6565
release:
6666
runs-on: ubuntu-latest
6767
needs: [pyright, test]
68-
if: github.repository == 'Zac-HD/flake8-trio' && github.ref == 'refs/heads/main'
68+
if: github.repository == 'python-trio/flake8-async' && github.ref == 'refs/heads/main'
6969
steps:
7070
- uses: actions/checkout@v4
7171
- name: Set up Python 3

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33

44
## Future
55
- Removed TRIO117, MultiError removed in trio 0.24.0
6+
- Renamed the library from flake8-trio to flake8-async, to indicate the checker supports more than just `trio`.
7+
- Renamed all error codes from TRIOxxx to ASYNCxxx
8+
- Renamed the binary from flake8-trio to flake8-async
9+
- Lots of internal renaming.
10+
- Added asyncio support for ASYNC106
11+
- added `--library`
612

713
## 23.5.1
814
- TRIO91X now supports comprehensions

README.md

+25-25
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/python-trio/flake8-trio/main.svg)](https://results.pre-commit.ci/latest/github/python-trio/flake8-trio/main)
1+
[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/python-trio/flake8-async/main.svg)](https://results.pre-commit.ci/latest/github/python-trio/flake8-async/main)
22
[![Checked with pyright](https://microsoft.github.io/pyright/img/pyright_badge.svg)](https://microsoft.github.io/pyright/)
33
# flake8-async
44

@@ -23,47 +23,47 @@ pip install flake8-async
2323
```
2424

2525
## List of warnings
26-
27-
- **ASYNC100**: A `with trio.fail_after(...):` or `with trio.move_on_after(...):`
26+
- **ASYNC100**: A `with [trio|anyio].fail_after(...):` or `with [trio|anyio].move_on_after(...):`
2827
context does not contain any `await` statements. This makes it pointless, as
2928
the timeout can only be triggered by a checkpoint.
30-
- **ASYNC101**: `yield` inside a nursery or cancel scope is only safe when implementing a context manager - otherwise, it breaks exception handling.
31-
- **ASYNC102**: It's unsafe to await inside `finally:` or `except BaseException/trio.Cancelled` unless you use a shielded
32-
cancel scope with a timeout.
33-
- **ASYNC103**: `except BaseException`, `except trio.Cancelled` or a bare `except:` with a code path that doesn't re-raise. If you don't want to re-raise `BaseException`, add a separate handler for `trio.Cancelled` before.
34-
- **ASYNC104**: `Cancelled` and `BaseException` must be re-raised - when a user tries to `return` or `raise` a different exception.
35-
- **ASYNC105**: Calling a trio async function without immediately `await`ing it.
36-
- **ASYNC106**: `trio`/`anyio` must be imported with `import trio`/`import anyio` for the linter to work.
37-
- **ASYNC109**: Async function definition with a `timeout` parameter - use `trio.[fail/move_on]_[after/at]` instead
38-
- **ASYNC110**: `while <condition>: await trio.sleep()` should be replaced by a `trio.Event`.
29+
- **ASYNC101**: `yield` inside a trio/anyio nursery or cancel scope is only safe when implementing a context manager - otherwise, it breaks exception handling.
30+
- **ASYNC102**: It's unsafe to await inside `finally:` or `except BaseException/trio.Cancelled/anyio.get_cancelled_exc_class()/asyncio.exceptions.CancelledError` unless you use a shielded cancel scope with a timeout. This is currently not able to detect asyncio shields.
31+
- **ASYNC103**: `except BaseException/trio.Cancelled/anyio.get_cancelled_exc_class()/asyncio.exceptions.CancelledError`, or a bare `except:` with a code path that doesn't re-raise. If you don't want to re-raise `BaseException`, add a separate handler for `trio.Cancelled`/`anyio.get_cancelled_exc_class()`/`asyncio.exceptions.CancelledError` before.
32+
- **ASYNC104**: `trio.Cancelled`/`anyio.get_cancelled_exc_class()`/`asyncio.exceptions.CancelledError`/`BaseException` must be re-raised. The same as ASYNC103, except specifically triggered on `return` or a different exception being raised.
33+
- **ASYNC105**: Calling a trio async function without immediately `await`ing it. This is only supported with trio functions, but you can get similar functionality with a type-checker.
34+
- **ASYNC106**: `trio`/`anyio`/`asyncio` must be imported with `import trio`/`import anyio`/`import asyncio` for the linter to work.
35+
- **ASYNC109**: Async function definition with a `timeout` parameter - use `[trio/anyio].[fail/move_on]_[after/at]` instead.
36+
- **ASYNC110**: `while <condition>: await [trio/anyio].sleep()` should be replaced by a `[trio|anyio].Event`.
3937
- **ASYNC111**: Variable, from context manager opened inside nursery, passed to `start[_soon]` might be invalidly accessed 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.
4038
- **ASYNC112**: 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.
4139
- **ASYNC113**: Using `nursery.start_soon` in `__aenter__` doesn't wait for the task to begin. Consider replacing with `nursery.start`.
4240
- **ASYNC114**: Startable function (i.e. has a `task_status` keyword parameter) not in `--startable-in-context-manager` parameter list, please add it so ASYNC113 can catch errors when using it.
43-
- **ASYNC115**: Replace `trio.sleep(0)` with the more suggestive `trio.lowlevel.checkpoint()`.
44-
- **ASYNC116**: `trio.sleep()` with >24 hour interval should usually be `trio.sleep_forever()`.
41+
- **ASYNC115**: Replace `[trio|anyio].sleep(0)` with the more suggestive `[trio|anyio].lowlevel.checkpoint()`.
42+
- **ASYNC116**: `[trio|anyio].sleep()` with >24 hour interval should usually be `[trio|anyio].sleep_forever()`.
4543
- **ASYNC118**: Don't assign the value of `anyio.get_cancelled_exc_class()` to a variable, since that breaks linter checks and multi-backend programs.
4644

4745
### Warnings for blocking sync calls in async functions
48-
- **ASYNC200**: User-configured error for blocking sync calls in async functions. Does nothing by default, see [`trio200-blocking-calls`](#trio200-blocking-calls) for how to configure it.
49-
- **ASYNC210**: Sync HTTP call in async function, use `httpx.AsyncClient`.
46+
Note: 22X, 23X and 24X has not had asyncio-specific suggestions written.
47+
- **ASYNC200**: User-configured error for blocking sync calls in async functions. Does nothing by default, see [`async200-blocking-calls`](#async200-blocking-calls) for how to configure it.
48+
- **ASYNC210**: Sync HTTP call in async function, use `httpx.AsyncClient`. This and the other ASYNC21x checks look for usage of `urllib3` and `httpx.Client`, and recommend using `httpx.AsyncClient` as that's the largest http client supporting anyio/trio.
5049
- **ASYNC211**: Likely sync HTTP call in async function, use `httpx.AsyncClient`. Looks for `urllib3` method calls on pool objects, but only matching on the method signature and not the object.
5150
- **ASYNC212**: Blocking sync HTTP call on httpx object, use httpx.AsyncClient.
52-
- **ASYNC220**: Sync process call in async function, use `await nursery.start(trio.run_process, ...)`.
53-
- **ASYNC221**: Sync process call in async function, use `await trio.run_process(...)`.
54-
- **ASYNC222**: Sync `os.*` call in async function, wrap in `await trio.to_thread.run_sync()`.
55-
- **ASYNC230**: Sync IO call in async function, use `trio.open_file(...)`.
56-
- **ASYNC231**: Sync IO call in async function, use `trio.wrap_file(...)`.
57-
- **ASYNC232**: Blocking sync call on file object, wrap the file object in `trio.wrap_file()` to get an async file object.
58-
- **ASYNC240**: Avoid using `os.path` in async functions, prefer using `trio.Path` objects.
51+
- **ASYNC220**: Sync process call in async function, use `await nursery.start([trio|anyio].run_process, ...)`.
52+
- **ASYNC221**: Sync process call in async function, use `await [trio|anyio].run_process(...)`.
53+
- **ASYNC222**: Sync `os.*` call in async function, wrap in `await [trio|anyio].to_thread.run_sync()`.
54+
- **ASYNC230**: Sync IO call in async function, use `[trio|anyio].open_file(...)`.
55+
- **ASYNC231**: Sync IO call in async function, use `[trio|anyio].wrap_file(...)`.
56+
- **ASYNC232**: Blocking sync call on file object, wrap the file object in `[trio|anyio].wrap_file()` to get an async file object.
57+
- **ASYNC240**: Avoid using `os.path` in async functions, prefer using `[trio|anyio].Path` objects.
5958

6059
### Warnings disabled by default
61-
- **ASYNC900**: Async generator without `@asynccontextmanager` not allowed.
62-
- **ASYNC910**: Exit or `return` from async function with no guaranteed checkpoint or exception since function definition.
60+
- **ASYNC900**: Async generator without `@asynccontextmanager` not allowed. You might want to enable this on a codebase since async generators are inherently unsafe and cleanup logic might not be performed. See https://github.com/python-trio/flake8-async/issues/211 and https://discuss.python.org/t/using-exceptiongroup-at-anthropic-experience-report/20888/6 for discussion.
61+
- **ASYNC910**: Exit or `return` from async function with no guaranteed checkpoint or exception since function definition. You might want to enable this on a codebase to make it easier to reason about checkpoints, and make the logic of ASYNC911 correct.
6362
- **ASYNC911**: Exit, `yield` or `return` from async iterable with no guaranteed checkpoint since possible function entry (yield or function definition)
6463
Checkpoints are `await`, `async for`, and `async with` (on one of enter/exit).
6564

6665
### Removed Warnings
66+
- **TRIOxxx**: All error codes are now renamed ASYNCxxx
6767
- **TRIO107**: Renamed to TRIO910
6868
- **TRIO108**: Renamed to TRIO911
6969
- **TRIO117**: Don't raise or catch `trio.[NonBase]MultiError`, prefer `[exceptiongroup.]BaseExceptionGroup`. `MultiError` was removed in trio==0.24.0.

flake8_async/__init__.py

+14
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,19 @@ def add_options(option_manager: OptionManager | ArgumentParser):
298298
" suggestions with [anyio|trio]."
299299
),
300300
)
301+
add_argument(
302+
"--asyncio",
303+
# action=store_true + parse_from_config does seem to work here, despite
304+
# https://github.com/PyCQA/flake8/issues/1770
305+
action="store_true",
306+
required=False,
307+
default=False,
308+
help=(
309+
"Change the default library to be asyncio instead of trio."
310+
" If anyio/trio is imported it will assume that is also available and"
311+
" print suggestions with [asyncio|anyio/trio]."
312+
),
313+
)
301314

302315
@staticmethod
303316
def parse_options(options: Namespace):
@@ -342,6 +355,7 @@ def get_matching_codes(
342355
startable_in_context_manager=options.startable_in_context_manager,
343356
trio200_blocking_calls=options.trio200_blocking_calls,
344357
anyio=options.anyio,
358+
asyncio=options.asyncio,
345359
disable_noqa=options.disable_noqa,
346360
)
347361

flake8_async/base.py

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class Options:
3232
startable_in_context_manager: Collection[str]
3333
trio200_blocking_calls: dict[str, str]
3434
anyio: bool
35+
asyncio: bool
3536
disable_noqa: bool
3637

3738

flake8_async/visitors/helpers.py

+3
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,9 @@ def has_exception(node: ast.expr) -> str | None:
237237
"trio.Cancelled",
238238
"anyio.get_cancelled_exc_class()",
239239
"get_cancelled_exc_class()",
240+
"asyncio.exceptions.CancelledError",
241+
"exceptions.CancelledError",
242+
"CancelledError",
240243
):
241244
return name
242245
return None

flake8_async/visitors/visitor103_104.py

+32-2
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,30 @@
2222
_suggestion_dict: dict[tuple[str, ...], str] = {
2323
("anyio",): "anyio.get_cancelled_exc_class()",
2424
("trio",): "trio.Cancelled",
25+
("asyncio",): "asyncio.exceptions.CancelledError",
2526
}
26-
_suggestion_dict[("anyio", "trio")] = "[" + "|".join(_suggestion_dict.values()) + "]"
27+
# TODO: ugly
28+
for a, b in (("anyio", "trio"), ("anyio", "asyncio"), ("asyncio", "trio")):
29+
_suggestion_dict[(a, b)] = (
30+
"[" + "|".join((_suggestion_dict[(a,)], _suggestion_dict[(b,)])) + "]"
31+
)
32+
_suggestion_dict[
33+
(
34+
"anyio",
35+
"asyncio",
36+
"trio",
37+
)
38+
] = (
39+
"["
40+
+ "|".join(
41+
(
42+
_suggestion_dict[("anyio",)],
43+
_suggestion_dict[("asyncio",)],
44+
_suggestion_dict[("trio",)],
45+
)
46+
)
47+
+ "]"
48+
)
2749

2850
_error_codes = {
2951
"ASYNC103": _async103_common_msg,
@@ -56,6 +78,7 @@ def visit_ExceptHandler(self, node: ast.ExceptHandler):
5678
marker = critical_except(node)
5779

5880
if marker is None:
81+
# not a critical exception handler
5982
return
6083

6184
# If previous excepts have handled trio.Cancelled, don't do anything - namely
@@ -69,14 +92,21 @@ def visit_ExceptHandler(self, node: ast.ExceptHandler):
6992
):
7093
error_code = "ASYNC103"
7194
self.cancelled_caught.add("anyio")
95+
elif marker.name in (
96+
"asyncio.exceptions.CancelledError",
97+
"exceptions.CancelledError",
98+
"CancelledError",
99+
):
100+
error_code = "ASYNC103"
101+
self.cancelled_caught.add("asyncio")
72102
else:
73103
if self.cancelled_caught:
74104
return
75105
if len(self.library) < 2:
76106
error_code = f"ASYNC103_{self.library_str}"
77107
else:
78108
error_code = f"ASYNC103_{'_'.join(sorted(self.library))}"
79-
self.cancelled_caught.update("trio", "anyio")
109+
self.cancelled_caught.update("trio", "anyio", "asyncio")
80110

81111
# Don't save the state of cancelled_caught, that's handled in Try and would
82112
# reset it between each except

flake8_async/visitors/visitor91x.py

+17-4
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ def copy(self):
9393

9494

9595
def checkpoint_statement(library: str) -> cst.SimpleStatementLine:
96+
# logic before this should stop code from wanting to insert the non-existing
97+
# asyncio.lowlevel.checkpoint
98+
assert library != "asyncio"
9699
return cst.SimpleStatementLine(
97100
[cst.Expr(cst.parse_expression(f"await {library}.lowlevel.checkpoint()"))]
98101
)
@@ -111,6 +114,7 @@ def __init__(self):
111114
self.noautofix: bool = False
112115
self.add_statement: cst.SimpleStatementLine | None = None
113116

117+
# used for inserting import if there's none
114118
self.explicitly_imported_library: dict[str, bool] = {
115119
"trio": False,
116120
"anyio": False,
@@ -145,8 +149,11 @@ def leave_SimpleStatementLine(
145149
# possible TODO: generate an error if transforming+visiting is done in a
146150
# single pass and emit-error-on-transform can be enabled/disabled. The error can't
147151
# be generated in the yield/return since it doesn't know if it will be autofixed.
148-
if self.add_statement is None or not self.should_autofix(original_node):
152+
if self.add_statement is None:
149153
return updated_node
154+
155+
# methods setting self.add_statement should have called self.should_autofix
156+
assert self.should_autofix(original_node)
150157
curr_add_statement = self.add_statement
151158
self.add_statement = None
152159

@@ -250,8 +257,12 @@ def __init__(self, *args: Any, **kwargs: Any):
250257
self.try_state = TryState()
251258

252259
def should_autofix(self, node: cst.CSTNode, code: str | None = None) -> bool:
253-
return not self.noautofix and super().should_autofix(
254-
node, "ASYNC911" if self.has_yield else "ASYNC910"
260+
return (
261+
not self.noautofix
262+
and super().should_autofix(
263+
node, "ASYNC911" if self.has_yield else "ASYNC910"
264+
)
265+
and self.library != ("asyncio",)
255266
)
256267

257268
def checkpoint_statement(self) -> cst.SimpleStatementLine:
@@ -359,7 +370,9 @@ def leave_Return(
359370
) -> cst.Return:
360371
if not self.async_function:
361372
return updated_node
362-
if self.check_function_exit(original_node):
373+
if self.check_function_exit(original_node) and self.should_autofix(
374+
original_node
375+
):
363376
self.add_statement = self.checkpoint_statement()
364377
# avoid duplicate error messages
365378
self.uncheckpointed_statements = set()

flake8_async/visitors/visitor_utility.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,13 @@ def __init__(self, *args: Any, **kwargs: Any):
117117
# see imports
118118
if self.options.anyio:
119119
self.add_library("anyio")
120+
if self.options.asyncio:
121+
self.add_library("asyncio")
120122

121123
def visit_Import(self, node: ast.Import):
122124
for alias in node.names:
123125
name = alias.name
124-
if name in ("trio", "anyio") and alias.asname is None:
126+
if name in ("trio", "anyio", "asyncio") and alias.asname is None:
125127
self.add_library(name)
126128

127129

@@ -134,11 +136,17 @@ def __init__(self, *args: Any, **kwargs: Any):
134136
# see imports
135137
if self.options.anyio:
136138
self.add_library("anyio")
139+
if self.options.asyncio:
140+
self.add_library("asyncio")
137141

138142
def visit_Import(self, node: cst.Import):
139143
for alias in node.names:
140144
if m.matches(
141-
alias, m.ImportAlias(name=m.Name("trio") | m.Name("anyio"), asname=None)
145+
alias,
146+
m.ImportAlias(
147+
name=m.Name("trio") | m.Name("anyio") | m.Name("asyncio"),
148+
asname=None,
149+
),
142150
):
143151
assert isinstance(alias.name.value, str)
144152
self.add_library(alias.name.value)

flake8_async/visitors/visitors.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
if TYPE_CHECKING:
1212
from collections.abc import Mapping
1313

14+
LIBRARIES = ("trio", "anyio", "asyncio")
15+
1416

1517
@error_class
1618
class Visitor106(Flake8AsyncVisitor):
@@ -19,12 +21,12 @@ class Visitor106(Flake8AsyncVisitor):
1921
}
2022

2123
def visit_ImportFrom(self, node: ast.ImportFrom):
22-
if node.module in ("trio", "anyio"):
24+
if node.module in LIBRARIES:
2325
self.error(node, node.module)
2426

2527
def visit_Import(self, node: ast.Import):
2628
for name in node.names:
27-
if name.name in ("trio", "anyio") and name.asname is not None:
29+
if name.name in LIBRARIES and name.asname is not None:
2830
self.error(node, name.name)
2931

3032

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ ignore = [
5454
"COM", # flake8-comma, handled by black
5555
"ANN", # annotations, handled by pyright/mypy
5656
"T20", # flake8-print
57-
"TID252", # relative imports from parent modules https://github.com/python-trio/flake8-trio/pull/196#discussion_r1200413372
57+
"TID252", # relative imports from parent modules https://github.com/python-trio/flake8-async/pull/196#discussion_r1200413372
5858
"D101",
5959
"D102",
6060
"D103",

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def local_file(name: str) -> Path:
2525
author="Zac Hatfield-Dodds, John Litborn, and Contributors",
2626
author_email="[email protected]",
2727
packages=find_packages(include=["flake8_async", "flake8_async.*"]),
28-
url="https://github.com/python-trio/flake8-trio",
28+
url="https://github.com/python-trio/flake8-async",
2929
license="MIT",
3030
description="A highly opinionated flake8 plugin for Trio-related problems.",
3131
zip_safe=False,

tests/autofix_files/async100.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# type: ignore
22
# AUTOFIX
3+
# NOASYNCIO
34

45
import trio
56

tests/autofix_files/async100_simple_autofix.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# NOASYNCIO
12
# AUTOFIX
23
import trio
34

tests/autofix_files/async910.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ async def foo_try_7(): # safe
382382
pass
383383

384384

385-
# https://github.com/Zac-HD/flake8-trio/issues/45
385+
# https://github.com/python-trio/flake8-async/issues/45
386386
async def to_queue(iter_func, queue):
387387
async with iter_func() as it:
388388
async for x in it:
@@ -499,7 +499,7 @@ async def foo_range_5(): # error: 0, "exit", Statement("function definition", l
499499
await trio.lowlevel.checkpoint()
500500

501501

502-
# https://github.com/Zac-HD/flake8-trio/issues/47
502+
# https://github.com/python-trio/flake8-async/issues/47
503503
async def f():
504504
while True:
505505
if ...:

tests/autofix_files/async910.py.diff

+1-1
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@
196196
+ await trio.lowlevel.checkpoint()
197197

198198

199-
# https://github.com/Zac-HD/flake8-trio/issues/47
199+
# https://github.com/python-trio/flake8-async/issues/47
200200
@@ x,6 x,7 @@
201201
# should error
202202
async def foo_comprehension_2(): # error: 0, "exit", Statement("function definition", lineno)

tests/autofix_files/noqa.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# AUTOFIX
22
# NOANYIO # TODO
3+
# NOASYNCIO
34
# ARG --enable=ASYNC100,ASYNC911
45
from typing import Any
56

tests/eval_files/anyio_trio.py

+3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# type: ignore
22
# ARG --enable=ASYNC220
33
# NOTRIO
4+
# NOASYNCIO
5+
# set base library so trio doesn't get replaced when running with anyio
6+
# BASE_LIBRARY anyio
47

58
# anyio eval will automatically prepend this test with `--anyio`
69
import trio # isort: skip

0 commit comments

Comments
 (0)