Coverage for pyTooling/Warning/__init__.py: 55%

187 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-20 03:46 +0000

1# ==================================================================================================================== # 

2# _____ _ _ __ __ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ \ \ / /_ _ _ __ _ __ (_)_ __ __ _ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` \ \ /\ / / _` | '__| '_ \| | '_ \ / _` | # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| |\ V V / (_| | | | | | | | | | | (_| | # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_/\_/ \__,_|_| |_| |_|_|_| |_|\__, | # 

7# |_| |___/ |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2025-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 solution to send warnings like exceptions to a handler in the upper part of the call-stack. 

33 

34.. hint:: 

35 

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

37""" 

38from threading import local, Lock 

39from types import TracebackType 

40from typing import List, Callable, Optional as Nullable, Type, Iterator, Self, Iterable, Tuple, Union 

41 

42from pyTooling.Decorators import export, readonly 

43from pyTooling.Common import getFullyQualifiedName 

44from pyTooling.Exceptions import ExceptionBase 

45 

46 

47__all__ = ["_threadLocalData", "AnyWarning"] 

48 

49 

50_threadLocalData = local() 

51"""A reference to the thread local data needed by the pyTooling.Warning classes.""" 

52 

53 

54@export 

55class CriticalWarning(BaseException): 

56 """ 

57 Base-exception of all critical warnings handled by :class:`WarningCollector`. 

58 

59 .. tip:: 

60 

61 Critical warnings must be unhandled within a call hierarchy, otherwise a :exc:`UnhandledCriticalWarningException` 

62 will be raised. 

63 """ 

64 

65 @readonly 

66 def HasNotes(self) -> bool: 

67 """ 

68 Read-only property to return if the warning has attached notes. 

69 

70 :returns: True, if the warning has attached notes. 

71 """ 

72 return hasattr(self, "__notes__") and self.__notes__ is not None and len(self.__notes__) > 0 

73 

74 @readonly 

75 def Notes(self) -> Tuple[str, ...]: 

76 """ 

77 Read-only property to access warning's attached notes. 

78 

79 :returns: Attached notes. 

80 """ 

81 return tuple(self.__notes__) if hasattr(self, "__notes__") else tuple() 

82 

83 

84@export 

85class Warning(BaseException): 

86 """ 

87 Base-exception of all warnings handled by :class:`WarningCollector`. 

88 

89 .. tip:: 

90 

91 Warnings can be unhandled within a call hierarchy. 

92 """ 

93 

94 @readonly 

95 def HasNotes(self) -> bool: 

96 """ 

97 Read-only property to return if the warning has attached notes. 

98 

99 :returns: True, if the warning has attached notes. 

100 """ 

101 return hasattr(self, "__notes__") and self.__notes__ is not None and len(self.__notes__) > 0 

102 

103 @readonly 

104 def Notes(self) -> Tuple[str, ...]: 

105 """ 

106 Read-only property to access warning's attached notes. 

107 

108 :returns: Attached notes. 

109 """ 

110 return tuple(self.__notes__) if hasattr(self, "__notes__") else tuple() 

111 

112 

113AnyWarning = Union[CriticalWarning, Warning] 

114 

115 

116@export 

117class UnhandledWarningException(ExceptionBase): # FIXME: to be removed in v9.0.0 

118 """ 

119 Deprecated. 

120 

121 .. deprecated:: v9.0.0 

122 

123 Please use :exc:`UnhandledCriticalWarningException`. 

124 """ 

125 

126 

127@export 

128class UnhandledCriticalWarningException(UnhandledWarningException): 

129 """ 

130 This exception is raised when a critical warning isn't handled by a :class:`WarningCollector` within the 

131 call-hierarchy. 

132 """ 

133 

134 

135@export 

136class UnhandledExceptionException(UnhandledWarningException): 

137 """ 

138 This exception is raised when an exception isn't handled by a :class:`WarningCollector` within the call-hierarchy. 

139 """ 

140 

141 

142@export 

143class WarningCollector: 

144 """ 

145 A context manager to collect warnings within the call hierarchy. 

146 """ 

147 _parent: Nullable["WarningCollector"] #: Parent WarningCollector 

148 _warnings: List[BaseException] #: List of collected warnings (and exceptions). 

149 _handler: Nullable[Callable[[BaseException], bool]] #: Optional handler function, which is called per collected warning. 

150 

151 __slots__ = ("_parent", "_warnings", "_handler") 

152 

153 def __init__( 

154 self, 

155 warnings: Nullable[List[BaseException]] = None, 

156 handler: Nullable[Callable[[BaseException], bool]] = None 

157 ) -> None: 

158 """ 

159 Initializes a warning collector. 

160 

