From f65b362573b41e7fc958d13c979a29ef133c1f55 Mon Sep 17 00:00:00 2001 From: Shardul D Date: Thu, 25 Jun 2026 10:21:37 +0530 Subject: [PATCH] gh-102201: Preserve exception __context__ chain in ExitStack/AsyncExitStack --- Lib/contextlib.py | 37 +++++- Lib/test/test_contextlib.py | 105 +++++++++++++++++- Lib/test/test_contextlib_async.py | 26 +++++ ...-06-25-10-00-00.gh-issue-102201.3BwRV1.rst | 5 + 4 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-25-10-00-00.gh-issue-102201.3BwRV1.rst diff --git a/Lib/contextlib.py b/Lib/contextlib.py index efc02bfa9243da..03960ddf0043d0 100644 --- a/Lib/contextlib.py +++ b/Lib/contextlib.py @@ -660,7 +660,23 @@ def _fix_exception_context(new_exc, old_exc): exc_details = None, None, None else: exc_details = type(exc), exc, exc.__traceback__ - if cb(*exc_details): + if frame_exc is None and exc is not None: + # gh-102201: No exception is being handled, so make the + # most recent one active (without disturbing its own + # __context__) while the callback runs, so the interpreter + # chains any exception the callback raises onto it, exactly + # as equivalent nested `with` statements would. + saved_tb = exc.__traceback__ + try: + raise exc + except BaseException: + try: + cb_suppress = cb(*exc_details) + finally: + exc.__traceback__ = saved_tb + else: + cb_suppress = cb(*exc_details) + if cb_suppress: suppressed_exc = True pending_raise = False exc = None @@ -790,7 +806,24 @@ def _fix_exception_context(new_exc, old_exc): exc_details = None, None, None else: exc_details = type(exc), exc, exc.__traceback__ - if is_sync: + if frame_exc is None and exc is not None: + # gh-102201: No exception is being handled, so make the + # most recent one active (without disturbing its own + # __context__) while the callback runs, so the interpreter + # chains any exception the callback raises onto it, exactly + # as equivalent nested `with` statements would. + saved_tb = exc.__traceback__ + try: + raise exc + except BaseException: + try: + if is_sync: + cb_suppress = cb(*exc_details) + else: + cb_suppress = await cb(*exc_details) + finally: + exc.__traceback__ = saved_tb + elif is_sync: cb_suppress = cb(*exc_details) else: cb_suppress = await cb(*exc_details) diff --git a/Lib/test/test_contextlib.py b/Lib/test/test_contextlib.py index e291f814edbd93..e48b1caed2b00d 100644 --- a/Lib/test/test_contextlib.py +++ b/Lib/test/test_contextlib.py @@ -1204,6 +1204,109 @@ def my_cm_with_exit_stack(): else: self.fail("Expected IndexError, but no exception was raised") + def test_exit_exception_chaining_no_active_exception(self): + # gh-102201: when the body completes normally but several callbacks + # raise during unwind, the resulting __context__ chain must match the + # one produced by equivalent nested `with` statements (previously the + # earlier callback exceptions were dropped from the chain). + class RaiseExc: + def __init__(self, exc_type): + self.exc_type = exc_type + def __enter__(self): + return self + def __exit__(self, *exc_details): + raise self.exc_type() + + def context_types(exc): + types = [] + while exc is not None: + types.append(type(exc)) + exc = exc.__context__ + return types + + # Reference behaviour: three nested ``with`` statements. + try: + with RaiseExc(IndexError): + with RaiseExc(KeyError): + with RaiseExc(AttributeError): + pass + except BaseException as exc: + reference = context_types(exc) + + try: + with self.exit_stack() as stack: + stack.enter_context(RaiseExc(IndexError)) + stack.enter_context(RaiseExc(KeyError)) + stack.enter_context(RaiseExc(AttributeError)) + except BaseException as exc: + self.assertEqual(context_types(exc), + [IndexError, KeyError, AttributeError]) + self.assertEqual(context_types(exc), reference) + else: + self.fail("Expected an exception to propagate") + + def test_exit_exception_bare_raise_no_active_exception(self): + # gh-102201: a bare `raise` in a callback during a clean-body unwind + # must behave like a bare `raise` in the equivalent nested `with` + # (RuntimeError if nothing is active, else re-raise the real previous + # exception) and must never expose a private sentinel exception. + def bare_raise(): + raise + + # First callback, nothing active -> RuntimeError, as for a bare `raise` + # in an innermost __exit__ after a normal body. + try: + with self.exit_stack() as stack: + stack.callback(bare_raise) + except RuntimeError as exc: + self.assertIn("No active exception", str(exc)) + except BaseException as exc: + self.fail(f"Expected RuntimeError, got {type(exc).__name__}") + else: + self.fail("Expected RuntimeError to propagate") + + # A later callback re-raises the real previous exception. + def raise_key(): + raise KeyError + try: + with self.exit_stack() as stack: + stack.callback(bare_raise) # runs last + stack.callback(raise_key) # runs first + except KeyError: + pass + except BaseException as exc: + self.fail(f"Expected KeyError, got {type(exc).__name__}") + else: + self.fail("Expected KeyError to propagate") + + def test_exit_exception_raise_from_no_active_exception(self): + # gh-102201: `raise ... from sys.exception()` in a clean-body callback + # must reference the real previous exception (or None), like nested + # `with`, never a private sentinel. + def raise_from(): + raise ValueError from sys.exception() + def raise_key(): + raise KeyError + + # First callback: nothing active -> __cause__ is None. + try: + with self.exit_stack() as stack: + stack.callback(raise_from) + except ValueError as exc: + self.assertIsNone(exc.__cause__) + else: + self.fail("Expected ValueError to propagate") + + # Later callback: __cause__ is the real previous exception. + try: + with self.exit_stack() as stack: + stack.callback(raise_from) # runs last + stack.callback(raise_key) # runs first + except ValueError as exc: + self.assertIsInstance(exc.__cause__, KeyError) + else: + self.fail("Expected ValueError to propagate") + def test_exit_exception_non_suppressing(self): # http://bugs.python.org/issue19092 def raise_exc(exc): @@ -1362,7 +1465,7 @@ class TestExitStack(TestBaseExitStack, unittest.TestCase): exit_stack = ExitStack callback_error_internal_frames = [ ('__exit__', 'raise exc'), - ('__exit__', 'if cb(*exc_details):'), + ('__exit__', 'cb_suppress = cb(*exc_details)'), ] diff --git a/Lib/test/test_contextlib_async.py b/Lib/test/test_contextlib_async.py index 95bdfdb3d9d4a6..8f874f1865e2f6 100644 --- a/Lib/test/test_contextlib_async.py +++ b/Lib/test/test_contextlib_async.py @@ -903,6 +903,32 @@ async def suppress_exc(*exc_details): self.assertIsInstance(inner_exc, ValueError) self.assertIsInstance(inner_exc.__context__, ZeroDivisionError) + @_async_test + async def test_async_exit_exception_chaining_no_active_exception(self): + # gh-102201: AsyncExitStack must reproduce the __context__ chain of + # equivalent nested `async with` statements even when the body + # completes normally (no active exception). + async def raise_exc(exc_type): + raise exc_type() + + def context_types(exc): + types = [] + while exc is not None: + types.append(type(exc)) + exc = exc.__context__ + return types + + try: + async with self.exit_stack() as stack: + stack.push_async_callback(raise_exc, IndexError) + stack.push_async_callback(raise_exc, KeyError) + stack.push_async_callback(raise_exc, AttributeError) + except BaseException as exc: + self.assertEqual(context_types(exc), + [IndexError, KeyError, AttributeError]) + else: + self.fail("Expected an exception to propagate") + @_async_test async def test_async_exit_exception_explicit_none_context(self): # Ensure AsyncExitStack chaining matches actual nested `with` statements diff --git a/Misc/NEWS.d/next/Library/2026-06-25-10-00-00.gh-issue-102201.3BwRV1.rst b/Misc/NEWS.d/next/Library/2026-06-25-10-00-00.gh-issue-102201.3BwRV1.rst new file mode 100644 index 00000000000000..a436d73027a899 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-25-10-00-00.gh-issue-102201.3BwRV1.rst @@ -0,0 +1,5 @@ +:class:`contextlib.ExitStack` and :class:`contextlib.AsyncExitStack` now +preserve the exception ``__context__`` chain for exceptions raised by their +callbacks while unwinding after the body completed normally, matching the +behaviour of equivalent nested :keyword:`with` statements. Previously the +earlier callback exceptions were dropped from the chain.