diff --git a/CHANGES.rst b/CHANGES.rst index 0c11a2c..8a847a8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,6 +3,16 @@ Version history This library adheres to `Semantic Versioning 2.0 `_. +**UNRELEASED** + +- Fixed + ``AttributeError: 'PatchedTracebackException' object has no attribute '__cause__'`` + on Python 3.10 (only) when a traceback is printed from an exception where an exception + group is set as the cause (#33) +- Fixed a loop in exception groups being rendered incorrectly (#35) +- Fixed the patched formatting functions (``format_exception()``etc.) not passing the + ``compact=True`` flag on Python 3.10 like the original functions do + **1.0.0rc9** - Added custom versions of several ``traceback`` functions that work with exception diff --git a/src/exceptiongroup/_formatting.py b/src/exceptiongroup/_formatting.py index 2c74162..0bffd91 100644 --- a/src/exceptiongroup/_formatting.py +++ b/src/exceptiongroup/_formatting.py @@ -76,7 +76,7 @@ def __init__( self, exc_type: type[BaseException], exc_value: BaseException, - exc_traceback: TracebackType, + exc_traceback: TracebackType | None, *, limit: int | None = None, lookup_lines: bool = True, @@ -88,8 +88,6 @@ def __init__( if sys.version_info >= (3, 10): kwargs["compact"] = compact - # Capture the original exception and its cause and context as - # TracebackExceptions traceback_exception_original_init( self, exc_type, @@ -102,33 +100,82 @@ def __init__( **kwargs, ) - seen_was_none = _seen is None - + is_recursive_call = _seen is not None if _seen is None: _seen = set() + _seen.add(id(exc_value)) + + # Convert __cause__ and __context__ to `TracebackExceptions`s, use a + # queue to avoid recursion (only the top-level call gets _seen == None) + if not is_recursive_call: + queue = [(self, exc_value)] + while queue: + te, e = queue.pop() + + if e and e.__cause__ is not None and id(e.__cause__) not in _seen: + cause = PatchedTracebackException( + type(e.__cause__), + e.__cause__, + e.__cause__.__traceback__, + limit=limit, + lookup_lines=lookup_lines, + capture_locals=capture_locals, + _seen=_seen, + ) + else: + cause = None - # Capture each of the exceptions in the ExceptionGroup along with each of - # their causes and contexts - if isinstance(exc_value, BaseExceptionGroup): - embedded = [] - for exc in exc_value.exceptions: - if id(exc) not in _seen: - embedded.append( - PatchedTracebackException( + if compact: + need_context = ( + cause is None and e is not None and not e.__suppress_context__ + ) + else: + need_context = True + if ( + e + and e.__context__ is not None + and need_context + and id(e.__context__) not in _seen + ): + context = PatchedTracebackException( + type(e.__context__), + e.__context__, + e.__context__.__traceback__, + limit=limit, + lookup_lines=lookup_lines, + capture_locals=capture_locals, + _seen=_seen, + ) + else: + context = None + + # Capture each of the exceptions in the ExceptionGroup along with each + # of their causes and contexts + if e and isinstance(e, BaseExceptionGroup): + exceptions = [] + for exc in e.exceptions: + texc = PatchedTracebackException( type(exc), exc, exc.__traceback__, lookup_lines=lookup_lines, capture_locals=capture_locals, - # copy the set of _seen exceptions so that duplicates - # shared between sub-exceptions are not omitted - _seen=None if seen_was_none else set(_seen), + _seen=_seen, ) - ) - self.exceptions = embedded - self.msg = exc_value.message - else: - self.exceptions = None + exceptions.append(texc) + else: + exceptions = None + + te.__cause__ = cause + te.__context__ = context + te.exceptions = exceptions + if cause: + queue.append((te.__cause__, e.__cause__)) + if context: + queue.append((te.__context__, e.__context__)) + if exceptions: + queue.extend(zip(te.exceptions, e.exceptions)) + self.__notes__ = getattr(exc_value, "__notes__", ()) def format(self, *, chain=True, _ctx=None): @@ -280,7 +327,9 @@ def format_exception_only(self): @singledispatch def format_exception_only(__exc: BaseException) -> List[str]: return list( - PatchedTracebackException(type(__exc), __exc, None).format_exception_only() + PatchedTracebackException( + type(__exc), __exc, None, compact=True + ).format_exception_only() ) @@ -297,7 +346,7 @@ def format_exception( ) -> List[str]: return list( PatchedTracebackException( - type(__exc), __exc, __exc.__traceback__, limit=limit + type(__exc), __exc, __exc.__traceback__, limit=limit, compact=True ).format(chain=chain) ) diff --git a/tests/test_formatting.py b/tests/test_formatting.py index 0270cb9..7cfc152 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -88,6 +88,70 @@ def test_exceptionhook(capsys: CaptureFixture) -> None: ) +def test_exceptiongroup_as_cause(capsys: CaptureFixture) -> None: + try: + raise Exception() from ExceptionGroup("", (Exception(),)) + except Exception as exc: + sys.excepthook(type(exc), exc, exc.__traceback__) + + lineno = test_exceptiongroup_as_cause.__code__.co_firstlineno + module_prefix = "" if sys.version_info >= (3, 11) else "exceptiongroup." + output = capsys.readouterr().err + assert output == ( + f"""\ + | {module_prefix}ExceptionGroup: (1 sub-exception) + +-+---------------- 1 ---------------- + | Exception + +------------------------------------ + +The above exception was the direct cause of the following exception: + +Traceback (most recent call last): + File "{__file__}", line {lineno + 2}, in test_exceptiongroup_as_cause + raise Exception() from ExceptionGroup("", (Exception(),)) +Exception +""" + ) + + +def test_exceptiongroup_loop(capsys: CaptureFixture) -> None: + e0 = Exception("e0") + eg0 = ExceptionGroup("eg0", (e0,)) + eg1 = ExceptionGroup("eg1", (eg0,)) + + try: + raise eg0 from eg1 + except ExceptionGroup as exc: + sys.excepthook(type(exc), exc, exc.__traceback__) + + lineno = test_exceptiongroup_loop.__code__.co_firstlineno + 6 + module_prefix = "" if sys.version_info >= (3, 11) else "exceptiongroup." + output = capsys.readouterr().err + assert output == ( + f"""\ + | {module_prefix}ExceptionGroup: eg1 (1 sub-exception) + +-+---------------- 1 ---------------- + | Exception Group Traceback (most recent call last): + | File "{__file__}", line {lineno}, in test_exceptiongroup_loop + | raise eg0 from eg1 + | {module_prefix}ExceptionGroup: eg0 (1 sub-exception) + +-+---------------- 1 ---------------- + | Exception: e0 + +------------------------------------ + +The above exception was the direct cause of the following exception: + + + Exception Group Traceback (most recent call last): + | File "{__file__}", line {lineno}, in test_exceptiongroup_loop + | raise eg0 from eg1 + | {module_prefix}ExceptionGroup: eg0 (1 sub-exception) + +-+---------------- 1 ---------------- + | Exception: e0 + +------------------------------------ +""" + ) + + def test_exceptionhook_format_exception_only(capsys: CaptureFixture) -> None: try: raise_excgroup()