Coverage for pyTooling/Warning/__init__.py: 87%
193 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# | |_) | |_| || | (_) | (_) | | | | | | (_| |\ 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.
38.. seealso::
40 :mod:`pyTooling.Exceptions`
41 |rarr| Exceptions, which are raised instead of collected.
42 :mod:`pyTooling.TerminalUI`
43 |rarr| Writing the collected warnings to the terminal.
44"""
45from __future__ import annotations
47from threading import current_thread, local, Lock
48from types import TracebackType
49from typing import Callable, Optional as Nullable, Iterator, Self, Iterable, Union
51from pyTooling.Decorators import export, readonly
52from pyTooling.Common import getFullyQualifiedName
53from pyTooling.Exceptions import ExceptionBase
56__all__ = ["_threadLocalData", "AnyWarning"]
59_threadLocalData = local()
60"""A reference to the thread local data needed by the pyTooling.Warning classes."""
63@export
64class CriticalWarning(BaseException):
65 """
66 Base-exception of all critical warnings handled by :class:`WarningCollector`.
68 .. important::
70 A critical warning must be collected by a :class:`WarningCollector` up the call-hierarchy, otherwise
71 :meth:`WarningCollector.Raise` raises an :exc:`UnhandledCriticalWarningError`.
72 """
74 @readonly
75 def HasNotes(self) -> bool:
76 """
77 Read-only property to return if the warning has attached notes.
79 :returns: True, if the warning has attached notes.
80 """
81 return hasattr(self, "__notes__") and self.__notes__ is not None and len(self.__notes__) > 0
83 @readonly
84 def Notes(self) -> tuple[str, ...]:
85 """
86 Read-only property to return warning's attached notes.
88 :returns: Attached notes.
89 """
90 return tuple(self.__notes__) if hasattr(self, "__notes__") else tuple()
93@export
94class Warning(BaseException):
95 """
96 Base-exception of all warnings handled by :class:`WarningCollector`.
98 .. attention::
100 A warning doesn't need a collector: without a :class:`WarningCollector` up the call-hierarchy, it's dropped
101 silently.
102 """
104 @readonly
105 def HasNotes(self) -> bool:
106 """
107 Read-only property to return if the warning has attached notes.
109 :returns: True, if the warning has attached notes.
110 """
111 return hasattr(self, "__notes__") and self.__notes__ is not None and len(self.__notes__) > 0
113 @readonly
114 def Notes(self) -> tuple[str, ...]:
115 """
116 Read-only property to return warning's attached notes.
118 :returns: Attached notes.
119 """
120 return tuple(self.__notes__) if hasattr(self, "__notes__") else tuple()
123AnyWarning = Union[CriticalWarning, Warning]
126@export
127class UnhandledCriticalWarningError(ExceptionBase):
128 """
129 This exception is raised when a critical warning isn't handled by a :class:`WarningCollector` within the
130 call-hierarchy.
132 :meth:`WarningCollector.Raise` raises it, if no collector is current in the calling thread. The critical warning is
133 its cause (``__cause__``), and a note says how to collect it.
134 """
137@export
138class UnhandledExceptionError(ExceptionBase):
139 """
140 This exception is raised when an exception isn't handled by a :class:`WarningCollector` within the call-hierarchy.
142 :meth:`WarningCollector.Raise` raises it, if no collector is current in the calling thread. The exception is its
143 cause (``__cause__``), and a note says how to collect it.
144 """
147@export
148class EscalatedWarningError(ExceptionBase):
149 """
150 This exception is raised when a :class:`WarningCollector` decides a collected warning is escalated.
152 :meth:`WarningCollector.Raise` hands a warning to the current collector, which appends it to its list and calls its
153 handler. A handler returning ``True`` asks for the warning to be escalated: :meth:`~WarningCollector.Raise` raises
154 this exception, and the warning becomes its cause (``__cause__``). The warning stays in the collector's list.
156 Any function reporting a warning can raise it, but only when the caller installed a handler returning ``True`` for
157 that warning.
158 """
161@export
162class WarningCollector:
163 """
164 A context manager to collect warnings within the call hierarchy.
166 Entering the context makes the collector the *current* one of its thread, and :meth:`Raise` hands a warning to the
167 current collector. Collectors are kept per thread: a warning raised in another thread doesn't reach it, see
168 :class:`SupervisedWarningCollector`.
170 Collectors can be nested. The previous current collector becomes the :attr:`Parent`, and only the innermost
171 collector receives a warning - the parent doesn't see it, unless it's passed on, e.g. by :meth:`AddWarning`.
173 The collected warnings are accessible by :attr:`Warnings`, by iteration, by :func:`len` and by index.
174 """
175 _parent: Nullable[WarningCollector] #: Parent WarningCollector
176 _warnings: list[BaseException] #: List of collected warnings (and exceptions).
177 _handler: Nullable[Callable[[BaseException], bool]] #: Optional handler function, which is called per collected warning.
179 __slots__ = ("_parent", "_warnings", "_handler")
181 def __init__(
182 self,
183 warnings: Nullable[list[BaseException]] = None,
184 handler: Nullable[Callable[[BaseException], bool]] = None
185 ) -> None:
186 """
187 Initializes a warning collector.
189 :param warnings: Optional, reference to a list of warnings, which can be modified (appended) by this warning
190 collector. If ``None``, an internal list is created and can be referenced by the collector's
191 instance.
192 :param handler: Optional, handler function, which is called with each collected warning and decides, if it's
193 escalated. If it returns ``True``, :meth:`Raise` raises an :exc:`EscalatedWarningError`, whose
194 cause is the warning. An exception raised by the handler propagates through :meth:`Raise`.
195 :raises TypeError: If optional parameter 'warnings' is not of type list.
196 :raises TypeError: If optional parameter 'handler' is not a callable.
197 """
198 if warnings is None:
199 warnings = []
200 elif not isinstance(warnings, list): 200 ↛ 201line 200 didn't jump to line 201 because the condition on line 200 was never true
201 ex = TypeError("Parameter 'warnings' is not a list.")
202 ex.add_note(f"Got type '{getFullyQualifiedName(warnings)}'.")
203 raise ex
205 if handler is not None and not isinstance(handler, Callable): 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true
206 ex = TypeError("Parameter 'handler' is not callable.")
207 ex.add_note(f"Got type '{getFullyQualifiedName(handler)}'.")
208 raise ex
210 self._parent = None
211 self._warnings = warnings
212 self._handler = handler
214 def __len__(self) -> int:
215 """
216 Returns the number of collected warnings.
218 :returns: Number of collected warnings.
219 """
220 return len(self._warnings)
222 def __iter__(self) -> Iterator[Warning | CriticalWarning | Exception]:
223 """
224 Return an iterator over all collected warnings.
226 :returns: Iterator over the collected warnings.
227 """
228 return iter(self._warnings)
230 def __getitem__(self, index: int) -> Warning | CriticalWarning | Exception:
231 """
232 Access a collected warning by index.
234 :param index: Index of the warning.
235 :returns: Collected warning.
236 """
237 return self._warnings[index]
239 def __enter__(self) -> Self:
240 """
241 Enter the context and make this warning collector the current one.
243 The previous current warning collector becomes its parent.
245 :returns: The warning collector instance.
246 """
247 global _threadLocalData
249 try:
250 self._parent = _threadLocalData.warningCollector
251 except AttributeError:
252 pass
254 _threadLocalData.warningCollector = self
256 return self
258 def __exit__(
259 self,
260 exc_type: Nullable[type[BaseException]] = None,
261 exc_val: Nullable[BaseException] = None,
262 exc_tb: Nullable[TracebackType] = None
263 ) -> Nullable[bool]:
264 """
265 Exit the context and make the parent warning collector the current one again.
267 :param exc_type: Optional, exception type.
268 :param exc_val: Optional, exception instance.
269 :param exc_tb: Optional, exception's traceback.
270 :returns: ``False``, so an exception raised in the context propagates.
271 """
272 global _threadLocalData
274 _threadLocalData.warningCollector = self._parent
276 return False
278 @property
279 def Parent(self) -> Nullable[Self]:
280 """
281 Property to access the parent warning collector.
283 :returns: The parent warning collector or ``None``.
284 """
285 return self._parent
287 @Parent.setter
288 def Parent(self, value: Self) -> None:
289 self._parent = value
291 @readonly
292 def Warnings(self) -> list[Warning | CriticalWarning | Exception]:
293 """
294 Read-only property to access the list of collected warnings.
296 :returns: A list of collected warnings.
297 """
298 return self._warnings
300 def AddWarning(self, warning: Warning | CriticalWarning | Exception) -> bool:
301 """
302 Add a warning to the list of warnings managed by this warning collector and call its handler.
304 An exception raised by the handler propagates.
306 :param warning: The warning to add to the collector's internal warning list.
307 :returns: Return ``True`` if the warning collector has a local handler callback and this handler returned
308 ``True``; otherwise ``False``.
309 :raises ValueError: If parameter ``warning`` is None.
310 :raises TypeError: If parameter ``warning`` is not of type :class:`Warning`, :class:`CriticalWarning` or
311 :exc:`Exception`.
312 """
313 if warning is None: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true
314 raise ValueError("Parameter 'warning' is None.")
315 elif not isinstance(warning, (Warning, CriticalWarning, Exception)): 315 ↛ 316line 315 didn't jump to line 316 because the condition on line 315 was never true
316 ex = TypeError("Parameter 'warning' is not of type 'Warning', 'CriticalWarning' or 'Exception'.")
317 ex.add_note(f"Got type '{getFullyQualifiedName(warning)}'.")
318 raise ex
320 self._warnings.append(warning)
322 return False if self._handler is None else self._handler(warning)
324 @classmethod
325 def Raise(
326 cls,
327 warning: Warning | CriticalWarning | Exception,
328 cause: Nullable[Exception] = None,
329 *,
330 notes: Nullable[str | Iterable[str]] = None
331 ) -> None:
332 """
333 Hand a warning to the current warning collector of the calling thread.
335 The current collector is the innermost :class:`WarningCollector` context entered in the calling thread. It appends
336 the warning to its list (:meth:`AddWarning`) and calls its handler. Without a current collector, a :class:`Warning`
337 is dropped, while a :class:`CriticalWarning` or an :exc:`Exception` is wrapped in an exception and raised.
339 Called inside a generator, the warning reaches the collector current when the generator is resumed, i.e. the one
340 of the code consuming it. An exception raised by the collector's handler propagates.
342 :param warning: Warning to send to the current warning collector.
343 :param cause: Optional, root cause to be added to the warning (``__cause__``).
344 :param notes: Optional, a single note or a list of notes to be added to the warning.
345 :raises ValueError: If parameter ``warning`` is None and a warning collector is current.
346 :raises TypeError: If parameter ``warning`` is not of type :class:`Warning`,
347 :class:`CriticalWarning` or :exc:`Exception` and a warning collector is
348 current.
349 :raises EscalatedWarningError: If the warning collector's handler asks for the warning to be escalated.
350 :raises UnhandledExceptionError: If no warning collector was found along the call-hierarchy to collect and
351 handle an exception.
352 :raises UnhandledCriticalWarningError: If no warning collector was found along the call-hierarchy to collect
353 and handle a critical warning. |br|
354 Add a with-statement using :class:`WarningCollector` somewhere up the
355 call-hierarchy to receive and collect warnings.
356 """
357 global _threadLocalData
359 if cause is not None:
360 warning.__cause__ = cause
362 if notes is not None:
363 if isinstance(notes, str): 363 ↛ 364line 363 didn't jump to line 364 because the condition on line 363 was never true
364 warning.add_note(notes)
365 else:
366 for note in notes:
367 warning.add_note(note)
369 if (warningCollector := getattr(_threadLocalData, "warningCollector", None)) is None:
370 ex = None
371 if isinstance(warning, Exception):
372 ex = UnhandledExceptionError(f"Unhandled Exception: {warning}")
373 elif isinstance(warning, CriticalWarning):
374 ex = UnhandledCriticalWarningError(f"Unhandled Critical Warning: {warning}")
376 if ex is not None:
377 ex.add_note(f"Add a 'with'-statement using '{cls.__name__}' somewhere up the call-hierarchy to receive and collect warnings.")
378 raise ex from warning
379 elif warningCollector.AddWarning(warning):
380 raise EscalatedWarningError(f"Warning: {warning}") from warning
383@export
384class SupervisedWarningCollectorError(ExceptionBase):
385 """
386 This exception is raised when a supervised warning collector is not the top-most collector in its thread.
388 A supervised collector hands its warnings to the thread supervisor, which only works if nothing else collects them
389 first.
390 """
393@export
394class SupervisedWarningCollector(WarningCollector):
395 """
396 A context manager to collect the warnings of a thread and hand them to a :class:`ThreadSupervisor`.
398 It's the top-most warning collector of its thread. On leaving the context, the collected warnings and an exception
399 leaving the block are handed to the supervisor, so the thread that started this one can read the warnings and
400 re-raise the exception (:meth:`ThreadSupervisor.ReRaise`).
401 """
402 _supervisor: Nullable[ThreadSupervisor] #: Supervisor collecting warnings and exceptions of all threads.
403 _exceptionHandler: Nullable[Callable[[BaseException], bool]] #: Handler called for an exception escaping the thread.
404 _finallyHandler: Nullable[Callable[[], None]] #: Handler called when the thread ends, in either case.
406 __slots__ = ("_supervisor", "_exceptionHandler", "_finallyHandler")
408 def __init__(
409 self,
410 warnings: Nullable[list[BaseException]] = None,
411 handler: Nullable[Callable[[BaseException], bool]] = None,
412 /,
413 supervisor: Nullable[ThreadSupervisor] = None,
414 exceptionHandler: Nullable[Callable[[BaseException], bool]] = None,
415 finallyHandler: Nullable[Callable[[], None]] = None
416 ) -> None:
417 """
418 Initializes a warning collector.
420 :param warnings: Optional, reference to a list of warnings, which can be modified (appended) by this warning
421 collector. If ``None``, an internal list is created and can be referenced by the
422 collector's instance.
423 :param handler: Optional, handler function, which is called with each collected warning and decides, if
424 it's escalated. If it returns ``True``, :meth:`Raise` raises an
425 :exc:`EscalatedWarningError`, whose cause is the warning.
426 :param supervisor: Optional, thread supervisor. On leaving the context, the collected warnings and an
427 exception leaving the block are handed to it, so the thread that started this one can
428 reraise them. Without a supervisor, an exception leaves the block unchanged.
429 :param exceptionHandler: Optional, handler function, called with an exception leaving the block when a supervisor is
430 set. Its result decides whether the exception is suppressed.
431 :param finallyHandler: Optional, function called when the context is left, whether or not an exception left it.
432 :raises TypeError: If optional parameter 'warnings' is not of type list.
433 :raises TypeError: If optional parameter 'handler' is not a callable.
434 """
435 super().__init__(warnings, handler)
437 self._supervisor = supervisor
438 self._exceptionHandler = exceptionHandler
439 self._finallyHandler = finallyHandler
441 def __enter__(self) -> Self:
442 """
443 Enter the context and make this warning collector the current one of its thread.
445 :returns: The warning collector instance.
446 :raises SupervisedWarningCollectorError: If this collector is not the top-most warning collector of its thread.
447 """
448 global _threadLocalData
450 if hasattr(_threadLocalData, "warningCollector") and _threadLocalData.warningCollector is not None: 450 ↛ 451line 450 didn't jump to line 451 because the condition on line 450 was never true
451 raise SupervisedWarningCollectorError("This warning collector is not the top-most warning collector within the current thread.")
453 _threadLocalData.warningCollector = self
455 return self
457 def __exit__(
458 self,
459 exc_type: Nullable[type[BaseException]] = None,
460 exc_val: Nullable[BaseException] = None,
461 exc_tb: Nullable[TracebackType] = None
462 ) -> Nullable[bool]:
463 """
464 Exit the context and hand the collected warnings and a raised exception to the supervisor.
466 :param exc_type: Optional, exception type.
467 :param exc_val: Optional, exception instance.
468 :param exc_tb: Optional, exception's traceback.
469 :returns: ``True`` to suppress a raised exception, if a supervisor is set; the exception handler's result
470 instead, if it handled a raised exception; ``None`` without a supervisor.
471 """
472 global _threadLocalData
474 _threadLocalData.warningCollector = None
476 if self._supervisor is not None: 476 ↛ 487line 476 didn't jump to line 487 because the condition on line 476 was always true
477 result = True
478 if len(self._warnings) > 0:
479 self._supervisor.AddWarnings(current_thread().name, self._warnings)
481 if exc_val is not None:
482 self._supervisor.AddException(current_thread().name, exc_val)
484 if self._exceptionHandler is not None: 484 ↛ 489line 484 didn't jump to line 489 because the condition on line 484 was always true
485 result = self._exceptionHandler(exc_val)
486 else:
487 result = None
489 if self._finallyHandler is not None: 489 ↛ 490line 489 didn't jump to line 490 because the condition on line 489 was never true
490 self._finallyHandler()
492 return result
495@export
496class SupervisedThreadError(ExceptionBase):
497 """
498 This exception is raised by :meth:`ThreadSupervisor.ReRaise`, if a supervised thread failed with an exception.
500 A :class:`SupervisedWarningCollector` hands the exception leaving its thread to the :class:`ThreadSupervisor`; it
501 becomes this exception's cause, and :attr:`~SupervisedThreadError.ThreadName` names the thread.
502 """
503 _threadName: str #: Name of the thread the exception was raised in.
505 def __init__(
506 self,
507 message: str,
508 /,
509 *,
510 threadName: str,
511 cause: Nullable[BaseException] = None
512 ) -> None:
513 """
514 Initializes the exception with the name of the thread that failed.
516 :param message: The exception's message.
517 :param threadName: Name of the thread that raised the collected exception.
518 :param cause: Optional, the exception collected from that thread.
519 """
520 super().__init__(message)
521 self._threadName = threadName
522 self.__cause__ = cause
524 @readonly
525 def ThreadName(self) -> str:
526 """
527 Read-only property to access the name of the thread that raised the exception (:attr:`_threadName`).
529 :returns: Name of the thread.
530 """
531 return self._threadName
534@export
535class ThreadSupervisor:
536 """
537 Thread-safe collector of exceptions and warnings raised in worker threads for surfacing on another thread.
539 This thread supervisor should be used in combination with :class:`SupervisedWarningCollector` to accumulate
540 exceptions (:class:`BaseException`) and warnings (:class:`CriticalWarning` or :class:`Warning`).
542 .. code-block:: python
544 @export
545 class MyThread(Thread):
546 def __init__(
547 self,
548 threadSupervisor: ThreadSupervisor,
549 stopEvent: Event
550 ) -> None:
551 super().__init__(name="MyThread", daemon=True)
553 self._threadSupervisor = threadSupervisor
554 self._stopEvent = stopEvent
556 def run(self) -> None:
557 def exceptionHandler(ex: BaseException) -> bool:
558 self._stopEvent.set()
559 return True
561 def finallyHandler() -> None:
562 ... # some finally code
564 with SupervisedWarningCollector(
565 supervisor=self._threadSupervisor,
566 exceptionHandler=exceptionHandler,
567 finallyHandler=finallyHandler
568 ) as warnings:
569 ... # thread body
571 .. code-block:: python
573 def RunVivadoPipeline(
574 self,
575 ) -> list[AnyWarning]:
576 stopEvent = Event()
577 threadSupervisor = ThreadSupervisor()
579 myThread = MyThread(threadSupervisor, stopEvent)
580 myThread.start()
582 try:
583 myThread.join()
584 except KeyboardInterrupt:
585 stopEvent.set()
586 myThread.join(timeout=2.0)
587 raise
589 threadSupervisor.ReRaise()
591 return threadSupervisor.Warnings
592 """
594 _lock: Lock #: Lock serializing the collection from multiple threads.
595 _exceptions: list[tuple[str, BaseException]] #: Exceptions of all supervised threads, as (thread name, exception).
596 _warnings: list[tuple[str, AnyWarning]] #: Warnings of all supervised threads, as (thread name, warning).
598 __slots__ = ("_lock", "_exceptions", "_warnings")
600 def __init__(self) -> None:
601 """
602 Initialize a thread supervisor with empty lists of exceptions and warnings.
603 """
604 self._lock = Lock()
605 self._exceptions = []
606 self._warnings = []
608 @readonly
609 def HasWarning(self) -> bool:
610 """
611 Check if at least one warning was collected from a supervised thread.
613 :returns: ``True``, if at least one warning was collected.
614 """
615 with self._lock:
616 return len(self._warnings) > 0
618 @readonly
619 def HasExceptions(self) -> bool:
620 """
621 Check if at least one exception was collected from a supervised thread.
623 :returns: ``True``, if at least one exception was collected.
624 """
625 with self._lock:
626 return len(self._exceptions) > 0
628 @readonly
629 def Warnings(self) -> list[AnyWarning]:
630 """
631 Read-only property to return all warnings collected from supervised threads (:attr:`_warnings`).
633 :returns: List of collected warnings, without their thread names.
634 """
635 with self._lock:
636 return [warning for _, warning in self._warnings]
638 def AddWarning(self, threadName: str, warning: AnyWarning) -> None:
639 """
640 Collect a warning raised in a supervised thread.
642 :param threadName: Name of the thread the warning was raised in.
643 :param warning: The warning to collect.
644 """
645 with self._lock:
646 self._warnings.append((threadName, warning))
648 def AddWarnings(self, threadName: str, warnings: list[AnyWarning]) -> None:
649 """
650 Collect several warnings raised in a supervised thread.
652 :param threadName: Name of the thread the warnings were raised in.
653 :param warnings: The warnings to collect.
654 """
655 with self._lock:
656 self._warnings.extend((threadName, warning) for warning in warnings)
658 def AddException(self, threadName: str, ex: BaseException) -> None:
659 """
660 Collect an exception that escaped a supervised thread.
662 :param threadName: Name of the thread the exception was raised in.
663 :param ex: The exception to collect.
664 """
665 with self._lock:
666 self._exceptions.append((threadName, ex))
668 def ReRaise(self, unwrapped: bool = False) -> None:
669 """
670 Re-raise the exceptions collected from the supervised threads in the supervising thread.
672 A single exception is re-raised as itself - wrapped in a :exc:`SupervisedThreadError` naming its thread,
673 unless ``unwrapped`` is set. Several exceptions are raised as an :exc:`ExceptionGroup`, so none of them is lost.
675 :param unwrapped: Optional, if ``True``, a single exception is raised as it was, without naming its
676 thread.
677 :raises SupervisedThreadError: If exactly one thread failed.
678 :raises ExceptionGroup: If more than one thread failed.
679 """
680 with self._lock:
681 if len(self._exceptions) == 0:
682 return
684 exceptions = list(self._exceptions)
686 if len(exceptions) == 1:
687 threadName, ex = exceptions[0]
688 if unwrapped:
689 raise ex
690 else:
691 raise SupervisedThreadError(f"Thread '{threadName}' failed.", threadName=threadName) from ex
693 elif unwrapped:
694 raise ExceptionGroup(
695 "Multiple threads failed.",
696 [ex for _, ex in exceptions]
697 )
698 else:
699 raise ExceptionGroup(
700 "Multiple threads failed.",
701 [
702 SupervisedThreadError(f"Thread '{threadName}' failed.", threadName=threadName, cause=ex)
703 for threadName, ex in exceptions
704 ]
705 )