Skip to content

Commit f03663a

Browse files
authored
Merge pull request #3010 from jakkdl/fail_after_documentation
Change `fail_after`&`move_on_after` to set deadline relative to entering. Add CancelScope.relative_deadline
2 parents b834f73 + 9961abc commit f03663a

File tree

9 files changed

+245
-29
lines changed

9 files changed

+245
-29
lines changed

docs/source/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ def setup(app: Sphinx) -> None:
229229
"pyopenssl": ("https://www.pyopenssl.org/en/stable/", None),
230230
"sniffio": ("https://sniffio.readthedocs.io/en/latest/", None),
231231
"trio-util": ("https://trio-util.readthedocs.io/en/latest/", None),
232+
"flake8-async": ("https://flake8-async.readthedocs.io/en/latest/", None),
232233
}
233234

234235
# See https://sphinx-hoverxref.readthedocs.io/en/latest/configuration.html

docs/source/reference-core.rst

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -527,8 +527,12 @@ objects.
527527

528528
.. autoattribute:: deadline
529529

530+
.. autoattribute:: relative_deadline
531+
530532
.. autoattribute:: shield
531533

534+
.. automethod:: is_relative()
535+
532536
.. automethod:: cancel()
533537

534538
.. attribute:: cancelled_caught
@@ -561,7 +565,8 @@ situation of just wanting to impose a timeout on some code:
561565
.. autofunction:: fail_at
562566
:with: cancel_scope
563567

564-
Cheat sheet:
568+
Cheat sheet
569+
+++++++++++
565570

566571
* If you want to impose a timeout on a function, but you don't care
567572
whether it timed out or not:
@@ -597,7 +602,6 @@ which is sometimes useful:
597602

598603
.. autofunction:: current_effective_deadline
599604

600-
601605
.. _tasks:
602606

603607
Tasks let you do multiple things at once

newsfragments/2512.breaking.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
:func:`trio.move_on_after` and :func:`trio.fail_after` previously set the deadline relative to initialization time, instead of more intuitively upon entering the context manager. This might change timeouts if a program relied on this behavior. If you want to restore previous behavior you should instead use ``trio.move_on_at(trio.current_time() + ...)``.
2+
flake8-async has a new rule to catch this, in case you're supporting older trio versions. See :ref:`ASYNC122`.

newsfragments/2512.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:meth:`CancelScope.relative_deadline` and :meth:`CancelScope.is_relative` added, as well as a ``relative_deadline`` parameter to ``__init__``. This allows initializing scopes ahead of time, but where the specified relative deadline doesn't count down until the scope is entered.

