From 507d69a5bf45cf4fab78b7fc8374b40e8670c937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Lozier?= Date: Sat, 7 Nov 2026 20:59:44 -0500 Subject: [PATCH 1/2] Respect __suppress_context__ when printing traceback --- .../PythonExceptions.BaseException.cs | 2 - src/core/IronPython/Runtime/PythonContext.cs | 29 +++---- tests/suite/test_traceback.py | 77 +++++++++++++++++++ 3 files changed, 92 insertions(+), 16 deletions(-) diff --git a/src/core/IronPython/Runtime/Exceptions/PythonExceptions.BaseException.cs b/src/core/IronPython/Runtime/Exceptions/PythonExceptions.BaseException.cs index 012720feb..57786de4f 100644 --- a/src/core/IronPython/Runtime/Exceptions/PythonExceptions.BaseException.cs +++ b/src/core/IronPython/Runtime/Exceptions/PythonExceptions.BaseException.cs @@ -304,8 +304,6 @@ public TraceBack? __traceback__ { internal bool HasCause => __cause__ != null; - internal bool IsImplicitException => __cause__ == null && __cause__ != __context__; - void IPythonObject.SetPythonType(PythonType/*!*/ newType) { if (_type.IsSystemType || newType.IsSystemType) { throw PythonOps.TypeError("__class__ assignment can only be performed on user defined types"); diff --git a/src/core/IronPython/Runtime/PythonContext.cs b/src/core/IronPython/Runtime/PythonContext.cs index f82e70d6b..5445e3cc4 100644 --- a/src/core/IronPython/Runtime/PythonContext.cs +++ b/src/core/IronPython/Runtime/PythonContext.cs @@ -1394,6 +1394,10 @@ static void Flush(CodeContext context, object obj) { public override string FormatException(Exception exception) { ContractUtils.RequiresNotNull(exception, nameof(exception)); + return FormatException(exception, new HashSet(ReferenceEqualityComparer.Instance)); + } + + private string FormatException(Exception exception, HashSet seen) { if (exception is SyntaxErrorException syntax_error) { return FormatPythonSyntaxError(syntax_error); } @@ -1401,21 +1405,18 @@ public override string FormatException(Exception exception) { StringBuilder result = new StringBuilder(); var pythonEx = PythonExceptions.ToPython(exception); - - if (exception.InnerException != null) { - if (exception.InnerException.GetPythonException() is PythonExceptions.BaseException pythonInnerException) { - // add the nested/chained exception - result.AppendLine(FormatException(exception.InnerException)); - result.AppendLine(); - - // check whether this is implicit or explicit - if (!((PythonExceptions.BaseException)pythonEx).IsImplicitException) { - result.AppendLine("The above exception was the direct cause of the following exception."); - } else { - result.AppendLine("During handing of the above exception, another exception occurred:"); - } - result.AppendLine(); + seen.Add(pythonEx); + + var chained = pythonEx.HasCause ? pythonEx.__cause__ : pythonEx.__suppress_context__ ? null : pythonEx.RawContext; + if (chained is not null && !seen.Contains(chained)) { + result.AppendLine(FormatException(chained.GetClrException(), seen)); + result.AppendLine(); + if (pythonEx.HasCause) { + result.AppendLine("The above exception was the direct cause of the following exception:"); + } else { + result.AppendLine("During handling of the above exception, another exception occurred:"); } + result.AppendLine(); } result.Append(FormatStackTraces(exception)); diff --git a/tests/suite/test_traceback.py b/tests/suite/test_traceback.py index c2110730b..dc5ebda81 100644 --- a/tests/suite/test_traceback.py +++ b/tests/suite/test_traceback.py @@ -538,4 +538,81 @@ def test_cp11923_second(self): os.remove(_t_test) os.remove(_t_test_a) + def test_chained_exception_output(self): + try: + from test.support.script_helper import assert_python_failure + except ImportError: # Python 3.4 + from test.script_helper import assert_python_failure + + cause = "The above exception was the direct cause of the following exception:" + context = "During handling of the above exception, another exception occurred:" + + def run_code(code): + _, _, err = assert_python_failure("-S", "-c", code) + return err.decode("ascii") + + def run(raise_stmt): + return run_code("try:\n {}['inner']\nexcept KeyError:\n " + raise_stmt) + + err = run("raise ValueError('outer')") + self.assertIn("KeyError", err) + self.assertIn(context, err) + self.assertNotIn(cause, err) + self.assertIn("ValueError: outer", err) + + err = run("raise ValueError('outer') from TypeError('cause')") + self.assertNotIn("KeyError", err) + self.assertIn("TypeError: cause", err) + self.assertIn(cause, err) + self.assertNotIn(context, err) + self.assertIn("ValueError: outer", err) + + err = run("raise ValueError('outer') from None") + self.assertNotIn("KeyError", err) + self.assertNotIn(cause, err) + self.assertNotIn(context, err) + self.assertIn("ValueError: outer", err) + + # raising the same exception object again updates the chain + err = run_code(""" +e = ValueError('outer') +try: + {}['inner'] +except KeyError: + try: + raise e + except ValueError: + pass +raise e from TypeError('cause') +""") + self.assertNotIn("KeyError", err) + self.assertIn("TypeError: cause", err) + self.assertIn(cause, err) + + err = run_code(""" +e = ValueError('outer') +try: + raise e +except ValueError: + pass +try: + {}['inner'] +except KeyError: + raise e +""") + self.assertIn("KeyError", err) + self.assertIn(context, err) + + # cause cycle + err = run_code(""" +a = ValueError('a') +b = TypeError('b') +a.__cause__ = b +b.__cause__ = a +raise a +""") + self.assertEqual(err.count("ValueError: a"), 1) + self.assertEqual(err.count("TypeError: b"), 1) + self.assertEqual(err.count(cause), 1) + run_test(__name__) From 30d0b8f485c5d4302c69a832e0fa7caab45cecbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Lozier?= Date: Sat, 7 Nov 2026 22:06:20 -0500 Subject: [PATCH 2/2] Fix regression --- .../Runtime/Exceptions/PythonExceptions.cs | 4 ++ .../Runtime/Operations/PythonOps.cs | 6 +-- tests/suite/test_traceback.py | 40 ++++++++++++++++--- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/core/IronPython/Runtime/Exceptions/PythonExceptions.cs b/src/core/IronPython/Runtime/Exceptions/PythonExceptions.cs index 439538ad6..9ca53520c 100644 --- a/src/core/IronPython/Runtime/Exceptions/PythonExceptions.cs +++ b/src/core/IronPython/Runtime/Exceptions/PythonExceptions.cs @@ -833,6 +833,10 @@ public static BaseException ToPython(System.Exception/*!*/ clrException) { #endif res = ToPythonNewStyle(clrException); + if (clrException.InnerException?.GetPythonException() is BaseException inner) { + // e.g. a Python exception wrapped in a AggregateException + res.__context__ = inner; + } clrException.SetPythonException(res); } diff --git a/src/core/IronPython/Runtime/Operations/PythonOps.cs b/src/core/IronPython/Runtime/Operations/PythonOps.cs index 0a2ac20ae..fe62ab6d0 100644 --- a/src/core/IronPython/Runtime/Operations/PythonOps.cs +++ b/src/core/IronPython/Runtime/Operations/PythonOps.cs @@ -2591,9 +2591,9 @@ private static Exception MakeExceptionWorker(CodeContext/*!*/ context, object? t } var contextException = GetRawContextException(); - if (keepContext || contextException == baseException) { - // throw() does not change the context, and raising the exception being handled - // (including a bare raise) does not make it its own context + if (keepContext || contextException is null || contextException == baseException) { + // throw() does not change the context, raising outside an except block keeps the previous context, + // and raising the exception being handled (including a bare raise) does not make it its own context contextException = baseException.RawContext; } else { // avoid creating cycles through the context chain diff --git a/tests/suite/test_traceback.py b/tests/suite/test_traceback.py index dc5ebda81..55d0cd139 100644 --- a/tests/suite/test_traceback.py +++ b/tests/suite/test_traceback.py @@ -6,7 +6,12 @@ import sys import unittest -from iptest import IronPythonTestCase, is_cli, run_test +from iptest import IronPythonTestCase, is_cli, run_test, skipUnlessIronPython + +try: + from test.support.script_helper import assert_python_failure +except ImportError: # Python 3.4 + from test.script_helper import assert_python_failure FILE = __file__ @@ -539,11 +544,6 @@ def test_cp11923_second(self): os.remove(_t_test_a) def test_chained_exception_output(self): - try: - from test.support.script_helper import assert_python_failure - except ImportError: # Python 3.4 - from test.script_helper import assert_python_failure - cause = "The above exception was the direct cause of the following exception:" context = "During handling of the above exception, another exception occurred:" @@ -603,6 +603,21 @@ def run(raise_stmt): self.assertIn("KeyError", err) self.assertIn(context, err) + # raising outside an except block keeps the previous context + err = run_code(""" +e = ValueError('outer') +try: + {}['inner'] +except KeyError: + try: + raise e + except ValueError: + pass +raise e +""") + self.assertIn("KeyError", err) + self.assertIn(context, err) + # cause cycle err = run_code(""" a = ValueError('a') @@ -615,4 +630,17 @@ def run(raise_stmt): self.assertEqual(err.count("TypeError: b"), 1) self.assertEqual(err.count(cause), 1) + @skipUnlessIronPython() + def test_chained_exception_output_clr(self): + # a Python exception wrapped by .NET (AggregateException) is shown as the context + _, _, err = assert_python_failure("-S", "-c", """ +import System +def f(): + raise ValueError('inner') +System.Threading.Tasks.Task.Run(System.Action(f)).Wait() +""") + err = err.decode("ascii") + self.assertIn("ValueError: inner", err) + self.assertIn("During handling of the above exception, another exception occurred:", err) + run_test(__name__)