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
« 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.
34.. hint::
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
42from pyTooling.Decorators import export, readonly
43from pyTooling.Common import getFullyQualifiedName
44from pyTooling.Exceptions import ExceptionBase
47__all__ = ["_threadLocalData", "AnyWarning"]
50_threadLocalData = local()
51"""A reference to the thread local data needed by the pyTooling.Warning classes."""
54@export
55class CriticalWarning(BaseException):
56 """
57 Base-exception of all critical warnings handled by :class:`WarningCollector`.
59 .. tip::
61 Critical warnings must be unhandled within a call hierarchy, otherwise a :exc:`UnhandledCriticalWarningException`
62 will be raised.
63 """
65 @readonly
66 def HasNotes(self) -> bool:
67 """
68 Read-only property to return if the warning has attached notes.
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
74 @readonly
75 def Notes(self) -> Tuple[str, ...]:
76 """
77 Read-only property to access warning's attached notes.
79 :returns: Attached notes.
80 """
81 return tuple(self.__notes__) if hasattr(self, "__notes__") else tuple()
84@export
85class Warning(BaseException):
86 """
87 Base-exception of all warnings handled by :class:`WarningCollector`.
89 .. tip::
91 Warnings can be unhandled within a call hierarchy.
92 """
94 @readonly
95 def HasNotes(self) -> bool:
96 """
97 Read-only property to return if the warning has attached notes.
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
103 @readonly
104 def Notes(self) -> Tuple[str, ...]:
105 """
106 Read-only property to access warning's attached notes.
108 :returns: Attached notes.
109 """
110 return tuple(self.__notes__) if hasattr(self, "__notes__") else tuple()
113AnyWarning = Union[CriticalWarning, Warning]
116@export
117class UnhandledWarningException(ExceptionBase): # FIXME: to be removed in v9.0.0
118 """
119 Deprecated.
121 .. deprecated:: v9.0.0
123 Please use :exc:`UnhandledCriticalWarningException`.
124 """
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 """
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 """
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.
151 __slots__ = ("_parent", "_warnings", "_handler")
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.
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
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
181 self._parent = None
182 self._warnings = warnings
183 self._handler = handler
185 def __len__(self) -> int:
186 """
187 Returns the number of collected warnings.
189 :returns: Number of collected warnings.
190 """
191 return len(self._warnings)
193 def __iter__(self) -> Iterator[Warning | CriticalWarning | Exception]:
194 """
195 Return an iterator over all collected warnings.
197 :returns: Iterator over the collected warnings.
198 """
199 return iter(self._warnings)
201 def __getitem__(self, index: int) -> Warning | CriticalWarning | Exception:
202 """
203 Access a collected warning by index.
205 :param index: Index of the warning.
206 :returns: Collected warning.
207 """
208 return self._warnings[index]
210 def __enter__(self) -> Self:
211 """
212 Enter the warning collector context.
214 :returns: The warning collector instance.
215 """
216 global _threadLocalData
218 try:
219 self._parent = _threadLocalData.warningCollector
220 except AttributeError:
221 pass
223 _threadLocalData.warningCollector = self
225 return self
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.
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
243 _threadLocalData.warningCollector = self._parent
245 @property
246 def Parent(self) -> Nullable[Self]:
247 """
248 Property to access the parent warning collector.
250 :returns: The parent warning collector or ``None``.
251 """
252 return self._parent
254 @Parent.setter
255 def Parent(self, value: Self) -> None:
256 self._parent = value
258 @readonly
259 def Warnings(self) -> List[Warning | CriticalWarning | Exception]:
260 """
261 Read-only property to access the list of collected warnings.
263 :returns: A list of collected warnings.
264 """
265 return self._warnings
267 def AddWarning(self, warning: Warning | CriticalWarning | Exception) -> bool:
268 """
269 Add a warning to the list of warnings managed by this warning collector.
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
284 self._warnings.append(warning)
286 return False if self._handler is None else self._handler(warning)
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.
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
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
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)
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}")
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
336@export
337class SupervisedWarningCollectorException(ExceptionBase):
338 pass
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]]
350 __slots__ = ("_supervisor", "_exceptionHandler", "_finallyHandler")
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.
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)
374 self._supervisor = supervisor
375 self._exceptionHandler = exceptionHandler
376 self._finallyHandler = finallyHandler
378 def __enter__(self) -> Self:
379 """
380 Enter the warning collector context.
382 :returns: The warning collector instance.
383 """
384 global _threadLocalData
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.")
389 _threadLocalData.warningCollector = self
391 return self
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.
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
409 _threadLocalData.warningCollector = None
411 if self._supervisor is not None:
412 result = True
413 if len(self._warnings) > 0:
414 self._supervisor.AddWarnings(self._warnings)
416 if exc_val is not None:
417 self._supervisor.AddException("", exc_val)
419 if self._exceptionHandler is not None:
420 result = self._exceptionHandler(exc_val)
421 else:
422 result = None
424 if self._finallyHandler is not None:
425 self._finallyHandler()
427 return result
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
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
443 @readonly
444 def ThreadName(self) -> str:
445 return self._threadName
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.
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 """
463 _lock: Lock
464 _exceptions: List[Tuple[str, BaseException]]
465 _warnings: List[Tuple[str, AnyWarning]]
467 __slots__ = ("_lock", "_exceptions", "_warnings")
469 def __init__(self) -> None:
470 self._lock = Lock()
471 self._exceptions = []
472 self._warnings = []
474 @property
475 def HasExceptions(self) -> bool:
476 with self._lock:
477 return len(self._exceptions) > 0
479 @property
480 def HasWarning(self) -> bool:
481 with self._lock:
482 return len(self._warnings) > 0
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]
491 def AddException(self, threadName: str, ex: BaseException) -> None:
492 with self._lock:
493 self._exceptions.append((threadName, ex))
495 def AddWarning(self, threadName: str, warning: AnyWarning) -> None:
496 with self._lock:
497 self._warnings.append((threadName, warning))
499 def AddWarnings(self, threadName: str, warnings: List[AnyWarning]) -> None:
500 with self._lock:
501 self._warnings.extend((threadName, warning) for warning in warnings)
503 def AddFromWarningCollector(self, threadName: str, warningCollector: WarningCollector) -> None:
504 with self._lock:
505 self._warnings.extend((threadName, warning) for warning in warningCollector._warnings)
507 def ReRaise(self, unwrapped: bool = False) -> None:
508 with self._lock:
509 if len(self._exceptions) == 0:
510 return
512 exceptions = list(self._exceptions)
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
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 )