]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-139551: add support for BaseExceptionGroup in IDLE (GH-139563)
authorLocked-chess-official <13140752715@163.com>
Sun, 12 Apr 2026 17:06:41 +0000 (01:06 +0800)
committerGitHub <noreply@github.com>
Sun, 12 Apr 2026 17:06:41 +0000 (10:06 -0700)
Meaningfully render ExceptionGroup tracebacks in the IDLE GUI REPL.

---------

Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com>
Co-authored-by: Gregory P. Smith <greg@krypto.org>
Lib/idlelib/idle_test/test_run.py
Lib/idlelib/run.py
Misc/NEWS.d/next/IDLE/2025-10-05-19-33-39.gh-issue-139551.TX9BRc.rst [new file with mode: 0644]

index 9a9d3b7b4e219c2e2d0f32cd0a22112598afe9c3..57bf5559c0fa88ab449e7e2338206a3822b87209 100644 (file)
@@ -82,6 +82,99 @@ class ExceptionTest(unittest.TestCase):
                         subtests += 1
         self.assertEqual(subtests, len(data2))  # All subtests ran?
 
+    def _capture_exception(self):
+        """Call run.print_exception() and return its stderr output."""
+        with captured_stderr() as output:
+            with mock.patch.object(run, 'cleanup_traceback') as ct:
+                ct.side_effect = lambda t, e: t
+                run.print_exception()
+        return output.getvalue()
+
+    @force_not_colorized
+    def test_print_exception_group_nested(self):
+        try:
+            try:
+                raise ExceptionGroup('inner', [ValueError('v1')])
+            except ExceptionGroup as inner:
+                raise ExceptionGroup('outer', [inner, TypeError('t1')])
+        except ExceptionGroup:
+            tb = self._capture_exception()
+
+        self.assertIn('ExceptionGroup: outer (2 sub-exceptions)', tb)
+        self.assertIn('ExceptionGroup: inner', tb)
+        self.assertIn('ValueError: v1', tb)
+        self.assertIn('TypeError: t1', tb)
+        # Verify tree structure characters.
+        self.assertIn('+-+---------------- 1 ----------------', tb)
+        self.assertIn('+---------------- 2 ----------------', tb)
+        self.assertIn('+------------------------------------', tb)
+
+    @force_not_colorized
+    def test_print_exception_group_chaining(self):
+        # __cause__ on a sub-exception exercises the prefixed
+        # chaining-message path (margin chars on separator lines).
+        sub = TypeError('t1')
+        sub.__cause__ = ValueError('original')
+        try:
+            raise ExceptionGroup('eg1', [sub])
+        except ExceptionGroup:
+            tb = self._capture_exception()
+        self.assertIn('ValueError: original', tb)
+        self.assertIn('| The above exception was the direct cause', tb)
+        self.assertIn('ExceptionGroup: eg1', tb)
+
+        # __context__ (implicit chaining) on a sub-exception.
+        sub = TypeError('t2')
+        sub.__context__ = ValueError('first')
+        try:
+            raise ExceptionGroup('eg2', [sub])
+        except ExceptionGroup:
+            tb = self._capture_exception()
+        self.assertIn('ValueError: first', tb)
+        self.assertIn('| During handling of the above exception', tb)
+        self.assertIn('ExceptionGroup: eg2', tb)
+
+    @force_not_colorized
+    def test_print_exception_group_seen(self):
+        shared = ValueError('shared')
+        try:
+            raise ExceptionGroup('eg', [shared, shared])
+        except ExceptionGroup:
+            tb = self._capture_exception()
+
+        self.assertIn('ValueError: shared', tb)
+        self.assertIn('<exception ValueError has printed>', tb)
+
+    @force_not_colorized
+    def test_print_exception_group_max_width(self):
+        excs = [ValueError(f'v{i}') for i in range(20)]
+        try:
+            raise ExceptionGroup('eg', excs)
+        except ExceptionGroup:
+            tb = self._capture_exception()
+
+        self.assertIn('+---------------- 15 ----------------', tb)
+        self.assertIn('+---------------- ... ----------------', tb)
+        self.assertIn('and 5 more exceptions', tb)
+        self.assertNotIn('+---------------- 16 ----------------', tb)
+
+    @force_not_colorized
+    def test_print_exception_group_max_depth(self):
+        def make_nested(depth):
+            if depth == 0:
+                return ValueError('leaf')
+            return ExceptionGroup(f'level{depth}',
+                                  [make_nested(depth - 1)])
+
+        try:
+            raise make_nested(15)
+        except ExceptionGroup:
+            tb = self._capture_exception()
+
+        self.assertIn('... (max_group_depth is 10)', tb)
+        self.assertIn('ExceptionGroup: level15', tb)
+        self.assertNotIn('ValueError: leaf', tb)
+
 # StdioFile tests.
 
 class S(str):
index a30db99a619a93b9cf228cab67531027187cc767..e1c40fee8f480535678d0e3f028e6d3afef7f581 100644 (file)
@@ -249,31 +249,94 @@ def print_exception():
     sys.last_type, sys.last_value, sys.last_traceback = excinfo
     sys.last_exc = val
     seen = set()
