Skip to content

Commit eff6c1a

Browse files
committed
Fix exception context in @contextmanagers
Previously, when using a `try...yield...except` construct within a `@contextmanager` function, an exception raised by the `yield` wouldn't be properly cleared when handled the `except` block. Instead, calling `sys.exception()` would continue to return the exception, leading to confusing tracebacks and logs. This was happening due to the way that the `@contextmanager` decorator drives its decorated generator function. When an exception occurs, it uses `.throw(exc)` to throw the exception into the generator. However, even if the generator handles this exception, because the exception was thrown into it in the context of the `@contextmanager` decorator handling it (and not being finished yet), `sys.exception()` was not being reset. In order to fix this, the exception context as the `@contextmanager` decorator is `__enter__`'d is stored and set as the current exception context just before throwing the new exception into the generator. Doing this means that after the generator handles the thrown exception, `sys.exception()` reverts back to what it was when the `@contextmanager` function was started.
1 parent 67a91f7 commit eff6c1a

3 files changed

Lines changed: 102 additions & 0 deletions

File tree

‎Lib/contextlib.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from collections import deque
77
from functools import wraps
88
from types import MethodType, GenericAlias
9+
import ctypes
910

1011
__all__ = ["asynccontextmanager", "contextmanager", "closing", "nullcontext",
1112
"AbstractContextManager", "AbstractAsyncContextManager",
@@ -14,6 +15,14 @@
1415
"chdir"]
1516

1617

18+
_PyErr_SetHandledException = ctypes.pythonapi.PyErr_SetHandledException
19+
_PyErr_SetHandledException.argtypes = [ctypes.py_object]
20+
def _set_current_exception(exc):
21+
if exc is not None and not isinstance(exc, BaseException):
22+
raise TypeError(f"{exc!r} is not an exception")
23+
_PyErr_SetHandledException(exc)
24+
25+
1726
class AbstractContextManager(abc.ABC):
1827

1928
"""An abstract base class for context managers."""
@@ -106,6 +115,7 @@ class _GeneratorContextManagerBase:
106115
"""Shared functionality for @contextmanager and @asynccontextmanager."""
107116

108117
def __init__(self, func, args, kwds):
118+
self.exc_context = None
109119
self.gen = func(*args, **kwds)
110120
self.func, self.args, self.kwds = func, args, kwds
111121
# Issue 19330: ensure context manager instances have good docstrings
@@ -134,6 +144,8 @@ class _GeneratorContextManager(
134144
"""Helper for @contextmanager decorator."""
135145

136146
def __enter__(self):
147+
# store the exception context on enter so it can be restored on exit
148+
self.exc_context = sys.exception()
137149
# do not keep args and kwds alive unnecessarily
138150
# they are only needed for recreation, which is not possible anymore
139151
del self.args, self.kwds, self.func
@@ -152,13 +164,20 @@ def __exit__(self, typ, value, traceback):
152164
try:
153165
raise RuntimeError("generator didn't stop")
154166
finally:
167+
del self.exc_context
155168
self.gen.close()
156169
else:
157170
if value is None:
158171
# Need to force instantiation so we can reliably
159172
# tell if we get the same exception back
160173
value = typ()
161174
try:
175+
# If the generator handles the exception thrown into it, the
176+
# exception context will revert to the actual current exception
177+
# context here. In order to make the context manager behave
178+
# like a normal function we set the current exception context
179+
# to what it was during the context manager's __enter__
180+
_set_current_exception(self.exc_context)
162181
self.gen.throw(value)
163182
except StopIteration as exc:
164183
# Suppress StopIteration *unless* it's the same exception that
@@ -197,6 +216,7 @@ def __exit__(self, typ, value, traceback):
197216
try:
198217
raise RuntimeError("generator didn't stop after throw()")
199218
finally:
219+
del self.exc_context
200220
self.gen.close()
201221

202222
class _AsyncGeneratorContextManager(

‎Lib/test/test_contextlib.py‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,86 @@ def woohoo():
306306
with woohoo():
307307
raise StopIteration
308308

309+
def test_contextmanager_handling_exception_resets_exc_info(self):
310+
# Test that sys.exc_info() is correctly unset after handling the error
311+
# when used within a context manager
312+
313+
@contextmanager
314+
def ctx(reraise=False):
315+
try:
316+
self.assertIsNone(sys.exception())
317+
yield
318+
except:
319+
self.assertIsInstance(sys.exception(), ZeroDivisionError)
320+
if reraise:
321+
raise
322+
else:
323+
self.assertIsNone(sys.exception())
324+
self.assertIsNone(sys.exception())
325+
326+
with ctx():
327+
pass
328+
329+
with ctx():
330+
1/0
331+
332+
with self.assertRaises(ZeroDivisionError):
333+
with ctx(reraise=True):
334+
1/0
335+
336+
def test_contextmanager_while_handling(self):
337+
# test that any exceptions currently being handled are preserved
338+
# through the context manager
339+
340+
@contextmanager
341+
def ctx(reraise=False):
342+
# called while handling an IndexError --> TypeError
343+
self.assertIsInstance(sys.exception(), TypeError)
344+
self.assertIsInstance(sys.exception().__context__, IndexError)
345+
exc_ctx = sys.exception()
346+
try:
347+
# raises a ValueError --> ZeroDivisionError
348+
yield
349+
except:
350+
self.assertIsInstance(sys.exception(), ZeroDivisionError)
351+
self.assertIsInstance(sys.exception().__context__, ValueError)
352+
# original error context is preserved
353+
self.assertIs(sys.exception().__context__.__context__, exc_ctx)
354+
if reraise:
355+
raise
356+
357+
# inner error handled, context should now be the original context
358+
self.assertIs(sys.exception(), exc_ctx)
359+
360+
try:
361+
raise IndexError()
362+
except:
363+
try:
364+
raise TypeError()
365+
except:
366+
with ctx():
367+
try:
368+
raise ValueError()
369+
except:
370+
self.assertIsInstance(sys.exception(), ValueError)
371+
1/0
372+
self.assertIsInstance(sys.exception(), TypeError)
373+
374+
try:
375+
raise IndexError()
376+
except:
377+
try:
378+
raise TypeError()
379+
except:
380+
with self.assertRaises(ZeroDivisionError):
381+
with ctx(reraise=True):
382+
try:
383+
raise ValueError()
384+
except:
385+
self.assertIsInstance(sys.exception(), ValueError)
386+
1/0
387+
self.assertIsInstance(sys.exception(), TypeError)
388+
309389
def _create_contextmanager_attribs(self):
310390
def attribs(**kw):
311391
def decorate(func):
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix handling of ``sys.exception()`` within ``@contextlib.contextmanager``
2+
functions. Patch by Carey Metcalfe.

0 commit comments

Comments
 (0)