diff --git a/docs/changelog.rst b/docs/changelog.rst
index 0cf1f93..1d29970 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -4,6 +4,10 @@ Changelog
`CalVer, YY.month.patch `_
+25.4.2
+======
+- Add :ref:`ASYNC125 ` constant-absolute-deadline
+
25.4.1
======
- Add match-case (structural pattern matching) support to ASYNC103, 104, 910, 911 & 912.
diff --git a/docs/rules.rst b/docs/rules.rst
index 974d99c..d662fcf 100644
--- a/docs/rules.rst
+++ b/docs/rules.rst
@@ -102,6 +102,14 @@ _`ASYNC124`: async-function-could-be-sync
This currently overlaps with :ref:`ASYNC910 ` and :ref:`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 ` and/or :ref:`ASYNC911 ` and check the methods they trigger on.
+_`ASYNC125`: constant-absolute-deadline
+ Passing constant values (other than :data:`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
======================================
diff --git a/docs/usage.rst b/docs/usage.rst
index 4ba6d70..429408e 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -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"]
diff --git a/flake8_async/__init__.py b/flake8_async/__init__.py
index a1f5546..a477ce3 100644
--- a/flake8_async/__init__.py
+++ b/flake8_async/__init__.py
@@ -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
diff --git a/flake8_async/visitors/helpers.py b/flake8_async/visitors/helpers.py
index 4646fc4..2203e2c 100644
--- a/flake8_async/visitors/helpers.py
+++ b/flake8_async/visitors/helpers.py
@@ -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
@@ -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 (
@@ -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
diff --git a/flake8_async/visitors/visitor102_120.py b/flake8_async/visitors/visitor102_120.py
index 759161b..97c665e 100644
--- a/flake8_async/visitors/visitor102_120.py
+++ b/flake8_async/visitors/visitor102_120.py
@@ -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
@@ -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
diff --git a/flake8_async/visitors/visitors.py b/flake8_async/visitors/visitors.py
index 92ed2b1..ace5b07 100644
--- a/flake8_async/visitors/visitors.py
+++ b/flake8_async/visitors/visitors.py
@@ -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
@@ -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
@@ -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
diff --git a/tests/eval_files/async125.py b/tests/eval_files/async125.py
new file mode 100644
index 0000000..eed3344
--- /dev/null
+++ b/tests/eval_files/async125.py
@@ -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)
diff --git a/tests/test_flake8_async.py b/tests/test_flake8_async.py
index cf0c995..adb0d32 100644
--- a/tests/test_flake8_async.py
+++ b/tests/test_flake8_async.py
@@ -510,6 +510,7 @@ def _parse_eval_file(
"ASYNC121",
"ASYNC122",
"ASYNC123",
+ "ASYNC125",
"ASYNC300",
"ASYNC912",
}