Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/trio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link
Contributor

@A5rocks A5rocks Apr 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this should be publicly exported. I think service nurseries take many (if not all) of its use cases and I'd rather encourage their use whenever we get them.

(At least for now. If we don't end up changing anything then at least we have a 1 line + docs change that exposes this)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

huh, you specifically said on gitter that a helper like this would be "genuinely useful" after @agronholm mentioned it?

https://matrix.to/#/!OqDVTrmPstKzivLwZW:gitter.im/$DaVcJo3WYQqFT_wFz_qbQsRqVJ3e7k_f8WnTl1ArwUU?via=gitter.im&via=matrix.org&via=ancap.tech

but I'm not entirely opposed to making it private for the moment and letting power users play around with it

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the main change since then is I hadn't really realized service nurseries/their implications. (I was vaguely aware of what they would do but hadn't put 2 and 2 together).

Really I thought through how this would do compared to service nurseries while typing up #1521 (comment) and I concluded that this primitive is basically only useful for service-nursery shaped tasks (e.g. for #3197 the service nursery would start a service that runs outcome.acapture(partial(anext, aiter)) (or something like that) and then sends through the memory channel which then promptly gets .unwrap()'ed). Also it vaguely feels like capturing intent is important and service nurseries do that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like there might still be scenarios where you want this in more complex stuff going on, depending on exactly how the service-nursery implementation ends up - but we can leave it private for now and see how usage pans out.

)
from ._core import (
BrokenResourceError as BrokenResourceError,
Expand Down
66 changes: 66 additions & 0 deletions src/trio/_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import (
TYPE_CHECKING,
Generic,
NoReturn,
)

import attrs
Expand All @@ -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
Expand Down Expand Up @@ -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])
69 changes: 69 additions & 0 deletions src/trio/_tests/test_channel.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import sys
from typing import Union

import pytest
Expand All @@ -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):
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
Loading