Skip to content

Commit fcc89d2

Browse files
committed
add async124 async-function-could-be-sync
1 parent af726f1 commit fcc89d2

File tree

7 files changed

+281
-5
lines changed

7 files changed

+281
-5
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+
24.10.3
8+
=======
9+
- Add :ref:`ASYNC124 <async124>` async-function-could-be-sync
10+
711
24.10.2
812
=======
913
- :ref:`ASYNC102 <async102>` now also warns about ``await()`` inside ``__aexit__``.

docs/rules.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ _`ASYNC123`: bad-exception-group-flattening
9494
Dropping this information makes diagnosing errors much more difficult.
9595
We recommend ``raise SomeNewError(...) from group`` if possible; or consider using `copy.copy` to shallow-copy the exception before re-raising (for copyable types), or re-raising the error from outside the `except` block.
9696

97+
_`ASYNC124`: async-function-could-be-sync
98+
Triggers when an async function contain none of ``await``, ``async for`` or ``async with``.
99+
Calling an async function is slower than calling regular functions, so if possible you
100+
might want to convert your function to be synchronous.
101+
This currently overlaps with :ref:`ASYNC910 <ASYNC910>` and :ref:`ASYNC911 <ASYNC911>` which, if enabled, will autofix the function to have :ref:`checkpoint`.
102+
97103
Blocking sync calls in async functions
98104
======================================
99105

flake8_async/visitors/visitor91x.py

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1-
"""Contains Visitor91X.
1+
"""Contains Visitor91X and Visitor124.
22
3-
910 looks for async functions without guaranteed checkpoints (or exceptions), and 911 does
4-
the same except for async iterables - while also requiring that they checkpoint between
5-
each yield.
3+
Visitor91X contains checks for
4+
* ASYNC100 cancel-scope-no-checkpoint
5+
* ASYNC910 async-function-no-checkpoint
6+
* ASYNC911 async-generator-no-checkpoint
7+
* ASYNC912 cancel-scope-no-guaranteed-checkpoint
8+
* ASYNC913 indefinite-loop-no-guaranteed-checkpoint
9+
10+
Visitor124 contains
11+
* ASYNC124 async-function-could-be-sync
612
"""
713

814
from __future__ import annotations
@@ -63,6 +69,47 @@ def func_empty_body(node: cst.FunctionDef) -> bool:
6369
)
6470

6571

72+
@error_class_cst
73+
class Visitor124(Flake8AsyncVisitor_cst):
74+
error_codes: Mapping[str, str] = {
75+
"ASYNC124": (
76+
"Async function with no `await` could be sync."
77+
" Async functions are more expensive to call."
78+
)
79+
}
80+
81+
def __init__(self, *args: Any, **kwargs: Any):
82+
super().__init__(*args, **kwargs)
83+
self.has_await = False
84+
85+
def visit_FunctionDef(self, node: cst.FunctionDef):
86+
# await in sync defs are not valid, but handling this will make ASYNC124
87+
# pop up in parent func as if the child function was async
88+
self.save_state(node, "has_await", copy=False)
89+
self.has_await = False
90+
91+
def leave_FunctionDef(
92+
self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef
93+
) -> cst.FunctionDef:
94+
if (
95+
original_node.asynchronous is not None
96+
and not self.has_await
97+
and not func_empty_body(original_node)
98+
):
99+
self.error(original_node)
100+
self.restore_state(original_node)
101+
return updated_node
102+
103+
def visit_Await(self, node: cst.Await):
104+
self.has_await = True
105+
106+
def visit_With(self, node: cst.With | cst.For):
107+
if node.asynchronous is not None:
108+
self.has_await = True
109+
110+
visit_For = visit_With
111+
112+
66113
@dataclass
67114
class LoopState:
68115
infinite_loop: bool = False

tests/autofix_files/async124.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Async function with no awaits could be sync.
2+
It currently does not care if 910/911 would also be triggered."""
3+
4+
# ARG --enable=ASYNC124,ASYNC910,ASYNC911
5+
6+
# 910/911 will also autofix async124, in the sense of adding a checkpoint. This is perhaps
7+
# not what the user wants though, so this would be a case in favor of making 910/911 not
8+
# trigger when async124 does.
9+
# AUTOFIX
10+
# ASYNCIO_NO_AUTOFIX
11+
from typing import Any
12+
import trio
13+
14+
15+
def condition() -> bool:
16+
return False
17+
18+
19+
async def foo() -> Any:
20+
await foo()
21+
22+
23+
async def foo_print(): # ASYNC124: 0 # ASYNC910: 0, "exit", Statement("function definition", lineno)
24+
print("hello")
25+
await trio.lowlevel.checkpoint()
26+
27+
28+
async def conditional_wait(): # ASYNC910: 0, "exit", Statement("function definition", lineno)
29+
if condition():
30+
await foo()
31+
await trio.lowlevel.checkpoint()
32+
33+
34+
async def foo_gen(): # ASYNC124: 0 # ASYNC911: 0, "exit", Statement("yield", lineno+1)
35+
await trio.lowlevel.checkpoint()
36+
yield # ASYNC911: 4, "yield", Statement("function definition", lineno-1)
37+
await trio.lowlevel.checkpoint()
38+
39+
40+
async def foo_async_with():
41+
async with foo_gen():
42+
...
43+
44+
45+
async def foo_async_for():
46+
async for i in foo_gen():
47+
...
48+
49+
50+
async def foo_nested(): # ASYNC124: 0 # ASYNC910: 0, "exit", Statement("function definition", lineno)
51+
async def foo_nested_2():
52+
await foo()
53+
await trio.lowlevel.checkpoint()
54+
55+
56+
async def foo_nested_sync(): # ASYNC124: 0 # ASYNC910: 0, "exit", Statement("function definition", lineno)
57+
def foo_nested_sync_child():
58+
await foo() # type: ignore[await-not-async]
59+
await trio.lowlevel.checkpoint()
60+
61+
62+
# We don't want to trigger on empty/pass functions because of inheritance.
63+
# Uses same logic as async91x.
64+
65+
66+
async def foo_empty():
67+
"blah"
68+
...
69+
70+
71+
async def foo_empty_pass():
72+
"foo"
73+
pass
74+
75+
76+
# we could consider filtering out functions named `test_.*` to not give false alarms on
77+
# tests that use async fixtures.
78+
# For ruff and for running through flake8 we could expect users to use per-file-ignores,
79+
# but running as standalone we don't currently support that. (though probs wouldn't be
80+
# too bad to add support).
81+
# See e.g. https://github.com/agronholm/anyio/issues/803 for why one might want an async
82+
# test without awaits.
83+
84+
85+
async def test_async_fixture(my_anyio_fixture): # ASYNC124: 0 # ASYNC910: 0, "exit", Statement("function definition", lineno)
86+
assert my_anyio_fixture.setup_worked_correctly
87+
await trio.lowlevel.checkpoint()

