Source code for sksundae.utils._timeout

from __future__ import annotations

import _thread
import threading


class TimeoutError(BaseException):
    pass


[docs] class Timeout: """Timeout context manager.""" __slots__ = ( '_seconds', '_name', '_raise_exc', '_timer', '_expired', '_exception', ) def __init__( self, seconds: float, name: str = 'Timeout', raise_exc: bool = True, ) -> None: """ Uses a thread to track the time spent in a context block and forces an exit (and optionally raises a `TimeoutError`) if the execution time exceeds a given number of seconds. See the notes for important details on edge cases where this may not work as expected. Parameters ---------- seconds : float Number of seconds before timing out. name : str, optional Name to identify the block in exit messages, by default 'Timeout'. raise_exc : bool, optional If True, raise a `TimeoutError` on exit when the given time limit is exceeded. Use False to handle the timeout manually. Notes ----- The `KeyboardInterrupt` that is used to force the exit of the main thread may not be immediate, depending on the state of the main thread. For example, if you are running `time.sleep`, the interruption will not be raised until the sleep is complete. If you are using extensions that are not written in pure Python, the interruption may not be raised at all, depending on how that extension was written to catch and handle exceptions. Examples -------- The `Timeout` class is used as a context manager, as shown below. Here, we force a timeout by setting the time limit to 1/1000 of the execution time to complete the `slow_fibonacci` function. .. code-block:: python def slow_fibonacci(n): if n < 2: return n return slow_fibonacci(n-1) + slow_fibonacci(n-2) with Timer() as timer: result = slow_fibonacci(30) exec_time = timer.elapsed_time time_limit = exec_time / 1000 with Timeout(time_limit) as timeout: result = slow_fibonacci(30) By default, the context manager will raise an exception if the context block exceeds the set time limit. If you want to suppress this behavior, and handle it manually you can do so using `raise_exc=False`. .. code-block:: python with Timeout(time_limit, raise_exc=False) as timeout: result = slow_fibonacci(30) if timeout.expired: raise timeout.exception Since we use a `KeyboardInterrupt` to stop the main thread, you may also want to differentiate between user-raised interrupts and timeouts. This also requires setting `raise_exc=False` to prevent an exception on exit. .. code-block:: python with Timeout(time_limit, raise_exc=False) as timeout: try: result = slow_fibonacci(30) except KeyboardInterrupt: if timeout.expired: print('Timeout expired.') else: print('User interrupt.') # or raise to stop execution While demonstrated with a standin function for the Fibonacci sequence, this context manager can be used with any block and can help catch and cancel long-running operations. Note that the `Timer` and `Timeout` utilities can also be used in the same context block, as demonstrated below. .. code-block:: python with Timer() as timer, Timeout(1000*exec_time) as timeout: result = slow_fibonacci(30) print(f"{timeout.expired=}") # should be False since limit is high timer.print_elapsed() """ self._name = name self._seconds = seconds self._raise_exc = raise_exc self._timer = None self._expired = False self._exception = TimeoutError( f"{self._name} exceeded {self._seconds:.2f} seconds." ) def __enter__(self) -> Timeout: """Create and start the timer when entering the context block.""" self._timer = threading.Timer(self._seconds, self._interrupt) self._timer.start() self._expired = False return self def __exit__(self, exc_type, exc_val, exc_tb) -> bool: """If the timer is still running, cancel, reset, and handle errors.""" if self._timer: self._timer.cancel() self._timer = None if self.expired and self._raise_exc: raise self.exception from None elif self.expired and not self._raise_exc: return True # suppress if expired but not raising return False # don't suppress exceptions @property def expired(self) -> bool: """Whether the timer has expired.""" return self._expired @property def exception(self) -> TimeoutError: """The exception to raise if the timer expired.""" return self._exception def _interrupt(self) -> None: """ Define the interruption to use when the timer runs out. Here the main thread is interrupted with KeyboardInterrupt and a flag is set to True to provide a way to differentiate from user-raised interrupts. """ self._expired = True _thread.interrupt_main() # raises KeyboardInterrupt