-
Notifications
You must be signed in to change notification settings - Fork 7
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
Changes from all commits
e7a68d6
4d31ab4
292121c
71694a0
2c64cb8
34d5ec8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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: | ||
|
@@ -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: | ||
|
@@ -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 | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I should refine my statement: they were introduced as a way to make lack of 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, | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,3 +6,4 @@ pytest | |
pytest-cov | ||
pytest-trio | ||
types-mock | ||
exceptiongroup |
There was a problem hiding this comment.
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.