Skip to content

Commit b791072

Browse files
committed
Split cancellable_sleep
In response to feedback, I've added `raise_if_cancelled()` to replace `cancellable_sleep(None)`. In response to the same feedback, I now handle the error if no invocation ID is available, so we simply perform a regular time.sleep. I've also deleted a couple of defunct print statements.
1 parent a5fa721 commit b791072

3 files changed

Lines changed: 79 additions & 43 deletions

File tree

src/labthings_fastapi/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from .invocation_contexts import (
3636
get_invocation_logger,
3737
cancellable_sleep,
38+
raise_if_cancelled,
3839
ThreadWithInvocationID,
3940
)
4041

@@ -62,5 +63,6 @@
6263
"ThingClient",
6364
"get_invocation_logger",
6465
"cancellable_sleep",
66+
"raise_if_cancelled",
6567
"ThreadWithInvocationID",
6668
]

src/labthings_fastapi/invocation_contexts.py

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from contextlib import contextmanager
2020
import logging
2121
from threading import Event, Thread
22+
import time
2223
from typing import Any, Callable
2324
from typing_extensions import Self
2425
from uuid import UUID, uuid4
@@ -191,8 +192,8 @@ def get_cancel_event(id: UUID | None = None) -> CancelEvent:
191192
return CancelEvent.get_for_id(id)
192193

193194