src/trio/_core/_run.py

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from contextlib import AbstractAsyncContextManager, contextmanager, suppress
1414
from contextvars import copy_context
1515
from heapq import heapify, heappop, heappush
16-
from math import inf
16+
from math import inf, isnan
1717
from time import perf_counter
1818
from typing import (
1919
TYPE_CHECKING,
@@ -543,8 +543,21 @@ class CancelScope:
543543
cancelled_caught: bool = attrs.field(default=False, init=False)
544544

545545
# Constructor arguments:
546-
_deadline: float = attrs.field(default=inf, kw_only=True, alias="deadline")
547-
_shield: bool = attrs.field(default=False, kw_only=True, alias="shield")
546+
_relative_deadline: float = attrs.field(default=inf, kw_only=True)
547+
_deadline: float = attrs.field(default=inf, kw_only=True)
548+
_shield: bool = attrs.field(default=False, kw_only=True)
549+
550+
def __attrs_post_init__(self) -> None:
551+
if isnan(self._deadline):
552+
raise ValueError("deadline must not be NaN")
553+
if isnan(self._relative_deadline):
554+
raise ValueError("relative deadline must not be NaN")
555+
if self._relative_deadline < 0:
556+
raise ValueError("timeout must be non-negative")
557+
if self._relative_deadline != inf and self._deadline != inf:
558+
raise ValueError(
559+
"Cannot specify both a deadline and a relative deadline",
560+
)
548561

549562
@enable_ki_protection
550563
def __enter__(self) -> Self:
@@ -554,6 +567,12 @@ def __enter__(self) -> Self:
554567
"Each CancelScope may only be used for a single 'with' block",
555568
)
556569
self._has_been_entered = True
570+
571+
if self._relative_deadline != inf:
572+
assert self._deadline == inf
573+
self._deadline = current_time() + self._relative_deadline
574+
self._relative_deadline = inf
575+
557576
if current_time() >= self._deadline:
558577
self.cancel()
559578
with self._might_change_registered_deadline():
@@ -734,13 +753,70 @@ def deadline(self) -> float:
734753
this can be overridden by the ``deadline=`` argument to
735754
the :class:`~trio.CancelScope` constructor.
736755
"""
756+
if self._relative_deadline != inf:
757+
assert self._deadline == inf
758+
warnings.warn(
759+
DeprecationWarning(
760+
"unentered relative cancel scope does not have an absolute deadline. Use `.relative_deadline`",
761+
),
762+
stacklevel=2,
763+
)
764+
return current_time() + self._relative_deadline
737765
return self._deadline
738766

739767
@deadline.setter
740768
def deadline(self, new_deadline: float) -> None:
769+
if isnan(new_deadline):
770+
raise ValueError("deadline must not be NaN")
771+
if self._relative_deadline != inf:
772+
assert self._deadline == inf
773+
warnings.warn(
774+
DeprecationWarning(
775+
"unentered relative cancel scope does not have an absolute deadline. Transforming into an absolute cancel scope. First set `.relative_deadline = math.inf` if you do want an absolute cancel scope.",
776+
),
777+
stacklevel=2,
778+
)
779+
self._relative_deadline = inf
741780
with self._might_change_registered_deadline():
742781
self._deadline = float(new_deadline)
743782

783+
@property
784+
def relative_deadline(self) -> float:
785+
if self._has_been_entered:
786+
return self._deadline - current_time()
787+
elif self._deadline != inf:
788+
assert self._relative_deadline == inf
789+
raise RuntimeError(
790+
"unentered non-relative cancel scope does not have a relative deadline",
791+
)
792+
return self._relative_deadline
793+
794+
@relative_deadline.setter
795+
def relative_deadline(self, new_relative_deadline: float) -> None:
796+
if isnan(new_relative_deadline):
797+
raise ValueError("relative deadline must not be NaN")
798+
if new_relative_deadline < 0:
799+
raise ValueError("relative deadline must be non-negative")
800+
if self._has_been_entered:
801+
with self._might_change_registered_deadline():
802+
self._deadline = current_time() + float(new_relative_deadline)
803+
elif self._deadline != inf:
804+
assert self._relative_deadline == inf
805+
raise RuntimeError(
806+
"unentered non-relative cancel scope does not have a relative deadline",
807+
)
808+
else:
809+
self._relative_deadline = new_relative_deadline
810+
811+
@property
812+
def is_relative(self) -> bool | None:
813+
"""Returns None after entering. Returns False if both deadline and
814+
relative_deadline are inf."""
815+
assert not (self._deadline != inf and self._relative_deadline != inf)
816+
if self._has_been_entered:
817+
return None
818+
return self._relative_deadline != inf
819+
744820
@property
745821
def shield(self) -> bool:
746822
"""Read-write, :class:`bool`, default :data:`False`. So long as

src/trio/_core/_tests/test_run.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import types
1010
import weakref
1111
from contextlib import ExitStack, contextmanager, suppress
12-
from math import inf
12+
from math import inf, nan
1313
from typing import TYPE_CHECKING, Any, NoReturn, TypeVar, cast
1414

1515
import outcome
@@ -365,6 +365,27 @@ async def test_cancel_scope_repr(mock_clock: _core.MockClock) -> None:
365365
assert "exited" in repr(scope)
366366

367367

368+
async def test_cancel_scope_validation() -> None:
369+
with pytest.raises(
370+
ValueError,
371+
match="^Cannot specify both a deadline and a relative deadline$",
372+
):
373+
_core.CancelScope(deadline=7, relative_deadline=3)
374+
scope = _core.CancelScope()
375+
376+
with pytest.raises(ValueError, match="^deadline must not be NaN$"):
377+
scope.deadline = nan
378+
with pytest.raises(ValueError, match="^relative deadline must not be NaN$"):
379+
scope.relative_deadline = nan
380+
381+
with pytest.raises(ValueError, match="^relative deadline must be non-negative$"):
382+
scope.relative_deadline = -3
383+
scope.relative_deadline = 5
384+
assert scope.relative_deadline == 5
385+
386+
# several related tests of CancelScope are implicitly handled by test_timeouts.py
387+
388+
368389
def test_cancel_points() -> None:
369390
async def main1() -> None:
370391
with _core.CancelScope() as scope:

src/trio/_tests/test_timeouts.py

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,20 @@
44
import outcome
55
import pytest
66

7+
import trio
8+
79
from .. import _core
810
from .._core._tests.tutil import slow
9-
from .._timeouts import *
11+
from .._timeouts import (
12+
TooSlowError,
13+
fail_after,
14+
fail_at,
15+
move_on_after,
16+
move_on_at,
17+
sleep,
18+
sleep_forever,
19+
sleep_until,
20+
)
1021
from ..testing import assert_checkpoints
1122

1223
T = TypeVar("T")
@@ -155,7 +166,7 @@ async def test_timeouts_raise_value_error() -> None:
155166
):
156167
with pytest.raises(
157168
ValueError,
158-
match="^(duration|deadline|timeout) must (not )*be (non-negative|NaN)$",
169+
match="^(deadline|`seconds`) must (not )*be (non-negative|NaN)$",
159170
):
160171
await fun(val)
161172

@@ -169,7 +180,79 @@ async def test_timeouts_raise_value_error() -> None:
169180
):
170181
with pytest.raises(
171182
ValueError,
172-
match="^(duration|deadline|timeout) must (not )*be (non-negative|NaN)$",
183+
match="^(deadline|`seconds`) must (not )*be (non-negative|NaN)$",
173184
):
174185
with cm(val):
175186
pass # pragma: no cover
187+
188+
189+
async def test_timeout_deadline_on_entry(mock_clock: _core.MockClock) -> None:
190+
rcs = move_on_after(5)
191+
assert rcs.relative_deadline == 5
192+
193+
mock_clock.jump(3)
194+
start = _core.current_time()
195+
with rcs as cs:
196+
assert cs.is_relative is None
197+
198+
# This would previously be start+2
199+
assert cs.deadline == start + 5
200+
assert cs.relative_deadline == 5
201+
202+
cs.deadline = start + 3
203+
assert cs.deadline == start + 3
204+
assert cs.relative_deadline == 3
205+
206+
cs.relative_deadline = 4
207+
assert cs.deadline == start + 4
208+
assert cs.relative_deadline == 4
209+
210+
rcs = move_on_after(5)
211+
assert rcs.shield is False
212+
rcs.shield = True
213+
assert rcs.shield is True
214+
215+
mock_clock.jump(3)
216+
start = _core.current_time()
217+
with rcs as cs:
218+
assert cs.deadline == start + 5
219+
220+
assert rcs is cs
221+
222+
223+
async def test_invalid_access_unentered(mock_clock: _core.MockClock) -> None:
224+
cs = move_on_after(5)
225+
mock_clock.jump(3)
226+
start = _core.current_time()
227+
228+
match_str = "^unentered relative cancel scope does not have an absolute deadline"
229+
with pytest.warns(DeprecationWarning, match=match_str):
230+
assert cs.deadline == start + 5
231+
mock_clock.jump(1)
232+
# this is hella sketchy, but they *have* been warned
233+
with pytest.warns(DeprecationWarning, match=match_str):
234+
assert cs.deadline == start + 6
235+
236+
with pytest.warns(DeprecationWarning, match=match_str):
237+
cs.deadline = 7
238+
# now transformed into absolute
239+
assert cs.deadline == 7
240+
assert not cs.is_relative
241+
242+
cs = move_on_at(5)
243+
244+
match_str = (
245+
"^unentered non-relative cancel scope does not have a relative deadline$"
246+
)
247+
with pytest.raises(RuntimeError, match=match_str):
248+
assert cs.relative_deadline
249+
with pytest.raises(RuntimeError, match=match_str):
250+
cs.relative_deadline = 7
251+
252+
253+
@pytest.mark.xfail(reason="not implemented")
254+
async def test_fail_access_before_entering() -> None: # pragma: no cover
255+
my_fail_at = fail_at(5)
256+
assert my_fail_at.deadline # type: ignore[attr-defined]
257+
my_fail_after = fail_after(5)
258+
assert my_fail_after.relative_deadline # type: ignore[attr-defined]

0 commit comments

Comments
 (0)