tests/autofix_files/async124.py.diff

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
+++
3+
@@ x,6 x,7 @@
4+
# AUTOFIX
5+
# ASYNCIO_NO_AUTOFIX
6+
from typing import Any
7+
+import trio
8+
9+
10+
def condition() -> bool:
11+
@@ x,15 x,19 @@
12+
13+
async def foo_print(): # ASYNC124: 0 # ASYNC910: 0, "exit", Statement("function definition", lineno)
14+
print("hello")
15+
+ await trio.lowlevel.checkpoint()
16+
17+
18+
async def conditional_wait(): # ASYNC910: 0, "exit", Statement("function definition", lineno)
19+
if condition():
20+
await foo()
21+
+ await trio.lowlevel.checkpoint()
22+
23+
24+
async def foo_gen(): # ASYNC124: 0 # ASYNC911: 0, "exit", Statement("yield", lineno+1)
25+
+ await trio.lowlevel.checkpoint()
26+
yield # ASYNC911: 4, "yield", Statement("function definition", lineno-1)
27+
+ await trio.lowlevel.checkpoint()
28+
29+
30+
async def foo_async_with():
31+
@@ x,11 x,13 @@
32+
async def foo_nested(): # ASYNC124: 0 # ASYNC910: 0, "exit", Statement("function definition", lineno)
33+
async def foo_nested_2():
34+
await foo()
35+
+ await trio.lowlevel.checkpoint()
36+
37+
38+
async def foo_nested_sync(): # ASYNC124: 0 # ASYNC910: 0, "exit", Statement("function definition", lineno)
39+
def foo_nested_sync_child():
40+
await foo() # type: ignore[await-not-async]
41+
+ await trio.lowlevel.checkpoint()
42+
43+
44+
# We don't want to trigger on empty/pass functions because of inheritance.
45+
@@ x,3 x,4 @@
46+
47+
async def test_async_fixture(my_anyio_fixture): # ASYNC124: 0 # ASYNC910: 0, "exit", Statement("function definition", lineno)
48+
assert my_anyio_fixture.setup_worked_correctly
49+
+ await trio.lowlevel.checkpoint()

