From 31706e818ac041781d39c6b51efe65787e915e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 25 Jan 2022 11:18:07 +0200 Subject: [PATCH 01/93] Add news fragment for #2210 --- newsfragments/2210.removal.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 newsfragments/2210.removal.rst diff --git a/newsfragments/2210.removal.rst b/newsfragments/2210.removal.rst new file mode 100644 index 000000000..aa7c74034 --- /dev/null +++ b/newsfragments/2210.removal.rst @@ -0,0 +1 @@ +Remove support for Python 3.6. \ No newline at end of file From 743354b02b60d49b23fbfef3ddef40729cd40fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 16 Jan 2022 00:12:03 +0200 Subject: [PATCH 02/93] Replace MultiError with (Base)ExceptionGroup Closes #2211. --- docs/source/design.rst | 3 - docs/source/reference-core.rst | 109 +-- docs/source/tutorial.rst | 8 +- newsfragments/2211.removal.rst | 4 + trio/__init__.py | 1 - trio/_core/__init__.py | 2 - trio/_core/_multierror.py | 517 ------------ trio/_core/_run.py | 27 +- trio/_core/tests/test_multierror.py | 772 ------------------ .../tests/test_multierror_scripts/__init__.py | 2 - .../tests/test_multierror_scripts/_common.py | 7 - .../apport_excepthook.py | 13 - .../custom_excepthook.py | 18 - .../ipython_custom_exc.py | 36 - .../simple_excepthook.py | 21 - .../simple_excepthook_IPython.py | 7 - .../simple_excepthook_partial.py | 13 - trio/_core/tests/test_run.py | 56 +- trio/_highlevel_open_tcp_listeners.py | 7 +- trio/_highlevel_open_tcp_stream.py | 14 +- .../test_highlevel_open_tcp_listeners.py | 7 +- trio/tests/test_highlevel_open_tcp_stream.py | 7 +- 22 files changed, 81 insertions(+), 1570 deletions(-) create mode 100644 newsfragments/2211.removal.rst delete mode 100644 trio/_core/_multierror.py delete mode 100644 trio/_core/tests/test_multierror.py delete mode 100644 trio/_core/tests/test_multierror_scripts/__init__.py delete mode 100644 trio/_core/tests/test_multierror_scripts/_common.py delete mode 100644 trio/_core/tests/test_multierror_scripts/apport_excepthook.py delete mode 100644 trio/_core/tests/test_multierror_scripts/custom_excepthook.py delete mode 100644 trio/_core/tests/test_multierror_scripts/ipython_custom_exc.py delete mode 100644 trio/_core/tests/test_multierror_scripts/simple_excepthook.py delete mode 100644 trio/_core/tests/test_multierror_scripts/simple_excepthook_IPython.py delete mode 100644 trio/_core/tests/test_multierror_scripts/simple_excepthook_partial.py diff --git a/docs/source/design.rst b/docs/source/design.rst index e0a8f939b..25647fe64 100644 --- a/docs/source/design.rst +++ b/docs/source/design.rst @@ -465,9 +465,6 @@ There are two notable sub-modules that are largely independent of the rest of Trio, and could (possibly should?) be extracted into their own independent packages: -* ``_multierror.py``: Implements :class:`MultiError` and associated - infrastructure. - * ``_ki.py``: Implements the core infrastructure for safe handling of :class:`KeyboardInterrupt`. diff --git a/docs/source/reference-core.rst b/docs/source/reference-core.rst index 514b5072c..e320088cf 100644 --- a/docs/source/reference-core.rst +++ b/docs/source/reference-core.rst @@ -641,7 +641,7 @@ crucial things to keep in mind: * Any unhandled exceptions are re-raised inside the parent task. If there are multiple exceptions, then they're collected up into a - single :exc:`MultiError` exception. + single :exc:`BaseExceptionGroup` or :exc:`ExceptionGroup` exception. Since all tasks are descendents of the initial task, one consequence of this is that :func:`run` can't finish until all tasks have @@ -712,14 +712,9 @@ limitation. Consider code like:: what? In some sense, the answer should be "both of these at once", but in Python there can only be one exception at a time. -Trio's answer is that it raises a :exc:`MultiError` object. This is a +Trio's answer is that it raises a :exc:`BaseExceptionGroup` object. This is a special exception which encapsulates multiple exception objects – -either regular exceptions or nested :exc:`MultiError`\s. To make these -easier to work with, Trio installs a custom `sys.excepthook` that -knows how to print nice tracebacks for unhandled :exc:`MultiError`\s, -and it also provides some helpful utilities like -:meth:`MultiError.catch`, which allows you to catch "part of" a -:exc:`MultiError`. +either regular exceptions or nested :exc:`BaseExceptionGroup`\s. Spawning tasks without becoming a parent @@ -837,104 +832,6 @@ The nursery API See :meth:`~Nursery.start`. -Working with :exc:`MultiError`\s -++++++++++++++++++++++++++++++++ - -.. autoexception:: MultiError - - .. attribute:: exceptions - - The list of exception objects that this :exc:`MultiError` - represents. - - .. automethod:: filter - - .. automethod:: catch - :with: - -Examples: - -Suppose we have a handler function that discards :exc:`ValueError`\s:: - - def handle_ValueError(exc): - if isinstance(exc, ValueError): - return None - else: - return exc - -Then these both raise :exc:`KeyError`:: - - with MultiError.catch(handle_ValueError): - raise MultiError([KeyError(), ValueError()]) - - with MultiError.catch(handle_ValueError): - raise MultiError([ - ValueError(), - MultiError([KeyError(), ValueError()]), - ]) - -And both of these raise nothing at all:: - - with MultiError.catch(handle_ValueError): - raise MultiError([ValueError(), ValueError()]) - - with MultiError.catch(handle_ValueError): - raise MultiError([ - MultiError([ValueError(), ValueError()]), - ValueError(), - ]) - -You can also return a new or modified exception, for example:: - - def convert_ValueError_to_MyCustomError(exc): - if isinstance(exc, ValueError): - # Similar to 'raise MyCustomError from exc' - new_exc = MyCustomError(...) - new_exc.__cause__ = exc - return new_exc - else: - return exc - -In the example above, we set ``__cause__`` as a form of explicit -context chaining. :meth:`MultiError.filter` and -:meth:`MultiError.catch` also perform implicit exception chaining – if -you return a new exception object, then the new object's -``__context__`` attribute will automatically be set to the original -exception. - -We also monkey patch :class:`traceback.TracebackException` to be able -to handle formatting :exc:`MultiError`\s. This means that anything that -formats exception messages like :mod:`logging` will work out of the -box:: - - import logging - - logging.basicConfig() - - try: - raise MultiError([ValueError("foo"), KeyError("bar")]) - except: - logging.exception("Oh no!") - raise - -Will properly log the inner exceptions: - -.. code-block:: none - - ERROR:root:Oh no! - Traceback (most recent call last): - File "", line 2, in - trio.MultiError: ValueError('foo',), KeyError('bar',) - - Details of embedded exception 1: - - ValueError: foo - - Details of embedded exception 2: - - KeyError: 'bar' - - .. _task-local-storage: Task-local storage diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index 08206569f..19289ca99 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -13,14 +13,13 @@ Tutorial still probably read this, because Trio is different.) Trio turns Python into a concurrent language. It takes the core - async/await syntax introduced in 3.5, and uses it to add three + async/await syntax introduced in 3.5, and uses it to add two new pieces of semantics: - cancel scopes: a generic system for managing timeouts and cancellation - nurseries: which let your program do multiple things at the same time - - MultiErrors: for when multiple things go wrong at once Of course it also provides a complete suite of APIs for doing networking, file I/O, using worker threads, @@ -57,8 +56,6 @@ Tutorial and demonstrate start() then point out that you can just use serve_tcp() - exceptions and MultiError - example: catch-all logging in our echo server review of the three (or four) core language extensions @@ -1149,9 +1146,6 @@ TODO: explain :exc:`Cancelled` TODO: explain how cancellation is also used when one child raises an exception -TODO: show an example :exc:`MultiError` traceback and walk through its -structure - TODO: maybe a brief discussion of :exc:`KeyboardInterrupt` handling? .. diff --git a/newsfragments/2211.removal.rst b/newsfragments/2211.removal.rst new file mode 100644 index 000000000..728ac2f2f --- /dev/null +++ b/newsfragments/2211.removal.rst @@ -0,0 +1,4 @@ +``trio.MultiError`` has been removed in favor of the built-in ``BaseExceptionGroup`` +(and its derivative ``ExceptionGroup``), falling back to the backport_ on Python < 3.11. + +.. _backport: https://pypi.org/project/exceptiongroup/ diff --git a/trio/__init__.py b/trio/__init__.py index a50ec3331..528ea15f0 100644 --- a/trio/__init__.py +++ b/trio/__init__.py @@ -22,7 +22,6 @@ Cancelled, BusyResourceError, ClosedResourceError, - MultiError, run, open_nursery, CancelScope, diff --git a/trio/_core/__init__.py b/trio/_core/__init__.py index 2bd0c74e6..8e3e526cf 100644 --- a/trio/_core/__init__.py +++ b/trio/_core/__init__.py @@ -17,8 +17,6 @@ EndOfChannel, ) -from ._multierror import MultiError - from ._ki import ( enable_ki_protection, disable_ki_protection, diff --git a/trio/_core/_multierror.py b/trio/_core/_multierror.py deleted file mode 100644 index 514f8764c..000000000 --- a/trio/_core/_multierror.py +++ /dev/null @@ -1,517 +0,0 @@ -import sys -import traceback -import textwrap -import warnings - -import attr - -################################################################ -# MultiError -################################################################ - - -def _filter_impl(handler, root_exc): - # We have a tree of MultiError's, like: - # - # MultiError([ - # ValueError, - # MultiError([ - # KeyError, - # ValueError, - # ]), - # ]) - # - # or similar. - # - # We want to - # 1) apply the filter to each of the leaf exceptions -- each leaf - # might stay the same, be replaced (with the original exception - # potentially sticking around as __context__ or __cause__), or - # disappear altogether. - # 2) simplify the resulting tree -- remove empty nodes, and replace - # singleton MultiError's with their contents, e.g.: - # MultiError([KeyError]) -> KeyError - # (This can happen recursively, e.g. if the two ValueErrors above - # get caught then we'll just be left with a bare KeyError.) - # 3) preserve sensible tracebacks - # - # It's the tracebacks that are most confusing. As a MultiError - # propagates through the stack, it accumulates traceback frames, but - # the exceptions inside it don't. Semantically, the traceback for a - # leaf exception is the concatenation the tracebacks of all the - # exceptions you see when traversing the exception tree from the root - # to that leaf. Our correctness invariant is that this concatenated - # traceback should be the same before and after. - # - # The easy way to do that would be to, at the beginning of this - # function, "push" all tracebacks down to the leafs, so all the - # MultiErrors have __traceback__=None, and all the leafs have complete - # tracebacks. But whenever possible, we'd actually prefer to keep - # tracebacks as high up in the tree as possible, because this lets us - # keep only a single copy of the common parts of these exception's - # tracebacks. This is cheaper (in memory + time -- tracebacks are - # unpleasantly quadratic-ish to work with, and this might matter if - # you have thousands of exceptions, which can happen e.g. after - # cancelling a large task pool, and no-one will ever look at their - # tracebacks!), and more importantly, factoring out redundant parts of - # the tracebacks makes them more readable if/when users do see them. - # - # So instead our strategy is: - # - first go through and construct the new tree, preserving any - # unchanged subtrees - # - then go through the original tree (!) and push tracebacks down - # until either we hit a leaf, or we hit a subtree which was - # preserved in the new tree. - - # This used to also support async handler functions. But that runs into: - # https://bugs.python.org/issue29600 - # which is difficult to fix on our end. - - # Filters a subtree, ignoring tracebacks, while keeping a record of - # which MultiErrors were preserved unchanged - def filter_tree(exc, preserved): - if isinstance(exc, MultiError): - new_exceptions = [] - changed = False - for child_exc in exc.exceptions: - new_child_exc = filter_tree(child_exc, preserved) - if new_child_exc is not child_exc: - changed = True - if new_child_exc is not None: - new_exceptions.append(new_child_exc) - if not new_exceptions: - return None - elif changed: - return MultiError(new_exceptions) - else: - preserved.add(id(exc)) - return exc - else: - new_exc = handler(exc) - # Our version of implicit exception chaining - if new_exc is not None and new_exc is not exc: - new_exc.__context__ = exc - return new_exc - - def push_tb_down(tb, exc, preserved): - if id(exc) in preserved: - return - new_tb = concat_tb(tb, exc.__traceback__) - if isinstance(exc, MultiError): - for child_exc in exc.exceptions: - push_tb_down(new_tb, child_exc, preserved) - exc.__traceback__ = None - else: - exc.__traceback__ = new_tb - - preserved = set() - new_root_exc = filter_tree(root_exc, preserved) - push_tb_down(None, root_exc, preserved) - # Delete the local functions to avoid a reference cycle (see - # test_simple_cancel_scope_usage_doesnt_create_cyclic_garbage) - del filter_tree, push_tb_down - return new_root_exc - - -# Normally I'm a big fan of (a)contextmanager, but in this case I found it -# easier to use the raw context manager protocol, because it makes it a lot -# easier to reason about how we're mutating the traceback as we go. (End -# result: if the exception gets modified, then the 'raise' here makes this -# frame show up in the traceback; otherwise, we leave no trace.) -@attr.s(frozen=True) -class MultiErrorCatcher: - _handler = attr.ib() - - def __enter__(self): - pass - - def __exit__(self, etype, exc, tb): - if exc is not None: - filtered_exc = MultiError.filter(self._handler, exc) - - if filtered_exc is exc: - # Let the interpreter re-raise it - return False - if filtered_exc is None: - # Swallow the exception - return True - # When we raise filtered_exc, Python will unconditionally blow - # away its __context__ attribute and replace it with the original - # exc we caught. So after we raise it, we have to pause it while - # it's in flight to put the correct __context__ back. - old_context = filtered_exc.__context__ - try: - raise filtered_exc - finally: - _, value, _ = sys.exc_info() - assert value is filtered_exc - value.__context__ = old_context - # delete references from locals to avoid creating cycles - # see test_MultiError_catch_doesnt_create_cyclic_garbage - del _, filtered_exc, value - - -class MultiError(BaseException): - """An exception that contains other exceptions; also known as an - "inception". - - It's main use is to represent the situation when multiple child tasks all - raise errors "in parallel". - - Args: - exceptions (list): The exceptions - - Returns: - If ``len(exceptions) == 1``, returns that exception. This means that a - call to ``MultiError(...)`` is not guaranteed to return a - :exc:`MultiError` object! - - Otherwise, returns a new :exc:`MultiError` object. - - Raises: - TypeError: if any of the passed in objects are not instances of - :exc:`BaseException`. - - """ - - def __init__(self, exceptions): - # Avoid recursion when exceptions[0] returned by __new__() happens - # to be a MultiError and subsequently __init__() is called. - if hasattr(self, "exceptions"): - # __init__ was already called on this object - assert len(exceptions) == 1 and exceptions[0] is self - return - self.exceptions = exceptions - - def __new__(cls, exceptions): - exceptions = list(exceptions) - for exc in exceptions: - if not isinstance(exc, BaseException): - raise TypeError("Expected an exception object, not {!r}".format(exc)) - if len(exceptions) == 1: - # If this lone object happens to itself be a MultiError, then - # Python will implicitly call our __init__ on it again. See - # special handling in __init__. - return exceptions[0] - else: - # The base class __new__() implicitly invokes our __init__, which - # is what we want. - # - # In an earlier version of the code, we didn't define __init__ and - # simply set the `exceptions` attribute directly on the new object. - # However, linters expect attributes to be initialized in __init__. - return BaseException.__new__(cls, exceptions) - - def __str__(self): - return ", ".join(repr(exc) for exc in self.exceptions) - - def __repr__(self): - return "".format(self) - - @classmethod - def filter(cls, handler, root_exc): - """Apply the given ``handler`` to all the exceptions in ``root_exc``. - - Args: - handler: A callable that takes an atomic (non-MultiError) exception - as input, and returns either a new exception object or None. - root_exc: An exception, often (though not necessarily) a - :exc:`MultiError`. - - Returns: - A new exception object in which each component exception ``exc`` has - been replaced by the result of running ``handler(exc)`` – or, if - ``handler`` returned None for all the inputs, returns None. - - """ - - return _filter_impl(handler, root_exc) - - @classmethod - def catch(cls, handler): - """Return a context manager that catches and re-throws exceptions - after running :meth:`filter` on them. - - Args: - handler: as for :meth:`filter` - - """ - - return MultiErrorCatcher(handler) - - -# Clean up exception printing: -MultiError.__module__ = "trio" - -################################################################ -# concat_tb -################################################################ - -# We need to compute a new traceback that is the concatenation of two existing -# tracebacks. This requires copying the entries in 'head' and then pointing -# the final tb_next to 'tail'. -# -# NB: 'tail' might be None, which requires some special handling in the ctypes -# version. -# -# The complication here is that Python doesn't actually support copying or -# modifying traceback objects, so we have to get creative... -# -# On CPython, we use ctypes. On PyPy, we use "transparent proxies". -# -# Jinja2 is a useful source of inspiration: -# https://github.com/pallets/jinja/blob/master/jinja2/debug.py - -try: - import tputil -except ImportError: - have_tproxy = False -else: - have_tproxy = True - -if have_tproxy: - # http://doc.pypy.org/en/latest/objspace-proxies.html - def copy_tb(base_tb, tb_next): - def controller(operation): - # Rationale for pragma: I looked fairly carefully and tried a few - # things, and AFAICT it's not actually possible to get any - # 'opname' that isn't __getattr__ or __getattribute__. So there's - # no missing test we could add, and no value in coverage nagging - # us about adding one. - if operation.opname in [ - "__getattribute__", - "__getattr__", - ]: # pragma: no cover - if operation.args[0] == "tb_next": - return tb_next - return operation.delegate() - - return tputil.make_proxy(controller, type(base_tb), base_tb) - - -else: - # ctypes it is - import ctypes - - # How to handle refcounting? I don't want to use ctypes.py_object because - # I don't understand or trust it, and I don't want to use - # ctypes.pythonapi.Py_{Inc,Dec}Ref because we might clash with user code - # that also tries to use them but with different types. So private _ctypes - # APIs it is! - import _ctypes - - class CTraceback(ctypes.Structure): - _fields_ = [ - ("PyObject_HEAD", ctypes.c_byte * object().__sizeof__()), - ("tb_next", ctypes.c_void_p), - ("tb_frame", ctypes.c_void_p), - ("tb_lasti", ctypes.c_int), - ("tb_lineno", ctypes.c_int), - ] - - def copy_tb(base_tb, tb_next): - # TracebackType has no public constructor, so allocate one the hard way - try: - raise ValueError - except ValueError as exc: - new_tb = exc.__traceback__ - c_new_tb = CTraceback.from_address(id(new_tb)) - - # At the C level, tb_next either pointer to the next traceback or is - # NULL. c_void_p and the .tb_next accessor both convert NULL to None, - # but we shouldn't DECREF None just because we assigned to a NULL - # pointer! Here we know that our new traceback has only 1 frame in it, - # so we can assume the tb_next field is NULL. - assert c_new_tb.tb_next is None - # If tb_next is None, then we want to set c_new_tb.tb_next to NULL, - # which it already is, so we're done. Otherwise, we have to actually - # do some work: - if tb_next is not None: - _ctypes.Py_INCREF(tb_next) - c_new_tb.tb_next = id(tb_next) - - assert c_new_tb.tb_frame is not None - _ctypes.Py_INCREF(base_tb.tb_frame) - old_tb_frame = new_tb.tb_frame - c_new_tb.tb_frame = id(base_tb.tb_frame) - _ctypes.Py_DECREF(old_tb_frame) - - c_new_tb.tb_lasti = base_tb.tb_lasti - c_new_tb.tb_lineno = base_tb.tb_lineno - - try: - return new_tb - finally: - # delete references from locals to avoid creating cycles - # see test_MultiError_catch_doesnt_create_cyclic_garbage - del new_tb, old_tb_frame - - -def concat_tb(head, tail): - # We have to use an iterative algorithm here, because in the worst case - # this might be a RecursionError stack that is by definition too deep to - # process by recursion! - head_tbs = [] - pointer = head - while pointer is not None: - head_tbs.append(pointer) - pointer = pointer.tb_next - current_head = tail - for head_tb in reversed(head_tbs): - current_head = copy_tb(head_tb, tb_next=current_head) - return current_head - - -################################################################ -# MultiError traceback formatting -# -# What follows is terrible, terrible monkey patching of -# traceback.TracebackException to add support for handling -# MultiErrors -################################################################ - -traceback_exception_original_init = traceback.TracebackException.__init__ - - -def traceback_exception_init( - self, - exc_type, - exc_value, - exc_traceback, - *, - limit=None, - lookup_lines=True, - capture_locals=False, - compact=False, - _seen=None, -): - if sys.version_info >= (3, 10): - kwargs = {"compact": compact} - else: - kwargs = {} - - # Capture the original exception and its cause and context as TracebackExceptions - traceback_exception_original_init( - self, - exc_type, - exc_value, - exc_traceback, - limit=limit, - lookup_lines=lookup_lines, - capture_locals=capture_locals, - _seen=_seen, - **kwargs, - ) - - seen_was_none = _seen is None - - if _seen is None: - _seen = set() - - # Capture each of the exceptions in the MultiError along with each of their causes and contexts - if isinstance(exc_value, MultiError): - embedded = [] - for exc in exc_value.exceptions: - if id(exc) not in _seen: - embedded.append( - traceback.TracebackException.from_exception( - exc, - limit=limit, - lookup_lines=lookup_lines, - capture_locals=capture_locals, - # copy the set of _seen exceptions so that duplicates - # shared between sub-exceptions are not omitted - _seen=None if seen_was_none else set(_seen), - ) - ) - self.embedded = embedded - else: - self.embedded = [] - - -traceback.TracebackException.__init__ = traceback_exception_init # type: ignore -traceback_exception_original_format = traceback.TracebackException.format - - -def traceback_exception_format(self, *, chain=True): - yield from traceback_exception_original_format(self, chain=chain) - - for i, exc in enumerate(self.embedded): - yield "\nDetails of embedded exception {}:\n\n".format(i + 1) - yield from (textwrap.indent(line, " " * 2) for line in exc.format(chain=chain)) - - -traceback.TracebackException.format = traceback_exception_format # type: ignore - - -def trio_excepthook(etype, value, tb): - for chunk in traceback.format_exception(etype, value, tb): - sys.stderr.write(chunk) - - -monkeypatched_or_warned = False - -if "IPython" in sys.modules: - import IPython - - ip = IPython.get_ipython() - if ip is not None: - if ip.custom_exceptions != (): - warnings.warn( - "IPython detected, but you already have a custom exception " - "handler installed. I'll skip installing Trio's custom " - "handler, but this means MultiErrors will not show full " - "tracebacks.", - category=RuntimeWarning, - ) - monkeypatched_or_warned = True - else: - - def trio_show_traceback(self, etype, value, tb, tb_offset=None): - # XX it would be better to integrate with IPython's fancy - # exception formatting stuff (and not ignore tb_offset) - trio_excepthook(etype, value, tb) - - ip.set_custom_exc((MultiError,), trio_show_traceback) - monkeypatched_or_warned = True - -if sys.excepthook is sys.__excepthook__: - sys.excepthook = trio_excepthook - monkeypatched_or_warned = True - -# Ubuntu's system Python has a sitecustomize.py file that import -# apport_python_hook and replaces sys.excepthook. -# -# The custom hook captures the error for crash reporting, and then calls -# sys.__excepthook__ to actually print the error. -# -# We don't mind it capturing the error for crash reporting, but we want to -# take over printing the error. So we monkeypatch the apport_python_hook -# module so that instead of calling sys.__excepthook__, it calls our custom -# hook. -# -# More details: https://github.com/python-trio/trio/issues/1065 -if getattr(sys.excepthook, "__name__", None) == "apport_excepthook": - import apport_python_hook - - assert sys.excepthook is apport_python_hook.apport_excepthook - - # Give it a descriptive name as a hint for anyone who's stuck trying to - # debug this mess later. - class TrioFakeSysModuleForApport: - pass - - fake_sys = TrioFakeSysModuleForApport() - fake_sys.__dict__.update(sys.__dict__) - fake_sys.__excepthook__ = trio_excepthook # type: ignore - apport_python_hook.sys = fake_sys - - monkeypatched_or_warned = True - -if not monkeypatched_or_warned: - warnings.warn( - "You seem to already have a custom sys.excepthook handler " - "installed. I'll skip installing Trio's custom handler, but this " - "means MultiErrors will not show full tracebacks.", - category=RuntimeWarning, - ) diff --git a/trio/_core/_run.py b/trio/_core/_run.py index f3adfc758..fac3a7e12 100644 --- a/trio/_core/_run.py +++ b/trio/_core/_run.py @@ -28,7 +28,6 @@ KIManager, enable_ki_protection, ) -from ._multierror import MultiError from ._traps import ( Abort, wait_task_rescheduled, @@ -43,6 +42,9 @@ from .. import _core from .._util import Final, NoPublicConstructor, coroutine_or_error +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + DEADLINE_HEAP_MIN_PRUNE_THRESHOLD = 1000 _NO_SEND = object() @@ -447,12 +449,6 @@ def __enter__(self): task._activate_cancel_status(self._cancel_status) return self - def _exc_filter(self, exc): - if isinstance(exc, Cancelled): - self.cancelled_caught = True - return None - return exc - def _close(self, exc): if self._cancel_status is None: new_exc = RuntimeError( @@ -510,7 +506,16 @@ def _close(self, exc): and self._cancel_status.effectively_cancelled and not self._cancel_status.parent_cancellation_is_visible_to_us ): - exc = MultiError.filter(self._exc_filter, exc) + if isinstance(exc, Cancelled): + self.cancelled_caught = True + exc = None + elif isinstance(exc, BaseExceptionGroup): + matched, exc = exc.split(Cancelled) + if matched: + self.cancelled_caught = True + + while isinstance(exc, BaseExceptionGroup) and len(exc.exceptions) == 1: + exc = exc.exceptions[0] self._cancel_status.close() with self._might_change_registered_deadline(): self._cancel_status = None @@ -910,7 +915,9 @@ def _child_finished(self, task, outcome): self._check_nursery_closed() async def _nested_child_finished(self, nested_child_exc): - """Returns MultiError instance if there are pending exceptions.""" + """ + Returns BaseExceptionGroup instance if there are pending exceptions. + """ if nested_child_exc is not None: self._add_exc(nested_child_exc) self._nested_child_running = False @@ -939,7 +946,7 @@ def aborted(raise_cancel): assert popped is self if self._pending_excs: try: - return MultiError(self._pending_excs) + return BaseExceptionGroup("multiple tasks failed", self._pending_excs) finally: # avoid a garbage cycle # (see test_nursery_cancel_doesnt_create_cyclic_garbage) diff --git a/trio/_core/tests/test_multierror.py b/trio/_core/tests/test_multierror.py deleted file mode 100644 index e4a30e0e6..000000000 --- a/trio/_core/tests/test_multierror.py +++ /dev/null @@ -1,772 +0,0 @@ -import gc -import logging -import pytest - -from traceback import ( - extract_tb, - print_exception, - format_exception, -) -from traceback import _cause_message # type: ignore -import sys -import os -import re -from pathlib import Path -import subprocess - -from .tutil import slow - -from .._multierror import MultiError, concat_tb -from ..._core import open_nursery - - -class NotHashableException(Exception): - code = None - - def __init__(self, code): - super().__init__() - self.code = code - - def __eq__(self, other): - if not isinstance(other, NotHashableException): - return False - return self.code == other.code - - -async def raise_nothashable(code): - raise NotHashableException(code) - - -def raiser1(): - raiser1_2() - - -def raiser1_2(): - raiser1_3() - - -def raiser1_3(): - raise ValueError("raiser1_string") - - -def raiser2(): - raiser2_2() - - -def raiser2_2(): - raise KeyError("raiser2_string") - - -def raiser3(): - raise NameError - - -def get_exc(raiser): - try: - raiser() - except Exception as exc: - return exc - - -def get_tb(raiser): - return get_exc(raiser).__traceback__ - - -def einfo(exc): - return (type(exc), exc, exc.__traceback__) - - -def test_concat_tb(): - - tb1 = get_tb(raiser1) - tb2 = get_tb(raiser2) - - # These return a list of (filename, lineno, fn name, text) tuples - # https://docs.python.org/3/library/traceback.html#traceback.extract_tb - entries1 = extract_tb(tb1) - entries2 = extract_tb(tb2) - - tb12 = concat_tb(tb1, tb2) - assert extract_tb(tb12) == entries1 + entries2 - - tb21 = concat_tb(tb2, tb1) - assert extract_tb(tb21) == entries2 + entries1 - - # Check degenerate cases - assert extract_tb(concat_tb(None, tb1)) == entries1 - assert extract_tb(concat_tb(tb1, None)) == entries1 - assert concat_tb(None, None) is None - - # Make sure the original tracebacks didn't get mutated by mistake - assert extract_tb(get_tb(raiser1)) == entries1 - assert extract_tb(get_tb(raiser2)) == entries2 - - -def test_MultiError(): - exc1 = get_exc(raiser1) - exc2 = get_exc(raiser2) - - assert MultiError([exc1]) is exc1 - m = MultiError([exc1, exc2]) - assert m.exceptions == [exc1, exc2] - assert "ValueError" in str(m) - assert "ValueError" in repr(m) - - with pytest.raises(TypeError): - MultiError(object()) - with pytest.raises(TypeError): - MultiError([KeyError(), ValueError]) - - -def test_MultiErrorOfSingleMultiError(): - # For MultiError([MultiError]), ensure there is no bad recursion by the - # constructor where __init__ is called if __new__ returns a bare MultiError. - exceptions = [KeyError(), ValueError()] - a = MultiError(exceptions) - b = MultiError([a]) - assert b == a - assert b.exceptions == exceptions - - -async def test_MultiErrorNotHashable(): - exc1 = NotHashableException(42) - exc2 = NotHashableException(4242) - exc3 = ValueError() - assert exc1 != exc2 - assert exc1 != exc3 - - with pytest.raises(MultiError): - async with open_nursery() as nursery: - nursery.start_soon(raise_nothashable, 42) - nursery.start_soon(raise_nothashable, 4242) - - -def test_MultiError_filter_NotHashable(): - excs = MultiError([NotHashableException(42), ValueError()]) - - def handle_ValueError(exc): - if isinstance(exc, ValueError): - return None - else: - return exc - - filtered_excs = MultiError.filter(handle_ValueError, excs) - assert isinstance(filtered_excs, NotHashableException) - - -def test_traceback_recursion(): - exc1 = RuntimeError() - exc2 = KeyError() - exc3 = NotHashableException(42) - # Note how this creates a loop, where exc1 refers to exc1 - # This could trigger an infinite recursion; the 'seen' set is supposed to prevent - # this. - exc1.__cause__ = MultiError([exc1, exc2, exc3]) - format_exception(*einfo(exc1)) - - -def make_tree(): - # Returns an object like: - # MultiError([ - # MultiError([ - # ValueError, - # KeyError, - # ]), - # NameError, - # ]) - # where all exceptions except the root have a non-trivial traceback. - exc1 = get_exc(raiser1) - exc2 = get_exc(raiser2) - exc3 = get_exc(raiser3) - - # Give m12 a non-trivial traceback - try: - raise MultiError([exc1, exc2]) - except BaseException as m12: - return MultiError([m12, exc3]) - - -def assert_tree_eq(m1, m2): - if m1 is None or m2 is None: - assert m1 is m2 - return - assert type(m1) is type(m2) - assert extract_tb(m1.__traceback__) == extract_tb(m2.__traceback__) - assert_tree_eq(m1.__cause__, m2.__cause__) - assert_tree_eq(m1.__context__, m2.__context__) - if isinstance(m1, MultiError): - assert len(m1.exceptions) == len(m2.exceptions) - for e1, e2 in zip(m1.exceptions, m2.exceptions): - assert_tree_eq(e1, e2) - - -def test_MultiError_filter(): - def null_handler(exc): - return exc - - m = make_tree() - assert_tree_eq(m, m) - assert MultiError.filter(null_handler, m) is m - assert_tree_eq(m, make_tree()) - - # Make sure we don't pick up any detritus if run in a context where - # implicit exception chaining would like to kick in - m = make_tree() - try: - raise ValueError - except ValueError: - assert MultiError.filter(null_handler, m) is m - assert_tree_eq(m, make_tree()) - - def simple_filter(exc): - if isinstance(exc, ValueError): - return None - if isinstance(exc, KeyError): - return RuntimeError() - return exc - - new_m = MultiError.filter(simple_filter, make_tree()) - assert isinstance(new_m, MultiError) - assert len(new_m.exceptions) == 2 - # was: [[ValueError, KeyError], NameError] - # ValueError disappeared & KeyError became RuntimeError, so now: - assert isinstance(new_m.exceptions[0], RuntimeError) - assert isinstance(new_m.exceptions[1], NameError) - - # implicit chaining: - assert isinstance(new_m.exceptions[0].__context__, KeyError) - - # also, the traceback on the KeyError incorporates what used to be the - # traceback on its parent MultiError - orig = make_tree() - # make sure we have the right path - assert isinstance(orig.exceptions[0].exceptions[1], KeyError) - # get original traceback summary - orig_extracted = ( - extract_tb(orig.__traceback__) - + extract_tb(orig.exceptions[0].__traceback__) - + extract_tb(orig.exceptions[0].exceptions[1].__traceback__) - ) - - def p(exc): - print_exception(type(exc), exc, exc.__traceback__) - - p(orig) - p(orig.exceptions[0]) - p(orig.exceptions[0].exceptions[1]) - p(new_m.exceptions[0].__context__) - # compare to the new path - assert new_m.__traceback__ is None - new_extracted = extract_tb(new_m.exceptions[0].__context__.__traceback__) - assert orig_extracted == new_extracted - - # check preserving partial tree - def filter_NameError(exc): - if isinstance(exc, NameError): - return None - return exc - - m = make_tree() - new_m = MultiError.filter(filter_NameError, m) - # with the NameError gone, the other branch gets promoted - assert new_m is m.exceptions[0] - - # check fully handling everything - def filter_all(exc): - return None - - assert MultiError.filter(filter_all, make_tree()) is None - - -def test_MultiError_catch(): - # No exception to catch - - def noop(_): - pass # pragma: no cover - - with MultiError.catch(noop): - pass - - # Simple pass-through of all exceptions - m = make_tree() - with pytest.raises(MultiError) as excinfo: - with MultiError.catch(lambda exc: exc): - raise m - assert excinfo.value is m - # Should be unchanged, except that we added a traceback frame by raising - # it here - assert m.__traceback__ is not None - assert m.__traceback__.tb_frame.f_code.co_name == "test_MultiError_catch" - assert m.__traceback__.tb_next is None - m.__traceback__ = None - assert_tree_eq(m, make_tree()) - - # Swallows everything - with MultiError.catch(lambda _: None): - raise make_tree() - - def simple_filter(exc): - if isinstance(exc, ValueError): - return None - if isinstance(exc, KeyError): - return RuntimeError() - return exc - - with pytest.raises(MultiError) as excinfo: - with MultiError.catch(simple_filter): - raise make_tree() - new_m = excinfo.value - assert isinstance(new_m, MultiError) - assert len(new_m.exceptions) == 2 - # was: [[ValueError, KeyError], NameError] - # ValueError disappeared & KeyError became RuntimeError, so now: - assert isinstance(new_m.exceptions[0], RuntimeError) - assert isinstance(new_m.exceptions[1], NameError) - # Make sure that Python did not successfully attach the old MultiError to - # our new MultiError's __context__ - assert not new_m.__suppress_context__ - assert new_m.__context__ is None - - # check preservation of __cause__ and __context__ - v = ValueError() - v.__cause__ = KeyError() - with pytest.raises(ValueError) as excinfo: - with MultiError.catch(lambda exc: exc): - raise v - assert isinstance(excinfo.value.__cause__, KeyError) - - v = ValueError() - context = KeyError() - v.__context__ = context - with pytest.raises(ValueError) as excinfo: - with MultiError.catch(lambda exc: exc): - raise v - assert excinfo.value.__context__ is context - assert not excinfo.value.__suppress_context__ - - for suppress_context in [True, False]: - v = ValueError() - context = KeyError() - v.__context__ = context - v.__suppress_context__ = suppress_context - distractor = RuntimeError() - with pytest.raises(ValueError) as excinfo: - - def catch_RuntimeError(exc): - if isinstance(exc, RuntimeError): - return None - else: - return exc - - with MultiError.catch(catch_RuntimeError): - raise MultiError([v, distractor]) - assert excinfo.value.__context__ is context - assert excinfo.value.__suppress_context__ == suppress_context - - -@pytest.mark.skipif( - sys.implementation.name != "cpython", reason="Only makes sense with refcounting GC" -) -def test_MultiError_catch_doesnt_create_cyclic_garbage(): - # https://github.com/python-trio/trio/pull/2063 - gc.collect() - old_flags = gc.get_debug() - - def make_multi(): - # make_tree creates cycles itself, so a simple - raise MultiError([get_exc(raiser1), get_exc(raiser2)]) - - def simple_filter(exc): - if isinstance(exc, ValueError): - return Exception() - if isinstance(exc, KeyError): - return RuntimeError() - assert False, "only ValueError and KeyError should exist" # pragma: no cover - - try: - gc.set_debug(gc.DEBUG_SAVEALL) - with pytest.raises(MultiError): - # covers MultiErrorCatcher.__exit__ and _multierror.copy_tb - with MultiError.catch(simple_filter): - raise make_multi() - gc.collect() - assert not gc.garbage - finally: - gc.set_debug(old_flags) - gc.garbage.clear() - - -def assert_match_in_seq(pattern_list, string): - offset = 0 - print("looking for pattern matches...") - for pattern in pattern_list: - print("checking pattern:", pattern) - reobj = re.compile(pattern) - match = reobj.search(string, offset) - assert match is not None - offset = match.end() - - -def test_assert_match_in_seq(): - assert_match_in_seq(["a", "b"], "xx a xx b xx") - assert_match_in_seq(["b", "a"], "xx b xx a xx") - with pytest.raises(AssertionError): - assert_match_in_seq(["a", "b"], "xx b xx a xx") - - -def test_format_exception(): - exc = get_exc(raiser1) - formatted = "".join(format_exception(*einfo(exc))) - assert "raiser1_string" in formatted - assert "in raiser1_3" in formatted - assert "raiser2_string" not in formatted - assert "in raiser2_2" not in formatted - assert "direct cause" not in formatted - assert "During handling" not in formatted - - exc = get_exc(raiser1) - exc.__cause__ = get_exc(raiser2) - formatted = "".join(format_exception(*einfo(exc))) - assert "raiser1_string" in formatted - assert "in raiser1_3" in formatted - assert "raiser2_string" in formatted - assert "in raiser2_2" in formatted - assert "direct cause" in formatted - assert "During handling" not in formatted - # ensure cause included - assert _cause_message in formatted - - exc = get_exc(raiser1) - exc.__context__ = get_exc(raiser2) - formatted = "".join(format_exception(*einfo(exc))) - assert "raiser1_string" in formatted - assert "in raiser1_3" in formatted - assert "raiser2_string" in formatted - assert "in raiser2_2" in formatted - assert "direct cause" not in formatted - assert "During handling" in formatted - - exc.__suppress_context__ = True - formatted = "".join(format_exception(*einfo(exc))) - assert "raiser1_string" in formatted - assert "in raiser1_3" in formatted - assert "raiser2_string" not in formatted - assert "in raiser2_2" not in formatted - assert "direct cause" not in formatted - assert "During handling" not in formatted - - # chain=False - exc = get_exc(raiser1) - exc.__context__ = get_exc(raiser2) - formatted = "".join(format_exception(*einfo(exc), chain=False)) - assert "raiser1_string" in formatted - assert "in raiser1_3" in formatted - assert "raiser2_string" not in formatted - assert "in raiser2_2" not in formatted - assert "direct cause" not in formatted - assert "During handling" not in formatted - - # limit - exc = get_exc(raiser1) - exc.__context__ = get_exc(raiser2) - # get_exc adds a frame that counts against the limit, so limit=2 means we - # get 1 deep into the raiser stack - formatted = "".join(format_exception(*einfo(exc), limit=2)) - print(formatted) - assert "raiser1_string" in formatted - assert "in raiser1" in formatted - assert "in raiser1_2" not in formatted - assert "raiser2_string" in formatted - assert "in raiser2" in formatted - assert "in raiser2_2" not in formatted - - exc = get_exc(raiser1) - exc.__context__ = get_exc(raiser2) - formatted = "".join(format_exception(*einfo(exc), limit=1)) - print(formatted) - assert "raiser1_string" in formatted - assert "in raiser1" not in formatted - assert "raiser2_string" in formatted - assert "in raiser2" not in formatted - - # handles loops - exc = get_exc(raiser1) - exc.__cause__ = exc - formatted = "".join(format_exception(*einfo(exc))) - assert "raiser1_string" in formatted - assert "in raiser1_3" in formatted - assert "raiser2_string" not in formatted - assert "in raiser2_2" not in formatted - # ensure duplicate exception is not included as cause - assert _cause_message not in formatted - - # MultiError - formatted = "".join(format_exception(*einfo(make_tree()))) - print(formatted) - - assert_match_in_seq( - [ - # Outer exception is MultiError - r"MultiError:", - # First embedded exception is the embedded MultiError - r"\nDetails of embedded exception 1", - # Which has a single stack frame from make_tree raising it - r"in make_tree", - # Then it has two embedded exceptions - r" Details of embedded exception 1", - r"in raiser1_2", - # for some reason ValueError has no quotes - r"ValueError: raiser1_string", - r" Details of embedded exception 2", - r"in raiser2_2", - # But KeyError does have quotes - r"KeyError: 'raiser2_string'", - # And finally the NameError, which is a sibling of the embedded - # MultiError - r"\nDetails of embedded exception 2:", - r"in raiser3", - r"NameError", - ], - formatted, - ) - - # Prints duplicate exceptions in sub-exceptions - exc1 = get_exc(raiser1) - - def raise1_raiser1(): - try: - raise exc1 - except: - raise ValueError("foo") - - def raise2_raiser1(): - try: - raise exc1 - except: - raise KeyError("bar") - - exc2 = get_exc(raise1_raiser1) - exc3 = get_exc(raise2_raiser1) - - try: - raise MultiError([exc2, exc3]) - except MultiError as e: - exc = e - - formatted = "".join(format_exception(*einfo(exc))) - print(formatted) - - assert_match_in_seq( - [ - r"Traceback", - # Outer exception is MultiError - r"MultiError:", - # First embedded exception is the embedded ValueError with cause of raiser1 - r"\nDetails of embedded exception 1", - # Print details of exc1 - r" Traceback", - r"in get_exc", - r"in raiser1", - r"ValueError: raiser1_string", - # Print details of exc2 - r"\n During handling of the above exception, another exception occurred:", - r" Traceback", - r"in get_exc", - r"in raise1_raiser1", - r" ValueError: foo", - # Second embedded exception is the embedded KeyError with cause of raiser1 - r"\nDetails of embedded exception 2", - # Print details of exc1 again - r" Traceback", - r"in get_exc", - r"in raiser1", - r"ValueError: raiser1_string", - # Print details of exc3 - r"\n During handling of the above exception, another exception occurred:", - r" Traceback", - r"in get_exc", - r"in raise2_raiser1", - r" KeyError: 'bar'", - ], - formatted, - ) - - -def test_logging(caplog): - exc1 = get_exc(raiser1) - exc2 = get_exc(raiser2) - - m = MultiError([exc1, exc2]) - - message = "test test test" - try: - raise m - except MultiError as exc: - logging.getLogger().exception(message) - # Join lines together - formatted = "".join(format_exception(type(exc), exc, exc.__traceback__)) - assert message in caplog.text - assert formatted in caplog.text - - -def run_script(name, use_ipython=False): - import trio - - trio_path = Path(trio.__file__).parent.parent - script_path = Path(__file__).parent / "test_multierror_scripts" / name - - env = dict(os.environ) - print("parent PYTHONPATH:", env.get("PYTHONPATH")) - if "PYTHONPATH" in env: # pragma: no cover - pp = env["PYTHONPATH"].split(os.pathsep) - else: - pp = [] - pp.insert(0, str(trio_path)) - pp.insert(0, str(script_path.parent)) - env["PYTHONPATH"] = os.pathsep.join(pp) - print("subprocess PYTHONPATH:", env.get("PYTHONPATH")) - - if use_ipython: - lines = [script_path.read_text(), "exit()"] - - cmd = [ - sys.executable, - "-u", - "-m", - "IPython", - # no startup files - "--quick", - "--TerminalIPythonApp.code_to_run=" + "\n".join(lines), - ] - else: - cmd = [sys.executable, "-u", str(script_path)] - print("running:", cmd) - completed = subprocess.run( - cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT - ) - print("process output:") - print(completed.stdout.decode("utf-8")) - return completed - - -def check_simple_excepthook(completed): - assert_match_in_seq( - [ - "in ", - "MultiError", - "Details of embedded exception 1", - "in exc1_fn", - "ValueError", - "Details of embedded exception 2", - "in exc2_fn", - "KeyError", - ], - completed.stdout.decode("utf-8"), - ) - - -def test_simple_excepthook(): - completed = run_script("simple_excepthook.py") - check_simple_excepthook(completed) - - -def test_custom_excepthook(): - # Check that user-defined excepthooks aren't overridden - completed = run_script("custom_excepthook.py") - assert_match_in_seq( - [ - # The warning - "RuntimeWarning", - "already have a custom", - # The message printed by the custom hook, proving we didn't - # override it - "custom running!", - # The MultiError - "MultiError:", - ], - completed.stdout.decode("utf-8"), - ) - - -# This warning is triggered by ipython 7.5.0 on python 3.8 -import warnings - -warnings.filterwarnings( - "ignore", - message='.*"@coroutine" decorator is deprecated', - category=DeprecationWarning, - module="IPython.*", -) -try: - import IPython -except ImportError: # pragma: no cover - have_ipython = False -else: - have_ipython = True - -need_ipython = pytest.mark.skipif(not have_ipython, reason="need IPython") - - -@slow -@need_ipython -def test_ipython_exc_handler(): - completed = run_script("simple_excepthook.py", use_ipython=True) - check_simple_excepthook(completed) - - -@slow -@need_ipython -def test_ipython_imported_but_unused(): - completed = run_script("simple_excepthook_IPython.py") - check_simple_excepthook(completed) - - -@slow -def test_partial_imported_but_unused(): - # Check that a functools.partial as sys.excepthook doesn't cause an exception when - # importing trio. This was a problem due to the lack of a .__name__ attribute and - # happens when inside a pytest-qt test case for example. - completed = run_script("simple_excepthook_partial.py") - completed.check_returncode() - - -@slow -@need_ipython -def test_ipython_custom_exc_handler(): - # Check we get a nice warning (but only one!) if the user is using IPython - # and already has some other set_custom_exc handler installed. - completed = run_script("ipython_custom_exc.py", use_ipython=True) - assert_match_in_seq( - [ - # The warning - "RuntimeWarning", - "IPython detected", - "skip installing Trio", - # The MultiError - "MultiError", - "ValueError", - "KeyError", - ], - completed.stdout.decode("utf-8"), - ) - # Make sure our other warning doesn't show up - assert "custom sys.excepthook" not in completed.stdout.decode("utf-8") - - -@slow -@pytest.mark.skipif( - not Path("/usr/lib/python3/dist-packages/apport_python_hook.py").exists(), - reason="need Ubuntu with python3-apport installed", -) -def test_apport_excepthook_monkeypatch_interaction(): - completed = run_script("apport_excepthook.py") - stdout = completed.stdout.decode("utf-8") - - # No warning - assert "custom sys.excepthook" not in stdout - - # Proper traceback - assert_match_in_seq( - ["Details of embedded", "KeyError", "Details of embedded", "ValueError"], - stdout, - ) diff --git a/trio/_core/tests/test_multierror_scripts/__init__.py b/trio/_core/tests/test_multierror_scripts/__init__.py deleted file mode 100644 index a1f6cb598..000000000 --- a/trio/_core/tests/test_multierror_scripts/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# This isn't really a package, everything in here is a standalone script. This -# __init__.py is just to fool setup.py into actually installing the things. diff --git a/trio/_core/tests/test_multierror_scripts/_common.py b/trio/_core/tests/test_multierror_scripts/_common.py deleted file mode 100644 index 0c70df184..000000000 --- a/trio/_core/tests/test_multierror_scripts/_common.py +++ /dev/null @@ -1,7 +0,0 @@ -# https://coverage.readthedocs.io/en/latest/subprocess.html -try: - import coverage -except ImportError: # pragma: no cover - pass -else: - coverage.process_startup() diff --git a/trio/_core/tests/test_multierror_scripts/apport_excepthook.py b/trio/_core/tests/test_multierror_scripts/apport_excepthook.py deleted file mode 100644 index 12e7fb085..000000000 --- a/trio/_core/tests/test_multierror_scripts/apport_excepthook.py +++ /dev/null @@ -1,13 +0,0 @@ -# The apport_python_hook package is only installed as part of Ubuntu's system -# python, and not available in venvs. So before we can import it we have to -# make sure it's on sys.path. -import sys - -sys.path.append("/usr/lib/python3/dist-packages") -import apport_python_hook - -apport_python_hook.install() - -import trio - -raise trio.MultiError([KeyError("key_error"), ValueError("value_error")]) diff --git a/trio/_core/tests/test_multierror_scripts/custom_excepthook.py b/trio/_core/tests/test_multierror_scripts/custom_excepthook.py deleted file mode 100644 index 564c5833b..000000000 --- a/trio/_core/tests/test_multierror_scripts/custom_excepthook.py +++ /dev/null @@ -1,18 +0,0 @@ -import _common - -import sys - - -def custom_excepthook(*args): - print("custom running!") - return sys.__excepthook__(*args) - - -sys.excepthook = custom_excepthook - -# Should warn that we'll get kinda-broken tracebacks -import trio - -# The custom excepthook should run, because Trio was polite and didn't -# override it -raise trio.MultiError([ValueError(), KeyError()]) diff --git a/trio/_core/tests/test_multierror_scripts/ipython_custom_exc.py b/trio/_core/tests/test_multierror_scripts/ipython_custom_exc.py deleted file mode 100644 index b3fd110e5..000000000 --- a/trio/_core/tests/test_multierror_scripts/ipython_custom_exc.py +++ /dev/null @@ -1,36 +0,0 @@ -import _common - -# Override the regular excepthook too -- it doesn't change anything either way -# because ipython doesn't use it, but we want to make sure Trio doesn't warn -# about it. -import sys - - -def custom_excepthook(*args): - print("custom running!") - return sys.__excepthook__(*args) - - -sys.excepthook = custom_excepthook - -import IPython - -ip = IPython.get_ipython() - - -# Set this to some random nonsense -class SomeError(Exception): - pass - - -def custom_exc_hook(etype, value, tb, tb_offset=None): - ip.showtraceback() - - -ip.set_custom_exc((SomeError,), custom_exc_hook) - -import trio - -# The custom excepthook should run, because Trio was polite and didn't -# override it -raise trio.MultiError([ValueError(), KeyError()]) diff --git a/trio/_core/tests/test_multierror_scripts/simple_excepthook.py b/trio/_core/tests/test_multierror_scripts/simple_excepthook.py deleted file mode 100644 index 94004525d..000000000 --- a/trio/_core/tests/test_multierror_scripts/simple_excepthook.py +++ /dev/null @@ -1,21 +0,0 @@ -import _common - -import trio - - -def exc1_fn(): - try: - raise ValueError - except Exception as exc: - return exc - - -def exc2_fn(): - try: - raise KeyError - except Exception as exc: - return exc - - -# This should be printed nicely, because Trio overrode sys.excepthook -raise trio.MultiError([exc1_fn(), exc2_fn()]) diff --git a/trio/_core/tests/test_multierror_scripts/simple_excepthook_IPython.py b/trio/_core/tests/test_multierror_scripts/simple_excepthook_IPython.py deleted file mode 100644 index 6aa12493b..000000000 --- a/trio/_core/tests/test_multierror_scripts/simple_excepthook_IPython.py +++ /dev/null @@ -1,7 +0,0 @@ -import _common - -# To tickle the "is IPython loaded?" logic, make sure that Trio tolerates -# IPython loaded but not actually in use -import IPython - -import simple_excepthook diff --git a/trio/_core/tests/test_multierror_scripts/simple_excepthook_partial.py b/trio/_core/tests/test_multierror_scripts/simple_excepthook_partial.py deleted file mode 100644 index e97fc39d5..000000000 --- a/trio/_core/tests/test_multierror_scripts/simple_excepthook_partial.py +++ /dev/null @@ -1,13 +0,0 @@ -import functools -import sys - -import _common - -# just making sure importing Trio doesn't fail if sys.excepthook doesn't have a -# .__name__ attribute - -sys.excepthook = functools.partial(sys.excepthook) - -assert not hasattr(sys.excepthook, "__name__") - -import trio diff --git a/trio/_core/tests/test_run.py b/trio/_core/tests/test_run.py index 863dad990..c9d73f4c9 100644 --- a/trio/_core/tests/test_run.py +++ b/trio/_core/tests/test_run.py @@ -37,6 +37,9 @@ assert_checkpoints, ) +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup, ExceptionGroup, catch + # slightly different from _timeouts.sleep_forever because it returns the value # its rescheduled with, which is really only useful for tests of @@ -167,8 +170,8 @@ async def main(): def test_main_and_task_both_crash(): - # If main crashes and there's also a task crash, then we get both in a - # MultiError + # If main crashes and there's also a task crash, then we get both in an + # ExceptionGroup async def crasher(): raise ValueError @@ -177,7 +180,7 @@ async def main(): nursery.start_soon(crasher) raise KeyError - with pytest.raises(_core.MultiError) as excinfo: + with pytest.raises(ExceptionGroup) as excinfo: _core.run(main) print(excinfo.value) assert {type(exc) for exc in excinfo.value.exceptions} == { @@ -195,7 +198,7 @@ async def main(): nursery.start_soon(crasher, KeyError) nursery.start_soon(crasher, ValueError) - with pytest.raises(_core.MultiError) as excinfo: + with pytest.raises(ExceptionGroup) as excinfo: _core.run(main) assert {type(exc) for exc in excinfo.value.exceptions} == { ValueError, @@ -432,7 +435,7 @@ async def crasher(): # And one that raises a different error nursery.start_soon(crasher) # t4 # and then our __aexit__ also receives an outer Cancelled - except _core.MultiError as multi_exc: + except BaseExceptionGroup as multi_exc: # Since the outer scope became cancelled before the # nursery block exited, all cancellations inside the # nursery block continue propagating to reach the @@ -771,7 +774,7 @@ async def task2(): with pytest.raises(RuntimeError) as exc_info: await nursery_mgr.__aexit__(*sys.exc_info()) assert "which had already been exited" in str(exc_info.value) - assert type(exc_info.value.__context__) is _core.MultiError + assert type(exc_info.value.__context__) is ExceptionGroup assert len(exc_info.value.__context__.exceptions) == 3 cancelled_in_context = False for exc in exc_info.value.__context__.exceptions: @@ -912,7 +915,7 @@ async def main(): _core.run(main) -def test_system_task_crash_MultiError(): +def test_system_task_crash_ExceptionGroup(): async def crasher1(): raise KeyError @@ -932,7 +935,7 @@ async def main(): _core.run(main) me = excinfo.value.__cause__ - assert isinstance(me, _core.MultiError) + assert isinstance(me, ExceptionGroup) assert len(me.exceptions) == 2 for exc in me.exceptions: assert isinstance(exc, (KeyError, ValueError)) @@ -940,7 +943,7 @@ async def main(): def test_system_task_crash_plus_Cancelled(): # Set up a situation where a system task crashes with a - # MultiError([Cancelled, ValueError]) + # BaseExceptionGroup([Cancelled, ValueError]) async def crasher(): try: await sleep_forever() @@ -1111,11 +1114,11 @@ async def test_nursery_exception_chaining_doesnt_make_context_loops(): async def crasher(): raise KeyError - with pytest.raises(_core.MultiError) as excinfo: + with pytest.raises(ExceptionGroup) as excinfo: async with _core.open_nursery() as nursery: nursery.start_soon(crasher) raise ValueError - # the MultiError should not have the KeyError or ValueError as context + # the ExceptionGroup should not have the KeyError or ValueError as context assert excinfo.value.__context__ is None @@ -1615,7 +1618,7 @@ async def test_trivial_yields(): with _core.CancelScope() as cancel_scope: cancel_scope.cancel() - with pytest.raises(_core.MultiError) as excinfo: + with pytest.raises(BaseExceptionGroup) as excinfo: async with _core.open_nursery(): raise KeyError assert len(excinfo.value.exceptions) == 2 @@ -1705,7 +1708,7 @@ async def raise_keyerror_after_started(task_status=_core.TASK_STATUS_IGNORED): async with _core.open_nursery() as nursery: with _core.CancelScope() as cs: cs.cancel() - with pytest.raises(_core.MultiError) as excinfo: + with pytest.raises(BaseExceptionGroup) as excinfo: await nursery.start(raise_keyerror_after_started) assert {type(e) for e in excinfo.value.exceptions} == { _core.Cancelled, @@ -1820,7 +1823,7 @@ async def fail(): async with _core.open_nursery() as nursery: nursery.start_soon(fail) raise StopIteration - except _core.MultiError as e: + except ExceptionGroup as e: assert tuple(map(type, e.exceptions)) == (StopIteration, ValueError) @@ -1855,13 +1858,9 @@ async def __anext__(self): def handle(exc): nonlocal got_stop - if isinstance(exc, StopAsyncIteration): - got_stop = True - return None - else: # pragma: no cover - return exc + got_stop = True - with _core.MultiError.catch(handle): + with catch({StopAsyncIteration: handle}): async with _core.open_nursery() as nursery: for i, f in enumerate(nexts): nursery.start_soon(self._accumulate, f, items, i) @@ -1882,14 +1881,14 @@ async def my_child_task(): try: # Trick: For now cancel/nursery scopes still leave a bunch of tb gunk - # behind. But if there's a MultiError, they leave it on the MultiError, - # which lets us get a clean look at the KeyError itself. Someday I - # guess this will always be a MultiError (#611), but for now we can - # force it by raising two exceptions. + # behind. But if there's an ExceptionGroup, they leave it on the + # ExceptionGroup, which lets us get a clean look at the KeyError + # itself. Someday I guess this will always be an ExceptionGroup (#611), + # but for now we can force it by raising two exceptions. async with _core.open_nursery() as nursery: nursery.start_soon(my_child_task) nursery.start_soon(my_child_task) - except _core.MultiError as exc: + except ExceptionGroup as exc: first_exc = exc.exceptions[0] assert isinstance(first_exc, KeyError) # The top frame in the exception traceback should be inside the child @@ -2231,7 +2230,7 @@ async def crasher(): with pytest.raises(ValueError): async with _core.open_nursery() as nursery: - # cover MultiError.filter and NurseryManager.__aexit__ + # cover NurseryManager.__aexit__ nursery.start_soon(crasher) gc.collect() @@ -2263,8 +2262,9 @@ async def crasher(): outer.cancel() # And one that raises a different error nursery.start_soon(crasher) - # so that outer filters a Cancelled from the MultiError and - # covers CancelScope.__exit__ (and NurseryManager.__aexit__) + # so that outer filters a Cancelled from the BaseExceptionGroup + # and covers CancelScope.__exit__ + # (and NurseryManager.__aexit__) # (See https://github.com/python-trio/trio/pull/2063) gc.collect() diff --git a/trio/_highlevel_open_tcp_listeners.py b/trio/_highlevel_open_tcp_listeners.py index 80f2c7a18..955b38ffe 100644 --- a/trio/_highlevel_open_tcp_listeners.py +++ b/trio/_highlevel_open_tcp_listeners.py @@ -5,6 +5,9 @@ import trio from . import socket as tsocket +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + # Default backlog size: # @@ -138,7 +141,9 @@ async def open_tcp_listeners(port, *, host=None, backlog=None): errno.EAFNOSUPPORT, "This system doesn't support any of the kinds of " "socket that that address could use", - ) from trio.MultiError(unsupported_address_families) + ) from ExceptionGroup( + "All socket creation attempts failed", unsupported_address_families + ) return listeners diff --git a/trio/_highlevel_open_tcp_stream.py b/trio/_highlevel_open_tcp_stream.py index 545fac864..5987a2356 100644 --- a/trio/_highlevel_open_tcp_stream.py +++ b/trio/_highlevel_open_tcp_stream.py @@ -1,8 +1,12 @@ +import sys from contextlib import contextmanager import trio from trio.socket import getaddrinfo, SOCK_STREAM, socket +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + # Implementation of RFC 6555 "Happy eyeballs" # https://tools.ietf.org/html/rfc6555 # @@ -114,8 +118,10 @@ def close_all(): sock.close() except BaseException as exc: errs.append(exc) - if errs: - raise trio.MultiError(errs) + if len(errs) == 1: + raise errs[0] + elif errs: + raise BaseExceptionGroup("Multiple close operations failed", errs) def reorder_for_rfc_6555_section_5_4(targets): @@ -364,7 +370,9 @@ async def attempt_connect(socket_args, sockaddr, attempt_failed): msg = "all attempts to connect to {} failed".format( format_host_port(host, port) ) - raise OSError(msg) from trio.MultiError(oserrors) + raise OSError(msg) from BaseExceptionGroup( + "multiple connection attempts failed", oserrors + ) else: stream = trio.SocketStream(winning_socket) open_sockets.remove(winning_socket) diff --git a/trio/tests/test_highlevel_open_tcp_listeners.py b/trio/tests/test_highlevel_open_tcp_listeners.py index d5fc576ec..d9fe26676 100644 --- a/trio/tests/test_highlevel_open_tcp_listeners.py +++ b/trio/tests/test_highlevel_open_tcp_listeners.py @@ -1,3 +1,5 @@ +import sys + import pytest import socket as stdlib_socket @@ -11,6 +13,9 @@ from .. import socket as tsocket from .._core.tests.tutil import slow, creates_ipv6, binds_ipv6 +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + async def test_open_tcp_listeners_basic(): listeners = await open_tcp_listeners(0) @@ -239,7 +244,7 @@ async def test_open_tcp_listeners_some_address_families_unavailable( await open_tcp_listeners(80, host="example.org") assert "This system doesn't support" in str(exc_info.value) - if isinstance(exc_info.value.__cause__, trio.MultiError): + if isinstance(exc_info.value.__cause__, ExceptionGroup): for subexc in exc_info.value.__cause__.exceptions: assert "nope" in str(subexc) else: diff --git a/trio/tests/test_highlevel_open_tcp_stream.py b/trio/tests/test_highlevel_open_tcp_stream.py index eaaff3e17..0f3b6a0ba 100644 --- a/trio/tests/test_highlevel_open_tcp_stream.py +++ b/trio/tests/test_highlevel_open_tcp_stream.py @@ -13,6 +13,9 @@ format_host_port, ) +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + def test_close_all(): class CloseMe: @@ -436,7 +439,7 @@ async def test_all_fail(autojump_clock): expect_error=OSError, ) assert isinstance(exc, OSError) - assert isinstance(exc.__cause__, trio.MultiError) + assert isinstance(exc.__cause__, BaseExceptionGroup) assert len(exc.__cause__.exceptions) == 4 assert trio.current_time() == (0.1 + 0.2 + 10) assert scenario.connect_times == { @@ -556,7 +559,7 @@ async def test_cancel(autojump_clock): ("3.3.3.3", 10, "success"), ("4.4.4.4", 10, "success"), ], - expect_error=trio.MultiError, + expect_error=BaseExceptionGroup, ) # What comes out should be 1 or more Cancelled errors that all belong # to this cancel_scope; this is the easiest way to check that From 6fdd0fe279518ce11ba0ae554aa36764414219af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 17 Jan 2022 01:15:49 +0200 Subject: [PATCH 03/93] Collapse exception groups containing a single exception --- trio/_core/_run.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/trio/_core/_run.py b/trio/_core/_run.py index fac3a7e12..592280abf 100644 --- a/trio/_core/_run.py +++ b/trio/_core/_run.py @@ -118,6 +118,28 @@ class IdlePrimedTypes(enum.Enum): ################################################################ +def collapse_exception_group(excgroup): + """Recursively collapse any single-exception groups into that single contained + exception. + + """ + exceptions = list(excgroup.exceptions) + modified = False + for i, exc in enumerate(exceptions): + if isinstance(exc, BaseExceptionGroup): + new_exc = collapse_exception_group(exc) + if new_exc is not exc: + modified = True + exceptions[i] = new_exc + + if len(exceptions) == 1: + return exceptions[0] + elif modified: + return excgroup.derive(exceptions) + else: + return excgroup + + @attr.s(eq=False, slots=True) class Deadlines: """A container of deadlined cancel scopes. @@ -514,8 +536,9 @@ def _close(self, exc): if matched: self.cancelled_caught = True - while isinstance(exc, BaseExceptionGroup) and len(exc.exceptions) == 1: - exc = exc.exceptions[0] + if isinstance(exc, BaseExceptionGroup): + exc = collapse_exception_group(exc) + self._cancel_status.close() with self._might_change_registered_deadline(): self._cancel_status = None From 13de11dc80e4654fd92dfe1c82cb4236a624871f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 17 Jan 2022 01:24:48 +0200 Subject: [PATCH 04/93] Require exceptiongroup unconditionally for tests --- test-requirements.in | 1 + test-requirements.txt | 2 ++ trio/_core/tests/test_run.py | 3 ++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/test-requirements.in b/test-requirements.in index 99854204d..75e69f2c9 100644 --- a/test-requirements.in +++ b/test-requirements.in @@ -7,6 +7,7 @@ trustme # for the ssl tests pylint # for pylint finding all symbols tests jedi # for jedi code completion tests cryptography>=36.0.0 # 35.0.0 is transitive but fails +exceptiongroup # for catch() # Tools black; implementation_name == "cpython" diff --git a/test-requirements.txt b/test-requirements.txt index f1162efdb..15f8d58b9 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -32,6 +32,8 @@ cryptography==36.0.1 # trustme decorator==5.1.0 # via ipython +exceptiongroup==1.0.0rc1 + # via -r test-requirements.in flake8==4.0.1 # via -r test-requirements.in idna==3.3 diff --git a/trio/_core/tests/test_run.py b/trio/_core/tests/test_run.py index c9d73f4c9..0c563d531 100644 --- a/trio/_core/tests/test_run.py +++ b/trio/_core/tests/test_run.py @@ -16,6 +16,7 @@ import outcome import sniffio import pytest +from exceptiongroup import catch from .tutil import ( slow, @@ -38,7 +39,7 @@ ) if sys.version_info < (3, 11): - from exceptiongroup import BaseExceptionGroup, ExceptionGroup, catch + from exceptiongroup import BaseExceptionGroup, ExceptionGroup # slightly different from _timeouts.sleep_forever because it returns the value From 9ecca9bac6c99fa342f2334d4a6f9705257edb30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 25 Jan 2022 14:29:09 +0200 Subject: [PATCH 05/93] Added exceptiongroup to docs requirements --- docs-requirements.in | 1 + docs-requirements.txt | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs-requirements.in b/docs-requirements.in index 23a1b0f65..dc9888ec6 100644 --- a/docs-requirements.in +++ b/docs-requirements.in @@ -13,6 +13,7 @@ async_generator >= 1.9 idna outcome sniffio +exceptiongroup # See note in test-requirements.in immutables >= 0.6 diff --git a/docs-requirements.txt b/docs-requirements.txt index b9c1f3d79..0f2ca5635 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -1,8 +1,8 @@ # -# This file is autogenerated by pip-compile +# This file is autogenerated by pip-compile with python 3.7 # To update, run: # -# pip-compile --output-file docs-requirements.txt docs-requirements.in +# pip-compile --output-file=docs-requirements.txt docs-requirements.in # alabaster==0.7.12 # via sphinx @@ -28,6 +28,8 @@ docutils==0.17.1 # via # sphinx # sphinx-rtd-theme +exceptiongroup==1.0.0rc1 + # via -r docs-requirements.in idna==3.3 # via # -r docs-requirements.in @@ -36,6 +38,8 @@ imagesize==1.2.0 # via sphinx immutables==0.16 # via -r docs-requirements.in +importlib-metadata==4.10.1 + # via click incremental==21.3.0 # via towncrier jinja2==3.0.2 @@ -87,5 +91,14 @@ toml==0.10.2 # via towncrier towncrier==21.3.0 # via -r docs-requirements.in +typing-extensions==4.0.1 + # via + # immutables + # importlib-metadata urllib3==1.26.7 # via requests +zipp==3.7.0 + # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# setuptools From 21757c89904879a89856d87253d5fab11c426b72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 25 Jan 2022 14:37:24 +0200 Subject: [PATCH 06/93] Fixed dangling references in the documentation --- docs/source/history.rst | 10 +++++----- docs/source/reference-core.rst | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/source/history.rst b/docs/source/history.rst index 3936df867..a8fd58467 100644 --- a/docs/source/history.rst +++ b/docs/source/history.rst @@ -232,9 +232,9 @@ Bugfixes - On Ubuntu systems, the system Python includes a custom unhandled-exception hook to perform `crash reporting `__. Unfortunately, Trio wants to use - the same hook to print nice `MultiError` tracebacks, causing a + the same hook to print nice ``MultiError`` tracebacks, causing a conflict. Previously, Trio would detect the conflict, print a warning, - and you just wouldn't get nice `MultiError` tracebacks. Now, Trio has + and you just wouldn't get nice ``MultiError`` tracebacks. Now, Trio has gotten clever enough to integrate its hook with Ubuntu's, so the two systems should Just Work together. (`#1065 `__) - Fixed an over-strict test that caused failures on Alpine Linux. @@ -436,7 +436,7 @@ Features violated. (One common source of such violations is an async generator that yields within a cancel scope.) The previous behavior was an inscrutable chain of TrioInternalErrors. (`#882 `__) -- MultiError now defines its ``exceptions`` attribute in ``__init__()`` +- ``MultiError`` now defines its ``exceptions`` attribute in ``__init__()`` to better support linters and code autocompletion. (`#1066 `__) - Use ``__slots__`` in more places internally, which should make Trio slightly faster. (`#984 `__) @@ -457,7 +457,7 @@ Bugfixes :meth:`~trio.Path.cwd`, are now async functions. Previously, a bug in the forwarding logic meant :meth:`~trio.Path.cwd` was synchronous and :meth:`~trio.Path.home` didn't work at all. (`#960 `__) -- An exception encapsulated within a :class:`MultiError` doesn't need to be +- An exception encapsulated within a `MultiError` doesn't need to be hashable anymore. .. note:: @@ -1248,7 +1248,7 @@ Other changes interfering with direct use of :func:`~trio.testing.wait_all_tasks_blocked` in the same test. -* :meth:`MultiError.catch` now correctly preserves ``__context__``, +* ``MultiError.catch()`` now correctly preserves ``__context__``, despite Python's best attempts to stop us (`#165 `__) diff --git a/docs/source/reference-core.rst b/docs/source/reference-core.rst index e320088cf..658e14d08 100644 --- a/docs/source/reference-core.rst +++ b/docs/source/reference-core.rst @@ -641,7 +641,7 @@ crucial things to keep in mind: * Any unhandled exceptions are re-raised inside the parent task. If there are multiple exceptions, then they're collected up into a - single :exc:`BaseExceptionGroup` or :exc:`ExceptionGroup` exception. + single ``BaseExceptionGroup`` or ``ExceptionGroup`` exception. Since all tasks are descendents of the initial task, one consequence of this is that :func:`run` can't finish until all tasks have @@ -712,9 +712,9 @@ limitation. Consider code like:: what? In some sense, the answer should be "both of these at once", but in Python there can only be one exception at a time. -Trio's answer is that it raises a :exc:`BaseExceptionGroup` object. This is a +Trio's answer is that it raises a ``BaseExceptionGroup`` object. This is a special exception which encapsulates multiple exception objects – -either regular exceptions or nested :exc:`BaseExceptionGroup`\s. +either regular exceptions or nested ``BaseExceptionGroup``\s. Spawning tasks without becoming a parent From 61a1646072e91884005f35ebe472ef635dd04bb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 25 Jan 2022 19:12:12 +0200 Subject: [PATCH 07/93] Removed obsolete check The __cause__ of the OSError will always be an ExceptionGroup going forward. MultiError's constructor, given a single element exception array, returned just that, but ExceptionGroup does not work the same way. --- trio/tests/test_highlevel_open_tcp_listeners.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/trio/tests/test_highlevel_open_tcp_listeners.py b/trio/tests/test_highlevel_open_tcp_listeners.py index d9fe26676..e86d108d2 100644 --- a/trio/tests/test_highlevel_open_tcp_listeners.py +++ b/trio/tests/test_highlevel_open_tcp_listeners.py @@ -244,12 +244,9 @@ async def test_open_tcp_listeners_some_address_families_unavailable( await open_tcp_listeners(80, host="example.org") assert "This system doesn't support" in str(exc_info.value) - if isinstance(exc_info.value.__cause__, ExceptionGroup): - for subexc in exc_info.value.__cause__.exceptions: - assert "nope" in str(subexc) - else: - assert isinstance(exc_info.value.__cause__, OSError) - assert "nope" in str(exc_info.value.__cause__) + assert isinstance(exc_info.value.__cause__, ExceptionGroup) + for subexc in exc_info.value.__cause__.exceptions: + assert "nope" in str(subexc) else: listeners = await open_tcp_listeners(80) for listener in listeners: From ca05efb127928d6f708fe870dac0b6693cc371e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 25 Jan 2022 19:20:54 +0200 Subject: [PATCH 08/93] Removed one last MultiError reference in docs --- docs/source/history.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/history.rst b/docs/source/history.rst index a8fd58467..450817bb7 100644 --- a/docs/source/history.rst +++ b/docs/source/history.rst @@ -457,7 +457,7 @@ Bugfixes :meth:`~trio.Path.cwd`, are now async functions. Previously, a bug in the forwarding logic meant :meth:`~trio.Path.cwd` was synchronous and :meth:`~trio.Path.home` didn't work at all. (`#960 `__) -- An exception encapsulated within a `MultiError` doesn't need to be +- An exception encapsulated within a ``MultiError`` doesn't need to be hashable anymore. .. note:: From 0b159e8e83ec0ae0ee6299bc11bb1436073c4658 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 25 Jan 2022 19:23:51 +0200 Subject: [PATCH 09/93] Found one more MultiError, in a news fragment --- newsfragments/2063.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/newsfragments/2063.bugfix.rst b/newsfragments/2063.bugfix.rst index 85fe7aa52..a0f16db8f 100644 --- a/newsfragments/2063.bugfix.rst +++ b/newsfragments/2063.bugfix.rst @@ -1,3 +1,3 @@ -Trio now avoids creating cyclic garbage when a `MultiError` is generated and filtered, +Trio now avoids creating cyclic garbage when a ``MultiError`` is generated and filtered, including invisibly within the cancellation system. This means errors raised through nurseries and cancel scopes should result in less GC latency. \ No newline at end of file From e9aaabc55548b5a1c75003a838bb94b660376abe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Wed, 26 Jan 2022 01:52:52 +0200 Subject: [PATCH 10/93] Added a test for exception group collapsing --- trio/_core/tests/test_run.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/trio/_core/tests/test_run.py b/trio/_core/tests/test_run.py index 0c563d531..18dd4b231 100644 --- a/trio/_core/tests/test_run.py +++ b/trio/_core/tests/test_run.py @@ -1876,6 +1876,20 @@ def handle(exc): assert result == [[0, 0], [1, 1]] +async def test_nursery_collapse_exceptions(): + # Test that exception groups containing only a single exception are + # recursively collapsed + async def fail(): + raise ExceptionGroup("fail", [ValueError()]) + + try: + async with _core.open_nursery() as nursery: + nursery.start_soon(fail) + raise StopIteration + except ExceptionGroup as e: + assert tuple(map(type, e.exceptions)) == (StopIteration, ValueError) + + async def test_traceback_frame_removal(): async def my_child_task(): raise KeyError() From e2fbec01f2a5a3a28d09f8f005016368944f9d7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 30 Jan 2022 23:54:09 +0200 Subject: [PATCH 11/93] Updated an example to refer to BaseExceptionGroup --- docs/source/tutorial/echo-server.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/source/tutorial/echo-server.py b/docs/source/tutorial/echo-server.py index ffa63ab5f..3751cadd7 100644 --- a/docs/source/tutorial/echo-server.py +++ b/docs/source/tutorial/echo-server.py @@ -11,6 +11,7 @@ CONNECTION_COUNTER = count() + async def echo_server(server_stream): # Assign each connection a unique number to make our debug prints easier # to understand when there are multiple simultaneous connections. @@ -21,18 +22,20 @@ async def echo_server(server_stream): print(f"echo_server {ident}: received data {data!r}") await server_stream.send_all(data) print(f"echo_server {ident}: connection closed") - # FIXME: add discussion of MultiErrors to the tutorial, and use - # MultiError.catch here. (Not important in this case, but important if the - # server code uses nurseries internally.) + # FIXME: add discussion of (Base)ExceptionGroup to the tutorial, and use + # exceptiongroup.catch() here. (Not important in this case, but important + # if the server code uses nurseries internally.) except Exception as exc: # Unhandled exceptions will propagate into our parent and take # down the whole program. If the exception is KeyboardInterrupt, # that's what we want, but otherwise maybe not... print(f"echo_server {ident}: crashed: {exc!r}") + async def main(): await trio.serve_tcp(echo_server, PORT) + # We could also just write 'trio.run(trio.serve_tcp, echo_server, PORT)', but real # programs almost always end up doing other stuff too and then we'd have to go # back and factor it out into a separate function anyway. So it's simplest to From 95a7b9eff089730624ecd5135871951ef426fbcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 31 Jan 2022 00:55:01 +0200 Subject: [PATCH 12/93] Improved the docs regarding exception groups --- docs/source/conf.py | 3 ++- docs/source/reference-core.rst | 45 +++++++++++++++++++++++++++++---- newsfragments/2211.breaking.rst | 7 +++++ newsfragments/2211.removal.rst | 4 --- 4 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 newsfragments/2211.breaking.rst delete mode 100644 newsfragments/2211.removal.rst diff --git a/docs/source/conf.py b/docs/source/conf.py index 6045ffd82..760f8837a 100755 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -84,8 +84,9 @@ def setup(app): 'local_customization', ] +# FIXME: change the "python" link back to /3 when Python 3.11 is released intersphinx_mapping = { - "python": ('https://docs.python.org/3', None), + "python": ('https://docs.python.org/3.11', None), "outcome": ('https://outcome.readthedocs.io/en/latest/', None), } diff --git a/docs/source/reference-core.rst b/docs/source/reference-core.rst index 658e14d08..2cc9c0e3f 100644 --- a/docs/source/reference-core.rst +++ b/docs/source/reference-core.rst @@ -687,6 +687,8 @@ You might wonder why Trio can't just remember "this task should be cancelled in If you want a timeout to apply to one task but not another, then you need to put the cancel scope in that individual task's function -- ``child()``, in this example. +.. _exceptiongroups: + Errors in multiple child tasks ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -709,13 +711,46 @@ limitation. Consider code like:: ``broken1`` raises ``KeyError``. ``broken2`` raises ``IndexError``. Obviously ``parent`` should raise some error, but -what? In some sense, the answer should be "both of these at once", but -in Python there can only be one exception at a time. +what? The answer is that both exceptions are grouped in an `ExceptionGroup`. +The `ExceptionGroup` and its parent class `BaseExceptionGroup` are used to encapsulate +multiple exceptions being raised at once. + +To catch individual exceptions encapsulated in an exception group, the ``except*`` +clause was introduced in Python 3.11 (:pep:`654`). Here's how it works:: + + try: + async with trio.open_nursery() as nursery: + nursery.start_soon(broken1) + nursery.start_soon(broken2) + except* KeyError: + ... # handle each KeyError + except* IndexError: + ... # handle each IndexError + +But what if you can't use ``except*`` just yet? Well, for that there is the handy +exceptiongroup_ library which lets you approximate this behavior with exception handler +callbacks:: + + from exceptiongroup import catch + + def handle_keyerror(exc): + ... # handle each KeyError + + def handle_indexerror(exc): + ... # handle each IndexError + + with catch({ + KeyError: handle_keyerror, + IndexError: handle_indexerror + }): + async with trio.open_nursery() as nursery: + nursery.start_soon(broken1) + nursery.start_soon(broken2) -Trio's answer is that it raises a ``BaseExceptionGroup`` object. This is a -special exception which encapsulates multiple exception objects – -either regular exceptions or nested ``BaseExceptionGroup``\s. +.. hint:: If your code, written using ``except*``, would set local variables, you can do + the same with handler callbacks as long as you declare those variables ``nonlocal``. +.. _exceptiongroup: https://pypi.org/project/exceptiongroup/ Spawning tasks without becoming a parent ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/newsfragments/2211.breaking.rst b/newsfragments/2211.breaking.rst new file mode 100644 index 000000000..84ec5427c --- /dev/null +++ b/newsfragments/2211.breaking.rst @@ -0,0 +1,7 @@ +``trio.MultiError`` has been removed in favor of the built-in :exc:`BaseExceptionGroup` +(and its derivative :exc:`ExceptionGroup`), falling back to the backport_ on +Python < 3.11. +See the :ref:`updated documentation ` on how to deal with exception +groups. + +.. _backport: https://pypi.org/project/exceptiongroup/ diff --git a/newsfragments/2211.removal.rst b/newsfragments/2211.removal.rst deleted file mode 100644 index 728ac2f2f..000000000 --- a/newsfragments/2211.removal.rst +++ /dev/null @@ -1,4 +0,0 @@ -``trio.MultiError`` has been removed in favor of the built-in ``BaseExceptionGroup`` -(and its derivative ``ExceptionGroup``), falling back to the backport_ on Python < 3.11. - -.. _backport: https://pypi.org/project/exceptiongroup/ From 745f3cf798cb111fd2c21c542a2bb1aab8ad58c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 31 Jan 2022 01:11:18 +0200 Subject: [PATCH 13/93] Added Python 3.11 to the CI matrix --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65fa89917..6dbf6beaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python: ['3.7', '3.8', '3.9', '3.10'] + python: ['3.7', '3.8', '3.9', '3.10', '3.11-dev'] arch: ['x86', 'x64'] lsp: [''] lsp_extract_file: [''] @@ -72,7 +72,7 @@ jobs: strategy: fail-fast: false matrix: - python: ['pypy-3.7', 'pypy-3.8', '3.7', '3.8', '3.9', '3.10', '3.8-dev', '3.9-dev', '3.10-dev'] + python: ['pypy-3.7', 'pypy-3.8', '3.7', '3.8', '3.9', '3.10', '3.8-dev', '3.9-dev', '3.10-dev', '3.11-dev'] check_formatting: ['0'] pypy_nightly_branch: [''] extra_name: [''] @@ -114,7 +114,7 @@ jobs: strategy: fail-fast: false matrix: - python: ['3.7', '3.8', '3.9', '3.10'] + python: ['3.7', '3.8', '3.9', '3.10', '3.11-dev'] include: - python: '3.8' # <- not actually used arch: 'x64' From bc043aaa63cb5cd82ada7cc9b7bc1e3b0beb552f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 31 Jan 2022 01:46:27 +0200 Subject: [PATCH 14/93] Attempt at fixing Python 3.11 test runs --- test-requirements.in | 1 + test-requirements.txt | 37 +++++++++++++++++++++++++++---------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/test-requirements.in b/test-requirements.in index 024ea35b2..3c5079838 100644 --- a/test-requirements.in +++ b/test-requirements.in @@ -9,6 +9,7 @@ pylint # for pylint finding all symbols tests jedi # for jedi code completion tests cryptography>=36.0.0 # 35.0.0 is transitive but fails exceptiongroup # for catch() +wrapt @ git+https://github.com/grayjk/wrapt.git@issue-196 # fixes Python 3.11 compat for pylint # Tools black; implementation_name == "cpython" diff --git a/test-requirements.txt b/test-requirements.txt index e15470a0d..ed45c4655 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with python 3.7 # To update, run: # -# pip-compile --output-file=test-requirements.txt test-requirements.in +# pip-compile test-requirements.in # astor==0.8.1 # via -r test-requirements.in @@ -17,13 +17,13 @@ attrs==21.4.0 # pytest backcall==0.2.0 # via ipython -black==21.12b0 ; implementation_name == "cpython" +black==22.1.0 ; implementation_name == "cpython" # via -r test-requirements.in cffi==1.15.0 # via cryptography click==8.0.3 # via black -coverage[toml]==6.0.2 +coverage[toml]==6.3 # via pytest-cov cryptography==36.0.1 # via @@ -40,6 +40,12 @@ idna==3.3 # via # -r test-requirements.in # trustme +importlib-metadata==4.2.0 + # via + # click + # flake8 + # pluggy + # pytest iniconfig==1.1.1 # via pytest ipython==7.31.1 @@ -95,11 +101,11 @@ pycparser==2.21 # via cffi pyflakes==2.4.0 # via flake8 -pygments==2.10.0 +pygments==2.11.2 # via ipython pylint==2.12.2 # via -r test-requirements.in -pyopenssl==21.0.0 +pyopenssl==22.0.0 # via -r test-requirements.in pyparsing==3.0.7 # via packaging @@ -109,8 +115,6 @@ pytest==6.2.5 # pytest-cov pytest-cov==3.0.0 # via -r test-requirements.in -six==1.16.0 - # via pyopenssl sniffio==1.2.0 # via -r test-requirements.in sortedcontainers==2.4.0 @@ -119,7 +123,7 @@ toml==0.10.2 # via # pylint # pytest -tomli==1.2.3 +tomli==2.0.0 # via # black # coverage @@ -130,15 +134,28 @@ traitlets==5.1.1 # matplotlib-inline trustme==0.9.0 # via -r test-requirements.in +typed-ast==1.5.2 ; implementation_name == "cpython" and python_version < "3.8" + # via + # -r test-requirements.in + # astroid + # black + # mypy typing-extensions==4.0.1 ; implementation_name == "cpython" # via # -r test-requirements.in + # astroid # black + # importlib-metadata # mypy + # pylint wcwidth==0.2.5 # via prompt-toolkit -wrapt==1.13.3 - # via astroid +wrapt @ git+https://github.com/grayjk/wrapt.git@issue-196 + # via + # -r test-requirements.in + # astroid +zipp==3.7.0 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools From 797c234cc3b384f526432895a8d1351683082f8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 31 Jan 2022 01:49:52 +0200 Subject: [PATCH 15/93] Removed Windows and macOS py3.11 test jobs These platforms don't work with Python 3.11 on GitHub Actions yet. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6dbf6beaf..3c7fa94e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python: ['3.7', '3.8', '3.9', '3.10', '3.11-dev'] + python: ['3.7', '3.8', '3.9', '3.10'] arch: ['x86', 'x64'] lsp: [''] lsp_extract_file: [''] @@ -114,7 +114,7 @@ jobs: strategy: fail-fast: false matrix: - python: ['3.7', '3.8', '3.9', '3.10', '3.11-dev'] + python: ['3.7', '3.8', '3.9', '3.10'] include: - python: '3.8' # <- not actually used arch: 'x64' From 8a0977876febeceb47a676989a710dbebbcbeb2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 31 Jan 2022 01:52:46 +0200 Subject: [PATCH 16/93] Skip test_coroutine_or_error() on Python 3.11 --- trio/tests/test_util.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/trio/tests/test_util.py b/trio/tests/test_util.py index 2d57e0ebf..bf2f606f0 100644 --- a/trio/tests/test_util.py +++ b/trio/tests/test_util.py @@ -1,4 +1,6 @@ import signal +import sys + import pytest import trio @@ -93,6 +95,9 @@ def not_main_thread(): # @coroutine is deprecated since python 3.8, which is fine with us. @pytest.mark.filterwarnings("ignore:.*@coroutine.*:DeprecationWarning") +@pytest.mark.skipif( + sys.version_info >= (3, 11), reason="asyncio.coroutine was removed in Python 3.11" +) def test_coroutine_or_error(): class Deferred: "Just kidding" From 30358d1f21e3edd86811eebdde336dec476296f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 1 Feb 2022 01:34:43 +0200 Subject: [PATCH 17/93] Changed case to be consistent in BEG messages --- trio/_highlevel_open_tcp_stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trio/_highlevel_open_tcp_stream.py b/trio/_highlevel_open_tcp_stream.py index 5987a2356..d53038619 100644 --- a/trio/_highlevel_open_tcp_stream.py +++ b/trio/_highlevel_open_tcp_stream.py @@ -121,7 +121,7 @@ def close_all(): if len(errs) == 1: raise errs[0] elif errs: - raise BaseExceptionGroup("Multiple close operations failed", errs) + raise BaseExceptionGroup("multiple close operations failed", errs) def reorder_for_rfc_6555_section_5_4(targets): From 54eb0cebf3dcd8a03684189001a60a26ec9c16d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Thu, 3 Feb 2022 22:23:28 +0200 Subject: [PATCH 18/93] Restored (and deprecated) MultiError It now inherits from BaseExceptionGroup. --- newsfragments/2211.breaking.rst | 7 - newsfragments/2211.deprecated.rst | 12 + trio/__init__.py | 8 + trio/_core/_multierror.py | 378 ++++++++++++++++ trio/_core/_run.py | 5 +- trio/_core/tests/test_multierror.py | 409 ++++++++++++++++++ trio/_core/tests/test_run.py | 52 ++- trio/_highlevel_open_tcp_listeners.py | 8 +- trio/_highlevel_open_tcp_stream.py | 14 +- .../test_highlevel_open_tcp_listeners.py | 11 +- 10 files changed, 853 insertions(+), 51 deletions(-) delete mode 100644 newsfragments/2211.breaking.rst create mode 100644 newsfragments/2211.deprecated.rst create mode 100644 trio/_core/_multierror.py create mode 100644 trio/_core/tests/test_multierror.py diff --git a/newsfragments/2211.breaking.rst b/newsfragments/2211.breaking.rst deleted file mode 100644 index 84ec5427c..000000000 --- a/newsfragments/2211.breaking.rst +++ /dev/null @@ -1,7 +0,0 @@ -``trio.MultiError`` has been removed in favor of the built-in :exc:`BaseExceptionGroup` -(and its derivative :exc:`ExceptionGroup`), falling back to the backport_ on -Python < 3.11. -See the :ref:`updated documentation ` on how to deal with exception -groups. - -.. _backport: https://pypi.org/project/exceptiongroup/ diff --git a/newsfragments/2211.deprecated.rst b/newsfragments/2211.deprecated.rst new file mode 100644 index 000000000..6becff6b9 --- /dev/null +++ b/newsfragments/2211.deprecated.rst @@ -0,0 +1,12 @@ +``MultiError`` has been deprecated in favor of the standard :exc:`BaseExceptionGroup` +(introduced in :pep:`654`). On Python < 3.11, this exception and its derivative +:exc:`ExceptionGroup` are provided by the backport_. Trio still raises ``MultiError``, +but it has been refactored into a subclass of :exc:`BaseExceptionGroup` which users +should catch instead of ``MultiError``. Uses of the ``filter()`` class method should be +replaced with :meth:`BaseExceptionGroup.split`. Uses of the ``catch()`` class method +should be replaced with either ``except*`` clauses (on Python 3.11+) or the ``catch()`` +context manager provided by the backport_. + +See the :ref:`updated documentation ` for details. + +.. _backport: https://pypi.org/project/exceptiongroup/ diff --git a/trio/__init__.py b/trio/__init__.py index 528ea15f0..ec8b58e61 100644 --- a/trio/__init__.py +++ b/trio/__init__.py @@ -87,6 +87,8 @@ serve_ssl_over_tcp, ) +from ._core._multierror import MultiError as _MultiError + from ._deprecate import TrioDeprecationWarning # Submodules imported by default @@ -112,6 +114,12 @@ issue=1104, instead="trio.lowlevel.open_process", ), + "MultiError": _deprecate.DeprecatedAttribute( + value=_MultiError, + version="0.20.0", + issue=2211, + instead="BaseExceptionGroup (py3.11+) or exceptiongroup.BaseExceptionGroup", + ), } # Having the public path in .__module__ attributes is important for: diff --git a/trio/_core/_multierror.py b/trio/_core/_multierror.py new file mode 100644 index 000000000..2e3d0f0ab --- /dev/null +++ b/trio/_core/_multierror.py @@ -0,0 +1,378 @@ +import sys +import warnings +from typing import Sequence + +import attr +from exceptiongroup._exceptions import EBase, T + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + +################################################################ +# MultiError +################################################################ + + +def _filter_impl(handler, root_exc): + # We have a tree of MultiError's, like: + # + # MultiError([ + # ValueError, + # MultiError([ + # KeyError, + # ValueError, + # ]), + # ]) + # + # or similar. + # + # We want to + # 1) apply the filter to each of the leaf exceptions -- each leaf + # might stay the same, be replaced (with the original exception + # potentially sticking around as __context__ or __cause__), or + # disappear altogether. + # 2) simplify the resulting tree -- remove empty nodes, and replace + # singleton MultiError's with their contents, e.g.: + # MultiError([KeyError]) -> KeyError + # (This can happen recursively, e.g. if the two ValueErrors above + # get caught then we'll just be left with a bare KeyError.) + # 3) preserve sensible tracebacks + # + # It's the tracebacks that are most confusing. As a MultiError + # propagates through the stack, it accumulates traceback frames, but + # the exceptions inside it don't. Semantically, the traceback for a + # leaf exception is the concatenation the tracebacks of all the + # exceptions you see when traversing the exception tree from the root + # to that leaf. Our correctness invariant is that this concatenated + # traceback should be the same before and after. + # + # The easy way to do that would be to, at the beginning of this + # function, "push" all tracebacks down to the leafs, so all the + # MultiErrors have __traceback__=None, and all the leafs have complete + # tracebacks. But whenever possible, we'd actually prefer to keep + # tracebacks as high up in the tree as possible, because this lets us + # keep only a single copy of the common parts of these exception's + # tracebacks. This is cheaper (in memory + time -- tracebacks are + # unpleasantly quadratic-ish to work with, and this might matter if + # you have thousands of exceptions, which can happen e.g. after + # cancelling a large task pool, and no-one will ever look at their + # tracebacks!), and more importantly, factoring out redundant parts of + # the tracebacks makes them more readable if/when users do see them. + # + # So instead our strategy is: + # - first go through and construct the new tree, preserving any + # unchanged subtrees + # - then go through the original tree (!) and push tracebacks down + # until either we hit a leaf, or we hit a subtree which was + # preserved in the new tree. + + # This used to also support async handler functions. But that runs into: + # https://bugs.python.org/issue29600 + # which is difficult to fix on our end. + + # Filters a subtree, ignoring tracebacks, while keeping a record of + # which MultiErrors were preserved unchanged + def filter_tree(exc, preserved): + if isinstance(exc, MultiError): + new_exceptions = [] + changed = False + for child_exc in exc.exceptions: + new_child_exc = filter_tree(child_exc, preserved) + if new_child_exc is not child_exc: + changed = True + if new_child_exc is not None: + new_exceptions.append(new_child_exc) + if not new_exceptions: + return None + elif changed: + return MultiError(new_exceptions) + else: + preserved.add(id(exc)) + return exc + else: + new_exc = handler(exc) + # Our version of implicit exception chaining + if new_exc is not None and new_exc is not exc: + new_exc.__context__ = exc + return new_exc + + def push_tb_down(tb, exc, preserved): + if id(exc) in preserved: + return + new_tb = concat_tb(tb, exc.__traceback__) + if isinstance(exc, MultiError): + for child_exc in exc.exceptions: + push_tb_down(new_tb, child_exc, preserved) + exc.__traceback__ = None + else: + exc.__traceback__ = new_tb + + preserved = set() + new_root_exc = filter_tree(root_exc, preserved) + push_tb_down(None, root_exc, preserved) + # Delete the local functions to avoid a reference cycle (see + # test_simple_cancel_scope_usage_doesnt_create_cyclic_garbage) + del filter_tree, push_tb_down + return new_root_exc + + +# Normally I'm a big fan of (a)contextmanager, but in this case I found it +# easier to use the raw context manager protocol, because it makes it a lot +# easier to reason about how we're mutating the traceback as we go. (End +# result: if the exception gets modified, then the 'raise' here makes this +# frame show up in the traceback; otherwise, we leave no trace.) +@attr.s(frozen=True) +class MultiErrorCatcher: + _handler = attr.ib() + + def __enter__(self): + pass + + def __exit__(self, etype, exc, tb): + if exc is not None: + filtered_exc = _filter_impl(self._handler, exc) + + if filtered_exc is exc: + # Let the interpreter re-raise it + return False + if filtered_exc is None: + # Swallow the exception + return True + # When we raise filtered_exc, Python will unconditionally blow + # away its __context__ attribute and replace it with the original + # exc we caught. So after we raise it, we have to pause it while + # it's in flight to put the correct __context__ back. + old_context = filtered_exc.__context__ + try: + raise filtered_exc + finally: + _, value, _ = sys.exc_info() + assert value is filtered_exc + value.__context__ = old_context + # delete references from locals to avoid creating cycles + # see test_MultiError_catch_doesnt_create_cyclic_garbage + del _, filtered_exc, value + + +class MultiError(BaseExceptionGroup): + """An exception that contains other exceptions; also known as an + "inception". + + It's main use is to represent the situation when multiple child tasks all + raise errors "in parallel". + + Args: + exceptions (list): The exceptions + + Returns: + If ``len(exceptions) == 1``, returns that exception. This means that a + call to ``MultiError(...)`` is not guaranteed to return a + :exc:`MultiError` object! + + Otherwise, returns a new :exc:`MultiError` object. + + Raises: + TypeError: if any of the passed in objects are not instances of + :exc:`BaseException`. + + """ + + def __init__(self, exceptions, *, _collapse=True): + # Avoid recursion when exceptions[0] returned by __new__() happens + # to be a MultiError and subsequently __init__() is called. + if _collapse and hasattr(self, "_exceptions"): + # __init__ was already called on this object + assert len(exceptions) == 1 and exceptions[0] is self + return + + super().__init__("multiple tasks failed", exceptions) + + def __new__(cls, exceptions, *, _collapse=True): + exceptions = list(exceptions) + for exc in exceptions: + if not isinstance(exc, BaseException): + raise TypeError(f"Expected an exception object, not {exc!r}") + if _collapse and len(exceptions) == 1: + # If this lone object happens to itself be a MultiError, then + # Python will implicitly call our __init__ on it again. See + # special handling in __init__. + return exceptions[0] + else: + # The base class __new__() implicitly invokes our __init__, which + # is what we want. + # + # In an earlier version of the code, we didn't define __init__ and + # simply set the `exceptions` attribute directly on the new object. + # However, linters expect attributes to be initialized in __init__. + return super().__new__(cls, "multiple tasks failed", exceptions) + + def __str__(self): + return ", ".join(repr(exc) for exc in self.exceptions) + + def __repr__(self): + return "".format(self) + + def derive(self: T, __excs: Sequence[EBase]) -> T: + return MultiError(__excs, _collapse=False) + + @classmethod + def filter(cls, handler, root_exc): + """Apply the given ``handler`` to all the exceptions in ``root_exc``. + + Args: + handler: A callable that takes an atomic (non-MultiError) exception + as input, and returns either a new exception object or None. + root_exc: An exception, often (though not necessarily) a + :exc:`MultiError`. + + Returns: + A new exception object in which each component exception ``exc`` has + been replaced by the result of running ``handler(exc)`` – or, if + ``handler`` returned None for all the inputs, returns None. + + """ + warnings.warn( + "MultiError.filter() has been deprecated. " + "Use the .split() method instead.", + DeprecationWarning, + ) + + return _filter_impl(handler, root_exc) + + @classmethod + def catch(cls, handler): + """Return a context manager that catches and re-throws exceptions + after running :meth:`filter` on them. + + Args: + handler: as for :meth:`filter` + + """ + warnings.warn( + "MultiError.catch() has been deprecated. " + "Use except* or exceptiongroup.catch() instead.", + DeprecationWarning, + ) + + return MultiErrorCatcher(handler) + + +# Clean up exception printing: +MultiError.__module__ = "trio" + +################################################################ +# concat_tb +################################################################ + +# We need to compute a new traceback that is the concatenation of two existing +# tracebacks. This requires copying the entries in 'head' and then pointing +# the final tb_next to 'tail'. +# +# NB: 'tail' might be None, which requires some special handling in the ctypes +# version. +# +# The complication here is that Python doesn't actually support copying or +# modifying traceback objects, so we have to get creative... +# +# On CPython, we use ctypes. On PyPy, we use "transparent proxies". +# +# Jinja2 is a useful source of inspiration: +# https://github.com/pallets/jinja/blob/master/jinja2/debug.py + +try: + import tputil +except ImportError: + have_tproxy = False +else: + have_tproxy = True + +if have_tproxy: + # http://doc.pypy.org/en/latest/objspace-proxies.html + def copy_tb(base_tb, tb_next): + def controller(operation): + # Rationale for pragma: I looked fairly carefully and tried a few + # things, and AFAICT it's not actually possible to get any + # 'opname' that isn't __getattr__ or __getattribute__. So there's + # no missing test we could add, and no value in coverage nagging + # us about adding one. + if operation.opname in [ + "__getattribute__", + "__getattr__", + ]: # pragma: no cover + if operation.args[0] == "tb_next": + return tb_next + return operation.delegate() + + return tputil.make_proxy(controller, type(base_tb), base_tb) + +else: + # ctypes it is + import ctypes + + # How to handle refcounting? I don't want to use ctypes.py_object because + # I don't understand or trust it, and I don't want to use + # ctypes.pythonapi.Py_{Inc,Dec}Ref because we might clash with user code + # that also tries to use them but with different types. So private _ctypes + # APIs it is! + import _ctypes + + class CTraceback(ctypes.Structure): + _fields_ = [ + ("PyObject_HEAD", ctypes.c_byte * object().__sizeof__()), + ("tb_next", ctypes.c_void_p), + ("tb_frame", ctypes.c_void_p), + ("tb_lasti", ctypes.c_int), + ("tb_lineno", ctypes.c_int), + ] + + def copy_tb(base_tb, tb_next): + # TracebackType has no public constructor, so allocate one the hard way + try: + raise ValueError + except ValueError as exc: + new_tb = exc.__traceback__ + c_new_tb = CTraceback.from_address(id(new_tb)) + + # At the C level, tb_next either pointer to the next traceback or is + # NULL. c_void_p and the .tb_next accessor both convert NULL to None, + # but we shouldn't DECREF None just because we assigned to a NULL + # pointer! Here we know that our new traceback has only 1 frame in it, + # so we can assume the tb_next field is NULL. + assert c_new_tb.tb_next is None + # If tb_next is None, then we want to set c_new_tb.tb_next to NULL, + # which it already is, so we're done. Otherwise, we have to actually + # do some work: + if tb_next is not None: + _ctypes.Py_INCREF(tb_next) + c_new_tb.tb_next = id(tb_next) + + assert c_new_tb.tb_frame is not None + _ctypes.Py_INCREF(base_tb.tb_frame) + old_tb_frame = new_tb.tb_frame + c_new_tb.tb_frame = id(base_tb.tb_frame) + _ctypes.Py_DECREF(old_tb_frame) + + c_new_tb.tb_lasti = base_tb.tb_lasti + c_new_tb.tb_lineno = base_tb.tb_lineno + + try: + return new_tb + finally: + # delete references from locals to avoid creating cycles + # see test_MultiError_catch_doesnt_create_cyclic_garbage + del new_tb, old_tb_frame + + +def concat_tb(head, tail): + # We have to use an iterative algorithm here, because in the worst case + # this might be a RecursionError stack that is by definition too deep to + # process by recursion! + head_tbs = [] + pointer = head + while pointer is not None: + head_tbs.append(pointer) + pointer = pointer.tb_next + current_head = tail + for head_tb in reversed(head_tbs): + current_head = copy_tb(head_tb, tb_next=current_head) + return current_head diff --git a/trio/_core/_run.py b/trio/_core/_run.py index f6fa85317..9ff7f890f 100644 --- a/trio/_core/_run.py +++ b/trio/_core/_run.py @@ -28,6 +28,7 @@ KIManager, enable_ki_protection, ) +from ._multierror import MultiError from ._traps import ( Abort, wait_task_rescheduled, @@ -939,7 +940,7 @@ def _child_finished(self, task, outcome): async def _nested_child_finished(self, nested_child_exc): """ - Returns BaseExceptionGroup instance if there are pending exceptions. + Returns MultiError instance if there are pending exceptions. """ if nested_child_exc is not None: self._add_exc(nested_child_exc) @@ -969,7 +970,7 @@ def aborted(raise_cancel): assert popped is self if self._pending_excs: try: - return BaseExceptionGroup("multiple tasks failed", self._pending_excs) + return MultiError(self._pending_excs) finally: # avoid a garbage cycle # (see test_nursery_cancel_doesnt_create_cyclic_garbage) diff --git a/trio/_core/tests/test_multierror.py b/trio/_core/tests/test_multierror.py new file mode 100644 index 000000000..304b18558 --- /dev/null +++ b/trio/_core/tests/test_multierror.py @@ -0,0 +1,409 @@ +import gc +import logging +import pytest + +from traceback import ( + extract_tb, + print_exception, + format_exception, +) +from traceback import _cause_message # type: ignore +import sys +import re + +from .._multierror import MultiError, concat_tb +from ..._core import open_nursery + + +class NotHashableException(Exception): + code = None + + def __init__(self, code): + super().__init__() + self.code = code + + def __eq__(self, other): + if not isinstance(other, NotHashableException): + return False + return self.code == other.code + + +async def raise_nothashable(code): + raise NotHashableException(code) + + +def raiser1(): + raiser1_2() + + +def raiser1_2(): + raiser1_3() + + +def raiser1_3(): + raise ValueError("raiser1_string") + + +def raiser2(): + raiser2_2() + + +def raiser2_2(): + raise KeyError("raiser2_string") + + +def raiser3(): + raise NameError + + +def get_exc(raiser): + try: + raiser() + except Exception as exc: + return exc + + +def get_tb(raiser): + return get_exc(raiser).__traceback__ + + +def einfo(exc): + return (type(exc), exc, exc.__traceback__) + + +def test_concat_tb(): + + tb1 = get_tb(raiser1) + tb2 = get_tb(raiser2) + + # These return a list of (filename, lineno, fn name, text) tuples + # https://docs.python.org/3/library/traceback.html#traceback.extract_tb + entries1 = extract_tb(tb1) + entries2 = extract_tb(tb2) + + tb12 = concat_tb(tb1, tb2) + assert extract_tb(tb12) == entries1 + entries2 + + tb21 = concat_tb(tb2, tb1) + assert extract_tb(tb21) == entries2 + entries1 + + # Check degenerate cases + assert extract_tb(concat_tb(None, tb1)) == entries1 + assert extract_tb(concat_tb(tb1, None)) == entries1 + assert concat_tb(None, None) is None + + # Make sure the original tracebacks didn't get mutated by mistake + assert extract_tb(get_tb(raiser1)) == entries1 + assert extract_tb(get_tb(raiser2)) == entries2 + + +def test_MultiError(): + exc1 = get_exc(raiser1) + exc2 = get_exc(raiser2) + + assert MultiError([exc1]) is exc1 + m = MultiError([exc1, exc2]) + assert m.exceptions == (exc1, exc2) + assert "ValueError" in str(m) + assert "ValueError" in repr(m) + + with pytest.raises(TypeError): + MultiError(object()) + with pytest.raises(TypeError): + MultiError([KeyError(), ValueError]) + + +def test_MultiErrorOfSingleMultiError(): + # For MultiError([MultiError]), ensure there is no bad recursion by the + # constructor where __init__ is called if __new__ returns a bare MultiError. + exceptions = (KeyError(), ValueError()) + a = MultiError(exceptions) + b = MultiError([a]) + assert b == a + assert b.exceptions == exceptions + + +async def test_MultiErrorNotHashable(): + exc1 = NotHashableException(42) + exc2 = NotHashableException(4242) + exc3 = ValueError() + assert exc1 != exc2 + assert exc1 != exc3 + + with pytest.raises(MultiError): + async with open_nursery() as nursery: + nursery.start_soon(raise_nothashable, 42) + nursery.start_soon(raise_nothashable, 4242) + + +def test_MultiError_filter_NotHashable(): + excs = MultiError([NotHashableException(42), ValueError()]) + + def handle_ValueError(exc): + if isinstance(exc, ValueError): + return None + else: + return exc + + filtered_excs = pytest.deprecated_call(MultiError.filter, handle_ValueError, excs) + assert isinstance(filtered_excs, NotHashableException) + + +def test_traceback_recursion(): + exc1 = RuntimeError() + exc2 = KeyError() + exc3 = NotHashableException(42) + # Note how this creates a loop, where exc1 refers to exc1 + # This could trigger an infinite recursion; the 'seen' set is supposed to prevent + # this. + exc1.__cause__ = MultiError([exc1, exc2, exc3]) + format_exception(*einfo(exc1)) + + +def make_tree(): + # Returns an object like: + # MultiError([ + # MultiError([ + # ValueError, + # KeyError, + # ]), + # NameError, + # ]) + # where all exceptions except the root have a non-trivial traceback. + exc1 = get_exc(raiser1) + exc2 = get_exc(raiser2) + exc3 = get_exc(raiser3) + + # Give m12 a non-trivial traceback + try: + raise MultiError([exc1, exc2]) + except BaseException as m12: + return MultiError([m12, exc3]) + + +def assert_tree_eq(m1, m2): + if m1 is None or m2 is None: + assert m1 is m2 + return + assert type(m1) is type(m2) + assert extract_tb(m1.__traceback__) == extract_tb(m2.__traceback__) + assert_tree_eq(m1.__cause__, m2.__cause__) + assert_tree_eq(m1.__context__, m2.__context__) + if isinstance(m1, MultiError): + assert len(m1.exceptions) == len(m2.exceptions) + for e1, e2 in zip(m1.exceptions, m2.exceptions): + assert_tree_eq(e1, e2) + + +def test_MultiError_filter(): + def null_handler(exc): + return exc + + m = make_tree() + assert_tree_eq(m, m) + assert pytest.deprecated_call(MultiError.filter, null_handler, m) is m + assert_tree_eq(m, make_tree()) + + # Make sure we don't pick up any detritus if run in a context where + # implicit exception chaining would like to kick in + m = make_tree() + try: + raise ValueError + except ValueError: + assert pytest.deprecated_call(MultiError.filter, null_handler, m) is m + assert_tree_eq(m, make_tree()) + + def simple_filter(exc): + if isinstance(exc, ValueError): + return None + if isinstance(exc, KeyError): + return RuntimeError() + return exc + + new_m = pytest.deprecated_call(MultiError.filter, simple_filter, make_tree()) + assert isinstance(new_m, MultiError) + assert len(new_m.exceptions) == 2 + # was: [[ValueError, KeyError], NameError] + # ValueError disappeared & KeyError became RuntimeError, so now: + assert isinstance(new_m.exceptions[0], RuntimeError) + assert isinstance(new_m.exceptions[1], NameError) + + # implicit chaining: + assert isinstance(new_m.exceptions[0].__context__, KeyError) + + # also, the traceback on the KeyError incorporates what used to be the + # traceback on its parent MultiError + orig = make_tree() + # make sure we have the right path + assert isinstance(orig.exceptions[0].exceptions[1], KeyError) + # get original traceback summary + orig_extracted = ( + extract_tb(orig.__traceback__) + + extract_tb(orig.exceptions[0].__traceback__) + + extract_tb(orig.exceptions[0].exceptions[1].__traceback__) + ) + + def p(exc): + print_exception(type(exc), exc, exc.__traceback__) + + p(orig) + p(orig.exceptions[0]) + p(orig.exceptions[0].exceptions[1]) + p(new_m.exceptions[0].__context__) + # compare to the new path + assert new_m.__traceback__ is None + new_extracted = extract_tb(new_m.exceptions[0].__context__.__traceback__) + assert orig_extracted == new_extracted + + # check preserving partial tree + def filter_NameError(exc): + if isinstance(exc, NameError): + return None + return exc + + m = make_tree() + new_m = pytest.deprecated_call(MultiError.filter, filter_NameError, m) + # with the NameError gone, the other branch gets promoted + assert new_m is m.exceptions[0] + + # check fully handling everything + def filter_all(exc): + return None + + assert pytest.deprecated_call(MultiError.filter, filter_all, make_tree()) is None + + +def test_MultiError_catch(): + # No exception to catch + + def noop(_): + pass # pragma: no cover + + with pytest.deprecated_call(MultiError.catch, noop): + pass + + # Simple pass-through of all exceptions + m = make_tree() + with pytest.raises(MultiError) as excinfo: + with pytest.deprecated_call(MultiError.catch, lambda exc: exc): + raise m + assert excinfo.value is m + # Should be unchanged, except that we added a traceback frame by raising + # it here + assert m.__traceback__ is not None + assert m.__traceback__.tb_frame.f_code.co_name == "test_MultiError_catch" + assert m.__traceback__.tb_next is None + m.__traceback__ = None + assert_tree_eq(m, make_tree()) + + # Swallows everything + with pytest.deprecated_call(MultiError.catch, lambda _: None): + raise make_tree() + + def simple_filter(exc): + if isinstance(exc, ValueError): + return None + if isinstance(exc, KeyError): + return RuntimeError() + return exc + + with pytest.raises(MultiError) as excinfo: + with pytest.deprecated_call(MultiError.catch, simple_filter): + raise make_tree() + new_m = excinfo.value + assert isinstance(new_m, MultiError) + assert len(new_m.exceptions) == 2 + # was: [[ValueError, KeyError], NameError] + # ValueError disappeared & KeyError became RuntimeError, so now: + assert isinstance(new_m.exceptions[0], RuntimeError) + assert isinstance(new_m.exceptions[1], NameError) + # Make sure that Python did not successfully attach the old MultiError to + # our new MultiError's __context__ + assert not new_m.__suppress_context__ + assert new_m.__context__ is None + + # check preservation of __cause__ and __context__ + v = ValueError() + v.__cause__ = KeyError() + with pytest.raises(ValueError) as excinfo: + with pytest.deprecated_call(MultiError.catch, lambda exc: exc): + raise v + assert isinstance(excinfo.value.__cause__, KeyError) + + v = ValueError() + context = KeyError() + v.__context__ = context + with pytest.raises(ValueError) as excinfo: + with pytest.deprecated_call(MultiError.catch, lambda exc: exc): + raise v + assert excinfo.value.__context__ is context + assert not excinfo.value.__suppress_context__ + + for suppress_context in [True, False]: + v = ValueError() + context = KeyError() + v.__context__ = context + v.__suppress_context__ = suppress_context + distractor = RuntimeError() + with pytest.raises(ValueError) as excinfo: + + def catch_RuntimeError(exc): + if isinstance(exc, RuntimeError): + return None + else: + return exc + + with pytest.deprecated_call(MultiError.catch, catch_RuntimeError): + raise MultiError([v, distractor]) + assert excinfo.value.__context__ is context + assert excinfo.value.__suppress_context__ == suppress_context + + +@pytest.mark.skipif( + sys.implementation.name != "cpython", reason="Only makes sense with refcounting GC" +) +def test_MultiError_catch_doesnt_create_cyclic_garbage(): + # https://github.com/python-trio/trio/pull/2063 + gc.collect() + old_flags = gc.get_debug() + + def make_multi(): + # make_tree creates cycles itself, so a simple + raise MultiError([get_exc(raiser1), get_exc(raiser2)]) + + def simple_filter(exc): + if isinstance(exc, ValueError): + return Exception() + if isinstance(exc, KeyError): + return RuntimeError() + assert False, "only ValueError and KeyError should exist" # pragma: no cover + + try: + gc.set_debug(gc.DEBUG_SAVEALL) + with pytest.raises(MultiError): + # covers MultiErrorCatcher.__exit__ and _multierror.copy_tb + with pytest.deprecated_call(MultiError.catch, simple_filter): + raise make_multi() + gc.collect() + assert not gc.garbage + finally: + gc.set_debug(old_flags) + gc.garbage.clear() + + +def assert_match_in_seq(pattern_list, string): + offset = 0 + print("looking for pattern matches...") + for pattern in pattern_list: + print("checking pattern:", pattern) + reobj = re.compile(pattern) + match = reobj.search(string, offset) + assert match is not None + offset = match.end() + + +def test_assert_match_in_seq(): + assert_match_in_seq(["a", "b"], "xx a xx b xx") + assert_match_in_seq(["b", "a"], "xx b xx a xx") + with pytest.raises(AssertionError): + assert_match_in_seq(["a", "b"], "xx b xx a xx") diff --git a/trio/_core/tests/test_run.py b/trio/_core/tests/test_run.py index 18dd4b231..2dfacc3a5 100644 --- a/trio/_core/tests/test_run.py +++ b/trio/_core/tests/test_run.py @@ -29,6 +29,7 @@ ) from ... import _core +from ..._core._multierror import MultiError from .._run import DEADLINE_HEAP_MIN_PRUNE_THRESHOLD from ..._threads import to_thread_run_sync from ..._timeouts import sleep, fail_after @@ -171,8 +172,8 @@ async def main(): def test_main_and_task_both_crash(): - # If main crashes and there's also a task crash, then we get both in an - # ExceptionGroup + # If main crashes and there's also a task crash, then we get both in a + # MultiError async def crasher(): raise ValueError @@ -181,7 +182,7 @@ async def main(): nursery.start_soon(crasher) raise KeyError - with pytest.raises(ExceptionGroup) as excinfo: + with pytest.raises(MultiError) as excinfo: _core.run(main) print(excinfo.value) assert {type(exc) for exc in excinfo.value.exceptions} == { @@ -199,7 +200,7 @@ async def main(): nursery.start_soon(crasher, KeyError) nursery.start_soon(crasher, ValueError) - with pytest.raises(ExceptionGroup) as excinfo: + with pytest.raises(MultiError) as excinfo: _core.run(main) assert {type(exc) for exc in excinfo.value.exceptions} == { ValueError, @@ -436,7 +437,7 @@ async def crasher(): # And one that raises a different error nursery.start_soon(crasher) # t4 # and then our __aexit__ also receives an outer Cancelled - except BaseExceptionGroup as multi_exc: + except MultiError as multi_exc: # Since the outer scope became cancelled before the # nursery block exited, all cancellations inside the # nursery block continue propagating to reach the @@ -775,7 +776,7 @@ async def task2(): with pytest.raises(RuntimeError) as exc_info: await nursery_mgr.__aexit__(*sys.exc_info()) assert "which had already been exited" in str(exc_info.value) - assert type(exc_info.value.__context__) is ExceptionGroup + assert type(exc_info.value.__context__) is MultiError assert len(exc_info.value.__context__.exceptions) == 3 cancelled_in_context = False for exc in exc_info.value.__context__.exceptions: @@ -916,7 +917,7 @@ async def main(): _core.run(main) -def test_system_task_crash_ExceptionGroup(): +def test_system_task_crash_MultiError(): async def crasher1(): raise KeyError @@ -936,7 +937,7 @@ async def main(): _core.run(main) me = excinfo.value.__cause__ - assert isinstance(me, ExceptionGroup) + assert isinstance(me, MultiError) assert len(me.exceptions) == 2 for exc in me.exceptions: assert isinstance(exc, (KeyError, ValueError)) @@ -944,7 +945,7 @@ async def main(): def test_system_task_crash_plus_Cancelled(): # Set up a situation where a system task crashes with a - # BaseExceptionGroup([Cancelled, ValueError]) + # MultiError([Cancelled, ValueError]) async def crasher(): try: await sleep_forever() @@ -1115,11 +1116,11 @@ async def test_nursery_exception_chaining_doesnt_make_context_loops(): async def crasher(): raise KeyError - with pytest.raises(ExceptionGroup) as excinfo: + with pytest.raises(MultiError) as excinfo: async with _core.open_nursery() as nursery: nursery.start_soon(crasher) raise ValueError - # the ExceptionGroup should not have the KeyError or ValueError as context + # the MultiError should not have the KeyError or ValueError as context assert excinfo.value.__context__ is None @@ -1619,7 +1620,7 @@ async def test_trivial_yields(): with _core.CancelScope() as cancel_scope: cancel_scope.cancel() - with pytest.raises(BaseExceptionGroup) as excinfo: + with pytest.raises(MultiError) as excinfo: async with _core.open_nursery(): raise KeyError assert len(excinfo.value.exceptions) == 2 @@ -1709,7 +1710,7 @@ async def raise_keyerror_after_started(task_status=_core.TASK_STATUS_IGNORED): async with _core.open_nursery() as nursery: with _core.CancelScope() as cs: cs.cancel() - with pytest.raises(BaseExceptionGroup) as excinfo: + with pytest.raises(MultiError) as excinfo: await nursery.start(raise_keyerror_after_started) assert {type(e) for e in excinfo.value.exceptions} == { _core.Cancelled, @@ -1824,7 +1825,7 @@ async def fail(): async with _core.open_nursery() as nursery: nursery.start_soon(fail) raise StopIteration - except ExceptionGroup as e: + except MultiError as e: assert tuple(map(type, e.exceptions)) == (StopIteration, ValueError) @@ -1859,7 +1860,11 @@ async def __anext__(self): def handle(exc): nonlocal got_stop - got_stop = True + if isinstance(exc, StopAsyncIteration): + got_stop = True + return None + else: # pragma: no cover + return exc with catch({StopAsyncIteration: handle}): async with _core.open_nursery() as nursery: @@ -1886,7 +1891,7 @@ async def fail(): async with _core.open_nursery() as nursery: nursery.start_soon(fail) raise StopIteration - except ExceptionGroup as e: + except MultiError as e: assert tuple(map(type, e.exceptions)) == (StopIteration, ValueError) @@ -1896,14 +1901,14 @@ async def my_child_task(): try: # Trick: For now cancel/nursery scopes still leave a bunch of tb gunk - # behind. But if there's an ExceptionGroup, they leave it on the - # ExceptionGroup, which lets us get a clean look at the KeyError - # itself. Someday I guess this will always be an ExceptionGroup (#611), - # but for now we can force it by raising two exceptions. + # behind. But if there's a MultiError, they leave it on the MultiError, + # which lets us get a clean look at the KeyError itself. Someday I + # guess this will always be a MultiError (#611), but for now we can + # force it by raising two exceptions. async with _core.open_nursery() as nursery: nursery.start_soon(my_child_task) nursery.start_soon(my_child_task) - except ExceptionGroup as exc: + except MultiError as exc: first_exc = exc.exceptions[0] assert isinstance(first_exc, KeyError) # The top frame in the exception traceback should be inside the child @@ -2277,9 +2282,8 @@ async def crasher(): outer.cancel() # And one that raises a different error nursery.start_soon(crasher) - # so that outer filters a Cancelled from the BaseExceptionGroup - # and covers CancelScope.__exit__ - # (and NurseryManager.__aexit__) + # so that outer filters a Cancelled from the MultiError and + # covers CancelScope.__exit__ (and NurseryManager.__aexit__) # (See https://github.com/python-trio/trio/pull/2063) gc.collect() diff --git a/trio/_highlevel_open_tcp_listeners.py b/trio/_highlevel_open_tcp_listeners.py index 955b38ffe..cce6f1d60 100644 --- a/trio/_highlevel_open_tcp_listeners.py +++ b/trio/_highlevel_open_tcp_listeners.py @@ -4,9 +4,7 @@ import trio from . import socket as tsocket - -if sys.version_info < (3, 11): - from exceptiongroup import ExceptionGroup +from ._core._multierror import MultiError # Default backlog size: @@ -141,9 +139,7 @@ async def open_tcp_listeners(port, *, host=None, backlog=None): errno.EAFNOSUPPORT, "This system doesn't support any of the kinds of " "socket that that address could use", - ) from ExceptionGroup( - "All socket creation attempts failed", unsupported_address_families - ) + ) from MultiError(unsupported_address_families) return listeners diff --git a/trio/_highlevel_open_tcp_stream.py b/trio/_highlevel_open_tcp_stream.py index d53038619..382a5889c 100644 --- a/trio/_highlevel_open_tcp_stream.py +++ b/trio/_highlevel_open_tcp_stream.py @@ -1,12 +1,10 @@ -import sys +import warnings from contextlib import contextmanager import trio +from trio._core._multierror import MultiError from trio.socket import getaddrinfo, SOCK_STREAM, socket -if sys.version_info < (3, 11): - from exceptiongroup import BaseExceptionGroup - # Implementation of RFC 6555 "Happy eyeballs" # https://tools.ietf.org/html/rfc6555 # @@ -121,7 +119,9 @@ def close_all(): if len(errs) == 1: raise errs[0] elif errs: - raise BaseExceptionGroup("multiple close operations failed", errs) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + raise MultiError(errs) def reorder_for_rfc_6555_section_5_4(targets): @@ -370,9 +370,7 @@ async def attempt_connect(socket_args, sockaddr, attempt_failed): msg = "all attempts to connect to {} failed".format( format_host_port(host, port) ) - raise OSError(msg) from BaseExceptionGroup( - "multiple connection attempts failed", oserrors - ) + raise OSError(msg) from MultiError(oserrors) else: stream = trio.SocketStream(winning_socket) open_sockets.remove(winning_socket) diff --git a/trio/tests/test_highlevel_open_tcp_listeners.py b/trio/tests/test_highlevel_open_tcp_listeners.py index e86d108d2..103984739 100644 --- a/trio/tests/test_highlevel_open_tcp_listeners.py +++ b/trio/tests/test_highlevel_open_tcp_listeners.py @@ -14,7 +14,7 @@ from .._core.tests.tutil import slow, creates_ipv6, binds_ipv6 if sys.version_info < (3, 11): - from exceptiongroup import ExceptionGroup + from exceptiongroup import BaseExceptionGroup async def test_open_tcp_listeners_basic(): @@ -244,9 +244,12 @@ async def test_open_tcp_listeners_some_address_families_unavailable( await open_tcp_listeners(80, host="example.org") assert "This system doesn't support" in str(exc_info.value) - assert isinstance(exc_info.value.__cause__, ExceptionGroup) - for subexc in exc_info.value.__cause__.exceptions: - assert "nope" in str(subexc) + if isinstance(exc_info.value.__cause__, BaseExceptionGroup): + for subexc in exc_info.value.__cause__.exceptions: + assert "nope" in str(subexc) + else: + assert isinstance(exc_info.value.__cause__, OSError) + assert "nope" in str(exc_info.value.__cause__) else: listeners = await open_tcp_listeners(80) for listener in listeners: From f1cbcf750706c0ccd2e45aacddaebaf337869048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Fri, 4 Feb 2022 01:05:57 +0200 Subject: [PATCH 19/93] Removed unwarranted type annotations --- trio/_core/_multierror.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/trio/_core/_multierror.py b/trio/_core/_multierror.py index 2e3d0f0ab..ac6e3bd2b 100644 --- a/trio/_core/_multierror.py +++ b/trio/_core/_multierror.py @@ -3,7 +3,6 @@ from typing import Sequence import attr -from exceptiongroup._exceptions import EBase, T if sys.version_info < (3, 11): from exceptiongroup import BaseExceptionGroup @@ -212,7 +211,7 @@ def __str__(self): def __repr__(self): return "".format(self) - def derive(self: T, __excs: Sequence[EBase]) -> T: + def derive(self, __excs): return MultiError(__excs, _collapse=False) @classmethod From 0f5dc7978a242a3f9e852b7dc767679c97529e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Fri, 4 Feb 2022 01:13:05 +0200 Subject: [PATCH 20/93] Removed useless failing test --- trio/_core/tests/test_multierror.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/trio/_core/tests/test_multierror.py b/trio/_core/tests/test_multierror.py index 304b18558..a2a238800 100644 --- a/trio/_core/tests/test_multierror.py +++ b/trio/_core/tests/test_multierror.py @@ -149,17 +149,6 @@ def handle_ValueError(exc): assert isinstance(filtered_excs, NotHashableException) -def test_traceback_recursion(): - exc1 = RuntimeError() - exc2 = KeyError() - exc3 = NotHashableException(42) - # Note how this creates a loop, where exc1 refers to exc1 - # This could trigger an infinite recursion; the 'seen' set is supposed to prevent - # this. - exc1.__cause__ = MultiError([exc1, exc2, exc3]) - format_exception(*einfo(exc1)) - - def make_tree(): # Returns an object like: # MultiError([ From 36e0ebae12c8bb1bda331bd38c5f5da9d7bb4c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Fri, 4 Feb 2022 01:26:25 +0200 Subject: [PATCH 21/93] Removed unused function --- trio/_core/tests/test_multierror.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/trio/_core/tests/test_multierror.py b/trio/_core/tests/test_multierror.py index a2a238800..01e6253d0 100644 --- a/trio/_core/tests/test_multierror.py +++ b/trio/_core/tests/test_multierror.py @@ -67,10 +67,6 @@ def get_tb(raiser): return get_exc(raiser).__traceback__ -def einfo(exc): - return (type(exc), exc, exc.__traceback__) - - def test_concat_tb(): tb1 = get_tb(raiser1) From 20df5d85a4f50b171079b0e9f0e42897c136b33d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 8 Feb 2022 11:15:53 +0200 Subject: [PATCH 22/93] Update docs/source/reference-core.rst Co-authored-by: Joshua Oreman --- docs/source/reference-core.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/reference-core.rst b/docs/source/reference-core.rst index 76561e9b5..9639287d7 100644 --- a/docs/source/reference-core.rst +++ b/docs/source/reference-core.rst @@ -641,7 +641,7 @@ crucial things to keep in mind: * Any unhandled exceptions are re-raised inside the parent task. If there are multiple exceptions, then they're collected up into a - single ``BaseExceptionGroup`` or ``ExceptionGroup`` exception. + single :exc:`BaseExceptionGroup` or :exc:`ExceptionGroup` exception. Since all tasks are descendents of the initial task, one consequence of this is that :func:`run` can't finish until all tasks have From 2dc1c641d7cef6155b7bd9da983ec59fe63f470e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 8 Feb 2022 11:30:22 +0200 Subject: [PATCH 23/93] Update newsfragments/2211.deprecated.rst Co-authored-by: Joshua Oreman --- newsfragments/2211.deprecated.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/newsfragments/2211.deprecated.rst b/newsfragments/2211.deprecated.rst index 6becff6b9..b7e8e8544 100644 --- a/newsfragments/2211.deprecated.rst +++ b/newsfragments/2211.deprecated.rst @@ -1,5 +1,5 @@ ``MultiError`` has been deprecated in favor of the standard :exc:`BaseExceptionGroup` -(introduced in :pep:`654`). On Python < 3.11, this exception and its derivative +(introduced in :pep:`654`). On Python versions below 3.11, this exception and its derivative :exc:`ExceptionGroup` are provided by the backport_. Trio still raises ``MultiError``, but it has been refactored into a subclass of :exc:`BaseExceptionGroup` which users should catch instead of ``MultiError``. Uses of the ``filter()`` class method should be From e7faa467783f6a0ddc9960b92aa45870d1b0785f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 8 Feb 2022 11:31:01 +0200 Subject: [PATCH 24/93] Update docs/source/tutorial/echo-server.py Co-authored-by: Joshua Oreman --- docs/source/tutorial/echo-server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/source/tutorial/echo-server.py b/docs/source/tutorial/echo-server.py index 3751cadd7..d37e509af 100644 --- a/docs/source/tutorial/echo-server.py +++ b/docs/source/tutorial/echo-server.py @@ -11,7 +11,6 @@ CONNECTION_COUNTER = count() - async def echo_server(server_stream): # Assign each connection a unique number to make our debug prints easier # to understand when there are multiple simultaneous connections. From c41517af5b2fde0a0f7d69f27dd64cf08ab8f712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 8 Feb 2022 11:38:11 +0200 Subject: [PATCH 25/93] Update trio/_highlevel_open_tcp_stream.py --- trio/_highlevel_open_tcp_stream.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/trio/_highlevel_open_tcp_stream.py b/trio/_highlevel_open_tcp_stream.py index 382a5889c..c57d42104 100644 --- a/trio/_highlevel_open_tcp_stream.py +++ b/trio/_highlevel_open_tcp_stream.py @@ -119,9 +119,7 @@ def close_all(): if len(errs) == 1: raise errs[0] elif errs: - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - raise MultiError(errs) + raise MultiError(errs) def reorder_for_rfc_6555_section_5_4(targets): From 50963c6cf9bf316b91f65989d8c6f4ad5da95c3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 8 Feb 2022 13:03:40 +0200 Subject: [PATCH 26/93] Don't skip the entire test_coroutine_or_error test on py3.11 --- trio/tests/test_util.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/trio/tests/test_util.py b/trio/tests/test_util.py index bf2f606f0..40743f0cf 100644 --- a/trio/tests/test_util.py +++ b/trio/tests/test_util.py @@ -95,9 +95,6 @@ def not_main_thread(): # @coroutine is deprecated since python 3.8, which is fine with us. @pytest.mark.filterwarnings("ignore:.*@coroutine.*:DeprecationWarning") -@pytest.mark.skipif( - sys.version_info >= (3, 11), reason="asyncio.coroutine was removed in Python 3.11" -) def test_coroutine_or_error(): class Deferred: "Just kidding" @@ -113,9 +110,11 @@ async def f(): # pragma: no cover import asyncio - @asyncio.coroutine - def generator_based_coro(): # pragma: no cover - yield from asyncio.sleep(1) + if sys.version_info < (3, 11): + + @asyncio.coroutine + def generator_based_coro(): # pragma: no cover + yield from asyncio.sleep(1) with pytest.raises(TypeError) as excinfo: coroutine_or_error(generator_based_coro()) From ba17fbbfc9b396cd1524b17c9b617be7317e84dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 8 Feb 2022 13:05:20 +0200 Subject: [PATCH 27/93] Removed weird test code --- trio/_core/tests/test_run.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/trio/_core/tests/test_run.py b/trio/_core/tests/test_run.py index 2dfacc3a5..8fcf8c8b7 100644 --- a/trio/_core/tests/test_run.py +++ b/trio/_core/tests/test_run.py @@ -1860,11 +1860,7 @@ async def __anext__(self): def handle(exc): nonlocal got_stop - if isinstance(exc, StopAsyncIteration): - got_stop = True - return None - else: # pragma: no cover - return exc + got_stop = True with catch({StopAsyncIteration: handle}): async with _core.open_nursery() as nursery: From 8911e0a59c2e66e2c4598cf60f0d9d8d3a85793d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 8 Feb 2022 13:49:44 +0200 Subject: [PATCH 28/93] Update newsfragments/2211.deprecated.rst --- newsfragments/2211.deprecated.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/newsfragments/2211.deprecated.rst b/newsfragments/2211.deprecated.rst index b7e8e8544..ed7e9e1f2 100644 --- a/newsfragments/2211.deprecated.rst +++ b/newsfragments/2211.deprecated.rst @@ -4,8 +4,8 @@ but it has been refactored into a subclass of :exc:`BaseExceptionGroup` which users should catch instead of ``MultiError``. Uses of the ``filter()`` class method should be replaced with :meth:`BaseExceptionGroup.split`. Uses of the ``catch()`` class method -should be replaced with either ``except*`` clauses (on Python 3.11+) or the ``catch()`` -context manager provided by the backport_. +should be replaced with either ``except*`` clauses (on Python 3.11+) or the +``exceptiongroup.catch()`` context manager provided by the backport_. See the :ref:`updated documentation ` for details. From dfd084cc3ffd8d4ca6009e70157dfad5cff1aa36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Feb 2022 10:19:57 +0000 Subject: [PATCH 29/93] Bump prompt-toolkit from 3.0.26 to 3.0.27 Bumps [prompt-toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) from 3.0.26 to 3.0.27. - [Release notes](https://github.com/prompt-toolkit/python-prompt-toolkit/releases) - [Changelog](https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/CHANGELOG) - [Commits](https://github.com/prompt-toolkit/python-prompt-toolkit/compare/3.0.26...3.0.27) --- updated-dependencies: - dependency-name: prompt-toolkit dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index fa6243e7d..19a6a1c9c 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -89,7 +89,7 @@ platformdirs==2.4.1 # pylint pluggy==1.0.0 # via pytest -prompt-toolkit==3.0.26 +prompt-toolkit==3.0.27 # via ipython ptyprocess==0.7.0 # via pexpect From b3c79c8e09647e1f194cb3f24dea6944bdb1e42d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Feb 2022 10:22:27 +0000 Subject: [PATCH 30/93] Bump tomli from 2.0.0 to 2.0.1 Bumps [tomli](https://github.com/hukkin/tomli) from 2.0.0 to 2.0.1. - [Release notes](https://github.com/hukkin/tomli/releases) - [Changelog](https://github.com/hukkin/tomli/blob/master/CHANGELOG.md) - [Commits](https://github.com/hukkin/tomli/compare/2.0.0...2.0.1) --- updated-dependencies: - dependency-name: tomli dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index 19a6a1c9c..943e88024 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -121,7 +121,7 @@ sortedcontainers==2.4.0 # via -r test-requirements.in toml==0.10.2 # via pylint -tomli==2.0.0 +tomli==2.0.1 # via # black # coverage From 81feff2f26de031d1fde906ba6198c4125388b13 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Feb 2022 11:06:23 +0000 Subject: [PATCH 31/93] Bump platformdirs from 2.4.1 to 2.5.0 Bumps [platformdirs](https://github.com/platformdirs/platformdirs) from 2.4.1 to 2.5.0. - [Release notes](https://github.com/platformdirs/platformdirs/releases) - [Changelog](https://github.com/platformdirs/platformdirs/blob/main/CHANGES.rst) - [Commits](https://github.com/platformdirs/platformdirs/compare/2.4.1...2.5.0) --- updated-dependencies: - dependency-name: platformdirs dependency-type: indirect update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index 943e88024..a500b4d5b 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -83,7 +83,7 @@ pexpect==4.8.0 # via ipython pickleshare==0.7.5 # via ipython -platformdirs==2.4.1 +platformdirs==2.5.0 # via # black # pylint From bcb442b949ffc069d34358e8363005fee0f04063 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Feb 2022 11:56:37 +0000 Subject: [PATCH 32/93] Bump towncrier from 21.3.0 to 21.9.0 Bumps [towncrier](https://github.com/hawkowl/towncrier) from 21.3.0 to 21.9.0. - [Release notes](https://github.com/hawkowl/towncrier/releases) - [Changelog](https://github.com/twisted/towncrier/blob/master/NEWS.rst) - [Commits](https://github.com/hawkowl/towncrier/compare/21.3.0...21.9.0) --- updated-dependencies: - dependency-name: towncrier dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- docs-requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-requirements.txt b/docs-requirements.txt index 57c3106ad..38cd45cd1 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -87,9 +87,9 @@ sphinxcontrib-serializinghtml==1.1.5 # via sphinx sphinxcontrib-trio==1.1.2 # via -r docs-requirements.in -toml==0.10.2 +tomli==2.0.1 # via towncrier -towncrier==21.3.0 +towncrier==21.9.0 # via -r docs-requirements.in typing-extensions==4.0.1 # via From ab78e027c988b147e048f4f41b397d2999a69edc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Feb 2022 11:56:11 +0000 Subject: [PATCH 33/93] Bump prompt-toolkit from 3.0.27 to 3.0.28 Bumps [prompt-toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) from 3.0.27 to 3.0.28. - [Release notes](https://github.com/prompt-toolkit/python-prompt-toolkit/releases) - [Changelog](https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/CHANGELOG) - [Commits](https://github.com/prompt-toolkit/python-prompt-toolkit/compare/3.0.27...3.0.28) --- updated-dependencies: - dependency-name: prompt-toolkit dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index a500b4d5b..04b2bb28c 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -89,7 +89,7 @@ platformdirs==2.5.0 # pylint pluggy==1.0.0 # via pytest -prompt-toolkit==3.0.27 +prompt-toolkit==3.0.28 # via ipython ptyprocess==0.7.0 # via pexpect From 9abddfad23eea51ed26fa3c6c23b51d706fd761f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Feb 2022 10:17:29 +0000 Subject: [PATCH 34/93] Bump markupsafe from 2.0.1 to 2.1.0 Bumps [markupsafe](https://github.com/pallets/markupsafe) from 2.0.1 to 2.1.0. - [Release notes](https://github.com/pallets/markupsafe/releases) - [Changelog](https://github.com/pallets/markupsafe/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/markupsafe/compare/2.0.1...2.1.0) --- updated-dependencies: - dependency-name: markupsafe dependency-type: indirect update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- docs-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-requirements.txt b/docs-requirements.txt index 38cd45cd1..c999fbf67 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -46,7 +46,7 @@ jinja2==3.0.3 # via # sphinx # towncrier -markupsafe==2.0.1 +markupsafe==2.1.0 # via jinja2 outcome==1.1.0 # via -r docs-requirements.in From efc8b0158b8becb2e2482aa6cd4b41d4cc9588c5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Feb 2022 10:22:10 +0000 Subject: [PATCH 35/93] Bump click from 8.0.3 to 8.0.4 Bumps [click](https://github.com/pallets/click) from 8.0.3 to 8.0.4. - [Release notes](https://github.com/pallets/click/releases) - [Changelog](https://github.com/pallets/click/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/click/compare/8.0.3...8.0.4) --- updated-dependencies: - dependency-name: click dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- docs-requirements.txt | 2 +- test-requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-requirements.txt b/docs-requirements.txt index c999fbf67..6103f81d2 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -18,7 +18,7 @@ certifi==2021.10.8 # via requests charset-normalizer==2.0.11 # via requests -click==8.0.3 +click==8.0.4 # via # click-default-group # towncrier diff --git a/test-requirements.txt b/test-requirements.txt index 04b2bb28c..d9953a622 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -21,7 +21,7 @@ black==22.1.0 ; implementation_name == "cpython" # via -r test-requirements.in cffi==1.15.0 # via cryptography -click==8.0.3 +click==8.0.4 # via black coverage[toml]==6.3 # via pytest-cov From 9a4f5123c759cc4288e9ac9839c7eeff3c4384b1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Feb 2022 10:20:02 +0000 Subject: [PATCH 36/93] Bump platformdirs from 2.5.0 to 2.5.1 Bumps [platformdirs](https://github.com/platformdirs/platformdirs) from 2.5.0 to 2.5.1. - [Release notes](https://github.com/platformdirs/platformdirs/releases) - [Changelog](https://github.com/platformdirs/platformdirs/blob/main/CHANGES.rst) - [Commits](https://github.com/platformdirs/platformdirs/compare/2.5.0...2.5.1) --- updated-dependencies: - dependency-name: platformdirs dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index d9953a622..b463eb526 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -83,7 +83,7 @@ pexpect==4.8.0 # via ipython pickleshare==0.7.5 # via ipython -platformdirs==2.5.0 +platformdirs==2.5.1 # via # black # pylint From 6cb5ad97be463799145fa385e56e8e22a7cf8bd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Wed, 9 Mar 2022 21:21:15 +0200 Subject: [PATCH 37/93] Merged test requirements --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index b463eb526..994a3aea9 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -139,7 +139,7 @@ typed-ast==1.5.2 ; implementation_name == "cpython" and python_version < "3.8" # astroid # black # mypy -typing-extensions==4.0.1 ; implementation_name == "cpython" +typing-extensions==4.1.1 ; implementation_name == "cpython" # via # -r test-requirements.in # astroid From ee245a049bce4999ae7ef0c4d064e218790472fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Feb 2022 10:23:52 +0000 Subject: [PATCH 38/93] Bump pytest from 7.0.0 to 7.0.1 Bumps [pytest](https://github.com/pytest-dev/pytest) from 7.0.0 to 7.0.1. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/7.0.0...7.0.1) --- updated-dependencies: - dependency-name: pytest dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index 994a3aea9..a3c556fcb 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -109,7 +109,7 @@ pyopenssl==22.0.0 # via -r test-requirements.in pyparsing==3.0.7 # via packaging -pytest==7.0.0 +pytest==7.0.1 # via # -r test-requirements.in # pytest-cov From 1e36f8c8c7a3226a73cb4852fb2a2c6a444cd39c Mon Sep 17 00:00:00 2001 From: Quentin Pradet Date: Mon, 21 Feb 2022 15:26:03 +0400 Subject: [PATCH 39/93] Tickle sr.ht CI From 034bd326a829e19912e05bf04edbf428510c15b4 Mon Sep 17 00:00:00 2001 From: Quentin Pradet Date: Mon, 21 Feb 2022 15:46:39 +0400 Subject: [PATCH 40/93] Merge two competing newsfragments It turns out towncrier will ignore the removal and only consider the feature, which means we have to merge them. --- newsfragments/1104.feature.rst | 6 ++++++ newsfragments/1104.removal.rst | 5 ----- 2 files changed, 6 insertions(+), 5 deletions(-) delete mode 100644 newsfragments/1104.removal.rst diff --git a/newsfragments/1104.feature.rst b/newsfragments/1104.feature.rst index b2d4ceaa1..0ca202102 100644 --- a/newsfragments/1104.feature.rst +++ b/newsfragments/1104.feature.rst @@ -1,3 +1,9 @@ You can now conveniently spawn a child process in a background task and interact it with on the fly using ``process = await nursery.start(run_process, ...)``. See `run_process` for more details. +We recommend most users switch to this new API. Also note that: + +- ``trio.open_process`` has been deprecated in favor of + `trio.lowlevel.open_process`, +- The ``aclose`` method on `Process` has been deprecated along with + ``async with process_obj``. diff --git a/newsfragments/1104.removal.rst b/newsfragments/1104.removal.rst deleted file mode 100644 index f2a5a0a99..000000000 --- a/newsfragments/1104.removal.rst +++ /dev/null @@ -1,5 +0,0 @@ -``trio.open_process`` has been renamed to -`trio.lowlevel.open_process`, and the ``aclose`` method on `Process` -has been deprecated, along with ``async with process_obj``. We -recommend most users switch to the new -``nursery.start(trio.run_process, ...)`` API instead. From 80a751f996540b5ba05118cbf771dd3b0d435f7b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Feb 2022 11:26:23 +0000 Subject: [PATCH 41/93] Bump charset-normalizer from 2.0.11 to 2.0.12 Bumps [charset-normalizer](https://github.com/ousret/charset_normalizer) from 2.0.11 to 2.0.12. - [Release notes](https://github.com/ousret/charset_normalizer/releases) - [Changelog](https://github.com/Ousret/charset_normalizer/blob/master/CHANGELOG.md) - [Commits](https://github.com/ousret/charset_normalizer/compare/2.0.11...2.0.12) --- updated-dependencies: - dependency-name: charset-normalizer dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- docs-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-requirements.txt b/docs-requirements.txt index 6103f81d2..fa9bca2fc 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -16,7 +16,7 @@ babel==2.9.1 # via sphinx certifi==2021.10.8 # via requests -charset-normalizer==2.0.11 +charset-normalizer==2.0.12 # via requests click==8.0.4 # via From 9eb2d1c046511b6a2729174c18f865266c3cb1b3 Mon Sep 17 00:00:00 2001 From: Quentin Pradet Date: Mon, 21 Feb 2022 15:26:59 +0400 Subject: [PATCH 42/93] Tickle sr.ht CI From fd56492958c34008f0dc55894660dfd2258bc2ff Mon Sep 17 00:00:00 2001 From: Quentin Pradet Date: Mon, 21 Feb 2022 16:31:57 +0400 Subject: [PATCH 43/93] Bump version to 0.20.0 --- docs/source/history.rst | 39 ++++++++++++++++++++++++++++++++++ newsfragments/1104.feature.rst | 9 -------- newsfragments/2160.feature.rst | 6 ------ newsfragments/2193.bugfix.rst | 3 --- newsfragments/2203.bugfix.rst | 2 -- newsfragments/2209.bugfix.rst | 3 --- trio/_version.py | 2 +- 7 files changed, 40 insertions(+), 24 deletions(-) delete mode 100644 newsfragments/1104.feature.rst delete mode 100644 newsfragments/2160.feature.rst delete mode 100644 newsfragments/2193.bugfix.rst delete mode 100644 newsfragments/2203.bugfix.rst delete mode 100644 newsfragments/2209.bugfix.rst diff --git a/docs/source/history.rst b/docs/source/history.rst index 450817bb7..e2576fa64 100644 --- a/docs/source/history.rst +++ b/docs/source/history.rst @@ -5,6 +5,45 @@ Release history .. towncrier release notes start +Trio 0.20.0 (2022-02-21) +------------------------ + +Features +~~~~~~~~ + +- You can now conveniently spawn a child process in a background task + and interact it with on the fly using ``process = await + nursery.start(run_process, ...)``. See `run_process` for more details. + We recommend most users switch to this new API. Also note that: + + - ``trio.open_process`` has been deprecated in favor of + `trio.lowlevel.open_process`, + - The ``aclose`` method on `Process` has been deprecated along with + ``async with process_obj``. (`#1104 `__) +- Now context variables set with `contextvars` are preserved when running functions + in a worker thread with `trio.to_thread.run_sync`, or when running + functions from the worker thread in the parent Trio thread with + `trio.from_thread.run`, and `trio.from_thread.run_sync`. + This is done by automatically copying the `contextvars` context. + `trio.lowlevel.spawn_system_task` now also receives an optional ``context`` argument. (`#2160 `__) + + +Bugfixes +~~~~~~~~ + +- Trio now avoids creating cyclic garbage when a `MultiError` is generated and filtered, + including invisibly within the cancellation system. This means errors raised + through nurseries and cancel scopes should result in less GC latency. (`#2063 `__) +- Trio now deterministically cleans up file descriptors that were opened before + subprocess creation fails. Previously, they would remain open until the next run of + the garbage collector. (`#2193 `__) +- Add compatibility with OpenSSL 3.0 on newer Python and PyPy versions by working + around ``SSLEOFError`` not being raised properly. (`#2203 `__) +- Fix a bug that could cause `Process.wait` to hang on Linux systems using pidfds, if + another task were to access `Process.returncode` after the process exited but before + ``wait`` woke up (`#2209 `__) + + Trio 0.19.0 (2021-06-15) ------------------------ diff --git a/newsfragments/1104.feature.rst b/newsfragments/1104.feature.rst deleted file mode 100644 index 0ca202102..000000000 --- a/newsfragments/1104.feature.rst +++ /dev/null @@ -1,9 +0,0 @@ -You can now conveniently spawn a child process in a background task -and interact it with on the fly using ``process = await -nursery.start(run_process, ...)``. See `run_process` for more details. -We recommend most users switch to this new API. Also note that: - -- ``trio.open_process`` has been deprecated in favor of - `trio.lowlevel.open_process`, -- The ``aclose`` method on `Process` has been deprecated along with - ``async with process_obj``. diff --git a/newsfragments/2160.feature.rst b/newsfragments/2160.feature.rst deleted file mode 100644 index 00cb09a97..000000000 --- a/newsfragments/2160.feature.rst +++ /dev/null @@ -1,6 +0,0 @@ -Now context variables set with `contextvars` are preserved when running functions -in a worker thread with `trio.to_thread.run_sync`, or when running -functions from the worker thread in the parent Trio thread with -`trio.from_thread.run`, and `trio.from_thread.run_sync`. -This is done by automatically copying the `contextvars` context. -`trio.lowlevel.spawn_system_task` now also receives an optional ``context`` argument. diff --git a/newsfragments/2193.bugfix.rst b/newsfragments/2193.bugfix.rst deleted file mode 100644 index 979dec111..000000000 --- a/newsfragments/2193.bugfix.rst +++ /dev/null @@ -1,3 +0,0 @@ -Trio now deterministically cleans up file descriptors that were opened before -subprocess creation fails. Previously, they would remain open until the next run of -the garbage collector. diff --git a/newsfragments/2203.bugfix.rst b/newsfragments/2203.bugfix.rst deleted file mode 100644 index 55a01d737..000000000 --- a/newsfragments/2203.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -Add compatibility with OpenSSL 3.0 on newer Python and PyPy versions by working -around ``SSLEOFError`` not being raised properly. \ No newline at end of file diff --git a/newsfragments/2209.bugfix.rst b/newsfragments/2209.bugfix.rst deleted file mode 100644 index 8ea6094f1..000000000 --- a/newsfragments/2209.bugfix.rst +++ /dev/null @@ -1,3 +0,0 @@ -Fix a bug that could cause `Process.wait` to hang on Linux systems using pidfds, if -another task were to access `Process.returncode` after the process exited but before -``wait`` woke up diff --git a/trio/_version.py b/trio/_version.py index c99aa04a7..f1b741b96 100644 --- a/trio/_version.py +++ b/trio/_version.py @@ -1,3 +1,3 @@ # This file is imported from __init__.py and exec'd from setup.py -__version__ = "0.19.0+dev" +__version__ = "0.20.0" From af6233b86a3073968cf786212e294ebe9f8e0349 Mon Sep 17 00:00:00 2001 From: Quentin Pradet Date: Mon, 21 Feb 2022 17:29:53 +0400 Subject: [PATCH 44/93] Bump version to 0.20.0+dev --- trio/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trio/_version.py b/trio/_version.py index f1b741b96..5a02c2e46 100644 --- a/trio/_version.py +++ b/trio/_version.py @@ -1,3 +1,3 @@ # This file is imported from __init__.py and exec'd from setup.py -__version__ = "0.20.0" +__version__ = "0.20.0+dev" From a4876bc5b09fdd2b468da4efd0580fd37f57f186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Wed, 9 Mar 2022 21:19:07 +0200 Subject: [PATCH 45/93] Renamed the news fragment --- newsfragments/{2211.deprecated.rst => 2211.headline.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename newsfragments/{2211.deprecated.rst => 2211.headline.rst} (100%) diff --git a/newsfragments/2211.deprecated.rst b/newsfragments/2211.headline.rst similarity index 100% rename from newsfragments/2211.deprecated.rst rename to newsfragments/2211.headline.rst From 5c09f4470f45d0080f2eee4391ffeb5e9a10fc30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Wed, 9 Mar 2022 21:29:28 +0200 Subject: [PATCH 46/93] Updated wrapt in test requirements --- test-requirements.in | 2 +- test-requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test-requirements.in b/test-requirements.in index 3c5079838..35a87028e 100644 --- a/test-requirements.in +++ b/test-requirements.in @@ -9,7 +9,7 @@ pylint # for pylint finding all symbols tests jedi # for jedi code completion tests cryptography>=36.0.0 # 35.0.0 is transitive but fails exceptiongroup # for catch() -wrapt @ git+https://github.com/grayjk/wrapt.git@issue-196 # fixes Python 3.11 compat for pylint +wrapt @ git+https://github.com/GrahamDumpleton/wrapt.git@8f180bf981fc7a92094cfecfd7a9e5f591d4bd4b # fixes Python 3.11 compat for pylint # Tools black; implementation_name == "cpython" diff --git a/test-requirements.txt b/test-requirements.txt index a3c556fcb..69b4896d7 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -149,7 +149,7 @@ typing-extensions==4.1.1 ; implementation_name == "cpython" # pylint wcwidth==0.2.5 # via prompt-toolkit -wrapt @ git+https://github.com/grayjk/wrapt.git@issue-196 +wrapt @ git+https://github.com/GrahamDumpleton/wrapt.git@8f180bf981fc7a92094cfecfd7a9e5f591d4bd4b # via # -r test-requirements.in # astroid From c5af0a4f193adf7643de5ef50a07e6b82775c483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Wed, 9 Mar 2022 21:34:20 +0200 Subject: [PATCH 47/93] Changed wrapt target git hash so it's compatible with astroid --- test-requirements.in | 2 +- test-requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test-requirements.in b/test-requirements.in index 35a87028e..fd0d48237 100644 --- a/test-requirements.in +++ b/test-requirements.in @@ -9,7 +9,7 @@ pylint # for pylint finding all symbols tests jedi # for jedi code completion tests cryptography>=36.0.0 # 35.0.0 is transitive but fails exceptiongroup # for catch() -wrapt @ git+https://github.com/GrahamDumpleton/wrapt.git@8f180bf981fc7a92094cfecfd7a9e5f591d4bd4b # fixes Python 3.11 compat for pylint +wrapt @ git+https://github.com/GrahamDumpleton/wrapt.git@a1a06529f41cffcc9fd5132fcf1923ea538f3d3c # fixes Python 3.11 compat for pylint # Tools black; implementation_name == "cpython" diff --git a/test-requirements.txt b/test-requirements.txt index 69b4896d7..5653f6b29 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -149,7 +149,7 @@ typing-extensions==4.1.1 ; implementation_name == "cpython" # pylint wcwidth==0.2.5 # via prompt-toolkit -wrapt @ git+https://github.com/GrahamDumpleton/wrapt.git@8f180bf981fc7a92094cfecfd7a9e5f591d4bd4b +wrapt @ git+https://github.com/GrahamDumpleton/wrapt.git@a1a06529f41cffcc9fd5132fcf1923ea538f3d3c # via # -r test-requirements.in # astroid From b4791f805e9718798d257c1df0e9213306880365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sat, 14 May 2022 10:54:29 +0300 Subject: [PATCH 48/93] Fixed error in test_coroutine_or_error() on py3.11 --- trio/tests/test_util.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/trio/tests/test_util.py b/trio/tests/test_util.py index 40743f0cf..15ab09a80 100644 --- a/trio/tests/test_util.py +++ b/trio/tests/test_util.py @@ -116,9 +116,9 @@ async def f(): # pragma: no cover def generator_based_coro(): # pragma: no cover yield from asyncio.sleep(1) - with pytest.raises(TypeError) as excinfo: - coroutine_or_error(generator_based_coro()) - assert "asyncio" in str(excinfo.value) + with pytest.raises(TypeError) as excinfo: + coroutine_or_error(generator_based_coro()) + assert "asyncio" in str(excinfo.value) with pytest.raises(TypeError) as excinfo: coroutine_or_error(create_asyncio_future_in_new_loop()) From 70bb109b901a5542b668c8dbc614afea881ceffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sat, 14 May 2022 10:55:20 +0300 Subject: [PATCH 49/93] Updated dependencies Seems like we don't need to fetch wrapt directly from git anymore. --- docs-requirements.txt | 17 +++++++++++++++-- test-requirements.in | 1 - test-requirements.txt | 20 ++++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/docs-requirements.txt b/docs-requirements.txt index 5c0ca035b..def71ea6e 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -1,8 +1,8 @@ # -# This file is autogenerated by pip-compile +# This file is autogenerated by pip-compile with python 3.7 # To update, run: # -# pip-compile --output-file docs-requirements.txt docs-requirements.in +# pip-compile --output-file=docs-requirements.txt docs-requirements.in # alabaster==0.7.12 # via sphinx @@ -28,6 +28,8 @@ docutils==0.17.1 # via # sphinx # sphinx-rtd-theme +exceptiongroup==1.0.0rc5 + # via -r docs-requirements.in idna==3.3 # via # -r docs-requirements.in @@ -36,6 +38,8 @@ imagesize==1.3.0 # via sphinx immutables==0.17 # via -r docs-requirements.in +importlib-metadata==4.11.3 + # via click incremental==21.3.0 # via towncrier jinja2==3.0.3 @@ -87,5 +91,14 @@ tomli==2.0.1 # via towncrier towncrier==21.9.0 # via -r docs-requirements.in +typing-extensions==4.2.0 + # via + # immutables + # importlib-metadata urllib3==1.26.9 # via requests +zipp==3.8.0 + # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/test-requirements.in b/test-requirements.in index fd0d48237..024ea35b2 100644 --- a/test-requirements.in +++ b/test-requirements.in @@ -9,7 +9,6 @@ pylint # for pylint finding all symbols tests jedi # for jedi code completion tests cryptography>=36.0.0 # 35.0.0 is transitive but fails exceptiongroup # for catch() -wrapt @ git+https://github.com/GrahamDumpleton/wrapt.git@a1a06529f41cffcc9fd5132fcf1923ea538f3d3c # fixes Python 3.11 compat for pylint # Tools black; implementation_name == "cpython" diff --git a/test-requirements.txt b/test-requirements.txt index f0b4eec16..cbddabf8f 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -34,12 +34,20 @@ decorator==5.1.1 # via ipython dill==0.3.4 # via pylint +exceptiongroup==1.0.0rc5 + # via -r test-requirements.in flake8==4.0.1 # via -r test-requirements.in idna==3.3 # via # -r test-requirements.in # trustme +importlib-metadata==4.2.0 + # via + # click + # flake8 + # pluggy + # pytest iniconfig==1.1.1 # via pytest ipython==7.31.1 @@ -126,14 +134,26 @@ traitlets==5.1.1 # matplotlib-inline trustme==0.9.0 # via -r test-requirements.in +typed-ast==1.5.3 ; implementation_name == "cpython" and python_version < "3.8" + # via + # -r test-requirements.in + # astroid + # black + # mypy typing-extensions==4.2.0 ; implementation_name == "cpython" # via # -r test-requirements.in + # astroid + # black + # importlib-metadata # mypy + # pylint wcwidth==0.2.5 # via prompt-toolkit wrapt==1.14.0 # via astroid +zipp==3.8.0 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools From 1c403b17861f8f84440cd9c862afba26c918ec9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sat, 14 May 2022 11:31:43 +0300 Subject: [PATCH 50/93] Move Python 3.11 to its separate test job --- .github/workflows/ci.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c3577445..2405cdc17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: strategy: fail-fast: false matrix: - python: ['pypy-3.7', 'pypy-3.8', '3.7', '3.8', '3.9', '3.10', '3.8-dev', '3.9-dev', '3.10-dev', '3.11-dev'] + python: ['pypy-3.7', 'pypy-3.8', '3.7', '3.8', '3.9', '3.10', '3.8-dev', '3.9-dev', '3.10-dev'] check_formatting: ['0'] pypy_nightly_branch: [''] extra_name: [''] @@ -111,6 +111,25 @@ jobs: # Should match 'name:' up above JOB_NAME: 'Ubuntu (${{ matrix.python }}${{ matrix.extra_name }})' + # Requires a separate job to avoid using coverage which is currently broken on 3.11 + Python311: + name: 'Ubuntu (3.11-dev)' + timeout-minutes: 10 + runs-on: 'ubuntu-latest' + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: 3.11-dev + cache: pip + cache-dependency-path: test-requirements.txt + - name: Install dependencies + run: pip install -r test-requirements.txt + - name: Run tests + run: pytest -r -a --verbose + macOS: name: 'macOS (${{ matrix.python }})' timeout-minutes: 10 From a28bfaca8963f8614f20d7b61466f3344efe9f27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sat, 14 May 2022 11:33:57 +0300 Subject: [PATCH 51/93] Fixed bad pytest switch --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2405cdc17..eee8e409c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,7 +128,7 @@ jobs: - name: Install dependencies run: pip install -r test-requirements.txt - name: Run tests - run: pytest -r -a --verbose + run: pytest -ra --verbose macOS: name: 'macOS (${{ matrix.python }})' From 769459205a62a12dc813fbc6ad7e976522649192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sat, 14 May 2022 22:22:08 +0300 Subject: [PATCH 52/93] Implemented the strict_exception_groups setting --- trio/_core/_run.py | 59 +++++++++++++++++++++++++++++------- trio/_core/tests/test_run.py | 42 +++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/trio/_core/_run.py b/trio/_core/_run.py index 9ff7f890f..3f2ac9e3a 100644 --- a/trio/_core/_run.py +++ b/trio/_core/_run.py @@ -472,7 +472,7 @@ def __enter__(self): task._activate_cancel_status(self._cancel_status) return self - def _close(self, exc): + def _close(self, exc, collapse=True): if self._cancel_status is None: new_exc = RuntimeError( "Cancel scope stack corrupted: attempted to exit {!r} " @@ -537,7 +537,7 @@ def _close(self, exc): if matched: self.cancelled_caught = True - if isinstance(exc, BaseExceptionGroup): + if collapse and isinstance(exc, BaseExceptionGroup): exc = collapse_exception_group(exc) self._cancel_status.close() @@ -807,6 +807,7 @@ def started(self, value=None): self._old_nursery._check_nursery_closed() +@attr.s class NurseryManager: """Nursery context manager. @@ -817,11 +818,15 @@ class NurseryManager: """ + strict_exception_groups = attr.ib(default=False) + @enable_ki_protection async def __aenter__(self): self._scope = CancelScope() self._scope.__enter__() - self._nursery = Nursery._create(current_task(), self._scope) + self._nursery = Nursery._create( + current_task(), self._scope, self.strict_exception_groups + ) return self._nursery @enable_ki_protection @@ -829,7 +834,9 @@ async def __aexit__(self, etype, exc, tb): new_exc = await self._nursery._nested_child_finished(exc) # Tracebacks show the 'raise' line below out of context, so let's give # this variable a name that makes sense out of context. - combined_error_from_nursery = self._scope._close(new_exc) + combined_error_from_nursery = self._scope._close( + new_exc, collapse=not self.strict_exception_groups + ) if combined_error_from_nursery is None: return True elif combined_error_from_nursery is exc: @@ -857,15 +864,23 @@ def __exit__(self): # pragma: no cover assert False, """Never called, but should be defined""" -def open_nursery(): +def open_nursery(strict_exception_groups=None): """Returns an async context manager which must be used to create a new `Nursery`. It does not block on entry; on exit it blocks until all child tasks have exited. + Args: + strict_exception_groups (bool): If true, even a single raised exception will be + wrapped in an exception group. This will eventually become the default + behavior. If not specified, uses the value passed to :func:`run`. + """ - return NurseryManager() + if strict_exception_groups is None: + strict_exception_groups = GLOBAL_RUN_CONTEXT.runner.strict_exception_groups + + return NurseryManager(strict_exception_groups=strict_exception_groups) class Nursery(metaclass=NoPublicConstructor): @@ -890,8 +905,9 @@ class Nursery(metaclass=NoPublicConstructor): in response to some external event. """ - def __init__(self, parent_task, cancel_scope): + def __init__(self, parent_task, cancel_scope, strict_exception_groups): self._parent_task = parent_task + self._strict_exception_groups = strict_exception_groups parent_task._child_nurseries.append(self) # the cancel status that children inherit - we take a snapshot, so it # won't be affected by any changes in the parent. @@ -970,7 +986,9 @@ def aborted(raise_cancel): assert popped is self if self._pending_excs: try: - return MultiError(self._pending_excs) + return MultiError( + self._pending_excs, _collapse=not self._strict_exception_groups + ) finally: # avoid a garbage cycle # (see test_nursery_cancel_doesnt_create_cyclic_garbage) @@ -1309,6 +1327,7 @@ class Runner: instruments: Instruments = attr.ib() io_manager = attr.ib() ki_manager = attr.ib() + strict_exception_groups = attr.ib() # Run-local values, see _local.py _locals = attr.ib(factory=dict) @@ -1847,7 +1866,12 @@ def abort(_): # 'is_guest' to see the special cases we need to handle this. -def setup_runner(clock, instruments, restrict_keyboard_interrupt_to_checkpoints): +def setup_runner( + clock, + instruments, + restrict_keyboard_interrupt_to_checkpoints, + strict_exception_groups, +): """Create a Runner object and install it as the GLOBAL_RUN_CONTEXT.""" # It wouldn't be *hard* to support nested calls to run(), but I can't # think of a single good reason for it, so let's be conservative for @@ -1869,6 +1893,7 @@ def setup_runner(clock, instruments, restrict_keyboard_interrupt_to_checkpoints) io_manager=io_manager, system_context=system_context, ki_manager=ki_manager, + strict_exception_groups=strict_exception_groups, ) runner.asyncgens.install_hooks(runner) @@ -1886,6 +1911,7 @@ def run( clock=None, instruments=(), restrict_keyboard_interrupt_to_checkpoints=False, + strict_exception_groups=False, ): """Run a Trio-flavored async function, and return the result. @@ -1942,6 +1968,10 @@ def run( main thread (this is a Python limitation), or if you use :func:`open_signal_receiver` to catch SIGINT. + strict_exception_groups (bool): If true, nurseries will always wrap even a single + raised exception in an exception group. This can be overridden on the level of + individual nurseries. This will eventually become the default behavior. + Returns: Whatever ``async_fn`` returns. @@ -1958,7 +1988,10 @@ def run( __tracebackhide__ = True runner = setup_runner( - clock, instruments, restrict_keyboard_interrupt_to_checkpoints + clock, + instruments, + restrict_keyboard_interrupt_to_checkpoints, + strict_exception_groups, ) gen = unrolled_run(runner, async_fn, args) @@ -1987,6 +2020,7 @@ def start_guest_run( clock=None, instruments=(), restrict_keyboard_interrupt_to_checkpoints=False, + strict_exception_groups=False, ): """Start a "guest" run of Trio on top of some other "host" event loop. @@ -2038,7 +2072,10 @@ def my_done_callback(run_outcome): """ runner = setup_runner( - clock, instruments, restrict_keyboard_interrupt_to_checkpoints + clock, + instruments, + restrict_keyboard_interrupt_to_checkpoints, + strict_exception_groups, ) runner.is_guest = True runner.guest_tick_scheduled = True diff --git a/trio/_core/tests/test_run.py b/trio/_core/tests/test_run.py index 8fcf8c8b7..9160e0481 100644 --- a/trio/_core/tests/test_run.py +++ b/trio/_core/tests/test_run.py @@ -2346,3 +2346,45 @@ async def task(): nursery.start_soon(task) nursery.cancel_scope.cancel() assert destroyed + + +def test_run_strict_exception_groups(): + """ + Test that nurseries respect the global context setting of strict_exception_groups. + """ + + async def main(): + async with _core.open_nursery(): + raise Exception("foo") + + with pytest.raises(MultiError) as exc: + _core.run(main, strict_exception_groups=True) + + assert len(exc.value.exceptions) == 1 + assert type(exc.value.exceptions[0]) is Exception + assert exc.value.exceptions[0].args == ("foo",) + + +def test_run_strict_exception_groups_nursery_override(): + """ + Test that a nursery can override the global context setting of + strict_exception_groups. + """ + + async def main(): + async with _core.open_nursery(strict_exception_groups=False): + raise Exception("foo") + + with pytest.raises(Exception, match="foo"): + _core.run(main, strict_exception_groups=True) + + +async def test_nursery_strict_exception_groups(): + """Test that strict exception groups can be enabled on a per-nursery basis.""" + with pytest.raises(MultiError) as exc: + async with _core.open_nursery(strict_exception_groups=True): + raise Exception("foo") + + assert len(exc.value.exceptions) == 1 + assert type(exc.value.exceptions[0]) is Exception + assert exc.value.exceptions[0].args == ("foo",) From 161c0ab745d2529b2ec3f7bc0e242cd4338e9451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sat, 14 May 2022 23:43:05 +0300 Subject: [PATCH 53/93] Implemented NonBaseMultiError which inherits from both MultiError and ExceptionGroup This is intended to let users use "except Exception:" with MultiError. --- trio/_core/_multierror.py | 10 +++++++++- trio/_core/tests/test_multierror.py | 26 +++++++++++++++++++++++++- trio/_core/tests/test_run.py | 4 ++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/trio/_core/_multierror.py b/trio/_core/_multierror.py index ac6e3bd2b..0412b4c21 100644 --- a/trio/_core/_multierror.py +++ b/trio/_core/_multierror.py @@ -5,7 +5,7 @@ import attr if sys.version_info < (3, 11): - from exceptiongroup import BaseExceptionGroup + from exceptiongroup import BaseExceptionGroup, ExceptionGroup ################################################################ # MultiError @@ -203,6 +203,9 @@ def __new__(cls, exceptions, *, _collapse=True): # In an earlier version of the code, we didn't define __init__ and # simply set the `exceptions` attribute directly on the new object. # However, linters expect attributes to be initialized in __init__. + if all(isinstance(exc, Exception) for exc in exceptions): + cls = NonBaseMultiError + return super().__new__(cls, "multiple tasks failed", exceptions) def __str__(self): @@ -256,8 +259,13 @@ def catch(cls, handler): return MultiErrorCatcher(handler) +class NonBaseMultiError(MultiError, ExceptionGroup): + pass + + # Clean up exception printing: MultiError.__module__ = "trio" +NonBaseMultiError.__module__ = "trio" ################################################################ # concat_tb diff --git a/trio/_core/tests/test_multierror.py b/trio/_core/tests/test_multierror.py index 01e6253d0..5890e21c2 100644 --- a/trio/_core/tests/test_multierror.py +++ b/trio/_core/tests/test_multierror.py @@ -11,9 +11,12 @@ import sys import re -from .._multierror import MultiError, concat_tb +from .._multierror import MultiError, concat_tb, NonBaseMultiError from ..._core import open_nursery +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + class NotHashableException(Exception): code = None @@ -392,3 +395,24 @@ def test_assert_match_in_seq(): assert_match_in_seq(["b", "a"], "xx b xx a xx") with pytest.raises(AssertionError): assert_match_in_seq(["a", "b"], "xx b xx a xx") + + +def test_base_multierror(): + """ + Test that MultiError() witho at least one base exception will return a MultiError + object. + """ + + exc = MultiError([ZeroDivisionError(), KeyboardInterrupt()]) + assert type(exc) is MultiError + + +def test_non_base_multierror(): + """ + Test that MultiError() without base exceptions will return a NonBaseMultiError + object. + """ + + exc = MultiError([ZeroDivisionError(), ValueError()]) + assert type(exc) is NonBaseMultiError + assert isinstance(exc, ExceptionGroup) diff --git a/trio/_core/tests/test_run.py b/trio/_core/tests/test_run.py index 9160e0481..a3e0e3e28 100644 --- a/trio/_core/tests/test_run.py +++ b/trio/_core/tests/test_run.py @@ -29,7 +29,7 @@ ) from ... import _core -from ..._core._multierror import MultiError +from ..._core._multierror import MultiError, NonBaseMultiError from .._run import DEADLINE_HEAP_MIN_PRUNE_THRESHOLD from ..._threads import to_thread_run_sync from ..._timeouts import sleep, fail_after @@ -776,7 +776,7 @@ async def task2(): with pytest.raises(RuntimeError) as exc_info: await nursery_mgr.__aexit__(*sys.exc_info()) assert "which had already been exited" in str(exc_info.value) - assert type(exc_info.value.__context__) is MultiError + assert type(exc_info.value.__context__) is NonBaseMultiError assert len(exc_info.value.__context__.exceptions) == 3 cancelled_in_context = False for exc in exc_info.value.__context__.exceptions: From 226184f2dc89b90112204d758bec2db12d455d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 15 May 2022 00:39:26 +0300 Subject: [PATCH 54/93] Fixed MultiError reference in version history --- docs/source/history.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/history.rst b/docs/source/history.rst index e2576fa64..72d27955b 100644 --- a/docs/source/history.rst +++ b/docs/source/history.rst @@ -31,8 +31,8 @@ Features Bugfixes ~~~~~~~~ -- Trio now avoids creating cyclic garbage when a `MultiError` is generated and filtered, - including invisibly within the cancellation system. This means errors raised +- Trio now avoids creating cyclic garbage when a ``MultiError`` is generated and + filtered, including invisibly within the cancellation system. This means errors raised through nurseries and cancel scopes should result in less GC latency. (`#2063 `__) - Trio now deterministically cleans up file descriptors that were opened before subprocess creation fails. Previously, they would remain open until the next run of From 748ceb8a2f220bcad50886152c508f44bdda6cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 15 May 2022 00:41:56 +0300 Subject: [PATCH 55/93] Removed Python 3.11 from test matrix It's not ready yet (CFFI is segfaulting). --- .github/workflows/ci.yml | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eee8e409c..ece2b0d0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,25 +111,6 @@ jobs: # Should match 'name:' up above JOB_NAME: 'Ubuntu (${{ matrix.python }}${{ matrix.extra_name }})' - # Requires a separate job to avoid using coverage which is currently broken on 3.11 - Python311: - name: 'Ubuntu (3.11-dev)' - timeout-minutes: 10 - runs-on: 'ubuntu-latest' - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Setup python - uses: actions/setup-python@v2 - with: - python-version: 3.11-dev - cache: pip - cache-dependency-path: test-requirements.txt - - name: Install dependencies - run: pip install -r test-requirements.txt - - name: Run tests - run: pytest -ra --verbose - macOS: name: 'macOS (${{ matrix.python }})' timeout-minutes: 10 From dfdda5bd9e226f7a11dad3f12541ec19cf13e17f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 15 May 2022 01:34:41 +0300 Subject: [PATCH 56/93] Documented the MultiError compatibility issue --- docs/source/reference-core.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/source/reference-core.rst b/docs/source/reference-core.rst index 9639287d7..6fd4b6631 100644 --- a/docs/source/reference-core.rst +++ b/docs/source/reference-core.rst @@ -750,6 +750,20 @@ callbacks:: .. hint:: If your code, written using ``except*``, would set local variables, you can do the same with handler callbacks as long as you declare those variables ``nonlocal``. +For reasons of backwards compatibility, nurseries raise ``MultiError`` and +``NonBaseMultiError`` which inherit from `BaseExceptionGroup` and `ExceptionGroup`, +respectively. Users should refrain from attempting to raise or catch the trio specific +exceptions themselves, and treat them as if they were standard `BaseExceptionGroup` or +`ExceptionGroup` instances instead. + +The compatibility exception classes have one additional quirk: when only a single +exception is passed to them, they will spit out that single exception from the +constructor instead of the appropriate ``MultiError`` instance. This means that a +nursery can pass through any arbitrary exception raised by either a spawned task or the +host task. This behavior can be controlled by the ``strict_exception_groups=True`` +argument passed to either :func:`open_nursery` or :func:`run`. If enabled, a nursery +will always wrap even single exception raised in it in an exception group. + .. _exceptiongroup: https://pypi.org/project/exceptiongroup/ Spawning tasks without becoming a parent From e8a1b604ef0dbc17c63e1f0092262ee52b2e757b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 17 May 2022 09:28:43 +0300 Subject: [PATCH 57/93] Documentation updates --- docs/source/reference-core.rst | 44 ++++++++++++++++++++++------- trio/_core/tests/test_multierror.py | 2 +- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/docs/source/reference-core.rst b/docs/source/reference-core.rst index 6fd4b6631..bf9fd3202 100644 --- a/docs/source/reference-core.rst +++ b/docs/source/reference-core.rst @@ -750,19 +750,43 @@ callbacks:: .. hint:: If your code, written using ``except*``, would set local variables, you can do the same with handler callbacks as long as you declare those variables ``nonlocal``. -For reasons of backwards compatibility, nurseries raise ``MultiError`` and -``NonBaseMultiError`` which inherit from `BaseExceptionGroup` and `ExceptionGroup`, -respectively. Users should refrain from attempting to raise or catch the trio specific +For reasons of backwards compatibility, nurseries raise ``trio.MultiError`` and +``trio.NonBaseMultiError`` which inherit from `BaseExceptionGroup` and `ExceptionGroup`, +respectively. Users should refrain from attempting to raise or catch the Trio specific exceptions themselves, and treat them as if they were standard `BaseExceptionGroup` or `ExceptionGroup` instances instead. -The compatibility exception classes have one additional quirk: when only a single -exception is passed to them, they will spit out that single exception from the -constructor instead of the appropriate ``MultiError`` instance. This means that a -nursery can pass through any arbitrary exception raised by either a spawned task or the -host task. This behavior can be controlled by the ``strict_exception_groups=True`` -argument passed to either :func:`open_nursery` or :func:`run`. If enabled, a nursery -will always wrap even single exception raised in it in an exception group. +"Strict" versus "loose" ExceptionGroup semantics +++++++++++++++++++++++++++++++++++++++++++++++++ + +Ideally, in some abstract sense we'd want everything that *can* raise an +`ExceptionGroup` to *always* raise an `ExceptionGroup` (rather than, say, a single +`ValueError`). Otherwise, it would be easy to accidentally write something like ``except +ValueError:`` (not ``except*``), which works if a single exception is raised but fails to +catch _anything_ in the case of multiple simultaneous exceptions (even if one of them is +a ValueError). However, this is not how Trio worked in the past: as a concession to +practicality when the ``except*`` syntax hadn't been dreamed up yet, the old +``trio.MultiError`` was raised only when at least two exceptions occurred +simultaneously. Adding a layer of `ExceptionGroup` around every nursery, while +theoretically appealing, would probably break a lot of existing code in practice. + +Therefore, we've chosen to gate the newer, "stricter" behavior behind a parameter +called ``strict_exception_groups``. This is accepted as a parameter to +:func:`open_nursery`, to set the behavior for that nursery, and to :func:`trio.run`, +to set the default behavior for any nursery in your program that doesn't override it. + +* With ``strict_exception_groups=True``, the exception(s) coming out of a nursery will + always be wrapped in an `ExceptionGroup`, so you'll know that if you're handling + single errors correctly, multiple simultaneous errors will work as well. + +* With ``strict_exception_groups=False``, a nursery in which only one task has failed + will raise that task's exception without an additional layer of `ExceptionGroup` + wrapping, so you'll get maximum compatibility with code that was written to + support older versions of Trio. + +To maintain backwards compatibility, the default is ``strict_exception_groups=False``. +The default will eventually change to ``True`` in a future version of Trio, once +Python 3.11 and later versions are in wide use. .. _exceptiongroup: https://pypi.org/project/exceptiongroup/ diff --git a/trio/_core/tests/test_multierror.py b/trio/_core/tests/test_multierror.py index 5890e21c2..9b8d7356c 100644 --- a/trio/_core/tests/test_multierror.py +++ b/trio/_core/tests/test_multierror.py @@ -399,7 +399,7 @@ def test_assert_match_in_seq(): def test_base_multierror(): """ - Test that MultiError() witho at least one base exception will return a MultiError + Test that MultiError() with at least one base exception will return a MultiError object. """ From 4bd09bc346ee44ac3588ebcc2caaa3ab4ab46314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 17 May 2022 09:36:34 +0300 Subject: [PATCH 58/93] Changed open_tcp_listeners() and open_tcp_stream() to use ExceptionGroup directly --- trio/_highlevel_open_tcp_listeners.py | 14 +++++++++----- trio/_highlevel_open_tcp_stream.py | 8 ++++++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/trio/_highlevel_open_tcp_listeners.py b/trio/_highlevel_open_tcp_listeners.py index cce6f1d60..b650ac973 100644 --- a/trio/_highlevel_open_tcp_listeners.py +++ b/trio/_highlevel_open_tcp_listeners.py @@ -4,7 +4,9 @@ import trio from . import socket as tsocket -from ._core._multierror import MultiError + +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup # Default backlog size: @@ -135,11 +137,13 @@ async def open_tcp_listeners(port, *, host=None, backlog=None): raise if unsupported_address_families and not listeners: - raise OSError( - errno.EAFNOSUPPORT, + msg = ( "This system doesn't support any of the kinds of " - "socket that that address could use", - ) from MultiError(unsupported_address_families) + "socket that that address could use" + ) + raise OSError(errno.EAFNOSUPPORT, msg) from ExceptionGroup( + msg, unsupported_address_families + ) return listeners diff --git a/trio/_highlevel_open_tcp_stream.py b/trio/_highlevel_open_tcp_stream.py index c57d42104..740c0b175 100644 --- a/trio/_highlevel_open_tcp_stream.py +++ b/trio/_highlevel_open_tcp_stream.py @@ -1,10 +1,14 @@ -import warnings +import sys from contextlib import contextmanager import trio from trio._core._multierror import MultiError from trio.socket import getaddrinfo, SOCK_STREAM, socket +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + + # Implementation of RFC 6555 "Happy eyeballs" # https://tools.ietf.org/html/rfc6555 # @@ -368,7 +372,7 @@ async def attempt_connect(socket_args, sockaddr, attempt_failed): msg = "all attempts to connect to {} failed".format( format_host_port(host, port) ) - raise OSError(msg) from MultiError(oserrors) + raise OSError(msg) from ExceptionGroup(msg, oserrors) else: stream = trio.SocketStream(winning_socket) open_sockets.remove(winning_socket) From 1101ead769433b76bacd940d2925fa3a3c47a46f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 17 May 2022 09:43:35 +0300 Subject: [PATCH 59/93] Expose NonBaseMultiError in trio (as a deprecated attribute) --- trio/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/trio/__init__.py b/trio/__init__.py index ec8b58e61..d3be8fcb4 100644 --- a/trio/__init__.py +++ b/trio/__init__.py @@ -88,6 +88,7 @@ ) from ._core._multierror import MultiError as _MultiError +from ._core._multierror import NonBaseMultiError as _NonBaseMultiError from ._deprecate import TrioDeprecationWarning @@ -116,10 +117,16 @@ ), "MultiError": _deprecate.DeprecatedAttribute( value=_MultiError, - version="0.20.0", + version="0.21.0", issue=2211, instead="BaseExceptionGroup (py3.11+) or exceptiongroup.BaseExceptionGroup", ), + "NonBaseMultiError": _deprecate.DeprecatedAttribute( + value=_NonBaseMultiError, + version="0.21.0", + issue=2211, + instead="ExceptionGroup (py3.11+) or exceptiongroup.ExceptionGroup", + ), } # Having the public path in .__module__ attributes is important for: From cd11213084c80a22fa87c5d80b08d46c88876ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sat, 21 May 2022 00:38:23 +0300 Subject: [PATCH 60/93] Changed wording in MultiError deprecation messages --- trio/__init__.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/trio/__init__.py b/trio/__init__.py index d3be8fcb4..a7ddeb539 100644 --- a/trio/__init__.py +++ b/trio/__init__.py @@ -119,13 +119,19 @@ value=_MultiError, version="0.21.0", issue=2211, - instead="BaseExceptionGroup (py3.11+) or exceptiongroup.BaseExceptionGroup", + instead=( + "BaseExceptionGroup (on Python 3.11 and later) or " + "exceptiongroup.BaseExceptionGroup (earlier versions)" + ), ), "NonBaseMultiError": _deprecate.DeprecatedAttribute( value=_NonBaseMultiError, version="0.21.0", issue=2211, - instead="ExceptionGroup (py3.11+) or exceptiongroup.ExceptionGroup", + instead=( + "ExceptionGroup (on Python 3.11 and later) or " + "exceptiongroup.ExceptionGroup (earlier versions)" + ), ), } From 7088be4612b2506913b2df4cf7a097c9668bbd52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sat, 21 May 2022 00:40:13 +0300 Subject: [PATCH 61/93] Added missing install dependency on exceptiongroup --- setup.py | 1 + test-requirements.in | 3 ++- test-requirements.txt | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 732fa7acd..f6a9f8f4b 100644 --- a/setup.py +++ b/setup.py @@ -89,6 +89,7 @@ # cffi 1.14 fixes memory leak inside ffi.getwinerror() # cffi is required on Windows, except on PyPy where it is built-in "cffi>=1.14; os_name == 'nt' and implementation_name != 'pypy'", + "exceptiongroup; python_version < '3.11'" ], # This means, just install *everything* you see under trio/, even if it # doesn't look like a source file, so long as it appears in MANIFEST.in: diff --git a/test-requirements.in b/test-requirements.in index 024ea35b2..80abb7ebe 100644 --- a/test-requirements.in +++ b/test-requirements.in @@ -8,7 +8,6 @@ trustme # for the ssl tests pylint # for pylint finding all symbols tests jedi # for jedi code completion tests cryptography>=36.0.0 # 35.0.0 is transitive but fails -exceptiongroup # for catch() # Tools black; implementation_name == "cpython" @@ -30,3 +29,5 @@ async_generator >= 1.9 idna outcome sniffio +exceptiongroup; python_version < "3.11" + diff --git a/test-requirements.txt b/test-requirements.txt index 8902a6a1c..5857d92e7 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -34,7 +34,7 @@ decorator==5.1.1 # via ipython dill==0.3.4 # via pylint -exceptiongroup==1.0.0rc5 +exceptiongroup==1.0.0rc7 # via -r test-requirements.in flake8==4.0.1 # via -r test-requirements.in From e87de6c1900495d3872a7cb5a20d646fcc8f812b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 7 Jun 2022 22:11:55 +0300 Subject: [PATCH 62/93] Updated trio version in deprecation notes --- trio/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/trio/__init__.py b/trio/__init__.py index a7ddeb539..7a1e16dfb 100644 --- a/trio/__init__.py +++ b/trio/__init__.py @@ -117,7 +117,7 @@ ), "MultiError": _deprecate.DeprecatedAttribute( value=_MultiError, - version="0.21.0", + version="0.22.0", issue=2211, instead=( "BaseExceptionGroup (on Python 3.11 and later) or " @@ -126,7 +126,7 @@ ), "NonBaseMultiError": _deprecate.DeprecatedAttribute( value=_NonBaseMultiError, - version="0.21.0", + version="0.22.0", issue=2211, instead=( "ExceptionGroup (on Python 3.11 and later) or " From 6b5e4f6f06cccd147cb7ad76831b8f7e978fd3ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 7 Jun 2022 22:13:20 +0300 Subject: [PATCH 63/93] Fixed formatting error --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2375371a0..d2f09d505 100644 --- a/setup.py +++ b/setup.py @@ -89,7 +89,7 @@ # cffi 1.14 fixes memory leak inside ffi.getwinerror() # cffi is required on Windows, except on PyPy where it is built-in "cffi>=1.14; os_name == 'nt' and implementation_name != 'pypy'", - "exceptiongroup; python_version < '3.11'" + "exceptiongroup; python_version < '3.11'", ], # This means, just install *everything* you see under trio/, even if it # doesn't look like a source file, so long as it appears in MANIFEST.in: From 9ff7a4550a0eddbdcf27a86f7133a3c15d2e925c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 5 Jul 2022 01:40:51 +0300 Subject: [PATCH 64/93] Use trio's own deprecation mechanism --- trio/_core/_multierror.py | 9 ++++----- trio/_core/tests/test_multierror.py | 22 ++++++++++++++++------ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/trio/_core/_multierror.py b/trio/_core/_multierror.py index 0412b4c21..7305dff42 100644 --- a/trio/_core/_multierror.py +++ b/trio/_core/_multierror.py @@ -4,6 +4,8 @@ import attr +from trio._deprecate import warn_deprecated + if sys.version_info < (3, 11): from exceptiongroup import BaseExceptionGroup, ExceptionGroup @@ -233,12 +235,9 @@ def filter(cls, handler, root_exc): ``handler`` returned None for all the inputs, returns None. """ - warnings.warn( - "MultiError.filter() has been deprecated. " - "Use the .split() method instead.", - DeprecationWarning, + warn_deprecated( + "MultiError.filter()", "0.22.0", instead="MultiError.split()", issue=2211 ) - return _filter_impl(handler, root_exc) @classmethod diff --git a/trio/_core/tests/test_multierror.py b/trio/_core/tests/test_multierror.py index 9b8d7356c..1eaab6d4b 100644 --- a/trio/_core/tests/test_multierror.py +++ b/trio/_core/tests/test_multierror.py @@ -12,6 +12,7 @@ import re from .._multierror import MultiError, concat_tb, NonBaseMultiError +from ... import TrioDeprecationWarning from ..._core import open_nursery if sys.version_info < (3, 11): @@ -144,7 +145,9 @@ def handle_ValueError(exc): else: return exc - filtered_excs = pytest.deprecated_call(MultiError.filter, handle_ValueError, excs) + with pytest.warns(TrioDeprecationWarning): + filtered_excs = MultiError.filter(handle_ValueError, excs) + assert isinstance(filtered_excs, NotHashableException) @@ -189,7 +192,9 @@ def null_handler(exc): m = make_tree() assert_tree_eq(m, m) - assert pytest.deprecated_call(MultiError.filter, null_handler, m) is m + with pytest.warns(TrioDeprecationWarning): + assert MultiError.filter(null_handler, m) is m + assert_tree_eq(m, make_tree()) # Make sure we don't pick up any detritus if run in a context where @@ -198,7 +203,8 @@ def null_handler(exc): try: raise ValueError except ValueError: - assert pytest.deprecated_call(MultiError.filter, null_handler, m) is m + with pytest.warns(TrioDeprecationWarning): + assert MultiError.filter(null_handler, m) is m assert_tree_eq(m, make_tree()) def simple_filter(exc): @@ -208,7 +214,9 @@ def simple_filter(exc): return RuntimeError() return exc - new_m = pytest.deprecated_call(MultiError.filter, simple_filter, make_tree()) + with pytest.warns(TrioDeprecationWarning): + new_m = MultiError.filter(simple_filter, make_tree()) + assert isinstance(new_m, MultiError) assert len(new_m.exceptions) == 2 # was: [[ValueError, KeyError], NameError] @@ -250,7 +258,8 @@ def filter_NameError(exc): return exc m = make_tree() - new_m = pytest.deprecated_call(MultiError.filter, filter_NameError, m) + with pytest.warns(TrioDeprecationWarning): + new_m = MultiError.filter(filter_NameError, m) # with the NameError gone, the other branch gets promoted assert new_m is m.exceptions[0] @@ -258,7 +267,8 @@ def filter_NameError(exc): def filter_all(exc): return None - assert pytest.deprecated_call(MultiError.filter, filter_all, make_tree()) is None + with pytest.warns(TrioDeprecationWarning): + assert MultiError.filter(filter_all, make_tree()) is None def test_MultiError_catch(): From 2140b70debdcfd453235708c654b6592f11673a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 5 Jul 2022 01:41:14 +0300 Subject: [PATCH 65/93] Added comment on _collapse=False in derive() --- trio/_core/_multierror.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/trio/_core/_multierror.py b/trio/_core/_multierror.py index 7305dff42..31a7150d6 100644 --- a/trio/_core/_multierror.py +++ b/trio/_core/_multierror.py @@ -217,6 +217,8 @@ def __repr__(self): return "".format(self) def derive(self, __excs): + # We use _collapse=False here to get ExceptionGroup semantics, since derive() + # is part of the PEP 654 API return MultiError(__excs, _collapse=False) @classmethod From 1da1af457b44f6d28e52535e7df6237cc2fb9eb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 5 Jul 2022 01:59:10 +0300 Subject: [PATCH 66/93] Only modify MultiErrors in collapse_exception_group() --- trio/_core/_run.py | 4 ++-- trio/_core/tests/test_run.py | 14 -------------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/trio/_core/_run.py b/trio/_core/_run.py index 3f2ac9e3a..c200455b1 100644 --- a/trio/_core/_run.py +++ b/trio/_core/_run.py @@ -127,7 +127,7 @@ def collapse_exception_group(excgroup): exceptions = list(excgroup.exceptions) modified = False for i, exc in enumerate(exceptions): - if isinstance(exc, BaseExceptionGroup): + if isinstance(exc, MultiError): new_exc = collapse_exception_group(exc) if new_exc is not exc: modified = True @@ -537,7 +537,7 @@ def _close(self, exc, collapse=True): if matched: self.cancelled_caught = True - if collapse and isinstance(exc, BaseExceptionGroup): + if collapse and isinstance(exc, MultiError): exc = collapse_exception_group(exc) self._cancel_status.close() diff --git a/trio/_core/tests/test_run.py b/trio/_core/tests/test_run.py index a3e0e3e28..6eeaee9aa 100644 --- a/trio/_core/tests/test_run.py +++ b/trio/_core/tests/test_run.py @@ -1877,20 +1877,6 @@ def handle(exc): assert result == [[0, 0], [1, 1]] -async def test_nursery_collapse_exceptions(): - # Test that exception groups containing only a single exception are - # recursively collapsed - async def fail(): - raise ExceptionGroup("fail", [ValueError()]) - - try: - async with _core.open_nursery() as nursery: - nursery.start_soon(fail) - raise StopIteration - except MultiError as e: - assert tuple(map(type, e.exceptions)) == (StopIteration, ValueError) - - async def test_traceback_frame_removal(): async def my_child_task(): raise KeyError() From f9e11fce6c688f6e41d6ccb5e17773bb2cdc4024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 5 Jul 2022 02:11:10 +0300 Subject: [PATCH 67/93] Retain traceback frames from the MultiError itself --- trio/_core/_run.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/trio/_core/_run.py b/trio/_core/_run.py index c200455b1..10a77d56c 100644 --- a/trio/_core/_run.py +++ b/trio/_core/_run.py @@ -28,7 +28,7 @@ KIManager, enable_ki_protection, ) -from ._multierror import MultiError +from ._multierror import MultiError, concat_tb from ._traps import ( Abort, wait_task_rescheduled, @@ -134,6 +134,9 @@ def collapse_exception_group(excgroup): exceptions[i] = new_exc if len(exceptions) == 1: + exceptions[0].__traceback__ = concat_tb( + exceptions[0].__traceback__, excgroup.__traceback__ + ) return exceptions[0] elif modified: return excgroup.derive(exceptions) From 1d99b232d20ec2ddc65a3f8d7577bc8cdea5b898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sat, 9 Jul 2022 15:37:03 +0300 Subject: [PATCH 68/93] Improved prevention of MultiError double initialization If the instance has not been initialized yet, then accessing self.exceptions would cause an AttributeError. We detect that and only call the superclass initializer if that exception is not raised. --- trio/_core/_multierror.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/trio/_core/_multierror.py b/trio/_core/_multierror.py index 31a7150d6..b144d5f43 100644 --- a/trio/_core/_multierror.py +++ b/trio/_core/_multierror.py @@ -179,11 +179,10 @@ class MultiError(BaseExceptionGroup): """ def __init__(self, exceptions, *, _collapse=True): - # Avoid recursion when exceptions[0] returned by __new__() happens - # to be a MultiError and subsequently __init__() is called. - if _collapse and hasattr(self, "_exceptions"): - # __init__ was already called on this object - assert len(exceptions) == 1 and exceptions[0] is self + # Avoid double initialization when _collapse is True and exceptions[0] returned + # by __new__() happens to be a MultiError and subsequently __init__() is called. + if _collapse and getattr(self, "exceptions", None) is not None: + # This exception was already initialized. return super().__init__("multiple tasks failed", exceptions) From 2591808753ccd53b814ba51eb12ccd55379445a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sat, 9 Jul 2022 17:33:09 +0300 Subject: [PATCH 69/93] Reworded the comments in the exception group handling examples --- docs/source/reference-core.rst | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/source/reference-core.rst b/docs/source/reference-core.rst index bf9fd3202..399bd7a13 100644 --- a/docs/source/reference-core.rst +++ b/docs/source/reference-core.rst @@ -723,9 +723,9 @@ clause was introduced in Python 3.11 (:pep:`654`). Here's how it works:: nursery.start_soon(broken1) nursery.start_soon(broken2) except* KeyError: - ... # handle each KeyError + ... # handle the KeyErrors except* IndexError: - ... # handle each IndexError + ... # handle the IndexErrors But what if you can't use ``except*`` just yet? Well, for that there is the handy exceptiongroup_ library which lets you approximate this behavior with exception handler @@ -733,11 +733,11 @@ callbacks:: from exceptiongroup import catch - def handle_keyerror(exc): - ... # handle each KeyError + def handle_keyerrors(excgroup): + ... # handle the KeyErrors - def handle_indexerror(exc): - ... # handle each IndexError + def handle_indexerrors(excgroup): + ... # handle the IndexErrors with catch({ KeyError: handle_keyerror, From 8c77459c6f848e89cc8c859d8cfd2f443988dcda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Fri, 15 Jul 2022 12:13:04 +0300 Subject: [PATCH 70/93] Made all references to (Base)ExceptionGroup into links --- docs/source/reference-core.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/source/reference-core.rst b/docs/source/reference-core.rst index 399bd7a13..68a042ad7 100644 --- a/docs/source/reference-core.rst +++ b/docs/source/reference-core.rst @@ -711,9 +711,9 @@ limitation. Consider code like:: ``broken1`` raises ``KeyError``. ``broken2`` raises ``IndexError``. Obviously ``parent`` should raise some error, but -what? The answer is that both exceptions are grouped in an `ExceptionGroup`. -The `ExceptionGroup` and its parent class `BaseExceptionGroup` are used to encapsulate -multiple exceptions being raised at once. +what? The answer is that both exceptions are grouped in an :exc:`ExceptionGroup`. +:exc:`ExceptionGroup` and its parent class :exc:`BaseExceptionGroup` are used to +encapsulate multiple exceptions being raised at once. To catch individual exceptions encapsulated in an exception group, the ``except*`` clause was introduced in Python 3.11 (:pep:`654`). Here's how it works:: @@ -751,10 +751,10 @@ callbacks:: the same with handler callbacks as long as you declare those variables ``nonlocal``. For reasons of backwards compatibility, nurseries raise ``trio.MultiError`` and -``trio.NonBaseMultiError`` which inherit from `BaseExceptionGroup` and `ExceptionGroup`, -respectively. Users should refrain from attempting to raise or catch the Trio specific -exceptions themselves, and treat them as if they were standard `BaseExceptionGroup` or -`ExceptionGroup` instances instead. +``trio.NonBaseMultiError`` which inherit from :exc:`BaseExceptionGroup` and +:exc:`ExceptionGroup`, respectively. Users should refrain from attempting to raise or +catch the Trio specific exceptions themselves, and treat them as if they were standard +:exc:`BaseExceptionGroup` or :exc:`ExceptionGroup` instances instead. "Strict" versus "loose" ExceptionGroup semantics ++++++++++++++++++++++++++++++++++++++++++++++++ From b621e70e9b19e303034472e5c1bb3c1f9fbda058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 17 Jul 2022 11:23:39 +0300 Subject: [PATCH 71/93] Improved the documentation for exception group handling --- docs/source/reference-core.rst | 36 ++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/source/reference-core.rst b/docs/source/reference-core.rst index 68a042ad7..853d41a39 100644 --- a/docs/source/reference-core.rst +++ b/docs/source/reference-core.rst @@ -687,8 +687,6 @@ You might wonder why Trio can't just remember "this task should be cancelled in If you want a timeout to apply to one task but not another, then you need to put the cancel scope in that individual task's function -- ``child()``, in this example. -.. _exceptiongroups: - Errors in multiple child tasks ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -722,10 +720,16 @@ clause was introduced in Python 3.11 (:pep:`654`). Here's how it works:: async with trio.open_nursery() as nursery: nursery.start_soon(broken1) nursery.start_soon(broken2) - except* KeyError: - ... # handle the KeyErrors - except* IndexError: - ... # handle the IndexErrors + except* KeyError as excgroup: + for exc in excgroup.exceptions: + ... # handle each KeyError + except* IndexError as excgroup: + for exc in excgroup.exceptions: + ... # handle each IndexError + +If you want to reraise exceptions, or raise new ones, you can do so, but be aware that +exceptions raised in ``except*`` sections will be raised together in a new exception +group. But what if you can't use ``except*`` just yet? Well, for that there is the handy exceptiongroup_ library which lets you approximate this behavior with exception handler @@ -734,10 +738,12 @@ callbacks:: from exceptiongroup import catch def handle_keyerrors(excgroup): - ... # handle the KeyErrors + for exc in excgroup.exceptions: + ... # handle each KeyError def handle_indexerrors(excgroup): - ... # handle the IndexErrors + for exc in excgroup.exceptions: + ... # handle each IndexError with catch({ KeyError: handle_keyerror, @@ -747,8 +753,18 @@ callbacks:: nursery.start_soon(broken1) nursery.start_soon(broken2) -.. hint:: If your code, written using ``except*``, would set local variables, you can do - the same with handler callbacks as long as you declare those variables ``nonlocal``. +The semantics for the handler functions are equal to ``except*`` blocks, except for +setting local variables. If you need to set local variables, you need to declare them +inside the handler function(s) with the ``nonlocal`` keyword:: + + def handle_keyerrors(excgroup): + nonlocal myflag + myflag = True + + myflag = False + with catch({KeyError: handle_keyerror}): + async with trio.open_nursery() as nursery: + nursery.start_soon(broken1) For reasons of backwards compatibility, nurseries raise ``trio.MultiError`` and ``trio.NonBaseMultiError`` which inherit from :exc:`BaseExceptionGroup` and From 656fad4ee492940b22120067288735750a37db29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Wed, 20 Jul 2022 10:02:49 +0300 Subject: [PATCH 72/93] Fixed test collection error on Python 3.11 --- trio/_core/tests/test_run.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/trio/_core/tests/test_run.py b/trio/_core/tests/test_run.py index 6eeaee9aa..79f257918 100644 --- a/trio/_core/tests/test_run.py +++ b/trio/_core/tests/test_run.py @@ -16,7 +16,6 @@ import outcome import sniffio import pytest -from exceptiongroup import catch from .tutil import ( slow, @@ -40,7 +39,7 @@ ) if sys.version_info < (3, 11): - from exceptiongroup import BaseExceptionGroup, ExceptionGroup + from exceptiongroup import ExceptionGroup # slightly different from _timeouts.sleep_forever because it returns the value @@ -1858,14 +1857,12 @@ async def __anext__(self): items = [None] * len(nexts) got_stop = False - def handle(exc): - nonlocal got_stop - got_stop = True - - with catch({StopAsyncIteration: handle}): + try: async with _core.open_nursery() as nursery: for i, f in enumerate(nexts): nursery.start_soon(self._accumulate, f, items, i) + except ExceptionGroup as excgroup: + got_stop = bool(excgroup.split(StopAsyncIteration)) if got_stop: raise StopAsyncIteration From 6e86da32a561b14fb4d48d40273b308c9d4ad29a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Wed, 20 Jul 2022 10:23:48 +0300 Subject: [PATCH 73/93] Readded the exceptiongroups label --- docs/source/reference-core.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/source/reference-core.rst b/docs/source/reference-core.rst index 853d41a39..6507b6180 100644 --- a/docs/source/reference-core.rst +++ b/docs/source/reference-core.rst @@ -687,6 +687,8 @@ You might wonder why Trio can't just remember "this task should be cancelled in If you want a timeout to apply to one task but not another, then you need to put the cancel scope in that individual task's function -- ``child()``, in this example. +.. _exceptiongroups: + Errors in multiple child tasks ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 16c19b62eb12ed8015e1d46aec48f96de3e2cbc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 18 Sep 2022 12:49:36 +0300 Subject: [PATCH 74/93] Spelled out MultiError.filter() and MultiError.catch() --- newsfragments/2211.headline.rst | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/newsfragments/2211.headline.rst b/newsfragments/2211.headline.rst index ed7e9e1f2..c4320ba37 100644 --- a/newsfragments/2211.headline.rst +++ b/newsfragments/2211.headline.rst @@ -1,11 +1,12 @@ ``MultiError`` has been deprecated in favor of the standard :exc:`BaseExceptionGroup` -(introduced in :pep:`654`). On Python versions below 3.11, this exception and its derivative -:exc:`ExceptionGroup` are provided by the backport_. Trio still raises ``MultiError``, -but it has been refactored into a subclass of :exc:`BaseExceptionGroup` which users -should catch instead of ``MultiError``. Uses of the ``filter()`` class method should be -replaced with :meth:`BaseExceptionGroup.split`. Uses of the ``catch()`` class method -should be replaced with either ``except*`` clauses (on Python 3.11+) or the -``exceptiongroup.catch()`` context manager provided by the backport_. +(introduced in :pep:`654`). On Python versions below 3.11, this exception and its +derivative :exc:`ExceptionGroup` are provided by the backport_. Trio still raises +``MultiError``, but it has been refactored into a subclass of :exc:`BaseExceptionGroup` +which users should catch instead of ``MultiError``. Uses of the ``MultiError.filter()`` +class method should be replaced with :meth:`BaseExceptionGroup.split`. Uses of the +``MultiError.catch()`` class method should be replaced with either ``except*`` clauses +(on Python 3.11+) or the ``exceptiongroup.catch()`` context manager provided by the +backport_. See the :ref:`updated documentation ` for details. From c35fccc6ed702163df40c93a21ae222c9b0e0b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 18 Sep 2022 12:55:03 +0300 Subject: [PATCH 75/93] Fixed the instead= part in the deprecation warning --- trio/_core/_multierror.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/trio/_core/_multierror.py b/trio/_core/_multierror.py index b144d5f43..28a4e1b50 100644 --- a/trio/_core/_multierror.py +++ b/trio/_core/_multierror.py @@ -237,7 +237,10 @@ def filter(cls, handler, root_exc): """ warn_deprecated( - "MultiError.filter()", "0.22.0", instead="MultiError.split()", issue=2211 + "MultiError.filter()", + "0.22.0", + instead="BaseExceptionGroup.split()", + issue=2211, ) return _filter_impl(handler, root_exc) From 7b7acb944f1f4ea617b993e3467d6e484817b1da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 18 Sep 2022 12:58:28 +0300 Subject: [PATCH 76/93] Replaced warn() with warn_deprecated() --- trio/_core/_multierror.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/trio/_core/_multierror.py b/trio/_core/_multierror.py index 28a4e1b50..4a4a39ab9 100644 --- a/trio/_core/_multierror.py +++ b/trio/_core/_multierror.py @@ -253,10 +253,11 @@ def catch(cls, handler): handler: as for :meth:`filter` """ - warnings.warn( - "MultiError.catch() has been deprecated. " - "Use except* or exceptiongroup.catch() instead.", - DeprecationWarning, + warn_deprecated( + "MultiError.catch", + "0.22.0", + instead="except* or exceptiongroup.catch()", + issue=2211, ) return MultiErrorCatcher(handler) From 1075ff038c53fdfd3baa0bba7497fb2607e64232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 18 Sep 2022 13:48:16 +0300 Subject: [PATCH 77/93] Fixed the check for StopAsyncIteration --- trio/_core/tests/test_run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trio/_core/tests/test_run.py b/trio/_core/tests/test_run.py index 79f257918..b4edc4bdb 100644 --- a/trio/_core/tests/test_run.py +++ b/trio/_core/tests/test_run.py @@ -1862,7 +1862,7 @@ async def __anext__(self): for i, f in enumerate(nexts): nursery.start_soon(self._accumulate, f, items, i) except ExceptionGroup as excgroup: - got_stop = bool(excgroup.split(StopAsyncIteration)) + got_stop = bool(excgroup.split(StopAsyncIteration)[0]) if got_stop: raise StopAsyncIteration From 458f5e2ebddc4ed3a6d1dde4fea508658432d3f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 18 Sep 2022 13:51:16 +0300 Subject: [PATCH 78/93] Fixed comment in _nested_child_finished() --- trio/_core/_run.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/trio/_core/_run.py b/trio/_core/_run.py index 10a77d56c..c369958ad 100644 --- a/trio/_core/_run.py +++ b/trio/_core/_run.py @@ -958,9 +958,8 @@ def _child_finished(self, task, outcome): self._check_nursery_closed() async def _nested_child_finished(self, nested_child_exc): - """ - Returns MultiError instance if there are pending exceptions. - """ + # Returns MultiError instance (or any exception if the nursery is in loose mode + # and there is just one contained exception) if there are pending exceptions if nested_child_exc is not None: self._add_exc(nested_child_exc) self._nested_child_running = False From 0f39b3f3551c3766ee4a16d2f07110de81e9617c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 18 Sep 2022 14:42:31 +0300 Subject: [PATCH 79/93] Fixed deprecation checking in MultiError.catch() tests --- trio/_core/tests/test_multierror.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/trio/_core/tests/test_multierror.py b/trio/_core/tests/test_multierror.py index 1eaab6d4b..503d85adc 100644 --- a/trio/_core/tests/test_multierror.py +++ b/trio/_core/tests/test_multierror.py @@ -277,13 +277,13 @@ def test_MultiError_catch(): def noop(_): pass # pragma: no cover - with pytest.deprecated_call(MultiError.catch, noop): + with pytest.warns(TrioDeprecationWarning), MultiError.catch(noop): pass # Simple pass-through of all exceptions m = make_tree() with pytest.raises(MultiError) as excinfo: - with pytest.deprecated_call(MultiError.catch, lambda exc: exc): + with pytest.warns(TrioDeprecationWarning), MultiError.catch(lambda exc: exc): raise m assert excinfo.value is m # Should be unchanged, except that we added a traceback frame by raising @@ -295,7 +295,7 @@ def noop(_): assert_tree_eq(m, make_tree()) # Swallows everything - with pytest.deprecated_call(MultiError.catch, lambda _: None): + with pytest.warns(TrioDeprecationWarning), MultiError.catch(lambda _: None): raise make_tree() def simple_filter(exc): @@ -306,7 +306,7 @@ def simple_filter(exc): return exc with pytest.raises(MultiError) as excinfo: - with pytest.deprecated_call(MultiError.catch, simple_filter): + with pytest.warns(TrioDeprecationWarning), MultiError.catch(simple_filter): raise make_tree() new_m = excinfo.value assert isinstance(new_m, MultiError) @@ -324,7 +324,7 @@ def simple_filter(exc): v = ValueError() v.__cause__ = KeyError() with pytest.raises(ValueError) as excinfo: - with pytest.deprecated_call(MultiError.catch, lambda exc: exc): + with pytest.warns(TrioDeprecationWarning), MultiError.catch(lambda exc: exc): raise v assert isinstance(excinfo.value.__cause__, KeyError) @@ -332,7 +332,7 @@ def simple_filter(exc): context = KeyError() v.__context__ = context with pytest.raises(ValueError) as excinfo: - with pytest.deprecated_call(MultiError.catch, lambda exc: exc): + with pytest.warns(TrioDeprecationWarning), MultiError.catch(lambda exc: exc): raise v assert excinfo.value.__context__ is context assert not excinfo.value.__suppress_context__ @@ -351,8 +351,9 @@ def catch_RuntimeError(exc): else: return exc - with pytest.deprecated_call(MultiError.catch, catch_RuntimeError): - raise MultiError([v, distractor]) + with pytest.warns(TrioDeprecationWarning): + with MultiError.catch(catch_RuntimeError): + raise MultiError([v, distractor]) assert excinfo.value.__context__ is context assert excinfo.value.__suppress_context__ == suppress_context @@ -380,7 +381,7 @@ def simple_filter(exc): gc.set_debug(gc.DEBUG_SAVEALL) with pytest.raises(MultiError): # covers MultiErrorCatcher.__exit__ and _multierror.copy_tb - with pytest.deprecated_call(MultiError.catch, simple_filter): + with pytest.warns(TrioDeprecationWarning), MultiError.catch(simple_filter): raise make_multi() gc.collect() assert not gc.garbage From dc35213466b22c2745bde3c69b5978e15046d350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 18 Sep 2022 14:30:24 +0300 Subject: [PATCH 80/93] Reversed the order of tracebacks when concatenating --- trio/_core/_run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trio/_core/_run.py b/trio/_core/_run.py index c369958ad..247a124ca 100644 --- a/trio/_core/_run.py +++ b/trio/_core/_run.py @@ -135,7 +135,7 @@ def collapse_exception_group(excgroup): if len(exceptions) == 1: exceptions[0].__traceback__ = concat_tb( - exceptions[0].__traceback__, excgroup.__traceback__ + excgroup.__traceback__, exceptions[0].__traceback__ ) return exceptions[0] elif modified: From f9eff8e84b989df3f743e09ead2034ea5148fdc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 18 Sep 2022 23:44:08 +0300 Subject: [PATCH 81/93] Restored the IPython custom error handler Now it works with BaseExceptionGroup too. --- docs-requirements.in | 2 +- docs-requirements.txt | 12 +- setup.py | 2 +- test-requirements.in | 2 +- test-requirements.txt | 22 +++- trio/_core/_multierror.py | 28 ++++- trio/_core/tests/test_multierror.py | 108 ++++++++++++++++++ .../tests/test_multierror_scripts/__init__.py | 2 + .../tests/test_multierror_scripts/_common.py | 7 ++ .../ipython_custom_exc.py | 36 ++++++ .../simple_excepthook.py | 21 ++++ .../simple_excepthook_IPython.py | 7 ++ 12 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 trio/_core/tests/test_multierror_scripts/__init__.py create mode 100644 trio/_core/tests/test_multierror_scripts/_common.py create mode 100644 trio/_core/tests/test_multierror_scripts/ipython_custom_exc.py create mode 100644 trio/_core/tests/test_multierror_scripts/simple_excepthook.py create mode 100644 trio/_core/tests/test_multierror_scripts/simple_excepthook_IPython.py diff --git a/docs-requirements.in b/docs-requirements.in index 305aed888..7d6abc003 100644 --- a/docs-requirements.in +++ b/docs-requirements.in @@ -16,7 +16,7 @@ async_generator >= 1.9 idna outcome sniffio -exceptiongroup +exceptiongroup >= 1.0.0rc9 # See note in test-requirements.in immutables >= 0.6 diff --git a/docs-requirements.txt b/docs-requirements.txt index d9cbeb5e0..5e022f322 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with python 3.10 +# This file is autogenerated by pip-compile with python 3.7 # To update, run: # # pip-compile docs-requirements.in @@ -28,7 +28,7 @@ docutils==0.17.1 # via # sphinx # sphinx-rtd-theme -exceptiongroup==1.0.0rc8 +exceptiongroup==1.0.0rc9 # via -r docs-requirements.in idna==3.4 # via @@ -38,6 +38,8 @@ imagesize==1.4.1 # via sphinx immutables==0.18 # via -r docs-requirements.in +importlib-metadata==4.12.0 + # via click incremental==21.3.0 # via towncrier jinja2==3.0.3 @@ -90,8 +92,14 @@ tomli==2.0.1 # via towncrier towncrier==22.8.0 # via -r docs-requirements.in +typing-extensions==4.3.0 + # via + # immutables + # importlib-metadata urllib3==1.26.12 # via requests +zipp==3.8.1 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/setup.py b/setup.py index d2f09d505..00ba27076 100644 --- a/setup.py +++ b/setup.py @@ -89,7 +89,7 @@ # cffi 1.14 fixes memory leak inside ffi.getwinerror() # cffi is required on Windows, except on PyPy where it is built-in "cffi>=1.14; os_name == 'nt' and implementation_name != 'pypy'", - "exceptiongroup; python_version < '3.11'", + "exceptiongroup >= 1.0.0rc9; python_version < '3.11'", ], # This means, just install *everything* you see under trio/, even if it # doesn't look like a source file, so long as it appears in MANIFEST.in: diff --git a/test-requirements.in b/test-requirements.in index ae04d7bc7..cb9db8f89 100644 --- a/test-requirements.in +++ b/test-requirements.in @@ -30,5 +30,5 @@ async_generator >= 1.9 idna outcome sniffio -exceptiongroup; python_version < "3.11" +exceptiongroup >= 1.0.0rc9; python_version < "3.11" diff --git a/test-requirements.txt b/test-requirements.txt index 34768d31d..88123b0d6 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with python 3.10 +# This file is autogenerated by pip-compile with python 3.7 # To update, run: # # pip-compile test-requirements.in @@ -34,7 +34,7 @@ decorator==5.1.1 # via ipython dill==0.3.5.1 # via pylint -exceptiongroup==1.0.0rc8 ; python_version < "3.11" +exceptiongroup==1.0.0rc9 ; python_version < "3.11" # via -r test-requirements.in flake8==4.0.1 # via -r test-requirements.in @@ -42,6 +42,12 @@ idna==3.4 # via # -r test-requirements.in # trustme +importlib-metadata==4.2.0 + # via + # click + # flake8 + # pluggy + # pytest iniconfig==1.1.1 # via pytest ipython==7.31.1 @@ -130,6 +136,12 @@ traitlets==5.4.0 # matplotlib-inline trustme==0.9.0 # via -r test-requirements.in +typed-ast==1.5.4 ; implementation_name == "cpython" and python_version < "3.8" + # via + # -r test-requirements.in + # astroid + # black + # mypy types-cryptography==3.3.22 # via types-pyopenssl types-pyopenssl==22.0.9 ; implementation_name == "cpython" @@ -137,11 +149,17 @@ types-pyopenssl==22.0.9 ; implementation_name == "cpython" typing-extensions==4.3.0 ; implementation_name == "cpython" # via # -r test-requirements.in + # astroid + # black + # importlib-metadata # mypy + # pylint wcwidth==0.2.5 # via prompt-toolkit wrapt==1.14.1 # via astroid +zipp==3.8.1 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/trio/_core/_multierror.py b/trio/_core/_multierror.py index 4a4a39ab9..ddeb76878 100644 --- a/trio/_core/_multierror.py +++ b/trio/_core/_multierror.py @@ -7,7 +7,9 @@ from trio._deprecate import warn_deprecated if sys.version_info < (3, 11): - from exceptiongroup import BaseExceptionGroup, ExceptionGroup + from exceptiongroup import BaseExceptionGroup, ExceptionGroup, print_exception +else: + from traceback import print_exception ################################################################ # MultiError @@ -387,3 +389,27 @@ def concat_tb(head, tail): for head_tb in reversed(head_tbs): current_head = copy_tb(head_tb, tb_next=current_head) return current_head + + +# Remove when IPython gains support for exception groups +if "IPython" in sys.modules: + import IPython + + ip = IPython.get_ipython() + if ip is not None: + if ip.custom_exceptions != (): + warnings.warn( + "IPython detected, but you already have a custom exception " + "handler installed. I'll skip installing Trio's custom " + "handler, but this means exception groups will not show full " + "tracebacks.", + category=RuntimeWarning, + ) + else: + + def trio_show_traceback(self, etype, value, tb, tb_offset=None): + # XX it would be better to integrate with IPython's fancy + # exception formatting stuff (and not ignore tb_offset) + print_exception(value) + + ip.set_custom_exc((BaseExceptionGroup,), trio_show_traceback) diff --git a/trio/_core/tests/test_multierror.py b/trio/_core/tests/test_multierror.py index 503d85adc..12f860610 100644 --- a/trio/_core/tests/test_multierror.py +++ b/trio/_core/tests/test_multierror.py @@ -1,5 +1,9 @@ import gc import logging +import os +import subprocess +from pathlib import Path + import pytest from traceback import ( @@ -11,6 +15,7 @@ import sys import re +from .tutil import slow from .._multierror import MultiError, concat_tb, NonBaseMultiError from ... import TrioDeprecationWarning from ..._core import open_nursery @@ -427,3 +432,106 @@ def test_non_base_multierror(): exc = MultiError([ZeroDivisionError(), ValueError()]) assert type(exc) is NonBaseMultiError assert isinstance(exc, ExceptionGroup) + + +def run_script(name, use_ipython=False): + import trio + + trio_path = Path(trio.__file__).parent.parent + script_path = Path(__file__).parent / "test_multierror_scripts" / name + + env = dict(os.environ) + print("parent PYTHONPATH:", env.get("PYTHONPATH")) + if "PYTHONPATH" in env: # pragma: no cover + pp = env["PYTHONPATH"].split(os.pathsep) + else: + pp = [] + pp.insert(0, str(trio_path)) + pp.insert(0, str(script_path.parent)) + env["PYTHONPATH"] = os.pathsep.join(pp) + print("subprocess PYTHONPATH:", env.get("PYTHONPATH")) + + if use_ipython: + lines = [script_path.read_text(), "exit()"] + + cmd = [ + sys.executable, + "-u", + "-m", + "IPython", + # no startup files + "--quick", + "--TerminalIPythonApp.code_to_run=" + "\n".join(lines), + ] + else: + cmd = [sys.executable, "-u", str(script_path)] + print("running:", cmd) + completed = subprocess.run( + cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT + ) + print("process output:") + print(completed.stdout.decode("utf-8")) + return completed + + +def check_simple_excepthook(completed): + assert_match_in_seq( + [ + "in ", + "MultiError", + "--- 1 ---", + "in exc1_fn", + "ValueError", + "--- 2 ---", + "in exc2_fn", + "KeyError", + ], + completed.stdout.decode("utf-8"), + ) + + +try: + import IPython +except ImportError: # pragma: no cover + have_ipython = False +else: + have_ipython = True + +need_ipython = pytest.mark.skipif(not have_ipython, reason="need IPython") + + +@slow +@need_ipython +def test_ipython_exc_handler(): + completed = run_script("simple_excepthook.py", use_ipython=True) + check_simple_excepthook(completed) + + +@slow +@need_ipython +def test_ipython_imported_but_unused(): + completed = run_script("simple_excepthook_IPython.py") + check_simple_excepthook(completed) + + +@slow +@need_ipython +def test_ipython_custom_exc_handler(): + # Check we get a nice warning (but only one!) if the user is using IPython + # and already has some other set_custom_exc handler installed. + completed = run_script("ipython_custom_exc.py", use_ipython=True) + assert_match_in_seq( + [ + # The warning + "RuntimeWarning", + "IPython detected", + "skip installing Trio", + # The MultiError + "MultiError", + "ValueError", + "KeyError", + ], + completed.stdout.decode("utf-8"), + ) + # Make sure our other warning doesn't show up + assert "custom sys.excepthook" not in completed.stdout.decode("utf-8") diff --git a/trio/_core/tests/test_multierror_scripts/__init__.py b/trio/_core/tests/test_multierror_scripts/__init__.py new file mode 100644 index 000000000..a1f6cb598 --- /dev/null +++ b/trio/_core/tests/test_multierror_scripts/__init__.py @@ -0,0 +1,2 @@ +# This isn't really a package, everything in here is a standalone script. This +# __init__.py is just to fool setup.py into actually installing the things. diff --git a/trio/_core/tests/test_multierror_scripts/_common.py b/trio/_core/tests/test_multierror_scripts/_common.py new file mode 100644 index 000000000..0c70df184 --- /dev/null +++ b/trio/_core/tests/test_multierror_scripts/_common.py @@ -0,0 +1,7 @@ +# https://coverage.readthedocs.io/en/latest/subprocess.html +try: + import coverage +except ImportError: # pragma: no cover + pass +else: + coverage.process_startup() diff --git a/trio/_core/tests/test_multierror_scripts/ipython_custom_exc.py b/trio/_core/tests/test_multierror_scripts/ipython_custom_exc.py new file mode 100644 index 000000000..b3fd110e5 --- /dev/null +++ b/trio/_core/tests/test_multierror_scripts/ipython_custom_exc.py @@ -0,0 +1,36 @@ +import _common + +# Override the regular excepthook too -- it doesn't change anything either way +# because ipython doesn't use it, but we want to make sure Trio doesn't warn +# about it. +import sys + + +def custom_excepthook(*args): + print("custom running!") + return sys.__excepthook__(*args) + + +sys.excepthook = custom_excepthook + +import IPython + +ip = IPython.get_ipython() + + +# Set this to some random nonsense +class SomeError(Exception): + pass + + +def custom_exc_hook(etype, value, tb, tb_offset=None): + ip.showtraceback() + + +ip.set_custom_exc((SomeError,), custom_exc_hook) + +import trio + +# The custom excepthook should run, because Trio was polite and didn't +# override it +raise trio.MultiError([ValueError(), KeyError()]) diff --git a/trio/_core/tests/test_multierror_scripts/simple_excepthook.py b/trio/_core/tests/test_multierror_scripts/simple_excepthook.py new file mode 100644 index 000000000..94004525d --- /dev/null +++ b/trio/_core/tests/test_multierror_scripts/simple_excepthook.py @@ -0,0 +1,21 @@ +import _common + +import trio + + +def exc1_fn(): + try: + raise ValueError + except Exception as exc: + return exc + + +def exc2_fn(): + try: + raise KeyError + except Exception as exc: + return exc + + +# This should be printed nicely, because Trio overrode sys.excepthook +raise trio.MultiError([exc1_fn(), exc2_fn()]) diff --git a/trio/_core/tests/test_multierror_scripts/simple_excepthook_IPython.py b/trio/_core/tests/test_multierror_scripts/simple_excepthook_IPython.py new file mode 100644 index 000000000..6aa12493b --- /dev/null +++ b/trio/_core/tests/test_multierror_scripts/simple_excepthook_IPython.py @@ -0,0 +1,7 @@ +import _common + +# To tickle the "is IPython loaded?" logic, make sure that Trio tolerates +# IPython loaded but not actually in use +import IPython + +import simple_excepthook From 30b8f3b126050c2a7f71a6bac35472b809539170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Wed, 21 Sep 2022 02:22:50 +0300 Subject: [PATCH 82/93] Added link to specific IPython issue --- trio/_core/_multierror.py | 1 + 1 file changed, 1 insertion(+) diff --git a/trio/_core/_multierror.py b/trio/_core/_multierror.py index ddeb76878..8a326cb16 100644 --- a/trio/_core/_multierror.py +++ b/trio/_core/_multierror.py @@ -392,6 +392,7 @@ def concat_tb(head, tail): # Remove when IPython gains support for exception groups +# (https://github.com/ipython/ipython/issues/13753) if "IPython" in sys.modules: import IPython From 232688b45d77fa81d52e562ce65892f57a9ba904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Wed, 21 Sep 2022 02:25:56 +0300 Subject: [PATCH 83/93] Updated wording about extracting submodules to independent packages --- docs/source/design.rst | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/source/design.rst b/docs/source/design.rst index 25647fe64..c3a47ab30 100644 --- a/docs/source/design.rst +++ b/docs/source/design.rst @@ -461,12 +461,9 @@ of our public APIs without having to modify Trio internals. Inside ``trio._core`` ~~~~~~~~~~~~~~~~~~~~~ -There are two notable sub-modules that are largely independent of -the rest of Trio, and could (possibly should?) be extracted into their -own independent packages: - -* ``_ki.py``: Implements the core infrastructure for safe handling of - :class:`KeyboardInterrupt`. +The ``_ki.py`` module implements the core infrastructure for safe handling +of :class:`KeyboardInterrupt`. It's largely independent of the rest of Trio, +and could (possibly should?) be extracted into its own independent package. The most important submodule, where everything is integrated, is ``_run.py``. (This is also by far the largest submodule; it'd be nice From 0b8e908334a3367e2c299a9dca8e4b9df555da1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 25 Sep 2022 22:19:52 +0300 Subject: [PATCH 84/93] If present, patch Apport to support exception groups --- trio/_core/_multierror.py | 32 +++++++++++++++++++ trio/_core/tests/test_multierror.py | 19 +++++++++++ .../apport_excepthook.py | 13 ++++++++ 3 files changed, 64 insertions(+) create mode 100644 trio/_core/tests/test_multierror_scripts/apport_excepthook.py diff --git a/trio/_core/_multierror.py b/trio/_core/_multierror.py index 8a326cb16..ee9cbda41 100644 --- a/trio/_core/_multierror.py +++ b/trio/_core/_multierror.py @@ -414,3 +414,35 @@ def trio_show_traceback(self, etype, value, tb, tb_offset=None): print_exception(value) ip.set_custom_exc((BaseExceptionGroup,), trio_show_traceback) + + +# Ubuntu's system Python has a sitecustomize.py file that import +# apport_python_hook and replaces sys.excepthook. +# +# The custom hook captures the error for crash reporting, and then calls +# sys.__excepthook__ to actually print the error. +# +# We don't mind it capturing the error for crash reporting, but we want to +# take over printing the error. So we monkeypatch the apport_python_hook +# module so that instead of calling sys.__excepthook__, it calls our custom +# hook. +# +# More details: https://github.com/python-trio/trio/issues/1065 +if ( + sys.version_info < (3, 11) + and getattr(sys.excepthook, "__name__", None) == "apport_excepthook" +): + from types import ModuleType + + import apport_python_hook + from exceptiongroup import format_exception + + assert sys.excepthook is apport_python_hook.apport_excepthook + + def replacement_webhook(etype, value, tb): + sys.stderr.write("".join(format_exception(etype, value, tb))) + + fake_sys = ModuleType("trio_fake_sys") + fake_sys.__dict__.update(sys.__dict__) + fake_sys.__excepthook__ = replacement_webhook # type: ignore + apport_python_hook.sys = fake_sys diff --git a/trio/_core/tests/test_multierror.py b/trio/_core/tests/test_multierror.py index 12f860610..33e5afe92 100644 --- a/trio/_core/tests/test_multierror.py +++ b/trio/_core/tests/test_multierror.py @@ -535,3 +535,22 @@ def test_ipython_custom_exc_handler(): ) # Make sure our other warning doesn't show up assert "custom sys.excepthook" not in completed.stdout.decode("utf-8") + + +@slow +@pytest.mark.skipif( + not Path("/usr/lib/python3/dist-packages/apport_python_hook.py").exists(), + reason="need Ubuntu with python3-apport installed", +) +def test_apport_excepthook_monkeypatch_interaction(): + completed = run_script("apport_excepthook.py") + stdout = completed.stdout.decode("utf-8") + + # No warning + assert "custom sys.excepthook" not in stdout + + # Proper traceback + assert_match_in_seq( + ["Details of embedded", "KeyError", "Details of embedded", "ValueError"], + stdout, + ) diff --git a/trio/_core/tests/test_multierror_scripts/apport_excepthook.py b/trio/_core/tests/test_multierror_scripts/apport_excepthook.py new file mode 100644 index 000000000..12e7fb085 --- /dev/null +++ b/trio/_core/tests/test_multierror_scripts/apport_excepthook.py @@ -0,0 +1,13 @@ +# The apport_python_hook package is only installed as part of Ubuntu's system +# python, and not available in venvs. So before we can import it we have to +# make sure it's on sys.path. +import sys + +sys.path.append("/usr/lib/python3/dist-packages") +import apport_python_hook + +apport_python_hook.install() + +import trio + +raise trio.MultiError([KeyError("key_error"), ValueError("value_error")]) From 878af56191b813705558f690d37d43f541c47dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 25 Sep 2022 23:44:50 +0300 Subject: [PATCH 85/93] Added test coverage for collapse_exception_group() --- trio/_core/tests/test_run.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/trio/_core/tests/test_run.py b/trio/_core/tests/test_run.py index b4edc4bdb..bb3e8cc00 100644 --- a/trio/_core/tests/test_run.py +++ b/trio/_core/tests/test_run.py @@ -2371,3 +2371,17 @@ async def test_nursery_strict_exception_groups(): assert len(exc.value.exceptions) == 1 assert type(exc.value.exceptions[0]) is Exception assert exc.value.exceptions[0].args == ("foo",) + + +async def test_nursery_collapse(): + async def raise_error(): + raise RuntimeError("test error") + + with pytest.raises(MultiError) as exc: + async with _core.open_nursery() as nursery: + nursery.start_soon(raise_error) + async with _core.open_nursery(strict_exception_groups=True) as nursery2: + nursery2.start_soon(raise_error) + + assert len(exc.value.exceptions) == 2 + assert all(isinstance(e, RuntimeError) for e in exc.value.exceptions) From cc7d76a41b5cfa56624b4211ef75e55f614d688c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 26 Sep 2022 02:21:49 +0300 Subject: [PATCH 86/93] Changed cancel scopes to not collapse exceptions by default Instead, we mark collapsible MultiErrors and collapse only those when necessary. --- trio/_core/_multierror.py | 5 ++++- trio/_core/_run.py | 10 +++++----- trio/_core/tests/test_run.py | 18 +++++++++++++++--- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/trio/_core/_multierror.py b/trio/_core/_multierror.py index ee9cbda41..e47701b3a 100644 --- a/trio/_core/_multierror.py +++ b/trio/_core/_multierror.py @@ -187,6 +187,7 @@ def __init__(self, exceptions, *, _collapse=True): # This exception was already initialized. return + self.collapse = _collapse super().__init__("multiple tasks failed", exceptions) def __new__(cls, exceptions, *, _collapse=True): @@ -220,7 +221,9 @@ def __repr__(self): def derive(self, __excs): # We use _collapse=False here to get ExceptionGroup semantics, since derive() # is part of the PEP 654 API - return MultiError(__excs, _collapse=False) + exc = MultiError(__excs, _collapse=False) + exc.collapse = self.collapse + return exc @classmethod def filter(cls, handler, root_exc): diff --git a/trio/_core/_run.py b/trio/_core/_run.py index 247a124ca..78ca27382 100644 --- a/trio/_core/_run.py +++ b/trio/_core/_run.py @@ -127,13 +127,13 @@ def collapse_exception_group(excgroup): exceptions = list(excgroup.exceptions) modified = False for i, exc in enumerate(exceptions): - if isinstance(exc, MultiError): + if isinstance(exc, BaseExceptionGroup): new_exc = collapse_exception_group(exc) if new_exc is not exc: modified = True exceptions[i] = new_exc - if len(exceptions) == 1: + if len(exceptions) == 1 and isinstance(excgroup, MultiError) and excgroup.collapse: exceptions[0].__traceback__ = concat_tb( excgroup.__traceback__, exceptions[0].__traceback__ ) @@ -475,7 +475,7 @@ def __enter__(self): task._activate_cancel_status(self._cancel_status) return self - def _close(self, exc, collapse=True): + def _close(self, exc, collapse=False): if self._cancel_status is None: new_exc = RuntimeError( "Cancel scope stack corrupted: attempted to exit {!r} " @@ -540,8 +540,8 @@ def _close(self, exc, collapse=True): if matched: self.cancelled_caught = True - if collapse and isinstance(exc, MultiError): - exc = collapse_exception_group(exc) + if exc: + exc = collapse_exception_group(exc) self._cancel_status.close() with self._might_change_registered_deadline(): diff --git a/trio/_core/tests/test_run.py b/trio/_core/tests/test_run.py index bb3e8cc00..7762c7963 100644 --- a/trio/_core/tests/test_run.py +++ b/trio/_core/tests/test_run.py @@ -2374,14 +2374,26 @@ async def test_nursery_strict_exception_groups(): async def test_nursery_collapse(): + """ + Test that a single exception from a nested nursery with strict semantics doesn't get + collapsed. + """ + async def raise_error(): raise RuntimeError("test error") with pytest.raises(MultiError) as exc: async with _core.open_nursery() as nursery: + nursery.start_soon(sleep_forever) nursery.start_soon(raise_error) async with _core.open_nursery(strict_exception_groups=True) as nursery2: + nursery2.start_soon(sleep_forever) nursery2.start_soon(raise_error) - - assert len(exc.value.exceptions) == 2 - assert all(isinstance(e, RuntimeError) for e in exc.value.exceptions) + nursery.cancel_scope.cancel() + + exceptions = exc.value.exceptions + assert len(exceptions) == 2 + assert isinstance(exceptions[0], RuntimeError) + assert isinstance(exceptions[1], MultiError) + assert len(exceptions[1].exceptions) == 1 + assert isinstance(exceptions[1].exceptions[0], RuntimeError) From a20e2d8466781c2c645c761df089a585ec2da9b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 26 Sep 2022 09:57:28 +0300 Subject: [PATCH 87/93] Extended test coverage --- trio/_core/tests/test_run.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/trio/_core/tests/test_run.py b/trio/_core/tests/test_run.py index 7762c7963..6aef1552e 100644 --- a/trio/_core/tests/test_run.py +++ b/trio/_core/tests/test_run.py @@ -2373,10 +2373,10 @@ async def test_nursery_strict_exception_groups(): assert exc.value.exceptions[0].args == ("foo",) -async def test_nursery_collapse(): +async def test_nursery_collapse_strict(): """ Test that a single exception from a nested nursery with strict semantics doesn't get - collapsed. + collapsed when CancelledErrors are stripped from it. """ async def raise_error(): @@ -2397,3 +2397,27 @@ async def raise_error(): assert isinstance(exceptions[1], MultiError) assert len(exceptions[1].exceptions) == 1 assert isinstance(exceptions[1].exceptions[0], RuntimeError) + + +async def test_nursery_collapse_loose(): + """ + Test that a single exception from a nested nursery with loose semantics gets + collapsed when CancelledErrors are stripped from it. + """ + + async def raise_error(): + raise RuntimeError("test error") + + with pytest.raises(MultiError) as exc: + async with _core.open_nursery() as nursery: + nursery.start_soon(sleep_forever) + nursery.start_soon(raise_error) + async with _core.open_nursery() as nursery2: + nursery2.start_soon(sleep_forever) + nursery2.start_soon(raise_error) + nursery.cancel_scope.cancel() + + exceptions = exc.value.exceptions + assert len(exceptions) == 2 + assert isinstance(exceptions[0], RuntimeError) + assert isinstance(exceptions[1], RuntimeError) From 1aba33e46b98ee7f524186ab1a0e7ab6efd9cd77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 26 Sep 2022 10:00:45 +0300 Subject: [PATCH 88/93] Fixed the apport script test --- trio/_core/tests/test_multierror.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trio/_core/tests/test_multierror.py b/trio/_core/tests/test_multierror.py index 33e5afe92..edebf0dd5 100644 --- a/trio/_core/tests/test_multierror.py +++ b/trio/_core/tests/test_multierror.py @@ -551,6 +551,6 @@ def test_apport_excepthook_monkeypatch_interaction(): # Proper traceback assert_match_in_seq( - ["Details of embedded", "KeyError", "Details of embedded", "ValueError"], + ["--- 1 ---", "KeyError", "--- 2 ---", "ValueError"], stdout, ) From 0c283b7310d7df6f6bdaa60e2a498031eb3e44b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 26 Sep 2022 11:40:13 +0300 Subject: [PATCH 89/93] Fixed MultiError.collapse attribute missing on Python 3.11 --- trio/_core/_multierror.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/trio/_core/_multierror.py b/trio/_core/_multierror.py index e47701b3a..ef4c1592a 100644 --- a/trio/_core/_multierror.py +++ b/trio/_core/_multierror.py @@ -181,13 +181,14 @@ class MultiError(BaseExceptionGroup): """ def __init__(self, exceptions, *, _collapse=True): + self.collapse = _collapse + # Avoid double initialization when _collapse is True and exceptions[0] returned # by __new__() happens to be a MultiError and subsequently __init__() is called. if _collapse and getattr(self, "exceptions", None) is not None: # This exception was already initialized. return - self.collapse = _collapse super().__init__("multiple tasks failed", exceptions) def __new__(cls, exceptions, *, _collapse=True): From 0364b4860fb313384940cb4906919977d48fe40b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 26 Sep 2022 22:47:36 +0300 Subject: [PATCH 90/93] Added test coverage for cancel scope encountering an excgroup w/o Cancelled --- trio/_core/tests/test_run.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/trio/_core/tests/test_run.py b/trio/_core/tests/test_run.py index 6aef1552e..7b9af969d 100644 --- a/trio/_core/tests/test_run.py +++ b/trio/_core/tests/test_run.py @@ -2421,3 +2421,17 @@ async def raise_error(): assert len(exceptions) == 2 assert isinstance(exceptions[0], RuntimeError) assert isinstance(exceptions[1], RuntimeError) + + +async def test_cancel_scope_no_cancellederror(): + """ + Test that when a cancel scope encounters an exception group that does NOT contain + a Cancelled exception, it will NOT set the ``cancelled_caught`` flag. + """ + + with pytest.raises(ExceptionGroup): + with _core.CancelScope() as scope: + scope.cancel() + raise ExceptionGroup("test", [RuntimeError(), RuntimeError()]) + + assert not scope.cancelled_caught From bb31ed61434a007bc4d52ab04bae2e80cdcf5e1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 26 Sep 2022 22:56:55 +0300 Subject: [PATCH 91/93] Removed an except block that was never entered --- trio/_core/tests/test_run.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/trio/_core/tests/test_run.py b/trio/_core/tests/test_run.py index 7b9af969d..262ad6054 100644 --- a/trio/_core/tests/test_run.py +++ b/trio/_core/tests/test_run.py @@ -1855,17 +1855,11 @@ def __aiter__(self): async def __anext__(self): nexts = self.nexts items = [None] * len(nexts) - got_stop = False - try: - async with _core.open_nursery() as nursery: - for i, f in enumerate(nexts): - nursery.start_soon(self._accumulate, f, items, i) - except ExceptionGroup as excgroup: - got_stop = bool(excgroup.split(StopAsyncIteration)[0]) + async with _core.open_nursery() as nursery: + for i, f in enumerate(nexts): + nursery.start_soon(self._accumulate, f, items, i) - if got_stop: - raise StopAsyncIteration return items result = [] From 2fd8309d235aacb8b264490437cb968a8e577016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 27 Sep 2022 10:29:25 +0300 Subject: [PATCH 92/93] Fixed excepthook wrapper name --- trio/_core/_multierror.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/trio/_core/_multierror.py b/trio/_core/_multierror.py index ef4c1592a..c95f05b4c 100644 --- a/trio/_core/_multierror.py +++ b/trio/_core/_multierror.py @@ -443,10 +443,10 @@ def trio_show_traceback(self, etype, value, tb, tb_offset=None): assert sys.excepthook is apport_python_hook.apport_excepthook - def replacement_webhook(etype, value, tb): + def replacement_excepthook(etype, value, tb): sys.stderr.write("".join(format_exception(etype, value, tb))) fake_sys = ModuleType("trio_fake_sys") fake_sys.__dict__.update(sys.__dict__) - fake_sys.__excepthook__ = replacement_webhook # type: ignore + fake_sys.__excepthook__ = replacement_excepthook # type: ignore apport_python_hook.sys = fake_sys From 80889b2818fb8637e3d478580f31d182746c42bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 27 Sep 2022 10:35:13 +0300 Subject: [PATCH 93/93] Removed unused parameter --- trio/_core/_run.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/trio/_core/_run.py b/trio/_core/_run.py index 78ca27382..7e57cfa44 100644 --- a/trio/_core/_run.py +++ b/trio/_core/_run.py @@ -475,7 +475,7 @@ def __enter__(self): task._activate_cancel_status(self._cancel_status) return self - def _close(self, exc, collapse=False): + def _close(self, exc): if self._cancel_status is None: new_exc = RuntimeError( "Cancel scope stack corrupted: attempted to exit {!r} " @@ -837,9 +837,7 @@ async def __aexit__(self, etype, exc, tb): new_exc = await self._nursery._nested_child_finished(exc) # Tracebacks show the 'raise' line below out of context, so let's give # this variable a name that makes sense out of context. - combined_error_from_nursery = self._scope._close( - new_exc, collapse=not self.strict_exception_groups - ) + combined_error_from_nursery = self._scope._close(new_exc) if combined_error_from_nursery is None: return True elif combined_error_from_nursery is exc: