Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add async124 async-function-could-be-sync #309

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Changelog

`CalVer, YY.month.patch <https://calver.org/>`_

24.10.3
=======
- Add :ref:`ASYNC124 <async124>` async-function-could-be-sync

24.10.2
=======
- :ref:`ASYNC102 <async102>` now also warns about ``await()`` inside ``__aexit__``.
Expand Down
6 changes: 6 additions & 0 deletions docs/rules.rst
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ _`ASYNC123`: bad-exception-group-flattening
Dropping this information makes diagnosing errors much more difficult.
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.

_`ASYNC124`: async-function-could-be-sync
Triggers when an async function contain none of ``await``, ``async for`` or ``async with``.
Calling an async function is slower than calling regular functions, so if possible you
might want to convert your function to be synchronous.
This currently overlaps with :ref:`ASYNC910 <ASYNC910>` and :ref:`ASYNC911 <ASYNC911>` which, if enabled, will autofix the function to have :ref:`checkpoint`.

Blocking sync calls in async functions
======================================

Expand Down
2 changes: 1 addition & 1 deletion flake8_async/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@


# CalVer: YY.month.patch, e.g. first release of July 2022 == "22.7.1"
__version__ = "24.10.2"
__version__ = "24.10.3"


# taken from https://github.com/Zac-HD/shed
Expand Down
67 changes: 63 additions & 4 deletions flake8_async/visitors/visitor91x.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
"""Contains Visitor91X.
"""Contains Visitor91X and Visitor124.

910 looks for async functions without guaranteed checkpoints (or exceptions), and 911 does
the same except for async iterables - while also requiring that they checkpoint between
each yield.
Visitor91X contains checks for
* ASYNC100 cancel-scope-no-checkpoint
* ASYNC910 async-function-no-checkpoint
* ASYNC911 async-generator-no-checkpoint
* ASYNC912 cancel-scope-no-guaranteed-checkpoint
* ASYNC913 indefinite-loop-no-guaranteed-checkpoint

Visitor124 contains
* ASYNC124 async-function-could-be-sync
"""

from __future__ import annotations
Expand Down Expand Up @@ -63,6 +69,59 @@ def func_empty_body(node: cst.FunctionDef) -> bool:
)


@error_class_cst
class Visitor124(Flake8AsyncVisitor_cst):
error_codes: Mapping[str, str] = {
"ASYNC124": (
"Async function with no `await` could be sync."
" Async functions are more expensive to call."
)
}

def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
self.has_await = False

def visit_FunctionDef(self, node: cst.FunctionDef):
# await in sync defs are not valid, but handling this will make ASYNC124
# pop up in parent func as if the child function was async
self.save_state(node, "has_await", copy=False)
self.has_await = False

def leave_FunctionDef(
self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef
) -> cst.FunctionDef:
if (
original_node.asynchronous is not None
and not self.has_await
and not func_empty_body(original_node)
and not func_has_decorator(original_node, "overload")
# skip functions named 'text_xxx' with params, since they may be relying
# on async fixtures. This is esp bad as sync funcs relying on async fixtures
# is not well handled: https://github.com/pytest-dev/pytest/issues/10839
# also skip funcs with @fixtures and params
and not (
original_node.params.params
and (
original_node.name.value.startswith("test_")
or func_has_decorator(original_node, "fixture")
)
)
):
self.error(original_node)
self.restore_state(original_node)
return updated_node

def visit_Await(self, node: cst.Await):
self.has_await = True

def visit_With(self, node: cst.With | cst.For):
if node.asynchronous is not None:
self.has_await = True

visit_For = visit_With


@dataclass
class LoopState:
infinite_loop: bool = False
Expand Down
7 changes: 5 additions & 2 deletions tests/autofix_files/async910.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,12 @@ def foo_normal_func_1():
def foo_normal_func_2(): ...


# overload decorator
# overload decorator is skipped
# overload functions should always be empty, so the check is somewhat redundant,
# but making one non-empty to check the logic.
@overload
async def foo_overload_1(_: bytes): ...
async def foo_overload_1(_: bytes):
raise NotImplementedError


@typing.overload
Expand Down
101 changes: 101 additions & 0 deletions tests/eval_files/async124.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Async function with no awaits could be sync.
It currently does not care if 910/911 would also be triggered."""