194-
def cancellable_sleep(interval: float | None) -> None:
195-
"""Sleep for a specified time, allowing cancellation.
195+
def cancellable_sleep(interval: float) -> None:
196+
r"""Sleep for a specified time, allowing cancellation.
196197
197198
This function should be called from action functions instead of
198199
`time.sleep` to allow them to be cancelled. Usually, this
@@ -206,26 +207,38 @@ def cancellable_sleep(interval: float | None) -> None:
206207
This function uses `.Event.wait` internally, which suffers
207208
from timing errors on some platforms: it may have error of
208209
around 10-20ms. If that's a problem, consider using
209-
`time.sleep` instead. ``lt.cancellable_sleep(None)`` may then
210+
`time.sleep` instead. ``lt.raise_if_cancelled()`` may then
210211
be used to allow cancellation.
211212
212-
This function may only be called from an action thread, as it
213-
depends on the invocation ID being available from a context variable.
214-
Use `.set_invocation_id` to make it available outside of an action
215-
thread.
213+
If this function is called from outside of an action thread, it
214+
will revert to `time.sleep`\ .
215+
216+
:param interval: The length of time to wait for, in seconds.
217+
"""
218+
try:
219+
event = get_cancel_event()
220+
event.sleep(interval)
221+
except NoInvocationContextError:
222+
time.sleep(interval)
216223

217-
If ``interval`` is set to None, we do not call `.Event.wait` but
218-
instead we simply check whether the event is set.
219224

220-
:param interval: The length of time to sleep for, in seconds. If it
221-
is `None` we won't wait, but we will still check for a cancel
222-
event, and raise the exception if it is set.
225+
def raise_if_cancelled() -> None:
226+
"""Raise an exception if the current invocation has been cancelled.
227+
228+
This function checks for cancellation events and, if the current
229+
action invocation has been cancelled, it will raise an
230+
`.InvocationCancelledError` to signal the thread to terminate.
231+
It is equivalent to `.cancellable_sleep` but without waiting any
232+
time.
233+
234+
If called outside of an invocation context, this function does
235+
nothing, and will not raise an error.
223236
"""
224-
event = get_cancel_event()
225-
if interval is None:
237+
try:
238+
event = get_cancel_event()
226239
event.raise_if_set()
227-
else:
228-
event.sleep(interval)
240+
except NoInvocationContextError:
241+
pass
229242

230243

231244
def get_invocation_logger(id: UUID | None = None) -> logging.Logger:
@@ -335,11 +348,9 @@ def join_and_propagate_cancel(self, poll_interval: float = 0.2) -> None:
335348
cancellation: InvocationCancelledError | None = None
336349
self._polls = 0
337350
self._attempted_cancels = 0
338-
print(f"Checking for cancellation of invocation {get_invocation_id()}")
339-
print(f"so we can cancel {self.invocation_id}")
340351
while self.is_alive():
341352
try:
342-
cancellable_sleep(None)
353+
raise_if_cancelled()
343354
self._polls += 1
344355
except InvocationCancelledError as e:
345356
# Propagate the cancellation signal to the thread

tests/test_invocation_contexts.py

Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,10 @@ def test_cancel_event():
134134

135135
def test_cancellable_sleep():
136136
"""Check the module-level cancellable sleep."""
137-
with pytest.raises(exc.NoInvocationContextError):
138-
ic.cancellable_sleep(1)
139-
with pytest.raises(exc.NoInvocationContextError):
140-
ic.cancellable_sleep(None)
137+
# with no invocation context, the function should wait
138+
# and there should be no error.
139+
with assert_takes_time(0.02, 0.04):
140+
ic.cancellable_sleep(0.02)
141141

142142
with ic.fake_invocation_context():
143143
event = ic.get_cancel_event()
@@ -146,20 +146,30 @@ def test_cancellable_sleep():
146146
with assert_takes_time(0.02, 0.04):
147147
ic.cancellable_sleep(0.02)
148148

149-
# passing `None` should return immediately.
150-
with assert_takes_time(None, 0.002):
151-
ic.cancellable_sleep(None)
152-
153149
# check an exception gets raised and reset if appropriate
154150
event.set()
155151
with pytest.raises(exc.InvocationCancelledError):
156152
ic.cancellable_sleep(1)
157153
assert not event.is_set()
158154

155+
156+
def test_raise_if_cancelled():
157+
"""Check the module-level cancellable sleep."""
158+
# the function should return immediately.
159+
with assert_takes_time(None, 0.002):
160+
ic.raise_if_cancelled()
161+
162+
with ic.fake_invocation_context():
163+
event = ic.get_cancel_event()
164+
165+
# the function should return immediately.
166+
with assert_takes_time(None, 0.002):
167+
ic.raise_if_cancelled()
168+
159169
# check an exception gets raised and reset if appropriate
160170
event.set()
161171
with pytest.raises(exc.InvocationCancelledError):
162-
ic.cancellable_sleep(None)
172+
ic.raise_if_cancelled()
163173
assert not event.is_set()
164174

165175

@@ -179,19 +189,6 @@ def test_invocation_logger():
179189
assert logger.name.endswith(str(id))
180190

181191

182-
def run_function_in_thread_and_propagate_cancellation(func, *args):
183-
"""Run a function in a ThreadWithInvocationID."""
184-
t = ic.ThreadWithInvocationID(target=func, args=args)
185-
t.start()
186-
try:
187-
t.join_and_propagate_cancel(0.005)
188-
except exc.InvocationCancelledError:
189-
# We still want to return the finished thread if it's
190-
# cancelled.
191-
pass
192-
return t
193-
194-
195192
def test_thread_with_invocation_id():
196193
"""Test our custom thread subclass makes a new ID and can be cancelled."""
197194
ids = []
@@ -204,6 +201,9 @@ def test_thread_with_invocation_id():
204201
assert t.exception is None
205202
assert t.result is None
206203

204+
205+
def test_thread_with_invocation_id_cancel():
206+
"""Test the custom thread subclass responds to cancellation."""
207207
# Check cancellable sleep works in the thread
208208
t = ic.ThreadWithInvocationID(target=ic.cancellable_sleep, args=[1])
209209
assert isinstance(t.invocation_id, uuid.UUID)
@@ -213,20 +213,43 @@ def test_thread_with_invocation_id():
213213
t.join()
214214
assert isinstance(t.exception, exc.InvocationCancelledError)
215215

216-
# Check we capture the return value
216+
217+
def test_thread_with_invocation_id_return_value():
218+
"""Check we capture the return value when running in a ThreadWithInvocationID."""
217219
t = ic.ThreadWithInvocationID(target=lambda: True)
218220
t.start()
219221
t.join()
220222
assert t.exception is None
221223
assert t.result is True
222224

225+
226+
def run_function_in_thread_and_propagate_cancellation(func, *args):
227+
"""Run a function in a ThreadWithInvocationID."""
228+
t = ic.ThreadWithInvocationID(target=func, args=args)
229+
t.start()
230+
try:
231+
t.join_and_propagate_cancel(1)
232+
except exc.InvocationCancelledError:
233+
# We still want to return the finished thread if it's
234+
# cancelled.
235+
pass
236+
return t
237+
238+
239+
def test_thread_with_invocation_id_cancellation_propagates():
240+
"""Check that a cancel event can propagate to our thread.
241+
242+
``join_and_propagate_cancellation`` should cancel the spawned thread if
243+
the parent thread is cancelled while it's waiting for the spawned thread
244+
to join.
245+
"""
223246
# Check we can propagate cancellation.
224247
# First, we run `cancellable_sleep` and check it doesn't cancel
225248
with ic.fake_invocation_context():
226249
# First test our function - there is only one thread here, and we
227250
# check it finishes and doesn't error.
228251
t = run_function_in_thread_and_propagate_cancellation(
229-
ic.cancellable_sleep, 0.001
252+
ic.cancellable_sleep, 0.02
230253
)
231254
assert isinstance(t, ic.ThreadWithInvocationID)
232255
assert not t.is_alive()

0 commit comments

Comments
 (0)