+    exclude = ("run.py", "rpc.py", "threading.py", "queue.py",
+               "debugger_r.py", "bdb.py")
+    max_group_width = 15
+    max_group_depth = 10
+    group_depth = 0
+
+    def print_exc_group(typ, exc, tb, prefix=""):
+        nonlocal group_depth
+        group_depth += 1
+        prefix2 = prefix or "  "
+        if group_depth > max_group_depth:
+            print(f"{prefix2}| ... (max_group_depth is {max_group_depth})",
+                  file=efile)
+            group_depth -= 1
+            return
+        if tb:
+            if not prefix:
+                print("  + Exception Group Traceback (most recent call last):", file=efile)
+            else:
+                print(f"{prefix}| Exception Group Traceback (most recent call last):", file=efile)
+            tbe = traceback.extract_tb(tb)
+            cleanup_traceback(tbe, exclude)
+            for line in traceback.format_list(tbe):
+                for subline in line.rstrip().splitlines():
+                    print(f"{prefix2}| {subline}", file=efile)
+        lines = get_message_lines(typ, exc, tb)
+        for line in lines:
+            print(f"{prefix2}| {line}", end="", file=efile)
+        num_excs = len(exc.exceptions)
+        if num_excs <= max_group_width:
+            n = num_excs
+        else:
+            n = max_group_width + 1
+        for i, sub in enumerate(exc.exceptions[:n], 1):
+            truncated = (i > max_group_width)
+            first_line_pre = "+-" if i == 1 else "  "
+            title = str(i) if not truncated else '...'
+            print(f"{prefix2}{first_line_pre}+---------------- {title} ----------------", file=efile)
+            if truncated:
+                remaining = num_excs - max_group_width
+                plural = 's' if remaining > 1 else ''
+                print(f"{prefix2}  | and {remaining} more exception{plural}",
+                      file=efile)
+                need_print_underline = True
+            elif id(sub) not in seen:
+                if not prefix:
+                    print_exc(type(sub), sub, sub.__traceback__, "    ")
+                else:
+                    print_exc(type(sub), sub, sub.__traceback__, prefix + "  ")
+                need_print_underline = not isinstance(sub, BaseExceptionGroup)
+            else:
+                print(f"{prefix2}  | <exception {type(sub).__name__} has printed>", file=efile)
+                need_print_underline = True
+            if need_print_underline and i == n:
+                print(f"{prefix2}  +------------------------------------", file=efile)
+        group_depth -= 1
 
-    def print_exc(typ, exc, tb):
+    def print_exc(typ, exc, tb, prefix=""):
         seen.add(id(exc))
         context = exc.__context__
         cause = exc.__cause__
+        prefix2 = f"{prefix}| " if prefix else ""
         if cause is not None and id(cause) not in seen:
-            print_exc(type(cause), cause, cause.__traceback__)
-            print("\nThe above exception was the direct cause "
-                  "of the following exception:\n", file=efile)
+            print_exc(type(cause), cause, cause.__traceback__, prefix)
+            print(f"{prefix2}\n{prefix2}The above exception was the direct cause "
+                  f"of the following exception:\n{prefix2}", file=efile)
         elif (context is not None and
               not exc.__suppress_context__ and
               id(context) not in seen):
-            print_exc(type(context), context, context.__traceback__)
-            print("\nDuring handling of the above exception, "
-                  "another exception occurred:\n", file=efile)
-        if tb:
-            tbe = traceback.extract_tb(tb)
-            print('Traceback (most recent call last):', file=efile)
-            exclude = ("run.py", "rpc.py", "threading.py", "queue.py",
-                       "debugger_r.py", "bdb.py")
-            cleanup_traceback(tbe, exclude)
-            traceback.print_list(tbe, file=efile)
-        lines = get_message_lines(typ, exc, tb)
-        for line in lines:
-            print(line, end='', file=efile)
+            print_exc(type(context), context, context.__traceback__, prefix)
+            print(f"{prefix2}\n{prefix2}During handling of the above exception, "
+                  f"another exception occurred:\n{prefix2}", file=efile)
+        if isinstance(exc, BaseExceptionGroup):
+            print_exc_group(typ, exc, tb, prefix=prefix)
+        else:
+            if tb:
+                print(f"{prefix2}Traceback (most recent call last):", file=efile)
+                tbe = traceback.extract_tb(tb)
+                cleanup_traceback(tbe, exclude)
+                if prefix:
+                    for line in traceback.format_list(tbe):
+                        for subline in line.rstrip().splitlines():
+                            print(f"{prefix}| {subline}", file=efile)
+                else:
+                    traceback.print_list(tbe, file=efile)
+            lines = get_message_lines(typ, exc, tb)
+            for line in lines:
+                print(f"{prefix2}{line}", end="", file=efile)
 
     print_exc(typ, val, tb)
 
diff --git a/Misc/NEWS.d/next/IDLE/2025-10-05-19-33-39.gh-issue-139551.TX9BRc.rst b/Misc/NEWS.d/next/IDLE/2025-10-05-19-33-39.gh-issue-139551.TX9BRc.rst
new file mode 100644 (file)
index 0000000..5ea1dfc
--- /dev/null
@@ -0,0 +1 @@
+Support rendering :exc:`BaseExceptionGroup` in IDLE.