diff --git a/Lib/contextlib.py b/Lib/contextlib.py index efc02bfa9243da..1660a81416aa81 100644 --- a/Lib/contextlib.py +++ b/Lib/contextlib.py @@ -20,6 +20,12 @@ "chdir"] +try: + from _contextlib import _setup_genmgr_ctx +except ImportError: + _setup_genmgr_ctx = None + + class AbstractContextManager(abc.ABC): """An abstract base class for context managers.""" @@ -168,6 +174,7 @@ class _GeneratorContextManagerBase: """Shared functionality for @contextmanager and @asynccontextmanager.""" def __init__(self, func, args, kwds): + self.exc_context = None self.gen = func(*args, **kwds) self.func, self.args, self.kwds = func, args, kwds # Issue 19330: ensure context manager instances have good docstrings @@ -196,6 +203,8 @@ class _GeneratorContextManager( """Helper for @contextmanager decorator.""" def __enter__(self): + # store the exception context on enter so it can be restored on exit + self.exc_context = sys.exception() # do not keep args and kwds alive unnecessarily # they are only needed for recreation, which is not possible anymore del self.args, self.kwds, self.func @@ -205,6 +214,9 @@ def __enter__(self): raise RuntimeError("generator didn't yield") from None def __exit__(self, typ, value, traceback): + # don't keep the stored exception alive unnecessarily + exc_context = self.exc_context + self.exc_context = None if typ is None: try: next(self.gen) @@ -221,6 +233,20 @@ def __exit__(self, typ, value, traceback): # tell if we get the same exception back value = typ() try: + # Throw the current exception into the generator so it can + # handle it. + # Once the generator handles the thrown exception, the + # exception context within it should revert back to what it was + # before its "yield" statement (ie. what it was in self.__enter__). + # However, since we're still currently handling the exception + # that we throw into the generator here, the exception context + # in the generator wouldn't change. + # To work around this, we forcefully set the current exception + # context to be what it was just before the generator's yield + # statement before throwing the current exception into it. + # (see gh-111676). + if _setup_genmgr_ctx is not None: + _setup_genmgr_ctx(exc_context) self.gen.throw(value) except StopIteration as exc: # Suppress StopIteration *unless* it's the same exception that diff --git a/Lib/test/test_contextlib.py b/Lib/test/test_contextlib.py index e291f814edbd93..ea4f089edea22b 100644 --- a/Lib/test/test_contextlib.py +++ b/Lib/test/test_contextlib.py @@ -306,6 +306,98 @@ def woohoo(): with woohoo(): raise StopIteration + def test_contextmanager_handling_exception_resets_exc_info(self): + # Test that sys.exc_info() is correctly unset after handling the error + # when used within a context manager + + @contextmanager + def ctx(reraise=False): + try: + self.assertIsNone(sys.exception()) + yield + except: + self.assertIsInstance(sys.exception(), ZeroDivisionError) + if reraise: + raise + else: + self.assertIsNone(sys.exception()) + self.assertIsNone(sys.exception()) + + with ctx(): + pass + + with ctx(): + 1/0 + + with self.assertRaises(ZeroDivisionError): + with ctx(reraise=True): + 1/0 + + def test_contextmanager_preserves_handled_exception(self): + # test that any exceptions currently being handled are preserved + # through the context manager + + @contextmanager + def ctx(reraise=False): + # called while handling an IndexError --> TypeError + self.assertIsInstance(sys.exception(), TypeError) + self.assertIsInstance(sys.exception().__context__, IndexError) + exc_ctx = sys.exception() + try: + # raises a ValueError --> ZeroDivisionError + yield + except: + self.assertIsInstance(sys.exception(), ZeroDivisionError) + self.assertIsInstance(sys.exception().__context__, ValueError) + # original error context is preserved + self.assertIs(sys.exception().__context__.__context__, exc_ctx) + if reraise: + raise + + # inner error handled, context should now be the original context + self.assertIs(sys.exception(), exc_ctx) + + try: + raise IndexError() + except: + try: + raise TypeError() + except: + with ctx(): + self.assertIsInstance(sys.exception(), TypeError) + self.assertIsInstance(sys.exception().__context__, IndexError) + try: + raise ValueError() + except: + self.assertIsInstance(sys.exception(), ValueError) + self.assertIsInstance(sys.exception().__context__, TypeError) + self.assertIsInstance(sys.exception().__context__.__context__, IndexError) + 1/0 + self.assertIsInstance(sys.exception(), TypeError) + self.assertIsInstance(sys.exception().__context__, IndexError) + self.assertIsInstance(sys.exception(), IndexError) + + try: + raise IndexError() + except: + try: + raise TypeError() + except: + with self.assertRaises(ZeroDivisionError): + with ctx(reraise=True): + self.assertIsInstance(sys.exception(), TypeError) + self.assertIsInstance(sys.exception().__context__, IndexError) + try: + raise ValueError() + except: + self.assertIsInstance(sys.exception(), ValueError) + self.assertIsInstance(sys.exception().__context__, TypeError) + self.assertIsInstance(sys.exception().__context__.__context__, IndexError) + 1/0 + self.assertIsInstance(sys.exception(), TypeError) + self.assertIsInstance(sys.exception().__context__, IndexError) + self.assertIsInstance(sys.exception(), IndexError) + def _create_contextmanager_attribs(self): def attribs(**kw): def decorate(func): @@ -1579,5 +1671,138 @@ def test_exception(self): self.assertEqual(os.getcwd(), old_cwd) +try: + from _contextlib import _setup_genmgr_ctx +except ImportError: + _setup_genmgr_ctx = None + +if _setup_genmgr_ctx: + class SetExceptionTests(unittest.TestCase): + + def tearDown(self): + # make sure we don't leave the global exception set + _setup_genmgr_ctx(None); + + def test_set_exc_invalid_values(self): + for x in (0, "1", b"2"): + with self.assertRaises(TypeError): + _setup_genmgr_ctx(x); + + def test_clear_exc(self): + try: + raise ValueError() + except ValueError: + self.assertIsInstance(sys.exception(), ValueError) + _setup_genmgr_ctx(None) + self.assertIsNone(sys.exception()) + + def test_set_exc(self): + exc = ValueError() + self.assertIsNone(sys.exception()) + _setup_genmgr_ctx(exc) + self.assertIs(sys.exception(), exc) + + def test_set_exc_replaced_by_new_exception_and_restored(self): + exc = ValueError() + _setup_genmgr_ctx(exc) + self.assertIs(sys.exception(), exc) + try: + raise TypeError() + except TypeError: + self.assertIsInstance(sys.exception(), TypeError) + self.assertIs(sys.exception().__context__, exc) + + self.assertIs(sys.exception(), exc) + + def test_set_exc_popped_on_exit_except(self): + exc = ValueError() + try: + raise TypeError() + except TypeError: + self.assertIsInstance(sys.exception(), TypeError) + _setup_genmgr_ctx(exc) + self.assertIs(sys.exception(), exc) + self.assertIsNone(sys.exception()) + + def test_cleared_exc_overridden_and_restored(self): + try: + raise ValueError() + except ValueError: + try: + raise TypeError() + except TypeError: + self.assertIsInstance(sys.exception(), TypeError) + _setup_genmgr_ctx(None) + self.assertIsNone(sys.exception()) + try: + raise IndexError() + except IndexError: + self.assertIsInstance(sys.exception(), IndexError) + self.assertIsNone(sys.exception().__context__) + self.assertIsNone(sys.exception()) + self.assertIsInstance(sys.exception(), ValueError) + self.assertIsNone(sys.exception()) + + def test_clear_exc_in_generator(self): + def inner(): + self.assertIsNone(sys.exception()) + yield + self.assertIsInstance(sys.exception(), ValueError) + _setup_genmgr_ctx(None) + self.assertIsNone(sys.exception()) + yield + self.assertIsNone(sys.exception()) + + # with a single exception in exc_info stack + g = inner() + next(g) + try: + raise ValueError() + except: + self.assertIsInstance(sys.exception(), ValueError) + next(g) + self.assertIsInstance(sys.exception(), ValueError) + self.assertIsNone(sys.exception()) + with self.assertRaises(StopIteration): + next(g) + self.assertIsNone(sys.exception()) + + # with multiple exceptions in exc_info stack by chaining generators + def outer(): + g = inner() + self.assertIsNone(sys.exception()) + yield next(g) + self.assertIsInstance(sys.exception(), TypeError) + try: + raise ValueError() + except: + self.assertIsInstance(sys.exception(), ValueError) + self.assertIsInstance(sys.exception().__context__, TypeError) + yield next(g) + # at this point the TypeError from the caller has been handled + # by the caller's except block. Even still, it should still be + # referenced as the __context__ of the current exception. + self.assertIsInstance(sys.exception(), ValueError) + self.assertIsInstance(sys.exception().__context__, TypeError) + # not handling an exception, caller isn't handling one either + self.assertIsNone(sys.exception()) + with self.assertRaises(StopIteration): + next(g) + self.assertIsNone(sys.exception()) + + g = outer() + next(g) + try: + raise TypeError() + except: + self.assertIsInstance(sys.exception(), TypeError) + next(g) + self.assertIsInstance(sys.exception(), TypeError) + self.assertIsNone(sys.exception()) + with self.assertRaises(StopIteration): + next(g) + self.assertIsNone(sys.exception()) + + if __name__ == "__main__": unittest.main() diff --git a/Misc/NEWS.d/next/Library/2023-11-03-01-44-51.gh-issue-111375.qDBcCI.rst b/Misc/NEWS.d/next/Library/2023-11-03-01-44-51.gh-issue-111375.qDBcCI.rst new file mode 100644 index 00000000000000..2519b7e614e9a3 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2023-11-03-01-44-51.gh-issue-111375.qDBcCI.rst @@ -0,0 +1,2 @@ +Fix handling of ``sys.exception()`` within ``@contextlib.contextmanager`` +functions. Patch by Carey Metcalfe. diff --git a/Modules/Setup.bootstrap.in b/Modules/Setup.bootstrap.in index 65a1fefe72e92e..a1c97500861c41 100644 --- a/Modules/Setup.bootstrap.in +++ b/Modules/Setup.bootstrap.in @@ -31,6 +31,7 @@ _weakref _weakref.c # commonly used core modules _abc _abc.c +_contextlib _contextlibmodule.c _functools _functoolsmodule.c _locale _localemodule.c _opcode _opcode.c diff --git a/Modules/_contextlibmodule.c b/Modules/_contextlibmodule.c new file mode 100644 index 00000000000000..f18847a0ebbfbc --- /dev/null +++ b/Modules/_contextlibmodule.c @@ -0,0 +1,72 @@ + +/* Contextlib module */ + +#include "Python.h" + +#include "clinic/_contextlibmodule.c.h" + +/*[clinic input] +module _contextlib +[clinic start generated code]*/ +/*[clinic end generated code: output=da39a3ee5e6b4b0d input=60c4760e25c64b5e]*/ + + +/*[clinic input] +_contextlib._setup_genmgr_ctx + exception: object + +Force the provided exception to be set as the current exception + +Subsequent calls to sys.exception()/sys.exc_info() will return +the provided exception until another exception is caught in the +current thread or the execution stack returns to a frame where +another exception is being handled. +[clinic start generated code]*/ + +static PyObject * +_contextlib__setup_genmgr_ctx_impl(PyObject *module, PyObject *exception) +/*[clinic end generated code: output=d8996a9f8f52204b input=d61410ba5659bf6f]*/ +{ + if (!Py_IsNone(exception) && !PyExceptionInstance_Check(exception)){ + PyErr_SetString( + PyExc_TypeError, + "must be an exception/None" + ); + return NULL; + } + PyErr_SetHandledException(exception); + Py_RETURN_NONE; +} + + +/* module level code ********************************************************/ + +PyDoc_STRVAR(_contextlib_doc, +"Helpers for contextlib."); + +static PyMethodDef contextlib_methods[] = { + _CONTEXTLIB__SETUP_GENMGR_CTX_METHODDEF + {NULL, NULL} /* sentinel */ +}; + +static PyModuleDef_Slot contextlib_slots[] = { + _Py_ABI_SLOT, + {Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED}, + {Py_mod_gil, Py_MOD_GIL_NOT_USED}, + {0, NULL} +}; + +static struct PyModuleDef _contextlibmodule = { + PyModuleDef_HEAD_INIT, + .m_name = "_contextlib", + .m_doc = _contextlib_doc, + .m_size = 0, + .m_methods = contextlib_methods, + .m_slots = contextlib_slots, +}; + +PyMODINIT_FUNC +PyInit__contextlib(void) +{ + return PyModuleDef_Init(&_contextlibmodule); +} \ No newline at end of file diff --git a/Modules/clinic/_contextlibmodule.c.h b/Modules/clinic/_contextlibmodule.c.h new file mode 100644 index 00000000000000..681edb4a974c27 --- /dev/null +++ b/Modules/clinic/_contextlibmodule.c.h @@ -0,0 +1,73 @@ +/*[clinic input] +preserve +[clinic start generated code]*/ + +#if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE) +# include "pycore_gc.h" // PyGC_Head +# include "pycore_runtime.h" // _Py_ID() +#endif +#include "pycore_modsupport.h" // _PyArg_UnpackKeywords() + +PyDoc_STRVAR(_contextlib__setup_genmgr_ctx__doc__, +"_setup_genmgr_ctx($module, /, exception)\n" +"--\n" +"\n" +"Force the provided exception to be set as the current exception\n" +"\n" +"Subsequent calls to sys.exception()/sys.exc_info() will return\n" +"the provided exception until another exception is caught in the\n" +"current thread or the execution stack returns to a frame where\n" +"another exception is being handled."); + +#define _CONTEXTLIB__SETUP_GENMGR_CTX_METHODDEF \ + {"_setup_genmgr_ctx", _PyCFunction_CAST(_contextlib__setup_genmgr_ctx), METH_FASTCALL|METH_KEYWORDS, _contextlib__setup_genmgr_ctx__doc__}, + +static PyObject * +_contextlib__setup_genmgr_ctx_impl(PyObject *module, PyObject *exception); + +static PyObject * +_contextlib__setup_genmgr_ctx(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames) +{ + PyObject *return_value = NULL; + #if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE) + + #define NUM_KEYWORDS 1 + static struct { + PyGC_Head _this_is_not_used; + PyObject_VAR_HEAD + Py_hash_t ob_hash; + PyObject *ob_item[NUM_KEYWORDS]; + } _kwtuple = { + .ob_base = PyVarObject_HEAD_INIT(&PyTuple_Type, NUM_KEYWORDS) + .ob_hash = -1, + .ob_item = { &_Py_ID(exception), }, + }; + #undef NUM_KEYWORDS + #define KWTUPLE (&_kwtuple.ob_base.ob_base) + + #else // !Py_BUILD_CORE + # define KWTUPLE NULL + #endif // !Py_BUILD_CORE + + static const char * const _keywords[] = {"exception", NULL}; + static _PyArg_Parser _parser = { + .keywords = _keywords, + .fname = "_setup_genmgr_ctx", + .kwtuple = KWTUPLE, + }; + #undef KWTUPLE + PyObject *argsbuf[1]; + PyObject *exception; + + args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, &_parser, + /*minpos*/ 1, /*maxpos*/ 1, /*minkw*/ 0, /*varpos*/ 0, argsbuf); + if (!args) { + goto exit; + } + exception = args[0]; + return_value = _contextlib__setup_genmgr_ctx_impl(module, exception); + +exit: + return return_value; +} +/*[clinic end generated code: output=3c01ac3e1fdf06bf input=a9049054013a1b77]*/ diff --git a/PCbuild/pythoncore.vcxproj b/PCbuild/pythoncore.vcxproj index e255ed5af19125..68a148c37c93fb 100644 --- a/PCbuild/pythoncore.vcxproj +++ b/PCbuild/pythoncore.vcxproj @@ -444,6 +444,7 @@ + diff --git a/PCbuild/pythoncore.vcxproj.filters b/PCbuild/pythoncore.vcxproj.filters index 649ee1859ff996..6c24739e584b1c 100644 --- a/PCbuild/pythoncore.vcxproj.filters +++ b/PCbuild/pythoncore.vcxproj.filters @@ -989,6 +989,9 @@ Modules + + Modules + Modules diff --git a/Python/errors.c b/Python/errors.c index 48b03e5fd714b1..a1014bc6f1e0d4 100644 --- a/Python/errors.c +++ b/Python/errors.c @@ -121,7 +121,6 @@ _PyErr_GetTopmostException(PyThreadState *tstate) { exc_info = exc_info->previous_item; } - assert(!Py_IsNone(exc_info->exc_value)); return exc_info; } @@ -607,7 +606,7 @@ PyErr_GetHandledException(void) void _PyErr_SetHandledException(PyThreadState *tstate, PyObject *exc) { - Py_XSETREF(tstate->exc_info->exc_value, Py_XNewRef(exc == Py_None ? NULL : exc)); + Py_XSETREF(tstate->exc_info->exc_value, Py_XNewRef(exc ? exc : Py_None)); } void diff --git a/Tools/c-analyzer/cpython/_parser.py b/Tools/c-analyzer/cpython/_parser.py index d7248c34c59be4..3ef310fc6e739c 100644 --- a/Tools/c-analyzer/cpython/_parser.py +++ b/Tools/c-analyzer/cpython/_parser.py @@ -189,6 +189,7 @@ def format_tsv_lines(lines): ('Modules/_asynciomodule.c', 'Py_BUILD_CORE', '1'), ('Modules/_codecsmodule.c', 'Py_BUILD_CORE', '1'), ('Modules/_collectionsmodule.c', 'Py_BUILD_CORE', '1'), + ('Modules/_contextlibmodule.c', 'Py_BUILD_CORE', '1'), ('Modules/_ctypes/_ctypes.c', 'Py_BUILD_CORE', '1'), ('Modules/_ctypes/cfield.c', 'Py_BUILD_CORE', '1'), ('Modules/_cursesmodule.c', 'Py_BUILD_CORE', '1'),