161 :param warnings: An optional reference to a list of warnings, which can be modified (appended) by this warning 

162 collector. If ``None``, an internal list is created and can be referenced by the collector's 

163 instance. 

164 :param handler: An optional handler function, which processes the current warning and decides if a warning should 

165 be reraised as an exception. 

166 :raises TypeError: If optional parameter 'warnings' is not of type list. 

167 :raises TypeError: If optional parameter 'handler' is not a callable. 

168 """ 

169 if warnings is None: 

170 warnings = [] 

171 elif not isinstance(warnings, list): 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true

172 ex = TypeError(f"Parameter 'warnings' is not a list.") 

173 ex.add_note(f"Got type '{getFullyQualifiedName(warnings)}'.") 

174 raise ex 

175 

176 if handler is not None and not isinstance(handler, Callable): 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true

177 ex = TypeError(f"Parameter 'handler' is not callable.") 

178 ex.add_note(f"Got type '{getFullyQualifiedName(handler)}'.") 

179 raise ex 

180 

181 self._parent = None 

182 self._warnings = warnings 

183 self._handler = handler 

184 

185 def __len__(self) -> int: 

186 """ 

187 Returns the number of collected warnings. 

188 

189 :returns: Number of collected warnings. 

190 """ 

191 return len(self._warnings) 

192 

193 def __iter__(self) -> Iterator[Warning | CriticalWarning | Exception]: 

194 """ 

195 Return an iterator over all collected warnings. 

196 

197 :returns: Iterator over the collected warnings. 

198 """ 

199 return iter(self._warnings) 

200 

201 def __getitem__(self, index: int) -> Warning | CriticalWarning | Exception: 

202 """ 

203 Access a collected warning by index. 

204 

205 :param index: Index of the warning. 

206 :returns: Collected warning. 

207 """ 

208 return self._warnings[index] 

209 

210 def __enter__(self) -> Self: 

211 """ 

212 Enter the warning collector context. 

213 

214 :returns: The warning collector instance. 

215 """ 

216 global _threadLocalData 

217 

218 try: 

219 self._parent = _threadLocalData.warningCollector 

220 except AttributeError: 

221 pass 

222 

223 _threadLocalData.warningCollector = self 

224 

225 return self 

226 

227 def __exit__( 

228 self, 

229 exc_type: Nullable[Type[BaseException]] = None, 

230 exc_val: Nullable[BaseException] = None, 

231 exc_tb: Nullable[TracebackType] = None 

232 ) -> Nullable[bool]: 

233 """ 

234 Exit the warning collector context. 

235 

236 :param exc_type: Exception type 

237 :param exc_val: Exception instance 

238 :param exc_tb: Exception's traceback. 

239 :returns: ``None`` 

240 """ 

241 global _threadLocalData 

242 

243 _threadLocalData.warningCollector = self._parent 

244 

245 @property 

246 def Parent(self) -> Nullable[Self]: 

247 """ 

248 Property to access the parent warning collector. 

249 

250 :returns: The parent warning collector or ``None``. 

251 """ 

252 return self._parent 

253 

254 @Parent.setter 

255 def Parent(self, value: Self) -> None: 

256 self._parent = value 

257 

258 @readonly 

259 def Warnings(self) -> List[Warning | CriticalWarning | Exception]: 

260 """ 

261 Read-only property to access the list of collected warnings. 

262 

263 :returns: A list of collected warnings. 

264 """ 

265 return self._warnings 

266 

267 def AddWarning(self, warning: Warning | CriticalWarning | Exception) -> bool: 

268 """ 

269 Add a warning to the list of warnings managed by this warning collector. 

270 

271 :param warning: The warning to add to the collectors internal warning list. 

272 :returns: Return ``True`` if the warning collector has a local handler callback and this handler returned 

273 ``True``; otherwise ``False``. 

274 :raises ValueError: If parameter ``warning`` is None. 

275 :raises TypeError: If parameter ``warning`` is not of type :class:`Warning`. 

276 """ 

277 if warning is None: 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true

278 raise ValueError("Parameter 'warning' is None.") 

279 elif not isinstance(warning, (Warning, CriticalWarning, Exception)): 279 ↛ 280line 279 didn't jump to line 280 because the condition on line 279 was never true

280 ex = TypeError(f"Parameter 'warning' is not of type 'Warning', 'CriticalWarning' or 'Exception'.") 

281 ex.add_note(f"Got type '{getFullyQualifiedName(warning)}'.") 

282 raise ex 

283 

284 self._warnings.append(warning) 

285 

286 return False if self._handler is None else self._handler(warning) 

287 

288 @classmethod 

289 def Raise( 

290 cls, 

291 warning: Warning | CriticalWarning | Exception, 

292 cause: Nullable[Exception] = None, 

293 *, 

294 notes: Nullable[str | Iterable[str]] = None 

295 ) -> None: 

296 """ 

