From 4bff1205c8c618d2e07099be7e06facc58385937 Mon Sep 17 00:00:00 2001 From: Ram Rachum Date: Sun, 8 Mar 2026 12:27:09 +0200 Subject: [PATCH] Format ExceptionGroup gracefully on Python 3.11+ --- pysnooper/tracer.py | 2 +- pysnooper/utils.py | 16 +++++++++++ tests/test_pysnooper.py | 59 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/pysnooper/tracer.py b/pysnooper/tracer.py index 59509f2..f5e965b 100644 --- a/pysnooper/tracer.py +++ b/pysnooper/tracer.py @@ -569,7 +569,7 @@ class Tracer: format(**locals())) if event == 'exception': - exception = '\n'.join(traceback.format_exception_only(*arg[:2])).strip() + exception = utils.format_exception(*arg[:2]) if self.max_variable_length: exception = utils.truncate(exception, self.max_variable_length) self.write('{indent}{_FOREGROUND_RED}Exception:..... ' diff --git a/pysnooper/utils.py b/pysnooper/utils.py index ff9b9e8..a5e469e 100644 --- a/pysnooper/utils.py +++ b/pysnooper/utils.py @@ -3,6 +3,7 @@ import abc import re +import traceback import sys from .pycompat import ABC, string_types, collections_abc @@ -87,6 +88,21 @@ def truncate(string, max_length): return u'{}...{}'.format(string[:left], string[-right:]) +def format_exception(exc_type, exc_value): + try: + is_group = isinstance(exc_value, BaseExceptionGroup) + except NameError: + is_group = False + if is_group: + sub_types = ', '.join(type(e).__name__ for e in exc_value.exceptions) + message = exc_value.args[0] if exc_value.args else '' + return u"{}: '{}' ({} sub-exceptions: {})".format( + exc_type.__name__, message, + len(exc_value.exceptions), sub_types, + ) + return u'\n'.join(traceback.format_exception_only(exc_type, exc_value)).strip() + + def ensure_tuple(x): if isinstance(x, collections_abc.Iterable) and \ not isinstance(x, string_types): diff --git a/tests/test_pysnooper.py b/tests/test_pysnooper.py index 530a2f7..c80d79d 100644 --- a/tests/test_pysnooper.py +++ b/tests/test_pysnooper.py @@ -1908,6 +1908,65 @@ def test_exception(): ) +@pytest.mark.skipif(sys.version_info < (3, 11), + reason='ExceptionGroup requires Python 3.11+') +def test_exception_group(): + string_io = io.StringIO() + @pysnooper.snoop(string_io, color=False) + def f(): + raise ExceptionGroup('task errors', [ValueError('bad'), TypeError('wrong'), RuntimeError('fail')]) + + with pytest.raises(ExceptionGroup): + f() + + output = string_io.getvalue() + assert_output( + output, + ( + SourcePathEntry(), + CallEntry(), + LineEntry(), + ExceptionEntry(), + ExceptionValueEntry( + value_regex=r"ExceptionGroup: 'task errors' " + r"\(3 sub-exceptions: ValueError, TypeError, " + r"RuntimeError\)" + ), + CallEndedByExceptionEntry(), + ElapsedTimeEntry(), + ) + ) + + +@pytest.mark.skipif(sys.version_info < (3, 11), + reason='ExceptionGroup requires Python 3.11+') +def test_nested_exception_group(): + string_io = io.StringIO() + @pysnooper.snoop(string_io, color=False) + def f(): + raise ExceptionGroup('outer', [ExceptionGroup('inner', [ValueError('deep')]), TypeError('shallow')]) + + with pytest.raises(ExceptionGroup): + f() + + output = string_io.getvalue() + assert_output( + output, + ( + SourcePathEntry(), + CallEntry(), + LineEntry(), + ExceptionEntry(), + ExceptionValueEntry( + value_regex=r"ExceptionGroup: 'outer' " + r"\(2 sub-exceptions: ExceptionGroup, TypeError\)" + ), + CallEndedByExceptionEntry(), + ElapsedTimeEntry(), + ) + ) + + def test_exception_on_entry(): @pysnooper.snoop(color=False) def f(x):