Coverage for pyTooling/Timeout/__init__.py: 100%
94 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
1# ==================================================================================================================== #
2# _____ _ _ _____ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _|_ _(_)_ __ ___ ___ ___ _ _| |_ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | | | | '_ ` _ \ / _ \/ _ \| | | | __| #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| | | | | | | | | __/ (_) | |_| | |_ #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_| |_|_| |_| |_|\___|\___/ \__,_|\__| #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32A cooperative timeout for loops and blocking calls, valid for a ``with`` block.
34.. hint::
36 See :ref:`high-level help <COMMON/Timeout>` for explanations and usage examples.
38.. seealso::
40 :mod:`pyTooling.Stopwatch`
41 |rarr| Measuring how long something took, instead of limiting it.
42 :mod:`pyTooling.Warning`
43 |rarr| The warning collector, which is made current for a ``with`` block the same way.
44"""
45from __future__ import annotations
47from threading import local
48from time import perf_counter_ns
49from types import TracebackType
50from typing import Optional as Nullable, Self
52from pyTooling.Common import getFullyQualifiedName
53from pyTooling.Decorators import export, readonly
54from pyTooling.Exceptions import ToolingException
55from pyTooling.MetaClasses import ExtendedType
58_threadLocalData = local()
59"""A reference to the thread local data holding the current :class:`Timeout` of a thread."""
62@export
63class TimeoutExpiredError(ToolingException, TimeoutError):
64 """
65 The exception is raised by a loop or an operation, which aborts itself because its :class:`Timeout` expired.
67 It is a :exc:`TimeoutError` as well (and so an :exc:`OSError`), because the operation timed out; catching
68 :exc:`~pyTooling.Exceptions.ToolingException` still catches it.
69 """
71 _seconds: Nullable[float] #: The configured timeout in seconds.
72 _overrun: Nullable[float] #: Seconds by which the deadline was overrun when the error was raised.
74 def __init__(self, message: str, /, *, seconds: Nullable[float] = None, overrun: Nullable[float] = None) -> None:
75 """
76 Initializes the exception with the timeout, which expired, and by how much it was overrun.
78 :param message: The exception's message.
79 :param seconds: Optional, the configured timeout in seconds.
80 :param overrun: Optional, the seconds by which the deadline was overrun when the error was raised.
81 """
82 super().__init__(message)
83 self._seconds = seconds
84 self._overrun = overrun
86 @readonly
87 def Seconds(self) -> Nullable[float]:
88 """
89 Read-only property to access the configured timeout in seconds (:attr:`_seconds`).
91 :returns: The timeout in seconds, or ``None`` if it wasn't recorded.
92 """
93 return self._seconds
95 @readonly
96 def Overrun(self) -> Nullable[float]:
97 """
98 Read-only property to access the seconds by which the deadline was overrun when the error was raised
99 (:attr:`_overrun`).
101 A loop checking its timeout once per iteration notices the expiry only at its next check, so the overrun tells how
102 long the last iteration ran past the deadline.
104 :returns: The overrun in seconds, or ``None`` if it wasn't recorded.
105 """
106 return self._overrun
109@export
110class TimeoutUsageError(ToolingException):
111 """
112 The exception is raised when a :class:`Timeout` is used in a way it doesn't support.
114 A timeout is current for one ``with`` block at a time: entering it again while it's entered would overwrite its
115 parent and its deadline.
116 """
119@export
120class Timeout(metaclass=ExtendedType, slots=True):
121 """
122 A timeout, which is current for the thread while its ``with`` block runs.
124 Entering the context makes the timeout the current one of the calling thread; the previous current timeout becomes
125 its parent, and leaving the context restores it - as a :class:`~pyTooling.Warning.WarningCollector` is made current.
126 Code inside the block, how deep down the call hierarchy it may be, asks the classmethods :meth:`Remaining` and
127 :meth:`IsExpired` without the timeout being passed along; :meth:`Current` returns the timeout itself. A timeout is
128 entered once at a time: entering it again inside its own block raises :exc:`TimeoutUsageError`.
130 A nested timeout can only shorten the time left, never extend it: its deadline is the earlier of its own and the
131 parent's.
133 .. code-block:: Python
135 with Timeout(20.0):
136 for package in packages:
137 Timeout.RaiseIfExpired("Scanning the dependencies took longer than 20 seconds.")
139 response = session.get(package.URL, timeout=Timeout.Remaining())
141 .. important::
143 The timeout is **cooperative**. Python can't stop a running thread from outside, so nothing interrupts the code in
144 the ``with`` block when the deadline passes. A timeout takes effect where the code checks it - a loop aborting
145 itself when :meth:`IsExpired` turns ``True`` - and where the time left is handed to a blocking call as its own
146 timeout: ``requests`` and ``aiohttp`` requests, :meth:`threading.Thread.join`,
147 :meth:`concurrent.futures.Future.result`, :func:`asyncio.timeout` or :func:`asyncio.wait_for`.
149 .. rubric:: Threads and tasks
151 The current timeout is stored per thread. :mod:`asyncio` tasks run in the thread of their event loop, so they see
152 the timeout of that thread. A new thread starts without a current timeout; hand the time left over to it - either
153 as the timeout of waiting for it (``thread.join(timeout=Timeout.Remaining())``), or by entering a timeout in the
154 thread itself (``with Timeout(remaining):``, with ``remaining`` read before starting the thread).
155 """
157 _seconds: float #: The timeout in seconds.
158 _deadline: Nullable[int] #: Effective deadline (:func:`time.perf_counter_ns`) while the context is entered.
159 _parent: Nullable[Timeout] #: The timeout, which was current when this one was entered.
161 def __init__(self, seconds: float) -> None:
162 """
163 Initializes a timeout of the given number of seconds.
165 The time starts running when the context is entered.
167 :param seconds: The timeout in seconds.
168 :raises ValueError: If parameter 'seconds' is ``None``.
169 :raises TypeError: If parameter 'seconds' is not of type :class:`float` or :class:`int`.
170 :raises ValueError: If parameter 'seconds' is not greater than zero.
171 """
172 if seconds is None:
173 raise ValueError("Parameter 'seconds' is None.")
174 elif isinstance(seconds, bool) or not isinstance(seconds, (float, int)):
175 ex = TypeError("Parameter 'seconds' is not of type 'float' or 'int'.")
176 ex.add_note(f"Got type '{getFullyQualifiedName(seconds)}'.")
177 raise ex
178 elif not seconds > 0:
179 ex = ValueError("Parameter 'seconds' is not greater than zero.")
180 ex.add_note(f"Got value '{seconds}'.")
181 raise ex
183 self._seconds = float(seconds)
184 self._deadline = None
185 self._parent = None
187 @readonly
188 def Seconds(self) -> float:
189 """
190 Read-only property to access the timeout in seconds (:attr:`_seconds`).
192 :returns: The timeout in seconds.
193 """
194 return self._seconds
196 @readonly
197 def Deadline(self) -> Nullable[int]:
198 """
199 Read-only property to access the effective deadline (:attr:`_deadline`).
201 The deadline is a :func:`time.perf_counter_ns` value: the earlier of this timeout's own deadline and its parent's.
203 :returns: The deadline in nanoseconds while the context is entered, otherwise ``None``.
204 """
205 return self._deadline
207 @readonly
208 def Parent(self) -> Nullable[Timeout]:
209 """
210 Read-only property to access the timeout, which was current when this one was entered (:attr:`_parent`).
212 :returns: The parent timeout, or ``None``.
213 """
214 return self._parent
216 @classmethod
217 def Current(cls) -> Nullable[Timeout]:
218 """
219 Return the current timeout of the calling thread, like :func:`threading.current_thread` returns the thread.
221 :returns: The current timeout, or ``None`` if no timeout is current.
222 """
223 return getattr(_threadLocalData, "timeout", None)
225 @classmethod
226 def Remaining(cls) -> Nullable[float]:
227 """
228 Return the seconds left of the current timeout of the calling thread.
230 :returns: The seconds left, ``0.0`` when the timeout expired; ``None`` if no timeout is current.
231 """
232 if (timeout := getattr(_threadLocalData, "timeout", None)) is None:
233 return None
235 return max(0.0, (timeout._deadline - perf_counter_ns()) / 1e9)
237 @classmethod
238 def RaiseIfExpired(cls, message: str) -> None:
239 """
240 Raise a :exc:`TimeoutExpiredError` if the current timeout of the calling thread has expired.
242 This is the check a loop calls once per iteration instead of asking :meth:`IsExpired` and raising itself. The
243 error carries the configured timeout (:attr:`~TimeoutExpiredError.Seconds`) and by how much its deadline was
244 overrun (:attr:`~TimeoutExpiredError.Overrun`). Without a current timeout, nothing happens.
246 :param message: The message of the error, if the timeout expired.
247 :raises ValueError: If parameter 'message' is ``None``.
248 :raises TypeError: If parameter 'message' is not of type :class:`str`.
249 :raises TimeoutExpiredError: If the current timeout has expired.
250 """
251 if message is None:
252 raise ValueError("Parameter 'message' is None.")
253 elif not isinstance(message, str):
254 ex = TypeError("Parameter 'message' is not of type 'str'.")
255 ex.add_note(f"Got type '{getFullyQualifiedName(message)}'.")
256 raise ex
258 if (timeout := getattr(_threadLocalData, "timeout", None)) is None:
259 return
261 if (overrun := perf_counter_ns() - timeout._deadline) >= 0:
262 raise TimeoutExpiredError(message, seconds=timeout._seconds, overrun=overrun / 1e9)
264 @classmethod
265 def IsExpired(cls) -> bool:
266 """
267 Return if the current timeout of the calling thread has expired.
269 :returns: ``True``, if the current timeout expired; ``False`` if it didn't, or no timeout is current.
270 """
271 if (timeout := getattr(_threadLocalData, "timeout", None)) is None:
272 return False
274 return perf_counter_ns() >= timeout._deadline
276 def __enter__(self) -> Self:
277 """
278 Enter the context, start the timeout and make it the current one of the calling thread.
280 The previous current timeout becomes its parent. The deadline is the earlier of this timeout's own and the
281 parent's.
283 :returns: The timeout instance.
284 :raises TimeoutUsageError: If the timeout is entered already.
285 """
286 if self._deadline is not None:
287 raise TimeoutUsageError(f"Timeout of {self._seconds} seconds is entered already.")
289 self._parent = getattr(_threadLocalData, "timeout", None)
291 deadline = perf_counter_ns() + round(self._seconds * 1e9)
292 if self._parent is not None and self._parent._deadline < deadline:
293 deadline = self._parent._deadline
295 self._deadline = deadline
296 _threadLocalData.timeout = self
298 return self
300 def __exit__(
301 self,
302 exc_type: Nullable[type[BaseException]] = None,
303 exc_val: Nullable[BaseException] = None,
304 exc_tb: Nullable[TracebackType] = None
305 ) -> Nullable[bool]:
306 """
307 Exit the context and make the parent timeout the current one again.
309 :param exc_type: Optional, exception type.
310 :param exc_val: Optional, exception instance.
311 :param exc_tb: Optional, exception's traceback.
312 :returns: ``False``, so an exception raised in the context propagates.
313 """
314 _threadLocalData.timeout = self._parent
315 self._deadline = None
317 return False