From fd67ee120de34886eb5d78eb10bb2afd7b53e682 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Wed, 2 Apr 2025 13:36:18 +0200 Subject: [PATCH 1/3] add raise_single_exception_from_group --- src/trio/__init__.py | 1 + src/trio/_channel.py | 66 +++++++++++++++++++++++++++++++ src/trio/_tests/test_channel.py | 69 +++++++++++++++++++++++++++++++++ tox.ini | 2 +- 4 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/trio/__init__.py b/src/trio/__init__.py index 339ef7b0c3..b156986956 100644 --- a/src/trio/__init__.py +++ b/src/trio/__init__.py @@ -28,6 +28,7 @@ MemoryReceiveChannel as MemoryReceiveChannel, MemorySendChannel as MemorySendChannel, open_memory_channel as open_memory_channel, + raise_single_exception_from_group as raise_single_exception_from_group, ) from ._core import ( BrokenResourceError as BrokenResourceError, diff --git a/src/trio/_channel.py b/src/trio/_channel.py index 6410d9120c..8ca33355d7 100644 --- a/src/trio/_channel.py +++ b/src/trio/_channel.py @@ -5,6 +5,7 @@ from typing import ( TYPE_CHECKING, Generic, + NoReturn, ) import attrs @@ -21,6 +22,11 @@ from typing_extensions import Self +import sys + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + def _open_memory_channel( max_buffer_size: int | float, # noqa: PYI041 @@ -440,3 +446,63 @@ async def aclose(self) -> None: See `MemoryReceiveChannel.close`.""" self.close() await trio.lowlevel.checkpoint() + + +def _raise(exc: BaseException) -> NoReturn: + """This helper allows re-raising an exception without __context__ being set.""" + # cause does not need special handling, we simply avoid using `raise .. from ..` + __tracebackhide__ = True + context = exc.__context__ + try: + raise exc + finally: + exc.__context__ = context + del exc, context + + +# idk where to define this. It's a util, but exported, so _util doesn`t fit. +# it'll be used by bg_with_channel, but is not directly related to channels. +def raise_single_exception_from_group( + eg: BaseExceptionGroup[BaseException], +) -> NoReturn: + """This function takes an exception group that is assumed to have at most + one non-cancelled exception, which it reraises as a standalone exception. + + If a :exc:`KeyboardInterrupt` is encountered, a new KeyboardInterrupt is immediately + raised with the entire group as cause. + + If the group only contains :exc:`Cancelled` it reraises the first one encountered. + + It will retain context and cause of the contained exception, and entirely discard + the cause/context of the group(s). + + If multiple non-cancelled exceptions are encountered, it raises + :exc:`AssertionError`. + """ + cancelled_exceptions = [] + noncancelled_exceptions = [] + + # subgroup/split retains excgroup structure, so we need to manually traverse + def _parse_excg(e: BaseException) -> None: + if isinstance(e, KeyboardInterrupt): + # immediately bail out + raise KeyboardInterrupt from eg + + if isinstance(e, trio.Cancelled): + cancelled_exceptions.append(e) + elif isinstance(e, BaseExceptionGroup): + for sub_e in e.exceptions: + _parse_excg(sub_e) + else: + noncancelled_exceptions.append(e) + + _parse_excg(eg) + + if len(noncancelled_exceptions) > 1: + raise AssertionError( + "Attempted to unwrap exceptiongroup with multiple non-cancelled exceptions. This is often caused by a bug in the caller." + ) from eg + if len(noncancelled_exceptions) == 1: + _raise(noncancelled_exceptions[0]) + assert cancelled_exceptions, "internal error" + _raise(cancelled_exceptions[0]) diff --git a/src/trio/_tests/test_channel.py b/src/trio/_tests/test_channel.py index 104b17640f..82f2e7714e 100644 --- a/src/trio/_tests/test_channel.py +++ b/src/trio/_tests/test_channel.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from typing import Union import pytest @@ -9,6 +10,9 @@ from ..testing import assert_checkpoints, wait_all_tasks_blocked +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup, ExceptionGroup + async def test_channel() -> None: with pytest.raises(TypeError): @@ -411,3 +415,68 @@ async def do_send(s: trio.MemorySendChannel[int], v: int) -> None: assert await r.receive() == 1 with pytest.raises(trio.WouldBlock): r.receive_nowait() + + +async def test_raise_single_exception_from_group() -> None: + excinfo: pytest.ExceptionInfo[BaseException] + + exc = ValueError("foo") + cause = SyntaxError("cause") + context = TypeError("context") + exc.__cause__ = cause + exc.__context__ = context + cancelled = trio.Cancelled._create() + + with pytest.raises(ValueError, match="foo") as excinfo: + trio.raise_single_exception_from_group(ExceptionGroup("", [exc])) + assert excinfo.value.__cause__ == cause + assert excinfo.value.__context__ == context + + with pytest.raises(ValueError, match="foo") as excinfo: + trio.raise_single_exception_from_group( + ExceptionGroup("", [ExceptionGroup("", [exc])]) + ) + assert excinfo.value.__cause__ == cause + assert excinfo.value.__context__ == context + + with pytest.raises(ValueError, match="foo") as excinfo: + trio.raise_single_exception_from_group( + BaseExceptionGroup( + "", [cancelled, BaseExceptionGroup("", [cancelled, exc])] + ) + ) + assert excinfo.value.__cause__ == cause + assert excinfo.value.__context__ == context + + # multiple non-cancelled + eg = ExceptionGroup("", [ValueError("foo"), ValueError("bar")]) + with pytest.raises( + AssertionError, + match=r"^Attempted to unwrap exceptiongroup with multiple non-cancelled exceptions. This is often caused by a bug in the caller.$", + ) as excinfo: + trio.raise_single_exception_from_group(eg) + assert excinfo.value.__cause__ is eg + assert excinfo.value.__context__ is None + + # keyboardinterrupt overrides everything + eg_ki = BaseExceptionGroup( + "", + [ + ValueError("foo"), + ValueError("bar"), + KeyboardInterrupt("this exc doesn't get reraised"), + ], + ) + with pytest.raises(KeyboardInterrupt, match=r"^$") as excinfo: + trio.raise_single_exception_from_group(eg_ki) + assert excinfo.value.__cause__ is eg_ki + assert excinfo.value.__context__ is None + + # if we only got cancelled, first one is reraised + with pytest.raises(trio.Cancelled, match=r"^Cancelled$") as excinfo: + trio.raise_single_exception_from_group( + BaseExceptionGroup("", [cancelled, trio.Cancelled._create()]) + ) + assert excinfo.value is cancelled + assert excinfo.value.__cause__ is None + assert excinfo.value.__context__ is None diff --git a/tox.ini b/tox.ini index 46b9fae2bc..6eecf7b78a 100644 --- a/tox.ini +++ b/tox.ini @@ -17,7 +17,7 @@ labels = # * move to pyproject.toml? # * this means conditional deps need to be replaced -# protip: install uv-tox for faster venv generation +# protip: install tox-uv for faster venv generation [testenv] description = "Base environment for running tests depending on python version." From 16b3872c8f1bacea6061b9bac9af467283f9e264 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Mon, 7 Apr 2025 13:15:33 +0200 Subject: [PATCH 2/3] don't publicly export, move to _util --- src/trio/__init__.py | 1 - src/trio/_channel.py | 66 ------------------------------- src/trio/_tests/test_channel.py | 69 --------------------------------- src/trio/_tests/test_util.py | 69 +++++++++++++++++++++++++++++++++ src/trio/_util.py | 62 +++++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 136 deletions(-) diff --git a/src/trio/__init__.py b/src/trio/__init__.py index b156986956..339ef7b0c3 100644 --- a/src/trio/__init__.py +++ b/src/trio/__init__.py @@ -28,7 +28,6 @@ MemoryReceiveChannel as MemoryReceiveChannel, MemorySendChannel as MemorySendChannel, open_memory_channel as open_memory_channel, - raise_single_exception_from_group as raise_single_exception_from_group, ) from ._core import ( BrokenResourceError as BrokenResourceError, diff --git a/src/trio/_channel.py b/src/trio/_channel.py index 8ca33355d7..6410d9120c 100644 --- a/src/trio/_channel.py +++ b/src/trio/_channel.py @@ -5,7 +5,6 @@ from typing import ( TYPE_CHECKING, Generic, - NoReturn, ) import attrs @@ -22,11 +21,6 @@ from typing_extensions import Self -import sys - -if sys.version_info < (3, 11): - from exceptiongroup import BaseExceptionGroup - def _open_memory_channel( max_buffer_size: int | float, # noqa: PYI041 @@ -446,63 +440,3 @@ async def aclose(self) -> None: See `MemoryReceiveChannel.close`.""" self.close() await trio.lowlevel.checkpoint() - - -def _raise(exc: BaseException) -> NoReturn: - """This helper allows re-raising an exception without __context__ being set.""" - # cause does not need special handling, we simply avoid using `raise .. from ..` - __tracebackhide__ = True - context = exc.__context__ - try: - raise exc - finally: - exc.__context__ = context - del exc, context - - -# idk where to define this. It's a util, but exported, so _util doesn`t fit. -# it'll be used by bg_with_channel, but is not directly related to channels. -def raise_single_exception_from_group( - eg: BaseExceptionGroup[BaseException], -) -> NoReturn: - """This function takes an exception group that is assumed to have at most - one non-cancelled exception, which it reraises as a standalone exception. - - If a :exc:`KeyboardInterrupt` is encountered, a new KeyboardInterrupt is immediately - raised with the entire group as cause. - - If the group only contains :exc:`Cancelled` it reraises the first one encountered. - - It will retain context and cause of the contained exception, and entirely discard - the cause/context of the group(s). - - If multiple non-cancelled exceptions are encountered, it raises - :exc:`AssertionError`. - """ - cancelled_exceptions = [] - noncancelled_exceptions = [] - - # subgroup/split retains excgroup structure, so we need to manually traverse - def _parse_excg(e: BaseException) -> None: - if isinstance(e, KeyboardInterrupt): - # immediately bail out - raise KeyboardInterrupt from eg - - if isinstance(e, trio.Cancelled): - cancelled_exceptions.append(e) - elif isinstance(e, BaseExceptionGroup): - for sub_e in e.exceptions: - _parse_excg(sub_e) - else: - noncancelled_exceptions.append(e) - - _parse_excg(eg) - - if len(noncancelled_exceptions) > 1: - raise AssertionError( - "Attempted to unwrap exceptiongroup with multiple non-cancelled exceptions. This is often caused by a bug in the caller." - ) from eg - if len(noncancelled_exceptions) == 1: - _raise(noncancelled_exceptions[0]) - assert cancelled_exceptions, "internal error" - _raise(cancelled_exceptions[0]) diff --git a/src/trio/_tests/test_channel.py b/src/trio/_tests/test_channel.py index 82f2e7714e..104b17640f 100644 --- a/src/trio/_tests/test_channel.py +++ b/src/trio/_tests/test_channel.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys from typing import Union import pytest @@ -10,9 +9,6 @@ from ..testing import assert_checkpoints, wait_all_tasks_blocked -if sys.version_info < (3, 11): - from exceptiongroup import BaseExceptionGroup, ExceptionGroup - async def test_channel() -> None: with pytest.raises(TypeError): @@ -415,68 +411,3 @@ async def do_send(s: trio.MemorySendChannel[int], v: int) -> None: assert await r.receive() == 1 with pytest.raises(trio.WouldBlock): r.receive_nowait() - - -async def test_raise_single_exception_from_group() -> None: - excinfo: pytest.ExceptionInfo[BaseException] - - exc = ValueError("foo") - cause = SyntaxError("cause") - context = TypeError("context") - exc.__cause__ = cause - exc.__context__ = context - cancelled = trio.Cancelled._create() - - with pytest.raises(ValueError, match="foo") as excinfo: - trio.raise_single_exception_from_group(ExceptionGroup("", [exc])) - assert excinfo.value.__cause__ == cause - assert excinfo.value.__context__ == context - - with pytest.raises(ValueError, match="foo") as excinfo: - trio.raise_single_exception_from_group( - ExceptionGroup("", [ExceptionGroup("", [exc])]) - ) - assert excinfo.value.__cause__ == cause - assert excinfo.value.__context__ == context - - with pytest.raises(ValueError, match="foo") as excinfo: - trio.raise_single_exception_from_group( - BaseExceptionGroup( - "", [cancelled, BaseExceptionGroup("", [cancelled, exc])] - ) - ) - assert excinfo.value.__cause__ == cause - assert excinfo.value.__context__ == context - - # multiple non-cancelled - eg = ExceptionGroup("", [ValueError("foo"), ValueError("bar")]) - with pytest.raises( - AssertionError, - match=r"^Attempted to unwrap exceptiongroup with multiple non-cancelled exceptions. This is often caused by a bug in the caller.$", - ) as excinfo: - trio.raise_single_exception_from_group(eg) - assert excinfo.value.__cause__ is eg - assert excinfo.value.__context__ is None - - # keyboardinterrupt overrides everything - eg_ki = BaseExceptionGroup( - "", - [ - ValueError("foo"), - ValueError("bar"), - KeyboardInterrupt("this exc doesn't get reraised"), - ], - ) - with pytest.raises(KeyboardInterrupt, match=r"^$") as excinfo: - trio.raise_single_exception_from_group(eg_ki) - assert excinfo.value.__cause__ is eg_ki - assert excinfo.value.__context__ is None - - # if we only got cancelled, first one is reraised - with pytest.raises(trio.Cancelled, match=r"^Cancelled$") as excinfo: - trio.raise_single_exception_from_group( - BaseExceptionGroup("", [cancelled, trio.Cancelled._create()]) - ) - assert excinfo.value is cancelled - assert excinfo.value.__cause__ is None - assert excinfo.value.__context__ is None diff --git a/src/trio/_tests/test_util.py b/src/trio/_tests/test_util.py index 5036d76e52..ba11f5a311 100644 --- a/src/trio/_tests/test_util.py +++ b/src/trio/_tests/test_util.py @@ -24,9 +24,13 @@ fixup_module_metadata, generic_function, is_main_thread, + raise_single_exception_from_group, ) from ..testing import wait_all_tasks_blocked +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup, ExceptionGroup + if TYPE_CHECKING: from collections.abc import AsyncGenerator @@ -267,3 +271,68 @@ def test_fixup_module_metadata() -> None: mod.some_func() mod._private() mod.SomeClass().method() + + +async def test_raise_single_exception_from_group() -> None: + excinfo: pytest.ExceptionInfo[BaseException] + + exc = ValueError("foo") + cause = SyntaxError("cause") + context = TypeError("context") + exc.__cause__ = cause + exc.__context__ = context + cancelled = trio.Cancelled._create() + + with pytest.raises(ValueError, match="foo") as excinfo: + raise_single_exception_from_group(ExceptionGroup("", [exc])) + assert excinfo.value.__cause__ == cause + assert excinfo.value.__context__ == context + + with pytest.raises(ValueError, match="foo") as excinfo: + raise_single_exception_from_group( + ExceptionGroup("", [ExceptionGroup("", [exc])]) + ) + assert excinfo.value.__cause__ == cause + assert excinfo.value.__context__ == context + + with pytest.raises(ValueError, match="foo") as excinfo: + raise_single_exception_from_group( + BaseExceptionGroup( + "", [cancelled, BaseExceptionGroup("", [cancelled, exc])] + ) + ) + assert excinfo.value.__cause__ == cause + assert excinfo.value.__context__ == context + + # multiple non-cancelled + eg = ExceptionGroup("", [ValueError("foo"), ValueError("bar")]) + with pytest.raises( + AssertionError, + match=r"^Attempted to unwrap exceptiongroup with multiple non-cancelled exceptions. This is often caused by a bug in the caller.$", + ) as excinfo: + raise_single_exception_from_group(eg) + assert excinfo.value.__cause__ is eg + assert excinfo.value.__context__ is None + + # keyboardinterrupt overrides everything + eg_ki = BaseExceptionGroup( + "", + [ + ValueError("foo"), + ValueError("bar"), + KeyboardInterrupt("this exc doesn't get reraised"), + ], + ) + with pytest.raises(KeyboardInterrupt, match=r"^$") as excinfo: + raise_single_exception_from_group(eg_ki) + assert excinfo.value.__cause__ is eg_ki + assert excinfo.value.__context__ is None + + # if we only got cancelled, first one is reraised + with pytest.raises(trio.Cancelled, match=r"^Cancelled$") as excinfo: + raise_single_exception_from_group( + BaseExceptionGroup("", [cancelled, trio.Cancelled._create()]) + ) + assert excinfo.value is cancelled + assert excinfo.value.__cause__ is None + assert excinfo.value.__context__ is None diff --git a/src/trio/_util.py b/src/trio/_util.py index 9b8b1d7a46..2740268f55 100644 --- a/src/trio/_util.py +++ b/src/trio/_util.py @@ -4,6 +4,7 @@ import collections.abc import inspect import signal +import sys from abc import ABCMeta from collections.abc import Awaitable, Callable, Sequence from functools import update_wrapper @@ -20,6 +21,9 @@ import trio +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + # Explicit "Any" is not allowed CallT = TypeVar("CallT", bound=Callable[..., Any]) # type: ignore[explicit-any] T = TypeVar("T") @@ -353,3 +357,61 @@ def wraps( # type: ignore[explicit-any] else: from functools import wraps # noqa: F401 # this is re-exported + + +def _raise(exc: BaseException) -> NoReturn: + """This helper allows re-raising an exception without __context__ being set.""" + # cause does not need special handling, we simply avoid using `raise .. from ..` + __tracebackhide__ = True + context = exc.__context__ + try: + raise exc + finally: + exc.__context__ = context + del exc, context + + +def raise_single_exception_from_group( + eg: BaseExceptionGroup[BaseException], +) -> NoReturn: + """This function takes an exception group that is assumed to have at most + one non-cancelled exception, which it reraises as a standalone exception. + + If a :exc:`KeyboardInterrupt` is encountered, a new KeyboardInterrupt is immediately + raised with the entire group as cause. + + If the group only contains :exc:`Cancelled` it reraises the first one encountered. + + It will retain context and cause of the contained exception, and entirely discard + the cause/context of the group(s). + + If multiple non-cancelled exceptions are encountered, it raises + :exc:`AssertionError`. + """ + cancelled_exceptions = [] + noncancelled_exceptions = [] + + # subgroup/split retains excgroup structure, so we need to manually traverse + def _parse_excg(e: BaseException) -> None: + if isinstance(e, KeyboardInterrupt): + # immediately bail out + raise KeyboardInterrupt from eg + + if isinstance(e, trio.Cancelled): + cancelled_exceptions.append(e) + elif isinstance(e, BaseExceptionGroup): + for sub_e in e.exceptions: + _parse_excg(sub_e) + else: + noncancelled_exceptions.append(e) + + _parse_excg(eg) + + if len(noncancelled_exceptions) > 1: + raise AssertionError( + "Attempted to unwrap exceptiongroup with multiple non-cancelled exceptions. This is often caused by a bug in the caller." + ) from eg + if len(noncancelled_exceptions) == 1: + _raise(noncancelled_exceptions[0]) + assert cancelled_exceptions, "internal error" + _raise(cancelled_exceptions[0]) From edf83f6d8f5a30ecf38d1d1d8bd957e34a67eedd Mon Sep 17 00:00:00 2001 From: jakkdl Date: Wed, 9 Apr 2025 14:33:07 +0200 Subject: [PATCH 3/3] also immediately bail on SystemExit --- src/trio/_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/trio/_util.py b/src/trio/_util.py index 2740268f55..54fc5ff733 100644 --- a/src/trio/_util.py +++ b/src/trio/_util.py @@ -393,7 +393,7 @@ def raise_single_exception_from_group( # subgroup/split retains excgroup structure, so we need to manually traverse def _parse_excg(e: BaseException) -> None: - if isinstance(e, KeyboardInterrupt): + if isinstance(e, (KeyboardInterrupt, SystemExit)): # immediately bail out raise KeyboardInterrupt from eg