Skip to content

Commit b137551

Browse files
authored
add helper for unwrapping exception groups with single exception (#3240)
* add raise_single_exception_from_group
1 parent 1bdafca commit b137551

File tree

3 files changed

+132
-1
lines changed

3 files changed

+132
-1
lines changed

src/trio/_tests/test_util.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,13 @@
2424
fixup_module_metadata,
2525
generic_function,
2626
is_main_thread,
27+
raise_single_exception_from_group,
2728
)
2829
from ..testing import wait_all_tasks_blocked
2930

31+
if sys.version_info < (3, 11):
32+
from exceptiongroup import BaseExceptionGroup, ExceptionGroup
33+
3034
if TYPE_CHECKING:
3135
from collections.abc import AsyncGenerator
3236

@@ -267,3 +271,68 @@ def test_fixup_module_metadata() -> None:
267271
mod.some_func()
268272
mod._private()
269273
mod.SomeClass().method()
274+
275+
276+
async def test_raise_single_exception_from_group() -> None:
277+
excinfo: pytest.ExceptionInfo[BaseException]
278+
279+
exc = ValueError("foo")
280+
cause = SyntaxError("cause")
281+
context = TypeError("context")
282+
exc.__cause__ = cause
283+
exc.__context__ = context
284+
cancelled = trio.Cancelled._create()
285+
286+
with pytest.raises(ValueError, match="foo") as excinfo:
287+
raise_single_exception_from_group(ExceptionGroup("", [exc]))
288+
assert excinfo.value.__cause__ == cause
289+
assert excinfo.value.__context__ == context
290+
291+
with pytest.raises(ValueError, match="foo") as excinfo:
292+
raise_single_exception_from_group(
293+
ExceptionGroup("", [ExceptionGroup("", [exc])])
294+
)
295+
assert excinfo.value.__cause__ == cause
296+
assert excinfo.value.__context__ == context
297+
298+
with pytest.raises(ValueError, match="foo") as excinfo:
299+
raise_single_exception_from_group(
300+
BaseExceptionGroup(
301+
"", [cancelled, BaseExceptionGroup("", [cancelled, exc])]
302+
)
303+
)
304+
assert excinfo.value.__cause__ == cause
305+
assert excinfo.value.__context__ == context
306+
307+
# multiple non-cancelled
308+
eg = ExceptionGroup("", [ValueError("foo"), ValueError("bar")])
309+
with pytest.raises(
310+
AssertionError,
311+
match=r"^Attempted to unwrap exceptiongroup with multiple non-cancelled exceptions. This is often caused by a bug in the caller.$",
312+
) as excinfo:
313+
raise_single_exception_from_group(eg)
314+
assert excinfo.value.__cause__ is eg
315+
assert excinfo.value.__context__ is None
316+
317+
# keyboardinterrupt overrides everything
318+
eg_ki = BaseExceptionGroup(
319+
"",
320+
[
321+
ValueError("foo"),
322+
ValueError("bar"),
323+
KeyboardInterrupt("this exc doesn't get reraised"),
324+
],
325+
)
326+
with pytest.raises(KeyboardInterrupt, match=r"^$") as excinfo:
327+
raise_single_exception_from_group(eg_ki)
328+
assert excinfo.value.__cause__ is eg_ki
329+
assert excinfo.value.__context__ is None
330+
331+
# if we only got cancelled, first one is reraised
332+
with pytest.raises(trio.Cancelled, match=r"^Cancelled$") as excinfo:
333+
raise_single_exception_from_group(
334+
BaseExceptionGroup("", [cancelled, trio.Cancelled._create()])
335+
)
336+
assert excinfo.value is cancelled
337+
assert excinfo.value.__cause__ is None
338+
assert excinfo.value.__context__ is None

src/trio/_util.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import collections.abc
55
import inspect
66
import signal
7+
import sys
78
from abc import ABCMeta
89
from collections.abc import Awaitable, Callable, Sequence
910
from functools import update_wrapper
@@ -20,6 +21,9 @@
2021

2122
import trio
2223

24+
if sys.version_info < (3, 11):
25+
from exceptiongroup import BaseExceptionGroup
26+
2327
# Explicit "Any" is not allowed
2428
CallT = TypeVar("CallT", bound=Callable[..., Any]) # type: ignore[explicit-any]
2529
T = TypeVar("T")
@@ -353,3 +357,61 @@ def wraps( # type: ignore[explicit-any]
353357

354358
else:
355359
from functools import wraps # noqa: F401 # this is re-exported
360+
361+
362+
def _raise(exc: BaseException) -> NoReturn:
363+
"""This helper allows re-raising an exception without __context__ being set."""
364+
# cause does not need special handling, we simply avoid using `raise .. from ..`
365+
__tracebackhide__ = True
366+
context = exc.__context__
367+
try:
368+
raise exc
369+
finally:
370+
exc.__context__ = context
371+
del exc, context
372+
373+
374+
def raise_single_exception_from_group(
375+
eg: BaseExceptionGroup[BaseException],
376+
) -> NoReturn:
377+
"""This function takes an exception group that is assumed to have at most
378+
one non-cancelled exception, which it reraises as a standalone exception.
379+
380+
If a :exc:`KeyboardInterrupt` is encountered, a new KeyboardInterrupt is immediately
381+
raised with the entire group as cause.
382+
383+
If the group only contains :exc:`Cancelled` it reraises the first one encountered.
384+
385+
It will retain context and cause of the contained exception, and entirely discard
386+
the cause/context of the group(s).
387+
388+
If multiple non-cancelled exceptions are encountered, it raises
389+
:exc:`AssertionError`.
390+
"""
391+
cancelled_exceptions = []
392+
noncancelled_exceptions = []
393+
394+
# subgroup/split retains excgroup structure, so we need to manually traverse
395+
def _parse_excg(e: BaseException) -> None:
396+
if isinstance(e, (KeyboardInterrupt, SystemExit)):
397+
# immediately bail out
398+
raise KeyboardInterrupt from eg
399+
400+
if isinstance(e, trio.Cancelled):
401+
cancelled_exceptions.append(e)
402+
elif isinstance(e, BaseExceptionGroup):
403+
for sub_e in e.exceptions:
404+
_parse_excg(sub_e)
405+
else:
406+
noncancelled_exceptions.append(e)
407+
408+
_parse_excg(eg)
409+
410+
if len(noncancelled_exceptions) > 1:
411+
raise AssertionError(
412+
"Attempted to unwrap exceptiongroup with multiple non-cancelled exceptions. This is often caused by a bug in the caller."
413+
) from eg
414+
if len(noncancelled_exceptions) == 1:
415+
_raise(noncancelled_exceptions[0])
416+
assert cancelled_exceptions, "internal error"
417+
_raise(cancelled_exceptions[0])

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ labels =
1717
# * move to pyproject.toml?
1818
# * this means conditional deps need to be replaced
1919

20-
# protip: install uv-tox for faster venv generation
20+
# protip: install tox-uv for faster venv generation
2121

2222
[testenv]
2323
description = "Base environment for running tests depending on python version."

0 commit comments

Comments
 (0)