diff --git a/Doc/library/traceback.rst b/Doc/library/traceback.rst index 67ee73d4b2e..408da7fc5f0 100644 --- a/Doc/library/traceback.rst +++ b/Doc/library/traceback.rst @@ -135,7 +135,7 @@ The module defines the following functions: text line is not ``None``. -.. function:: format_exception_only(exc, /[, value]) +.. function:: format_exception_only(exc, /[, value], *, show_group=False) Format the exception part of a traceback using an exception value such as given by ``sys.last_value``. The return value is a list of strings, each @@ -149,6 +149,10 @@ The module defines the following functions: can be passed as the first argument. If *value* is provided, the first argument is ignored in order to provide backwards compatibility. + When *show_group* is ``True``, and the exception is an instance of + :exc:`BaseExceptionGroup`, the nested exceptions are included as + well, recursively, with indentation relative to their nesting depth. + .. versionchanged:: 3.10 The *etype* parameter has been renamed to *exc* and is now positional-only. @@ -156,6 +160,9 @@ The module defines the following functions: .. versionchanged:: 3.11 The returned list now includes any notes attached to the exception. + .. versionchanged:: 3.13 + *show_group* parameter was added. + .. function:: format_exception(exc, /[, value, tb], limit=None, chain=True) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 0c5d7c9c8c5..b43dca6f640 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -215,6 +215,155 @@ class TracebackCases(unittest.TestCase): str_name = '.'.join([X.__module__, X.__qualname__]) self.assertEqual(err[0], "%s: %s\n" % (str_name, str_value)) + def test_format_exception_group_without_show_group(self): + eg = ExceptionGroup('A', [ValueError('B')]) + err = traceback.format_exception_only(eg) + self.assertEqual(err, ['ExceptionGroup: A (1 sub-exception)\n']) + + def test_format_exception_group(self): + eg = ExceptionGroup('A', [ValueError('B')]) + err = traceback.format_exception_only(eg, show_group=True) + self.assertEqual(err, [ + 'ExceptionGroup: A (1 sub-exception)\n', + ' ValueError: B\n', + ]) + + def test_format_base_exception_group(self): + eg = BaseExceptionGroup('A', [BaseException('B')]) + err = traceback.format_exception_only(eg, show_group=True) + self.assertEqual(err, [ + 'BaseExceptionGroup: A (1 sub-exception)\n', + ' BaseException: B\n', + ]) + + def test_format_exception_group_with_note(self): + exc = ValueError('B') + exc.add_note('Note') + eg = ExceptionGroup('A', [exc]) + err = traceback.format_exception_only(eg, show_group=True) + self.assertEqual(err, [ + 'ExceptionGroup: A (1 sub-exception)\n', + ' ValueError: B\n', + ' Note\n', + ]) + + def test_format_exception_group_explicit_class(self): + eg = ExceptionGroup('A', [ValueError('B')]) + err = traceback.format_exception_only(ExceptionGroup, eg, show_group=True) + self.assertEqual(err, [ + 'ExceptionGroup: A (1 sub-exception)\n', + ' ValueError: B\n', + ]) + + def test_format_exception_group_multiple_exceptions(self): + eg = ExceptionGroup('A', [ValueError('B'), TypeError('C')]) + err = traceback.format_exception_only(eg, show_group=True) + self.assertEqual(err, [ + 'ExceptionGroup: A (2 sub-exceptions)\n', + ' ValueError: B\n', + ' TypeError: C\n', + ]) + + def test_format_exception_group_multiline_messages(self): + eg = ExceptionGroup('A\n1', [ValueError('B\n2')]) + err = traceback.format_exception_only(eg, show_group=True) + self.assertEqual(err, [ + 'ExceptionGroup: A\n1 (1 sub-exception)\n', + ' ValueError: B\n', + ' 2\n', + ]) + + def test_format_exception_group_multiline2_messages(self): + exc = ValueError('B\n\n2\n') + exc.add_note('\nC\n\n3') + eg = ExceptionGroup('A\n\n1\n', [exc, IndexError('D')]) + err = traceback.format_exception_only(eg, show_group=True) + self.assertEqual(err, [ + 'ExceptionGroup: A\n\n1\n (2 sub-exceptions)\n', + ' ValueError: B\n', + ' \n', + ' 2\n', + ' \n', + ' \n', # first char of `note` + ' C\n', + ' \n', + ' 3\n', # note ends + ' IndexError: D\n', + ]) + + def test_format_exception_group_syntax_error(self): + exc = SyntaxError("error", ("x.py", 23, None, "bad syntax")) + eg = ExceptionGroup('A\n1', [exc]) + err = traceback.format_exception_only(eg, show_group=True) + self.assertEqual(err, [ + 'ExceptionGroup: A\n1 (1 sub-exception)\n', + ' File "x.py", line 23\n', + ' bad syntax\n', + ' SyntaxError: error\n', + ]) + + def test_format_exception_group_nested_with_notes(self): + exc = IndexError('D') + exc.add_note('Note\nmultiline') + eg = ExceptionGroup('A', [ + ValueError('B'), + ExceptionGroup('C', [exc, LookupError('E')]), + TypeError('F'), + ]) + err = traceback.format_exception_only(eg, show_group=True) + self.assertEqual(err, [ + 'ExceptionGroup: A (3 sub-exceptions)\n', + ' ValueError: B\n', + ' ExceptionGroup: C (2 sub-exceptions)\n', + ' IndexError: D\n', + ' Note\n', + ' multiline\n', + ' LookupError: E\n', + ' TypeError: F\n', + ]) + + def test_format_exception_group_with_tracebacks(self): + def f(): + try: + 1 / 0 + except ZeroDivisionError as e: + return e + + def g(): + try: + raise TypeError('g') + except TypeError as e: + return e + + eg = ExceptionGroup('A', [ + f(), + ExceptionGroup('B', [g()]), + ]) + err = traceback.format_exception_only(eg, show_group=True) + self.assertEqual(err, [ + 'ExceptionGroup: A (2 sub-exceptions)\n', + ' ZeroDivisionError: division by zero\n', + ' ExceptionGroup: B (1 sub-exception)\n', + ' TypeError: g\n', + ]) + + def test_format_exception_group_with_cause(self): + def f(): + try: + try: + 1 / 0 + except ZeroDivisionError: + raise ValueError(0) + except Exception as e: + return e + + eg = ExceptionGroup('A', [f()]) + err = traceback.format_exception_only(eg, show_group=True) + self.assertEqual(err, [ + 'ExceptionGroup: A (1 sub-exception)\n', + ' ValueError: 0\n', + ]) + @requires_subprocess() def test_encoded_file(self): # Test that tracebacks are correctly printed for encoded source files: @@ -381,7 +530,7 @@ class TracebackCases(unittest.TestCase): self.assertEqual( str(inspect.signature(traceback.format_exception_only)), - '(exc, /, value=)') + '(exc, /, value=, *, show_group=False)') class PurePythonExceptionFormattingMixin: diff --git a/Lib/traceback.py b/Lib/traceback.py index 0d41c3432ed..b25a7291f6b 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -148,7 +148,7 @@ def format_exception(exc, /, value=_sentinel, tb=_sentinel, limit=None, \ return list(te.format(chain=chain)) -def format_exception_only(exc, /, value=_sentinel): +def format_exception_only(exc, /, value=_sentinel, *, show_group=False): """Format the exception part of a traceback. The return value is a list of strings, each ending in a newline. @@ -158,21 +158,26 @@ def format_exception_only(exc, /, value=_sentinel): contains several lines that (when printed) display detailed information about where the syntax error occurred. Following the message, the list contains the exception's ``__notes__``. + + When *show_group* is ``True``, and the exception is an instance of + :exc:`BaseExceptionGroup`, the nested exceptions are included as + well, recursively, with indentation relative to their nesting depth. """ if value is _sentinel: value = exc te = TracebackException(type(value), value, None, compact=True) - return list(te.format_exception_only()) + return list(te.format_exception_only(show_group=show_group)) # -- not official API but folk probably use these two functions. -def _format_final_exc_line(etype, value): +def _format_final_exc_line(etype, value, *, insert_final_newline=True): valuestr = _safe_string(value, 'exception') + end_char = "\n" if insert_final_newline else "" if value is None or not valuestr: - line = "%s\n" % etype + line = f"{etype}{end_char}" else: - line = "%s: %s\n" % (etype, valuestr) + line = f"{etype}: {valuestr}{end_char}" return line def _safe_string(value, what, func=str): @@ -889,6 +894,10 @@ class TracebackException: display detailed information about where the syntax error occurred. Following the message, generator also yields all the exception's ``__notes__``. + + When *show_group* is ``True``, and the exception is an instance of + :exc:`BaseExceptionGroup`, the nested exceptions are included as + well, recursively, with indentation relative to their nesting depth. """ indent = 3 * _depth * ' ' @@ -904,7 +913,17 @@ class TracebackException: stype = smod + '.' + stype if not issubclass(self.exc_type, SyntaxError): - yield indent + _format_final_exc_line(stype, self._str) + if _depth > 0: + # Nested exceptions needs correct handling of multiline messages. + formatted = _format_final_exc_line( + stype, self._str, insert_final_newline=False, + ).split('\n') + yield from [ + indent + l + '\n' + for l in formatted + ] + else: + yield _format_final_exc_line(stype, self._str) else: yield from [indent + l for l in self._format_syntax_error(stype)] diff --git a/Misc/NEWS.d/next/Library/2023-10-27-09-56-20.gh-issue-111388.SlmDbC.rst b/Misc/NEWS.d/next/Library/2023-10-27-09-56-20.gh-issue-111388.SlmDbC.rst new file mode 100644 index 00000000000..353196439a9 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2023-10-27-09-56-20.gh-issue-111388.SlmDbC.rst @@ -0,0 +1,2 @@ +Add ``show_group`` parameter to :func:`traceback.format_exception_only`, +which allows to format :exc:`ExceptionGroup` instances.