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