297 Walk the callstack frame by frame upwards and search for the first warning collector. 

298 

299 :param warning: Warning to send upwards in the call stack. 

300 :param cause: Optional, root cause to be added to the warning. 

301 :param notes: optional, a single note or a list of notes to be added to the warning. 

302 :raises Exception: If warning should be converted to an exception. 

303 :raises UnhandledExceptionException: If no warning collector was found along the call-hierarchy to collect and 

304 handle an exception. 

305 :raises UnhandledCriticalWarningException: If no warning collector was found along the call-hierarchy to collect and 

306 handle a critical warning. 

307 """ 

308 global _threadLocalData 

309 

310 if cause is not None: 310 ↛ 311line 310 didn't jump to line 311 because the condition on line 310 was never true

311 warning.__cause__ = cause 

312 

313 if notes is not None: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true

314 if isinstance(notes, str): 

315 warning.add_note(notes) 

316 else: 

317 for note in notes: 

318 warning.add_note(note) 

319 

320 try: 

321 warningCollector = _threadLocalData.warningCollector 

322 if warningCollector.AddWarning(warning): 

323 raise Exception(f"Warning: {warning}") from warning 

324 except AttributeError: 

325 ex = None 

326 if isinstance(warning, Exception): 

327 ex = UnhandledExceptionException(f"Unhandled Exception: {warning}") 

328 elif isinstance(warning, CriticalWarning): 

329 ex = UnhandledCriticalWarningException(f"Unhandled Critical Warning: {warning}") 

330 

331 if ex is not None: 

332 ex.add_note(f"Add a 'with'-statement using '{cls.__name__}' somewhere up the call-hierarchy to receive and collect warnings.") 

333 raise ex from warning 

334 

335 

336@export 

337class SupervisedWarningCollectorException(ExceptionBase): 

338 pass 

339 

340 

341@export 

342class SupervisedWarningCollector(WarningCollector): 

343 """ 

344 A context manager to collect warnings within the call hierarchy. 

345 """ 

346 _supervisor: Nullable["ThreadSupervisor"] 

347 _exceptionHandler: Nullable[Callable[[BaseException], bool]] 

348 _finallyHandler: Nullable[Callable[[], None]] 

349 

350 __slots__ = ("_supervisor", "_exceptionHandler", "_finallyHandler") 

351 

352 def __init__( 

353 self, 

354 warnings: Nullable[List[BaseException]] = None, 

355 handler: Nullable[Callable[[BaseException], bool]] = None, 

356 /, 

357 supervisor: Nullable["ThreadSupervisor"] = None, 

358 exceptionHandler: Nullable[Callable[[BaseException], bool]] = None, 

359 finallyHandler: Nullable[Callable[[], None]] = None 

360 ) -> None: 

361 """ 

362 Initializes a warning collector. 

363 

364 :param warnings: An optional reference to a list of warnings, which can be modified (appended) by this warning 

365 collector. If ``None``, an internal list is created and can be referenced by the collector's 

366 instance. 

367 :param handler: An optional handler function, which processes the current warning and decides if a warning should 

368 be reraised as an exception. 

369 :raises TypeError: If optional parameter 'warnings' is not of type list. 

370 :raises TypeError: If optional parameter 'handler' is not a callable. 

371 """ 

372 super().__init__(warnings, handler) 

373 

374 self._supervisor = supervisor 

375 self._exceptionHandler = exceptionHandler 

376 self._finallyHandler = finallyHandler 

377 

378 def __enter__(self) -> Self: 

379 """ 

380 Enter the warning collector context. 

381 

382 :returns: The warning collector instance. 

383 """ 

384 global _threadLocalData 

385 

386 if hasattr(_threadLocalData, "warningCollector") and _threadLocalData.warningCollector is not None: 

387 raise SupervisedWarningCollectorException("This warning collector is not the top-most warning collector within the current thread.") 

388 

389 _threadLocalData.warningCollector = self 

390 

391 return self 

392 

393 def __exit__( 

394 self, 

395 exc_type: Nullable[Type[BaseException]] = None, 

396 exc_val: Nullable[BaseException] = None, 

397 exc_tb: Nullable[TracebackType] = None 

398 ) -> Nullable[bool]: 

399 """ 

400 Exit the warning collector context. 

401 

402 :param exc_type: Exception type 

403 :param exc_val: Exception instance 

404 :param exc_tb: Exception's traceback. 

405 :returns: ``None`` 

