Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions Lib/contextlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
105 changes: 104 additions & 1 deletion Lib/test/test_contextlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)'),
]


Expand Down
26 changes: 26 additions & 0 deletions Lib/test/test_contextlib_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading