Skip to content

add ASYNC125 constant-absolute-deadline #366

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

Merged
merged 2 commits into from
Apr 2, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
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/>`_

25.4.2
======
- Add :ref:`ASYNC125 <async125>` constant-absolute-deadline

25.4.1
======
- Add match-case (structural pattern matching) support to ASYNC103, 104, 910, 911 & 912.
Expand Down
8 changes: 8 additions & 0 deletions docs/rules.rst
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,14 @@ _`ASYNC124`: async-function-could-be-sync
This currently overlaps with :ref:`ASYNC910 <ASYNC910>` and :ref:`ASYNC911 <ASYNC911>` which, if enabled, will autofix the function to have :ref:`checkpoint`.
This excludes class methods as they often have to be async for other reasons, if you really do want to check those you could manually run :ref:`ASYNC910 <ASYNC910>` and/or :ref:`ASYNC911 <ASYNC911>` and check the methods they trigger on.

_`ASYNC125`: constant-absolute-deadline
Passing constant values (other than :const:`math.inf`) to timeouts expecting absolute
deadlines is nonsensical. These should always be defined relative to
:func:`trio.current_time`/:func:`anyio.current_time`, or you might want to use
:func:`trio.fail_after`/`:func:`trio.move_on_after`/:func:`anyio.fail_after`/
:func:`anyio.move_on_after`, or the ``relative_deadline`` parameter to
:class:`trio.CancelScope`.

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

Expand Down
2 changes: 1 addition & 1 deletion docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ adding the following to your ``.pre-commit-config.yaml``:
minimum_pre_commit_version: '2.9.0'
repos:
- repo: https://github.com/python-trio/flake8-async
rev: 25.4.1
rev: 25.4.2
hooks:
- id: flake8-async
# args: ["--enable=ASYNC100,ASYNC112", "--disable=", "--autofix=ASYNC"]
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__ = "25.4.1"
__version__ = "25.4.2"


# taken from https://github.com/Zac-HD/shed
Expand Down
16 changes: 13 additions & 3 deletions flake8_async/visitors/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import annotations

import ast
from dataclasses import dataclass
from fnmatch import fnmatch
from typing import TYPE_CHECKING, NamedTuple, TypeVar, Union

Expand Down Expand Up @@ -287,11 +288,20 @@ def has_exception(node: ast.expr) -> str | None:
)


@dataclass
class MatchingCall:
node: ast.Call
name: str
base: str

def __str__(self) -> str:
return self.base + "." + self.name


# convenience function used in a lot of visitors
# should probably return a named tuple
def get_matching_call(
node: ast.AST, *names: str, base: Iterable[str] = ("trio", "anyio")
) -> tuple[ast.Call, str, str] | None:
) -> MatchingCall | None:
if isinstance(base, str):
base = (base,)
if (
Expand All @@ -301,7 +311,7 @@ def get_matching_call(
and node.func.value.id in base
and node.func.attr in names
):
return node, node.func.attr, node.func.value.id
return MatchingCall(node, node.func.attr, node.func.value.id)
return None


Expand Down
4 changes: 2 additions & 2 deletions flake8_async/visitors/visitor102_120.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class Visitor102(Flake8AsyncVisitor):
}

class TrioScope:
def __init__(self, node: ast.Call, funcname: str, _):
def __init__(self, node: ast.Call, funcname: str):
super().__init__()
self.node = node
self.funcname = funcname
Expand Down Expand Up @@ -126,7 +126,7 @@ def visit_With(self, node: ast.With | ast.AsyncWith):
if call is None:
continue

trio_scope = self.TrioScope(*call)
trio_scope = self.TrioScope(call.node, call.name)
# check if it's saved in a variable
if isinstance(item.optional_vars, ast.Name):
trio_scope.variable_name = item.optional_vars.id
Expand Down
49 changes: 45 additions & 4 deletions flake8_async/visitors/visitors.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,7 @@ def visit_Call(self, node: ast.Call):
and isinstance(node.args[0], ast.Constant)
and node.args[0].value == 0
):
# m[2] is set to node.func.value.id
self.error(node, m[2])
self.error(node, m.base)


@error_class
Expand Down Expand Up @@ -326,7 +325,7 @@ def visit_Call(self, node: ast.Call):
and arg.value > 86400
)
):
self.error(node, m[2])
self.error(node, m.base)


@error_class
Expand Down Expand Up @@ -452,7 +451,49 @@ def visit_Call(self, node: ast.Call):
node, "fail_after", "move_on_after", base=("trio", "anyio")
)
):
self.error(node, f"{match[2]}.{match[1]}")
self.error(node, str(match))


@error_class
class Visitor125(Flake8AsyncVisitor):
error_codes: Mapping[str, str] = {
"ASYNC125": (
"Using {} with a constant value is nonsensical, as the value is relative "
"to the runner clock. Use ``fail_after(...)``, ``move_on_after(...)``, "
"``CancelScope(relative_deadline=...)`` or calculate it relative to "
"``{}.current_time()``."
)
}

def visit_Call(self, node: ast.Call):
def is_constant(value: ast.expr) -> bool:
if isinstance(value, ast.Constant):
return True
if isinstance(value, ast.BinOp):
return is_constant(value.left) and is_constant(value.right)
return False

match = get_matching_call(
node, "fail_at", "move_on_at", "CancelScope", base=("trio", "anyio")
)
if match is None:
return

if match.name in ("fail_at", "move_on_at") and len(node.args) == 1:
value = node.args[0]
else:
for kw in node.keywords:
if kw.arg == "deadline":
value = kw.value
break
else:
return
if is_constant(value):
self.error(
value,
str(match),
match.base,
)


@error_class_cst
Expand Down
33 changes: 33 additions & 0 deletions tests/eval_files/async125.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import trio
from typing import Final

# ASYNCIO_NO_ERROR
# anyio.[fail/move_on]_at doesn't exist, but no harm in erroring if we encounter them

trio.fail_at(5) # ASYNC125: 13, "trio.fail_at", "trio"
trio.fail_at(deadline=5) # ASYNC125: 22, "trio.fail_at", "trio"
trio.move_on_at(10**3) # ASYNC125: 16, "trio.move_on_at", "trio"
trio.fail_at(7 * 3 + 2 / 5 - (8**7)) # ASYNC125: 13, "trio.fail_at", "trio"

trio.CancelScope(deadline=7) # ASYNC125: 26, "trio.CancelScope", "trio"
trio.CancelScope(shield=True, deadline=7) # ASYNC125: 39, "trio.CancelScope", "trio"

# we *could* tell them to use math.inf here ...
trio.fail_at(10**1000) # ASYNC125: 13, "trio.fail_at", "trio"

# _after is fine
trio.fail_after(5)
trio.move_on_after(2.3)

trio.fail_at(trio.current_time())
trio.fail_at(trio.current_time() + 7)

# relative_deadline is fine, though anyio doesn't have it
trio.CancelScope(relative_deadline=7)

# does not trigger on other "constants".. but we could opt to trigger on
# any all-caps variable, or on :Final
MY_CONST_VALUE = 7
trio.fail_at(MY_CONST_VALUE)
my_final_value: Final = 3
trio.fail_at(my_final_value)
1 change: 1 addition & 0 deletions tests/test_flake8_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ def _parse_eval_file(
"ASYNC121",
"ASYNC122",
"ASYNC123",
"ASYNC125",
"ASYNC300",
"ASYNC912",
}
Expand Down
Loading