# ARG --enable=ASYNC124,ASYNC910,ASYNC911

# 910/911 will also autofix async124, in the sense of adding a checkpoint. This is perhaps
# not what the user wants though, so this would be a case in favor of making 910/911 not
# trigger when async124 does.
# NOAUTOFIX # all errors get "fixed" except for foo_fix_no_subfix
from typing import Any, overload
from pytest import fixture


def condition() -> bool:
return False


async def foo() -> Any:
await foo()


async def foo_print(): # ASYNC124: 0 # ASYNC910: 0, "exit", Statement("function definition", lineno)
print("hello")


async def conditional_wait(): # ASYNC910: 0, "exit", Statement("function definition", lineno)
if condition():
await foo()


async def foo_gen(): # ASYNC124: 0 # ASYNC911: 0, "exit", Statement("yield", lineno+1)
yield # ASYNC911: 4, "yield", Statement("function definition", lineno-1)


async def foo_async_with():
async with foo_gen():
...


async def foo_async_for():
async for i in foo_gen():
...


async def foo_nested(): # ASYNC124: 0 # ASYNC910: 0, "exit", Statement("function definition", lineno)
async def foo_nested_2():
await foo()


async def foo_nested_sync(): # ASYNC124: 0 # ASYNC910: 0, "exit", Statement("function definition", lineno)
def foo_nested_sync_child():
await foo() # type: ignore[await-not-async]


# We don't want to trigger on empty/pass functions because of inheritance.
# Uses same logic as async91x.


async def foo_empty():
"blah"
...


async def foo_empty_pass():
"foo"
pass


# See e.g. https://github.com/agronholm/anyio/issues/803 for why one might want an async
# test without awaits.
async def test_async_fixture( # ASYNC910: 0, "exit", Statement("function definition", lineno)
my_async_fixture,
):
assert my_async_fixture.setup_worked_correctly


# no params -> no async fixtures
async def test_no_fixture(): # ASYNC124: 0 # ASYNC910: 0, "exit", Statement("function definition", lineno)
print("blah")


# skip @overload. They should always be empty, but /shrug
@overload
async def foo_overload():
print("blah")


async def foo_overload(): ...


# skip @[pytest.]fixture if they have any params, since they might depend on other
# async fixtures
@fixture
async def foo_fix(my_async_fixture):
print("blah")


# but @fixture with no params can be converted to sync
@fixture
async def foo_fix_no_subfix(): # ASYNC124: 0
print("blah")
7 changes: 5 additions & 2 deletions tests/eval_files/async910.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,12 @@ def foo_normal_func_1():
def foo_normal_func_2(): ...


# overload decorator
# overload decorator is skipped
# overload functions should always be empty, so the check is somewhat redundant,
# but making one non-empty to check the logic.
@overload
async def foo_overload_1(_: bytes): ...
async def foo_overload_1(_: bytes):
raise NotImplementedError


@typing.overload
Expand Down
4 changes: 3 additions & 1 deletion tests/test_flake8_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -619,20 +619,22 @@ def assert_correct_lines_and_codes(errors: Iterable[Error], expected: Iterable[E
expected_dict[e.line][e.code] += 1

error_count = 0
printed_header = False
for line in all_lines:
if error_dict[line] == expected_dict[line]:
continue

# go through all the codes on the line
for code in sorted({*error_dict[line], *expected_dict[line]}):
if error_count == 0:
if not printed_header:
print(
"Lines with different # of errors:",
"-" * 38,
f"| line | {'code':8} | actual | expected |",
sep="\n",
file=sys.stderr,
)
printed_header = True

print(
f"| {line:4}",
Expand Down
9 changes: 8 additions & 1 deletion tests/trio_options.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
"""Test file used in tests/test_decator.py to check decorator and command line."""

import asyncio

app = None


def condition() -> bool:
return False


@app.route # type: ignore
async def f():
print("hello world")
if condition():
await asyncio.sleep(0)
Loading