diff --git a/Doc/builtins/exceptions.rst b/Doc/builtins/exceptions.rst index ecf62fb6391b1b..680863cebfcdaf 100644 --- a/Doc/builtins/exceptions.rst +++ b/Doc/builtins/exceptions.rst @@ -100,6 +100,12 @@ attribute, as well as due to possible memory layout incompatibilities. conflicts in the future. Therefore, it's recommended to avoid subclassing multiple exception types altogether. +.. versionchanged:: 3.5.2 + + Setting :attr:`__context__` to self has no any effect. If set + ``__context__`` to the chain contained original exception, it is moved to + the start of the context chain. + Base classes ------------ diff --git a/Doc/c-api/exceptions.rst b/Doc/c-api/exceptions.rst index 0c8d14dc134ebc..8a1de127ec2870 100644 --- a/Doc/c-api/exceptions.rst +++ b/Doc/c-api/exceptions.rst @@ -888,6 +888,10 @@ Exception Objects it. There is no type check to make sure that *ctx* is an exception instance. This ":term:`steals `" a reference to *ctx*. + .. versionchanged:: 3.5.2 + If *ctx* is *ex*, the function has no any effect. If *ex* is in the + context chain started from *ctx*, it is moved to the start of the chain. + .. c:function:: PyObject* PyException_GetCause(PyObject *ex) diff --git a/Lib/test/test_exceptions.py b/Lib/test/test_exceptions.py index 963da1b0eae27c..bcb29cb448d5f7 100644 --- a/Lib/test/test_exceptions.py +++ b/Lib/test/test_exceptions.py @@ -987,6 +987,26 @@ def __exit__ (self, exc_type, exc_value, exc_tb): obj = wr() self.assertIsNone(obj) + def test_context_loop(self): + exc1 = Exception(1) + exc1.__context__ = exc1 + self.assertIsNone(exc1.__context__) + + exc2 = Exception(2) + exc2.__context__ = exc1 + self.assertIs(exc2.__context__, exc1) + exc1.__context__ = exc2 + self.assertIs(exc1.__context__, exc2) + self.assertIsNone(exc2.__context__) + + exc3 = Exception(3) + exc3.__context__ = exc1 + self.assertIs(exc3.__context__, exc1) + exc1.__context__ = exc3 + self.assertIs(exc1.__context__, exc3) + self.assertIs(exc3.__context__, exc2) + self.assertIsNone(exc2.__context__) + def test_exception_target_in_nested_scope(self): # issue 4617: This used to raise a SyntaxError # "can not delete variable 'e' referenced in nested scope" diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-21-00-00-00.gh-issue-69968.3kX7mQ.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-21-00-00-00.gh-issue-69968.3kX7mQ.rst new file mode 100644 index 00000000000000..c6ac440110b4ac --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-21-00-00-00.gh-issue-69968.3kX7mQ.rst @@ -0,0 +1,3 @@ +Setting :attr:`~BaseException.__context__` to the exception itself now has no +effect, and setting it to a chain containing the exception moves the +exception to the start of the chain, preventing cycles. diff --git a/Objects/exceptions.c b/Objects/exceptions.c index 781b5142ec326e..e9e5b1fd3c804a 100644 --- a/Objects/exceptions.c +++ b/Objects/exceptions.c @@ -551,9 +551,45 @@ PyException_GetContext(PyObject *self) void PyException_SetContext(PyObject *self, PyObject *context) { + assert(PyExceptionInstance_Check(self)); + assert(context == NULL || PyExceptionInstance_Check(context)); + + int self_in_chain = 0; + Py_BEGIN_CRITICAL_SECTION(self); - Py_XSETREF(PyBaseExceptionObject_CAST(self)->context, context); + PyBaseExceptionObject *base_self = PyBaseExceptionObject_CAST(self); + + PyObject *exc = context; + PyObject *prev_exc = NULL; + + while (exc != NULL) { + if (exc == self) { + if (prev_exc != NULL) { + /* Move self to the start of the chain. */ + PyBaseExceptionObject_CAST(prev_exc)->context = + base_self->context; + base_self->context = context; + } + self_in_chain = 1; + break; + } + assert(PyExceptionInstance_Check(exc)); + prev_exc = exc; + exc = PyBaseExceptionObject_CAST(exc)->context; + } + + if (self_in_chain) { + /* Self was already in the chain; drop the lost link reference. */ + assert(Py_REFCNT(self) > 1); + } + else { + Py_XSETREF(base_self->context, context); + } Py_END_CRITICAL_SECTION(); + + if (self_in_chain) { + Py_DECREF(self); + } } PyObject *