tests/eval_files/async124.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Async function with no awaits could be sync.
2+
It currently does not care if 910/911 would also be triggered."""
3+
4+
# ARG --enable=ASYNC124,ASYNC910,ASYNC911
5+
6+
# 910/911 will also autofix async124, in the sense of adding a checkpoint. This is perhaps
7+
# not what the user wants though, so this would be a case in favor of making 910/911 not
8+
# trigger when async124 does.
9+
# AUTOFIX
10+
# ASYNCIO_NO_AUTOFIX
11+
from typing import Any
12+
13+
14+
def condition() -> bool:
15+
return False
16+
17+
18+
async def foo() -> Any:
19+
await foo()
20+
21+
22+
async def foo_print(): # ASYNC124: 0 # ASYNC910: 0, "exit", Statement("function definition", lineno)
23+
print("hello")
24+
25+
26+
async def conditional_wait(): # ASYNC910: 0, "exit", Statement("function definition", lineno)
27+
if condition():
28+
await foo()
29+
30+
31+
async def foo_gen(): # ASYNC124: 0 # ASYNC911: 0, "exit", Statement("yield", lineno+1)
32+
yield # ASYNC911: 4, "yield", Statement("function definition", lineno-1)
33+
34+
35+
async def foo_async_with():
36+
async with foo_gen():
37+
...
38+
39+
40+
async def foo_async_for():
41+
async for i in foo_gen():
42+
...
43+
44+
45+
async def foo_nested(): # ASYNC124: 0 # ASYNC910: 0, "exit", Statement("function definition", lineno)
46+
async def foo_nested_2():
47+
await foo()
48+
49+
50+
async def foo_nested_sync(): # ASYNC124: 0 # ASYNC910: 0, "exit", Statement("function definition", lineno)
51+
def foo_nested_sync_child():
52+
await foo() # type: ignore[await-not-async]
53+
54+
55+
# We don't want to trigger on empty/pass functions because of inheritance.
56+
# Uses same logic as async91x.
57+
58+
59+
async def foo_empty():
60+
"blah"
61+
...
62+
63+
64+
async def foo_empty_pass():
65+
"foo"
66+
pass
67+
68+
69+
# we could consider filtering out functions named `test_.*` to not give false alarms on
70+
# tests that use async fixtures.
71+
# For ruff and for running through flake8 we could expect users to use per-file-ignores,
72+
# but running as standalone we don't currently support that. (though probs wouldn't be
73+
# too bad to add support).
74+
# See e.g. https://github.com/agronholm/anyio/issues/803 for why one might want an async
75+
# test without awaits.
76+
77+
78+
async def test_async_fixture(
79+
my_anyio_fixture,
80+
): # ASYNC124: 0 # ASYNC910: 0, "exit", Statement("function definition", lineno)
81+
assert my_anyio_fixture.setup_worked_correctly

tests/test_flake8_async.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,20 +619,22 @@ def assert_correct_lines_and_codes(errors: Iterable[Error], expected: Iterable[E
619619
expected_dict[e.line][e.code] += 1
620620

621621
error_count = 0
622+
printed_header = False
622623
for line in all_lines:
623624
if error_dict[line] == expected_dict[line]:
624625
continue
625626

626627
# go through all the codes on the line
627628
for code in sorted({*error_dict[line], *expected_dict[line]}):
628-
if error_count == 0:
629+
if not printed_header:
629630
print(
630631
"Lines with different # of errors:",
631632
"-" * 38,
632633
f"| line | {'code':8} | actual | expected |",
633634
sep="\n",
634635
file=sys.stderr,
635636
)
637+
printed_header = True
636638

637639
print(
638640
f"| {line:4}",

0 commit comments

Comments
 (0)