406 """ 

407 global _threadLocalData 

408 

409 _threadLocalData.warningCollector = None 

410 

411 if self._supervisor is not None: 

412 result = True 

413 if len(self._warnings) > 0: 

414 self._supervisor.AddWarnings(self._warnings) 

415 

416 if exc_val is not None: 

417 self._supervisor.AddException("", exc_val) 

418 

419 if self._exceptionHandler is not None: 

420 result = self._exceptionHandler(exc_val) 

421 else: 

422 result = None 

423 

424 if self._finallyHandler is not None: 

425 self._finallyHandler() 

426 

427 return result 

428 

429 

430@export 

431class SupervisedThreadException(ExceptionBase): 

432 """ 

433 The exception is raise if a supervised thread received an unhandled exception which got collected by 

434 :class:`ExceptionCollector`. 

435 """ 

436 _threadName: str 

437 

438 def __init__(self, threadName: str, message: str, /, cause: Nullable[BaseException] = None) -> None: 

439 super().__init__(message) 

440 self._threadName = threadName 

441 self.__cause__ = cause 

442 

443 @readonly 

444 def ThreadName(self) -> str: 

445 return self._threadName 

446 

447 

448@export 

449class ThreadSupervisor: 

450 """ 

451 Thread-safe collector of exceptions (:class:`BaseException`) and warnings (:class:`~pyTooling.Warning.Warning` or 

452 :class:`~pyTooling.Warning.CriticalWarning`) raised in worker threads for surfacing on the main thread. 

453 

454 Warnings need this too: ``pyTooling.Warning`` keeps its active collector in a module-level 

455 ``threading.local()``. A ``WarningCollector`` opened on the main thread is invisible inside a 

456 worker thread — ``WarningCollector.Raise(...)`` called there would hit the "no collector found" 

457 branch and raise ``UnhandledExceptionException``/``UnhandledCriticalWarningException`` instead 

458 of being collected. So every worker thread opens its *own* ``WarningCollector`` (see 

459 ``ClassifierThread``/``CommonFilterThread``/``TargetConsumerThread``) and hands its results to 

460 this box; the main thread merges them after all threads have joined. 

461 """ 

462 

463 _lock: Lock 

464 _exceptions: List[Tuple[str, BaseException]] 

465 _warnings: List[Tuple[str, AnyWarning]] 

466 

467 __slots__ = ("_lock", "_exceptions", "_warnings") 

468 

469 def __init__(self) -> None: 

470 self._lock = Lock() 

471 self._exceptions = [] 

472 self._warnings = [] 

473 

474 @property 

475 def HasExceptions(self) -> bool: 

476 with self._lock: 

477 return len(self._exceptions) > 0 

478 

479 @property 

480 def HasWarning(self) -> bool: 

481 with self._lock: 

482 return len(self._warnings) > 0 

483 

484 @property 

485 def Warnings(self) -> List[AnyWarning]: 

486 """Merged warnings from all threads, in the order each thread recorded them (not a 

487 cross-thread chronological order, since threads run concurrently).""" 

488 with self._lock: 

489 return [warning for _, warning in self._warnings] 

490 

491 def AddException(self, threadName: str, ex: BaseException) -> None: 

492 with self._lock: 

493 self._exceptions.append((threadName, ex)) 

494 

495 def AddWarning(self, threadName: str, warning: AnyWarning) -> None: 

496 with self._lock: 

497 self._warnings.append((threadName, warning)) 

498 

499 def AddWarnings(self, threadName: str, warnings: List[AnyWarning]) -> None: 

500 with self._lock: 

501 self._warnings.extend((threadName, warning) for warning in warnings) 

502 

503 def AddFromWarningCollector(self, threadName: str, warningCollector: WarningCollector) -> None: 

504 with self._lock: 

505 self._warnings.extend((threadName, warning) for warning in warningCollector._warnings) 

506 

507 def ReRaise(self, unwrapped: bool = False) -> None: 

508 with self._lock: 

509 if len(self._exceptions) == 0: 

510 return 

511 

512 exceptions = list(self._exceptions) 

513 

514 if len(exceptions) == 1: 

515 threadName, ex = exceptions[0] 

516 if unwrapped: 

517 raise ex 

518 else: 

519 raise SupervisedThreadException(threadName, f"Thread '{threadName}' failed.") from ex 

520 

521 elif unwrapped: 

522 raise ExceptionGroup( 

523 "Multiple threads failed.", 

524 [ex for _, ex in exceptions] 

525 ) 

526 else: 

527 raise ExceptionGroup( 

528 "Multiple threads failed.", 

529 [SupervisedThreadException(f"Thread '{threadName}' failed.", cause=ex) for threadName, ex in exceptions] 

530 )