Skip to content

Commit

Permalink
Add support for formatting of "ExceptionGroup" errors
Browse files Browse the repository at this point in the history
  • Loading branch information
Delgan committed Sep 8, 2023
1 parent 5c7bc8b commit 0157056
Show file tree
Hide file tree
Showing 15 changed files with 1,301 additions and 25 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
`Unreleased`_
=============

- Add support for formatting of ``ExceptionGroup`` errors (`#805 <https://github.com/Delgan/loguru/issues/805>`_).


`0.7.1`_ (2023-09-04)
=====================

Expand Down
115 changes: 90 additions & 25 deletions loguru/_better_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@
import tokenize
import traceback

if sys.version_info >= (3, 11):

def is_exception_group(exc):
return isinstance(exc, ExceptionGroup)

else:

def is_exception_group(exc):
return False


class SyntaxHighlighter:
_default_style = {
Expand Down Expand Up @@ -140,6 +150,15 @@ def _get_lib_dirs():
paths = {sysconfig.get_path(name, scheme) for scheme in schemes for name in names}
return [os.path.abspath(path).lower() + os.sep for path in paths if path in sys.path]

@staticmethod
def _indent(text, count, *, prefix="| "):
if count == 0:
yield text
return
for line in text.splitlines(True):
indented = " " * count + prefix + line
yield indented.rstrip() + "\n"

def _get_char(self, char, default):
try:
char.encode(self._encoding)
Expand Down Expand Up @@ -344,7 +363,9 @@ def _format_locations(self, frames_lines, *, has_introduction):

yield frame

def _format_exception(self, value, tb, *, seen=None, is_first=False, from_decorator=False):
def _format_exception(
self, value, tb, *, seen=None, is_first=False, from_decorator=False, group_nesting=0
):
# Implemented from built-in traceback module:
# https://github.com/python/cpython/blob/a5b76167/Lib/traceback.py#L468
exc_type, exc_value, exc_traceback = type(value), value, tb
Expand All @@ -356,42 +377,59 @@ def _format_exception(self, value, tb, *, seen=None, is_first=False, from_decora

if exc_value:
if exc_value.__cause__ is not None and id(exc_value.__cause__) not in seen:
for text in self._format_exception(
exc_value.__cause__, exc_value.__cause__.__traceback__, seen=seen
):
yield text
yield from self._format_exception(
exc_value.__cause__,
exc_value.__cause__.__traceback__,
seen=seen,
group_nesting=group_nesting,
)
cause = "The above exception was the direct cause of the following exception:"
if self._colorize:
cause = self._theme["cause"].format(cause)
if self._diagnose:
yield "\n\n" + cause + "\n\n\n"
yield from self._indent("\n\n" + cause + "\n\n\n", group_nesting)
else:
yield "\n" + cause + "\n\n"
yield from self._indent("\n" + cause + "\n\n", group_nesting)

elif (
exc_value.__context__ is not None
and id(exc_value.__context__) not in seen
and not exc_value.__suppress_context__
):
for text in self._format_exception(
exc_value.__context__, exc_value.__context__.__traceback__, seen=seen
):
yield text
yield from self._format_exception(
exc_value.__context__,
exc_value.__context__.__traceback__,
seen=seen,
group_nesting=group_nesting,
)
context = "During handling of the above exception, another exception occurred:"
if self._colorize:
context = self._theme["context"].format(context)
if self._diagnose:
yield "\n\n" + context + "\n\n\n"
yield from self._indent("\n\n" + context + "\n\n\n", group_nesting)
else:
yield "\n" + context + "\n\n"
yield from self._indent("\n" + context + "\n\n", group_nesting)

is_grouped = is_exception_group(value)

if is_grouped and group_nesting == 0:
yield from self._format_exception(
value,
tb,
seen=seen,
group_nesting=1,
is_first=is_first,
from_decorator=from_decorator,
)
return

try:
tracebacklimit = sys.tracebacklimit
traceback_limit = sys.tracebacklimit
except AttributeError:
tracebacklimit = None
traceback_limit = None

frames, final_source = self._extract_frames(
exc_traceback, is_first, limit=tracebacklimit, from_decorator=from_decorator
exc_traceback, is_first, limit=traceback_limit, from_decorator=from_decorator
)
exception_only = traceback.format_exception_only(exc_type, exc_value)

Expand All @@ -416,22 +454,49 @@ def _format_exception(self, value, tb, *, seen=None, is_first=False, from_decora

exception_only[-1] = error_message + "\n"

frames_lines = traceback.format_list(frames) + exception_only
has_introduction = bool(frames)

if self._colorize or self._backtrace or self._diagnose:
frames_lines = self._format_locations(frames_lines, has_introduction=has_introduction)

if is_first:
yield self._prefix

has_introduction = bool(frames)

if has_introduction:
introduction = "Traceback (most recent call last):"
if is_grouped:
introduction = "Exception Group Traceback (most recent call last):"
else:
introduction = "Traceback (most recent call last):"
if self._colorize:
introduction = self._theme["introduction"].format(introduction)
yield introduction + "\n"
if group_nesting == 1: # Implies we're processing the root ExceptionGroup.
yield from self._indent(introduction + "\n", group_nesting, prefix="+ ")
else:
yield from self._indent(introduction + "\n", group_nesting)

frames_lines = traceback.format_list(frames) + exception_only
if self._colorize or self._backtrace or self._diagnose:
frames_lines = self._format_locations(frames_lines, has_introduction=has_introduction)

yield "".join(frames_lines)
yield from self._indent("".join(frames_lines), group_nesting)

if is_grouped:
for n, exc in enumerate(value.exceptions, start=1):
ruler = "+" + (" %s " % ("..." if n > 15 else n)).center(35, "-")
yield from self._indent(ruler, group_nesting, prefix="+-" if n == 1 else " ")
if n > 15:
message = "and %d more exceptions\n" % (len(value.exceptions) - 15)
yield from self._indent(message, group_nesting + 1)
break
elif group_nesting == 10 and is_exception_group(exc):
message = "... (max_group_depth is 10)\n"
yield from self._indent(message, group_nesting + 1)
else:
yield from self._format_exception(
exc,
exc.__traceback__,
seen=seen,
group_nesting=group_nesting + 1,
)
if not is_exception_group(exc) or group_nesting == 10:
yield from self._indent("-" * 35, group_nesting + 1, prefix="+-")

def format_exception(self, type_, value, tb, *, from_decorator=False):
yield from self._format_exception(value, tb, is_first=True, from_decorator=from_decorator)
186 changes: 186 additions & 0 deletions tests/exceptions/output/modern/grouped_as_cause_and_context.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@

+ Exception Group Traceback (most recent call last):
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 31, in main
| raise ExceptionGroup("group_1", [error_1, error_2])
| ExceptionGroup: group_1 (2 sub-exceptions)
+-+---------------- 1 ----------------
| Traceback (most recent call last):
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 20, in main
| a()
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 10, in a
| 1 / 0
| ZeroDivisionError: division by zero
+---------------- 2 ----------------
| Traceback (most recent call last):
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 25, in main
| b()
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 14, in b
| raise ValueError("Error")
| ValueError: Error
+------------------------------------

The above exception was the direct cause of the following exception:

+ Exception Group Traceback (most recent call last):
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 33, in main
| raise ExceptionGroup("group_2", [error_2, error_1]) from err
| ExceptionGroup: group_2 (2 sub-exceptions)
+-+---------------- 1 ----------------
| Traceback (most recent call last):
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 25, in main
| b()
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 14, in b
| raise ValueError("Error")
| ValueError: Error
+---------------- 2 ----------------
| Traceback (most recent call last):
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 20, in main
| a()
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 10, in a
| 1 / 0
| ZeroDivisionError: division by zero
+------------------------------------

During handling of the above exception, another exception occurred:

+ Exception Group Traceback (most recent call last):
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 42, in <module>
| main()
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 35, in main
| raise ExceptionGroup("group_3", [err])
| ExceptionGroup: group_3 (1 sub-exception)
+-+---------------- 1 ----------------
| Exception Group Traceback (most recent call last):
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 33, in main
| raise ExceptionGroup("group_2", [error_2, error_1]) from err
| ExceptionGroup: group_2 (2 sub-exceptions)
+-+---------------- 1 ----------------
| Traceback (most recent call last):
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 25, in main
| b()
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 14, in b
| raise ValueError("Error")
| ValueError: Error
+---------------- 2 ----------------
| Traceback (most recent call last):
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 20, in main
| a()
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 10, in a
| 1 / 0
| ZeroDivisionError: division by zero
+------------------------------------

+ Exception Group Traceback (most recent call last):
|
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 31, in main
| raise ExceptionGroup("group_1", [error_1, error_2])
|  │ └ ValueError('Error')
|  └ ZeroDivisionError('division by zero')
|
| ExceptionGroup: group_1 (2 sub-exceptions)
+-+---------------- 1 ----------------
| Traceback (most recent call last):
|
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 20, in main
| a()
| └ <function a at 0xDEADBEEF>
|
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 10, in a
| 1 / 0
|
| ZeroDivisionError: division by zero
+---------------- 2 ----------------
| Traceback (most recent call last):
|
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 25, in main
| b()
| └ <function b at 0xDEADBEEF>
|
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 14, in b
| raise ValueError("Error")
|
| ValueError: Error
+------------------------------------


The above exception was the direct cause of the following exception:


+ Exception Group Traceback (most recent call last):
|
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 33, in main
| raise ExceptionGroup("group_2", [error_2, error_1]) from err
|  │ └ ZeroDivisionError('division by zero')
|  └ ValueError('Error')
|
| ExceptionGroup: group_2 (2 sub-exceptions)
+-+---------------- 1 ----------------
| Traceback (most recent call last):
|
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 25, in main
| b()
| └ <function b at 0xDEADBEEF>
|
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 14, in b
| raise ValueError("Error")
|
| ValueError: Error
+---------------- 2 ----------------
| Traceback (most recent call last):
|
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 20, in main
| a()
| └ <function a at 0xDEADBEEF>
|
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 10, in a
| 1 / 0
|
| ZeroDivisionError: division by zero
+------------------------------------


During handling of the above exception, another exception occurred:


+ Exception Group Traceback (most recent call last):
|
| > File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 42, in <module>
| main()
| └ <function main at 0xDEADBEEF>
|
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 35, in main
| raise ExceptionGroup("group_3", [err])
|
| ExceptionGroup: group_3 (1 sub-exception)
+-+---------------- 1 ----------------
| Exception Group Traceback (most recent call last):
|
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 33, in main
| raise ExceptionGroup("group_2", [error_2, error_1]) from err
|  │ └ ZeroDivisionError('division by zero')
|  └ ValueError('Error')
|
| ExceptionGroup: group_2 (2 sub-exceptions)
+-+---------------- 1 ----------------
| Traceback (most recent call last):
|
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 25, in main
| b()
| └ <function b at 0xDEADBEEF>
|
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 14, in b
| raise ValueError("Error")
|
| ValueError: Error
+---------------- 2 ----------------
| Traceback (most recent call last):
|
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 20, in main
| a()
| └ <function a at 0xDEADBEEF>
|
| File "tests/exceptions/source/modern/grouped_as_cause_and_context.py", line 10, in a
| 1 / 0
|
| ZeroDivisionError: division by zero
+------------------------------------
Loading

0 comments on commit 0157056

Please sign in to comment.