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

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. 

33 

34.. hint:: 

35 

36 See :ref:`high-level help <COMMON/Timeout>` for explanations and usage examples. 

37 

38.. seealso:: 

39 

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 

46 

47from threading import local 

48from time import perf_counter_ns 

49from types import TracebackType 

50from typing import Optional as Nullable, Self 

51 

52from pyTooling.Common import getFullyQualifiedName 

53from pyTooling.Decorators import export, readonly 

54from pyTooling.Exceptions import ToolingException 

55from pyTooling.MetaClasses import ExtendedType 

56 

57 

58_threadLocalData = local() 

59"""A reference to the thread local data holding the current :class:`Timeout` of a thread.""" 

60 

61 

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. 

66 

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 """ 

70 

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. 

73 

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. 

77 

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 

85 

86 @readonly 

87 def Seconds(self) -> Nullable[float]: 

88 """ 

89 Read-only property to access the configured timeout in seconds (:attr:`_seconds`). 

90 

91 :returns: The timeout in seconds, or ``None`` if it wasn't recorded. 

92 """ 

93 return self._seconds 

94 

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`). 

100 

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. 

103 

104 :returns: The overrun in seconds, or ``None`` if it wasn't recorded. 

105 """ 

106 return self._overrun 

107 

108 

109@export 

110class TimeoutUsageError(ToolingException): 

111 """ 

112 The exception is raised when a :class:`Timeout` is used in a way it doesn't support. 

113 

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 """ 

117 

118 

119@export 

120class Timeout(metaclass=ExtendedType, slots=True): 

121 """ 

122 A timeout, which is current for the thread while its ``with`` block runs. 

123 

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`. 

129 

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. 

132 

133 .. code-block:: Python 

134 

135 with Timeout(20.0): 

136 for package in packages: 

137 Timeout.RaiseIfExpired("Scanning the dependencies took longer than 20 seconds.") 

138 

139 response = session.get(package.URL, timeout=Timeout.Remaining()) 

140 

141 .. important:: 

142 

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`. 

148 

149 .. rubric:: Threads and tasks 

150 

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 """ 

156 

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. 

160 

161 def __init__(self, seconds: float) -> None: 

162 """ 

163 Initializes a timeout of the given number of seconds. 

164 

165 The time starts running when the context is entered. 

166 

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 

182 

183 self._seconds = float(seconds) 

184 self._deadline = None 

185 self._parent = None 

186 

187 @readonly 

188 def Seconds(self) -> float: 

189 """ 

190 Read-only property to access the timeout in seconds (:attr:`_seconds`). 

191 

192 :returns: The timeout in seconds. 

193 """ 

194 return self._seconds 

195 

196 @readonly 

197 def Deadline(self) -> Nullable[int]: 

198 """ 

199 Read-only property to access the effective deadline (:attr:`_deadline`). 

200 

201 The deadline is a :func:`time.perf_counter_ns` value: the earlier of this timeout's own deadline and its parent's. 

202 

203 :returns: The deadline in nanoseconds while the context is entered, otherwise ``None``. 

204 """ 

205 return self._deadline 

206 

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`). 

211 

212 :returns: The parent timeout, or ``None``. 

213 """ 

214 return self._parent 

215 

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. 

220 

221 :returns: The current timeout, or ``None`` if no timeout is current. 

222 """ 

223 return getattr(_threadLocalData, "timeout", None) 

224 

225 @classmethod 

226 def Remaining(cls) -> Nullable[float]: 

227 """ 

228 Return the seconds left of the current timeout of the calling thread. 

229 

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 

234 

235 return max(0.0, (timeout._deadline - perf_counter_ns()) / 1e9) 

236 

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. 

241 

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. 

245 

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 

257 

258 if (timeout := getattr(_threadLocalData, "timeout", None)) is None: 

259 return 

260 

261 if (overrun := perf_counter_ns() - timeout._deadline) >= 0: 

262 raise TimeoutExpiredError(message, seconds=timeout._seconds, overrun=overrun / 1e9) 

263 

264 @classmethod 

265 def IsExpired(cls) -> bool: 

266 """ 

267 Return if the current timeout of the calling thread has expired. 

268 

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 

273 

274 return perf_counter_ns() >= timeout._deadline 

275 

276 def __enter__(self) -> Self: 

277 """ 

278 Enter the context, start the timeout and make it the current one of the calling thread. 

279 

280 The previous current timeout becomes its parent. The deadline is the earlier of this timeout's own and the 

281 parent's. 

282 

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.") 

288 

289 self._parent = getattr(_threadLocalData, "timeout", None) 

290 

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 

294 

295 self._deadline = deadline 

296 _threadLocalData.timeout = self 

297 

298 return self 

299 

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. 

308 

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 

316 

317 return False