Skip to content

Change defer_to_* to be based on [Base]ExceptionGroup #23

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
package_dir={'': 'src'},
install_requires=[
'async_generator',
'trio >= 0.11.0'
'trio >= 0.11.0',
Copy link
Contributor

Choose a reason for hiding this comment

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

Note that trio-util supports old, pre-ExceptionGroup versions of trio. As the primary user of trio-util, my own organization is still on trio 0.21. Effectively, this PR is disallowing trio < 0.22.

"exceptiongroup; python_version < '3.11'",
],
python_requires='>=3.7',
classifiers=[
Expand Down
2 changes: 1 addition & 1 deletion src/trio_util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from ._awaitables import wait_all, wait_any
from ._cancel_scopes import move_on_when, run_and_cancelling
from ._compose_values import compose_values
from ._exceptions import defer_to_cancelled, multi_error_defer_to
from ._exceptions import defer_to_cancelled, exceptgroup_defer_to, multi_error_defer_to
from ._iterators import iter_fail_after, iter_move_on_after
from ._periodic import periodic
from ._repeated_event import RepeatedEvent
Expand Down
137 changes: 89 additions & 48 deletions src/trio_util/_exceptions.py
Original file line number Diff line number Diff line change
@@ -1,64 +1,84 @@
import sys
from collections import defaultdict
from contextlib import _GeneratorContextManager
from functools import wraps
from inspect import iscoroutinefunction
from typing import Type, Dict, List
from typing import TYPE_CHECKING, Any, Callable, ContextManager, Dict, Iterator, List, Type, TypeVar

import trio

if sys.version_info < (3, 11):
from exceptiongroup import BaseExceptionGroup

class _AsyncFriendlyGeneratorContextManager(_GeneratorContextManager):
T = TypeVar('T')
CallT = TypeVar('CallT', bound=Callable[..., object])

if TYPE_CHECKING:
from typing_extensions import ParamSpec, TypeAlias
ArgsT = ParamSpec('ArgsT')
_GCM: TypeAlias = _GeneratorContextManager
else:
# Dummy value to make _GCM[T] work at runtime.
_GCM = {T: _GeneratorContextManager}


class _AsyncFriendlyGeneratorContextManager(_GCM[T]):
"""
Hack contextlib._GeneratorContextManager so that resulting context
manager can properly decorate async functions.
"""

def __call__(self, func):
def __call__(self, func: CallT) -> CallT:
# We can't type the internals properly - inside the true branch the return type is async,
# but we can't express that. It needs to be a typevar bound to callable, so that it fully
# preserves overloads and things like that.
if iscoroutinefunction(func):
@wraps(func)
async def inner(*args, **kwargs):
async def inner(*args: object, **kwargs: object) -> object:
with self._recreate_cm(): # type: ignore[attr-defined] # pylint: disable=not-context-manager
return await func(*args, **kwargs)
else:
@wraps(func)
def inner(*args, **kwargs):
def inner(*args: Any, **kwargs: Any) -> Any:
with self._recreate_cm(): # type: ignore[attr-defined] # pylint: disable=not-context-manager
return func(*args, **kwargs)
return inner
return inner # type: ignore


def _async_friendly_contextmanager(func):
def _async_friendly_contextmanager(
func: 'Callable[ArgsT, Iterator[T]]',
) -> 'Callable[ArgsT, ContextManager[T]]':
"""
Equivalent to @contextmanager, except the resulting (non-async) context
manager works correctly as a decorator on async functions.
"""
@wraps(func)
def helper(*args, **kwargs):
def helper(*args: 'ArgsT.args', **kwargs: 'ArgsT.kwargs') -> ContextManager[T]:
return _AsyncFriendlyGeneratorContextManager(func, args, kwargs)
return helper


def defer_to_cancelled(*args: Type[Exception]):
"""Context manager which defers MultiError exceptions to Cancelled.
def defer_to_cancelled(*args: Type[BaseException]) -> ContextManager[None]:
"""Context manager which defers [Base]ExceptionGroup exceptions to Cancelled.

In the scope of this context manager, any raised trio.MultiError exception
In the scope of this context manager, any raised [Base]ExceptionGroup exception
which is a combination of the given exception types and trio.Cancelled
will have the exception types filtered, leaving only a Cancelled exception.

The intended use is where routine exceptions (e.g. which are part of an API)
might occur simultaneously with Cancelled (e.g. when using move_on_after()).
Without properly catching and filtering the resulting MultiError, an
Without properly catching and filtering the resulting ExceptionGroup, an
unhandled exception will occur. Often what is desired in this case is
for the Cancelled exception alone to propagate to the cancel scope.

Equivalent to ``multi_error_defer_to(trio.Cancelled, *args)``.
Equivalent to ``exceptgroup_defer_to(trio.Cancelled, *args)``.

:param args: One or more exception types which will defer to
trio.Cancelled. By default, all exception types will be filtered.

Example::

# If MultiError([Cancelled, Obstacle]) occurs, propagate only Cancelled
# If ExceptionGroup([Cancelled, Obstacle]) occurs, propagate only Cancelled
# to the parent cancel scope.
with defer_to_cancelled(Obstacle):
try:
Expand All @@ -68,21 +88,23 @@ def defer_to_cancelled(*args: Type[Exception]):
# handle API exception (unless Cancelled raised simultaneously)
...
"""
return multi_error_defer_to(trio.Cancelled, *args)
return exceptgroup_defer_to(trio.Cancelled, *args)


@_async_friendly_contextmanager
def multi_error_defer_to(*privileged_types: Type[BaseException],
propagate_multi_error=True,
strict=True):
def exceptgroup_defer_to(
*privileged_types: Type[BaseException],
propagate_exc_group: bool = True,
strict: bool = True,
) -> Iterator[None]:
"""
Defer a trio.MultiError exception to a single, privileged exception
Defer a [Base]ExceptionGroup exception to a single, privileged exception.

In the scope of this context manager, a raised MultiError will be coalesced
In the scope of this context manager, a raised ExceptionGroup will be coalesced
into a single exception with the highest privilege if the following
criteria is met:

1. every exception in the MultiError is an instance of one of the given
1. every exception in the ExceptionGroup is an instance of one of the given
privileged types

additionally, by default with strict=True:
Expand All @@ -91,51 +113,51 @@ def multi_error_defer_to(*privileged_types: Type[BaseException],
the exceptions by repr(). For example, this test fails if both
ValueError('foo') and ValueError('bar') are the most privileged.

If the criteria are not met, by default the original MultiError is
propagated. Use propagate_multi_error=False to instead raise a
If the criteria are not met, by default the original ExceptionGroup is
propagated. Use propagate_exc_group=False to instead raise a
RuntimeError in these cases.

Examples::

multi_error_defer_to(trio.Cancelled, MyException)
MultiError([Cancelled(), MyException()]) -> Cancelled()
MultiError([Cancelled(), MyException(),
MultiError([Cancelled(), Cancelled())]]) -> Cancelled()
MultiError([Cancelled(), MyException(), ValueError()]) -> *no change*
MultiError([MyException('foo'), MyException('foo')]) -> MyException('foo')
MultiError([MyException('foo'), MyException('bar')]) -> *no change*
exceptgroup_defer_to(trio.Cancelled, MyException)
ExceptionGroup([Cancelled(), MyException()]) -> Cancelled()
ExceptionGroup([Cancelled(), MyException(),
ExceptionGroup([Cancelled(), Cancelled())]]) -> Cancelled()
ExceptionGroup([Cancelled(), MyException(), ValueError()]) -> *no change*
ExceptionGroup([MyException('foo'), MyException('foo')]) -> MyException('foo')
ExceptionGroup([MyException('foo'), MyException('bar')]) -> *no change*

multi_error_defer_to(MyImportantException, trio.Cancelled, MyBaseException)
exceptgroup_defer_to(MyImportantException, trio.Cancelled, MyBaseException)
# where isinstance(MyDerivedException, MyBaseException)
# and isinstance(MyImportantException, MyBaseException)
MultiError([Cancelled(), MyDerivedException()]) -> Cancelled()
MultiError([MyImportantException(), Cancelled()]) -> MyImportantException()
ExceptionGroup([Cancelled(), MyDerivedException()]) -> Cancelled()
ExceptionGroup([MyImportantException(), Cancelled()]) -> MyImportantException()

:param privileged_types: exception types from highest priority to lowest
:param propagate_multi_error: if false, raise a RuntimeError where a
MultiError would otherwise be leaked
:param strict: propagate MultiError if there are multiple output exceptions
:param propagate_exc_group: if false, raise a RuntimeError where a
ExceptionGroup would otherwise be leaked
:param strict: propagate ExceptionGroup if there are multiple output exceptions
to chose from (i.e. multiple exceptions objects with differing repr()
are instances of the privileged type). When combined with
propagate_multi_error=False, this case will raise a RuntimeError.
propagate_exc_group=False, this case will raise a RuntimeError.
"""
try:
yield
except trio.MultiError as root_multi_error:
except BaseExceptionGroup as root_exc_group:
# flatten the exceptions in the MultiError, grouping by repr()
multi_errors = [root_multi_error]
exc_groups = [root_exc_group]
errors_by_repr = {} # exception_repr -> exception_object
while multi_errors:
multi_error = multi_errors.pop()
for e in multi_error.exceptions:
if isinstance(e, trio.MultiError):
multi_errors.append(e)
while exc_groups:
exc_group = exc_groups.pop()
for e in exc_group.exceptions:
if isinstance(e, BaseExceptionGroup):
exc_groups.append(e)
continue
if not isinstance(e, privileged_types):
# not in privileged list
if propagate_multi_error:
if propagate_exc_group:
raise
raise RuntimeError('Unhandled trio.MultiError') from root_multi_error
raise RuntimeError('Unhandled ExceptionGroup') from root_exc_group
errors_by_repr[repr(e)] = e
# group the resulting errors by index in the privileged type list
# priority_index -> exception_object
Expand All @@ -148,7 +170,26 @@ def multi_error_defer_to(*privileged_types: Type[BaseException],
priority_errors = errors_by_priority[min(errors_by_priority)]
if strict and len(priority_errors) > 1:
# multiple unique exception objects at the same priority
if propagate_multi_error:
if propagate_exc_group:
raise
raise RuntimeError('Unhandled trio.MultiError') from root_multi_error
raise RuntimeError('Unhandled ExceptionGroup') from root_exc_group
raise priority_errors[0]


def multi_error_defer_to(
*privileged_types: Type[BaseException],
propagate_multi_error: bool = True,
strict: bool = True,
) -> ContextManager[None]:
"""Deprecated alias for exceptgroup_defer_to()."""
import warnings
warnings.warn(
Comment on lines +179 to +186
Copy link
Contributor

Choose a reason for hiding this comment

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

The renaming and warning probably isn't worth it (I suspect my org is the only one using these functions).

I'm not sure it even makes sense to carry these exception utilities into the future. They were introduced as a way to make a pre-ExceptionGroup world more manageable.

Copy link
Contributor

Choose a reason for hiding this comment

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

I didn't consider this only really being for pre-ExceptionGroup code. Might just close this then, if they should be effectively deprecated?

I should refine my statement: they were introduced as a way to make lack of except* syntax more manageable. Without the syntax, it's a burden for API users to deal with "exception Foo or Foo wrapped inside an exception group". The filtering utils of MultiError and the exceptiongroup shim don't help with it.

Since projects don't always have freedom to use the newest python versions, these utilities still have a place. But it's still probably the case that no one is using them or will miss them.

'multi_error_defer_to() is deprecated, use exceptgroup_defer_to() instead.',
DeprecationWarning,
stacklevel=2,
)
return exceptgroup_defer_to(
*privileged_types,
propagate_exc_group=propagate_multi_error,
strict=strict,
)
1 change: 1 addition & 0 deletions test-requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ pytest
pytest-cov
pytest-trio
types-mock
exceptiongroup
23 changes: 19 additions & 4 deletions test-requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#
# This file is autogenerated by pip-compile with python 3.8
# To update, run:
# This file is autogenerated by pip-compile with Python 3.8
# by the following command:
#
# pip-compile --output-file=test-requirements.txt setup.py test-requirements.in
#
Expand All @@ -10,16 +10,29 @@ async-generator==1.10
# via
# pytest-trio
# trio
# trio-util (setup.py)
# trio_util (setup.py)
atomicwrites==1.4.1
# via pytest
attrs==20.3.0
# via
# outcome
# pytest
# trio
cffi==1.16.0
# via trio
click==8.0.1
# via pip-tools
colorama==0.4.6
# via
# click
# pylint
# pytest
coverage==5.5
# via pytest-cov
exceptiongroup==1.2.0 ; python_version < "3.11"
# via
# -r test-requirements.in
# trio_util (setup. py)
idna==3.1
# via trio
iniconfig==1.1.1
Expand Down Expand Up @@ -50,6 +63,8 @@ pluggy==0.13.1
# via pytest
py==1.10.0
# via pytest
pycparser==2.21
# via cffi
pylint==3.0.0a3
# via -r test-requirements.in
pyparsing==2.4.7
Expand Down Expand Up @@ -77,7 +92,7 @@ tomli==2.0.1
trio==0.18.0
# via
# pytest-trio
# trio-util (setup.py)
# trio_util (setup.py)
types-mock==4.0.1
# via -r test-requirements.in
typing-extensions==4.1.1
Expand Down
25 changes: 21 additions & 4 deletions test-requirements_trio-0.12.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#
# This file is autogenerated by pip-compile with python 3.8
# To update, run:
# This file is autogenerated by pip-compile with Python 3.8
# by the following command:
#
# pip-compile --output-file=test-requirements.txt setup.py test-requirements.in
#
Expand All @@ -10,16 +10,29 @@ async-generator==1.10
# via
# pytest-trio
# trio
# trio-util (setup.py)
# trio_util (setup.py)
atomicwrites==1.4.1
# via pytest
attrs==20.3.0
# via
# outcome
# pytest
# trio
cffi==1.16.0
# via trio
click==8.0.1
# via pip-tools
colorama==0.4.6
# via
# click
# pylint
# pytest
coverage==5.5
# via pytest-cov
exceptiongroup==1.2.0 ; python_version < "3.11"
# via
# -r test-requirements.in
# trio_util (setup. py)
idna==3.1
# via trio
iniconfig==1.1.1
Expand Down Expand Up @@ -50,6 +63,8 @@ pluggy==0.13.1
# via pytest
py==1.10.0
# via pytest
pycparser==2.21
# via cffi
pylint==3.0.0a3
# via -r test-requirements.in
pyparsing==2.4.7
Expand All @@ -72,10 +87,12 @@ toml==0.10.2
# pep517
# pylint
# pytest
tomli==2.0.1
# via mypy
trio==0.12.0
# via
# pytest-trio
# trio-util (setup.py)
# trio_util (setup.py)
types-mock==4.0.1
# via -r test-requirements.in
typing-extensions==4.1.1
Expand Down
Loading