From b8bf6c55750d59f0d005098fe7965a5417fed1bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Thu, 18 Aug 2022 20:18:19 +0300 Subject: [PATCH 1/5] Provide special versions of traceback formatting functions Fixes #14. --- .pre-commit-config.yaml | 4 +- CHANGES.rst | 6 ++ README.rst | 14 ++++ src/exceptiongroup/__init__.py | 11 ++- src/exceptiongroup/_formatting.py | 70 ++++++++++++++--- tests/test_formatting.py | 126 ++++++++++++++++++++++++++++++ 6 files changed, 218 insertions(+), 13 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 19143ad..55d6f93 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,3 @@ -exclude: ^src/wheel/vendored - repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.3.0 @@ -26,7 +24,7 @@ repos: rev: v2.37.3 hooks: - id: pyupgrade - args: ["--py37-plus"] + args: ["--py37-plus", "--keep-runtime-typing"] - repo: https://github.com/psf/black rev: 22.6.0 diff --git a/CHANGES.rst b/CHANGES.rst index 655fd7e..817285c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,6 +3,12 @@ Version history This library adheres to `Semantic Versioning 2.0 `_. +**UNRELEASED** + +- Added custom versions of ``traceback.format_exception()`` and + ``traceback.format_exception_only()`` that work with exception groups even if monkey + patching was disabled or blocked + **1.0.0rc8** - Don't monkey patch anything if ``sys.excepthook`` has been altered diff --git a/README.rst b/README.rst index 47f7e1c..3a2f62e 100644 --- a/README.rst +++ b/README.rst @@ -18,6 +18,9 @@ It contains the following: (installed on import) * An exception hook that handles formatting of exception groups through ``TracebackException`` (installed on import) +* Special versions of the ``traceback.format_exception()`` and + ``traceback.format_exception_only()`` functions that format exception groups correctly + even if monkey patching is disabled or blocked by another custom exception hook If this package is imported on Python 3.11 or later, the built-in implementations of the exception group classes are used instead, ``TracebackException`` is not monkey patched @@ -76,6 +79,17 @@ would be written with this backport like this: **NOTE**: Just like with ``except*``, you cannot handle ``BaseExceptionGroup`` or ``ExceptionGroup`` with ``catch()``. +Formatting exception groups +=========================== + +Normally, the monkey patching applied by this library on import will cause exception +groups to be printed properly in tracebacks. But in cases when the monkey patching is +blocked by a third party exception hook, or monkey patching is explicitly disabled, +you can still manually format exceptions using ``exceptiongroup.format_exception()`` or +``exceptiongroup.format_exception_only()``. They work just like their counterparts in +the ``traceback`` module, except that they use a separately patched subclass of +``TracebackException`` to perform the rendering. + Notes on monkey patching ======================== diff --git a/src/exceptiongroup/__init__.py b/src/exceptiongroup/__init__.py index bc2ee9a..95023e4 100644 --- a/src/exceptiongroup/__init__.py +++ b/src/exceptiongroup/__init__.py @@ -1,4 +1,10 @@ -__all__ = ["BaseExceptionGroup", "ExceptionGroup", "catch"] +__all__ = [ + "BaseExceptionGroup", + "ExceptionGroup", + "catch", + "format_exception", + "format_exception_only", +] import os import sys @@ -8,6 +14,7 @@ if sys.version_info < (3, 11): from ._exceptions import BaseExceptionGroup, ExceptionGroup + from ._formatting import format_exception, format_exception_only if os.getenv("EXCEPTIONGROUP_NO_PATCH") != "1": from . import _formatting # noqa: F401 @@ -15,5 +22,7 @@ BaseExceptionGroup.__module__ = __name__ ExceptionGroup.__module__ = __name__ else: + from traceback import format_exception, format_exception_only + BaseExceptionGroup = BaseExceptionGroup ExceptionGroup = ExceptionGroup diff --git a/src/exceptiongroup/_formatting.py b/src/exceptiongroup/_formatting.py index 63eebf9..5388931 100644 --- a/src/exceptiongroup/_formatting.py +++ b/src/exceptiongroup/_formatting.py @@ -8,8 +8,9 @@ import sys import textwrap import traceback +from functools import singledispatch from types import TracebackType -from typing import Any +from typing import Any, List, Optional from ._exceptions import BaseExceptionGroup @@ -252,22 +253,73 @@ def exceptiongroup_excepthook( sys.stderr.write("".join(traceback.format_exception(etype, value, tb))) +traceback_exception_original_init = traceback.TracebackException.__init__ +traceback_exception_original_format = traceback.TracebackException.format +traceback_exception_original_format_exception_only = ( + traceback.TracebackException.format_exception_only +) +traceback_exception_format_syntax_error = getattr( + traceback.TracebackException, "_format_syntax_error", None +) if sys.excepthook is sys.__excepthook__: - traceback_exception_original_init = traceback.TracebackException.__init__ traceback.TracebackException.__init__ = ( # type: ignore[assignment] traceback_exception_init ) - traceback_exception_original_format = traceback.TracebackException.format traceback.TracebackException.format = ( # type: ignore[assignment] traceback_exception_format ) - traceback_exception_original_format_exception_only = ( - traceback.TracebackException.format_exception_only - ) traceback.TracebackException.format_exception_only = ( # type: ignore[assignment] traceback_exception_format_exception_only ) - traceback_exception_format_syntax_error = getattr( - traceback.TracebackException, "_format_syntax_error", None - ) sys.excepthook = exceptiongroup_excepthook + PatchedTracebackException = traceback.TracebackException +else: + + class PatchedTracebackException(traceback.TracebackException): + pass + + PatchedTracebackException.__init__ = ( # type: ignore[assignment] + traceback_exception_init + ) + PatchedTracebackException.format = ( # type: ignore[assignment] + traceback_exception_format + ) + PatchedTracebackException.format_exception_only = ( # type: ignore[assignment] + traceback_exception_format_exception_only + ) + + +@singledispatch +def format_exception_only(__exc: BaseException) -> List[str]: + return list( + PatchedTracebackException(type(__exc), __exc, None).format_exception_only() + ) + + +@format_exception_only.register +def _(__exc: type, value: BaseException) -> List[str]: + return format_exception_only(value) + + +@singledispatch +def format_exception( + __exc: BaseException, + limit: Optional[int] = None, + chain: bool = True, +) -> List[str]: + return list( + PatchedTracebackException( + type(__exc), __exc, __exc.__traceback__, limit=limit + ).format(chain=chain) + ) + + +@format_exception.register +def _( + __exc: type, + value: BaseException, + tb: TracebackType, + limit: Optional[int] = None, + chain: bool = True, +) -> List[str]: + return format_exception(value, limit, chain) diff --git a/tests/test_formatting.py b/tests/test_formatting.py index 8b58561..32d930d 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -1,8 +1,38 @@ import sys +import pytest +from _pytest.fixtures import SubRequest +from _pytest.monkeypatch import MonkeyPatch + from exceptiongroup import ExceptionGroup +@pytest.fixture( + params=[ + pytest.param(True, id="patched"), + pytest.param( + False, + id="unpatched", + marks=[ + pytest.mark.skipif( + sys.version_info >= (3, 11), + reason="No patching is done on Python >= 3.11", + ) + ], + ), + ], +) +def patched(request: SubRequest) -> bool: + return request.param + + +@pytest.fixture( + params=[pytest.param(False, id="newstyle"), pytest.param(True, id="oldstyle")] +) +def old_argstyle(request: SubRequest) -> bool: + return request.param + + def test_formatting(capsys): exceptions = [] try: @@ -119,3 +149,99 @@ def test_formatting_syntax_error(capsys): SyntaxError: invalid syntax """ ) + + +def test_format_exception( + patched: bool, old_argstyle: bool, monkeypatch: MonkeyPatch +) -> None: + if not patched: + # Block monkey patching, then force the module to be re-imported + del sys.modules["exceptiongroup"] + del sys.modules["exceptiongroup._formatting"] + monkeypatch.setattr(sys, "excepthook", lambda *args: sys.__excepthook__(*args)) + + from exceptiongroup import format_exception + + exceptions = [] + try: + raise ValueError("foo") + except ValueError as exc: + exceptions.append(exc) + + try: + raise RuntimeError("bar") + except RuntimeError as exc: + exc.__notes__ = ["Note from bar handler"] + exceptions.append(exc) + + try: + exc = ExceptionGroup("test message", exceptions) + exc.add_note("Displays notes attached to the group too") + raise exc + except ExceptionGroup as exc: + if old_argstyle: + lines = format_exception(type(exc), exc, exc.__traceback__) + else: + lines = format_exception(exc) + + assert isinstance(lines, list) + lineno = test_format_exception.__code__.co_firstlineno + module_prefix = "" if sys.version_info >= (3, 11) else "exceptiongroup." + assert "".join(lines) == ( + f"""\ + + Exception Group Traceback (most recent call last): + | File "{__file__}", line {lineno + 26}, in test_format_exception + | raise exc + | {module_prefix}ExceptionGroup: test message (2 sub-exceptions) + | Displays notes attached to the group too + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | File "{__file__}", line {lineno + 13}, in test_format_exception + | raise ValueError("foo") + | ValueError: foo + +---------------- 2 ---------------- + | Traceback (most recent call last): + | File "{__file__}", line {lineno + 18}, in test_format_exception + | raise RuntimeError("bar") + | RuntimeError: bar + | Note from bar handler + +------------------------------------ +""" + ) + + +def test_format_exception_only( + patched: bool, old_argstyle: bool, monkeypatch: MonkeyPatch +) -> None: + if not patched: + # Block monkey patching, then force the module to be re-imported + del sys.modules["exceptiongroup"] + del sys.modules["exceptiongroup._formatting"] + monkeypatch.setattr(sys, "excepthook", lambda *args: sys.__excepthook__(*args)) + + from exceptiongroup import format_exception_only + + exceptions = [] + try: + raise ValueError("foo") + except ValueError as exc: + exceptions.append(exc) + + try: + raise RuntimeError("bar") + except RuntimeError as exc: + exc.__notes__ = ["Note from bar handler"] + exceptions.append(exc) + + exc = ExceptionGroup("test message", exceptions) + exc.add_note("Displays notes attached to the group too") + if old_argstyle: + output = format_exception_only(type(exc), exc) + else: + output = format_exception_only(exc) + + module_prefix = "" if sys.version_info >= (3, 11) else "exceptiongroup." + assert output == [ + f"{module_prefix}ExceptionGroup: test message (2 sub-exceptions)\n", + "Displays notes attached to the group too\n", + ] From b7c4e710bf96e785ed13ccf2196dc76c6baca53b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sat, 20 Aug 2022 13:05:18 +0300 Subject: [PATCH 2/5] Fixed TypeError when calling format_exception() w/o monkey patching --- src/exceptiongroup/_formatting.py | 5 +++-- tests/test_formatting.py | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/exceptiongroup/_formatting.py b/src/exceptiongroup/_formatting.py index 5388931..daab402 100644 --- a/src/exceptiongroup/_formatting.py +++ b/src/exceptiongroup/_formatting.py @@ -83,9 +83,10 @@ def traceback_exception_init( for exc in exc_value.exceptions: if id(exc) not in _seen: embedded.append( - traceback.TracebackException.from_exception( + PatchedTracebackException( + type(exc), exc, - limit=limit, + exc.__traceback__, lookup_lines=lookup_lines, capture_locals=capture_locals, # copy the set of _seen exceptions so that duplicates diff --git a/tests/test_formatting.py b/tests/test_formatting.py index 32d930d..640a5de 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -156,6 +156,7 @@ def test_format_exception( ) -> None: if not patched: # Block monkey patching, then force the module to be re-imported + del sys.modules["traceback"] del sys.modules["exceptiongroup"] del sys.modules["exceptiongroup._formatting"] monkeypatch.setattr(sys, "excepthook", lambda *args: sys.__excepthook__(*args)) @@ -190,18 +191,18 @@ def test_format_exception( assert "".join(lines) == ( f"""\ + Exception Group Traceback (most recent call last): - | File "{__file__}", line {lineno + 26}, in test_format_exception + | File "{__file__}", line {lineno + 27}, in test_format_exception | raise exc | {module_prefix}ExceptionGroup: test message (2 sub-exceptions) | Displays notes attached to the group too +-+---------------- 1 ---------------- | Traceback (most recent call last): - | File "{__file__}", line {lineno + 13}, in test_format_exception + | File "{__file__}", line {lineno + 14}, in test_format_exception | raise ValueError("foo") | ValueError: foo +---------------- 2 ---------------- | Traceback (most recent call last): - | File "{__file__}", line {lineno + 18}, in test_format_exception + | File "{__file__}", line {lineno + 19}, in test_format_exception | raise RuntimeError("bar") | RuntimeError: bar | Note from bar handler @@ -215,6 +216,7 @@ def test_format_exception_only( ) -> None: if not patched: # Block monkey patching, then force the module to be re-imported + del sys.modules["traceback"] del sys.modules["exceptiongroup"] del sys.modules["exceptiongroup._formatting"] monkeypatch.setattr(sys, "excepthook", lambda *args: sys.__excepthook__(*args)) From 5a6c523df51f680d1de00dbb6703ad3adcdfed53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Fri, 26 Aug 2022 19:46:21 +0300 Subject: [PATCH 3/5] Always use the patched version of TracebackException for formatting Another library (trio) might've patched the original TBE class after we applied our patches. --- src/exceptiongroup/_formatting.py | 385 +++++++++++++++--------------- 1 file changed, 186 insertions(+), 199 deletions(-) diff --git a/src/exceptiongroup/_formatting.py b/src/exceptiongroup/_formatting.py index daab402..a462f34 100644 --- a/src/exceptiongroup/_formatting.py +++ b/src/exceptiongroup/_formatting.py @@ -42,65 +42,6 @@ def _safe_string(value, what, func=str): return f"<{what} {func.__name__}() failed>" -def traceback_exception_init( - self, - exc_type: type[BaseException], - exc_value: BaseException, - exc_traceback: TracebackType, - *, - limit: int | None = None, - lookup_lines: bool = True, - capture_locals: bool = False, - compact: bool = False, - _seen: set[int] | None = None, -) -> None: - kwargs: dict[str, Any] = {} - 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, - exc_value, - exc_traceback, - limit=limit, - lookup_lines=lookup_lines, - capture_locals=capture_locals, - _seen=_seen, - **kwargs, - ) - - seen_was_none = _seen is None - - if _seen is None: - _seen = set() - - # 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( - 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), - ) - ) - self.exceptions = embedded - self.msg = exc_value.message - else: - self.exceptions = None - self.__notes__ = getattr(exc_value, "__notes__", ()) - - class _ExceptionPrintContext: def __init__(self): self.seen = set() @@ -124,134 +65,195 @@ def emit(self, text_gen, margin_char=None): yield textwrap.indent(text, indent_str, lambda line: True) -def traceback_exception_format_exception_only(self): - """Format the exception part of the traceback. - The return value is a generator of strings, each ending in a newline. - Normally, the generator emits a single string; however, for - SyntaxError exceptions, it emits several lines that (when - printed) display detailed information about where the syntax - error occurred. - The message indicating which exception occurred is always the last - string in the output. - """ - if self.exc_type is None: - yield traceback._format_final_exc_line(None, self._str) - return - - stype = self.exc_type.__qualname__ - smod = self.exc_type.__module__ - if smod not in ("__main__", "builtins"): - if not isinstance(smod, str): - smod = "" - stype = smod + "." + stype - - if not issubclass(self.exc_type, SyntaxError): - yield _format_final_exc_line(stype, self._str) - elif traceback_exception_format_syntax_error is not None: - yield from traceback_exception_format_syntax_error(self, stype) - else: - yield from traceback_exception_original_format_exception_only(self) - - if isinstance(self.__notes__, collections.abc.Sequence): - for note in self.__notes__: - note = _safe_string(note, "note") - yield from [line + "\n" for line in note.split("\n")] - elif self.__notes__ is not None: - yield _safe_string(self.__notes__, "__notes__", func=repr) - - -def traceback_exception_format(self, *, chain=True, _ctx=None): - if _ctx is None: - _ctx = _ExceptionPrintContext() - - output = [] - exc = self - if chain: - while exc: - if exc.__cause__ is not None: - chained_msg = _cause_message - chained_exc = exc.__cause__ - elif exc.__context__ is not None and not exc.__suppress_context__: - chained_msg = _context_message - chained_exc = exc.__context__ - else: - chained_msg = None - chained_exc = None +def exceptiongroup_excepthook( + etype: type[BaseException], value: BaseException, tb: TracebackType | None +) -> None: + sys.stderr.write("".join(traceback.format_exception(etype, value, tb))) - output.append((chained_msg, exc)) - exc = chained_exc - else: - output.append((None, exc)) - - for msg, exc in reversed(output): - if msg is not None: - yield from _ctx.emit(msg) - if exc.exceptions is None: - if exc.stack: - yield from _ctx.emit("Traceback (most recent call last):\n") - yield from _ctx.emit(exc.stack.format()) - yield from _ctx.emit(exc.format_exception_only()) - elif _ctx.exception_group_depth > max_group_depth: - # exception group, but depth exceeds limit - yield from _ctx.emit(f"... (max_group_depth is {max_group_depth})\n") + +class PatchedTracebackException(traceback.TracebackException): + def __init__( + self, + exc_type: type[BaseException], + exc_value: BaseException, + exc_traceback: TracebackType, + *, + limit: int | None = None, + lookup_lines: bool = True, + capture_locals: bool = False, + compact: bool = False, + _seen: set[int] | None = None, + ) -> None: + kwargs: dict[str, Any] = {} + 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, + exc_value, + exc_traceback, + limit=limit, + lookup_lines=lookup_lines, + capture_locals=capture_locals, + _seen=_seen, + **kwargs, + ) + + seen_was_none = _seen is None + + if _seen is None: + _seen = set() + + # 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( + 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), + ) + ) + self.exceptions = embedded + self.msg = exc_value.message else: - # format exception group - is_toplevel = _ctx.exception_group_depth == 0 - if is_toplevel: - _ctx.exception_group_depth += 1 - - if exc.stack: - yield from _ctx.emit( - "Exception Group Traceback (most recent call last):\n", - margin_char="+" if is_toplevel else None, - ) - yield from _ctx.emit(exc.stack.format()) - - yield from _ctx.emit(exc.format_exception_only()) - num_excs = len(exc.exceptions) - if num_excs <= max_group_width: - n = num_excs - else: - n = max_group_width + 1 - _ctx.need_close = False - for i in range(n): - last_exc = i == n - 1 - if last_exc: - # The closing frame may be added by a recursive call - _ctx.need_close = True - - if max_group_width is not None: - truncated = i >= max_group_width - else: - truncated = False - title = f"{i + 1}" if not truncated else "..." - yield ( - _ctx.indent() - + ("+-" if i == 0 else " ") - + f"+---------------- {title} ----------------\n" - ) - _ctx.exception_group_depth += 1 - if not truncated: - yield from exc.exceptions[i].format(chain=chain, _ctx=_ctx) + self.exceptions = None + self.__notes__ = getattr(exc_value, "__notes__", ()) + + def format(self, *, chain=True, _ctx=None): + if _ctx is None: + _ctx = _ExceptionPrintContext() + + output = [] + exc = self + if chain: + while exc: + if exc.__cause__ is not None: + chained_msg = _cause_message + chained_exc = exc.__cause__ + elif exc.__context__ is not None and not exc.__suppress_context__: + chained_msg = _context_message + chained_exc = exc.__context__ else: - remaining = num_excs - max_group_width - plural = "s" if remaining > 1 else "" - yield from _ctx.emit(f"and {remaining} more exception{plural}\n") - - if last_exc and _ctx.need_close: - yield (_ctx.indent() + "+------------------------------------\n") - _ctx.need_close = False - _ctx.exception_group_depth -= 1 + chained_msg = None + chained_exc = None - if is_toplevel: - assert _ctx.exception_group_depth == 1 - _ctx.exception_group_depth = 0 + output.append((chained_msg, exc)) + exc = chained_exc + else: + output.append((None, exc)) + + for msg, exc in reversed(output): + if msg is not None: + yield from _ctx.emit(msg) + if exc.exceptions is None: + if exc.stack: + yield from _ctx.emit("Traceback (most recent call last):\n") + yield from _ctx.emit(exc.stack.format()) + yield from _ctx.emit(exc.format_exception_only()) + elif _ctx.exception_group_depth > max_group_depth: + # exception group, but depth exceeds limit + yield from _ctx.emit(f"... (max_group_depth is {max_group_depth})\n") + else: + # format exception group + is_toplevel = _ctx.exception_group_depth == 0 + if is_toplevel: + _ctx.exception_group_depth += 1 + + if exc.stack: + yield from _ctx.emit( + "Exception Group Traceback (most recent call last):\n", + margin_char="+" if is_toplevel else None, + ) + yield from _ctx.emit(exc.stack.format()) + yield from _ctx.emit(exc.format_exception_only()) + num_excs = len(exc.exceptions) + if num_excs <= max_group_width: + n = num_excs + else: + n = max_group_width + 1 + _ctx.need_close = False + for i in range(n): + last_exc = i == n - 1 + if last_exc: + # The closing frame may be added by a recursive call + _ctx.need_close = True + + if max_group_width is not None: + truncated = i >= max_group_width + else: + truncated = False + title = f"{i + 1}" if not truncated else "..." + yield ( + _ctx.indent() + + ("+-" if i == 0 else " ") + + f"+---------------- {title} ----------------\n" + ) + _ctx.exception_group_depth += 1 + if not truncated: + yield from exc.exceptions[i].format(chain=chain, _ctx=_ctx) + else: + remaining = num_excs - max_group_width + plural = "s" if remaining > 1 else "" + yield from _ctx.emit( + f"and {remaining} more exception{plural}\n" + ) + + if last_exc and _ctx.need_close: + yield _ctx.indent() + "+------------------------------------\n" + _ctx.need_close = False + _ctx.exception_group_depth -= 1 + + if is_toplevel: + assert _ctx.exception_group_depth == 1 + _ctx.exception_group_depth = 0 + + def format_exception_only(self): + """Format the exception part of the traceback. + The return value is a generator of strings, each ending in a newline. + Normally, the generator emits a single string; however, for + SyntaxError exceptions, it emits several lines that (when + printed) display detailed information about where the syntax + error occurred. + The message indicating which exception occurred is always the last + string in the output. + """ + if self.exc_type is None: + yield traceback._format_final_exc_line(None, self._str) + return + + stype = self.exc_type.__qualname__ + smod = self.exc_type.__module__ + if smod not in ("__main__", "builtins"): + if not isinstance(smod, str): + smod = "" + stype = smod + "." + stype + + if not issubclass(self.exc_type, SyntaxError): + yield _format_final_exc_line(stype, self._str) + elif traceback_exception_format_syntax_error is not None: + yield from traceback_exception_format_syntax_error(self, stype) + else: + yield from traceback_exception_original_format_exception_only(self) -def exceptiongroup_excepthook( - etype: type[BaseException], value: BaseException, tb: TracebackType | None -) -> None: - sys.stderr.write("".join(traceback.format_exception(etype, value, tb))) + if isinstance(self.__notes__, collections.abc.Sequence): + for note in self.__notes__: + note = _safe_string(note, "note") + yield from [line + "\n" for line in note.split("\n")] + elif self.__notes__ is not None: + yield _safe_string(self.__notes__, "__notes__", func=repr) traceback_exception_original_init = traceback.TracebackException.__init__ @@ -264,30 +266,15 @@ def exceptiongroup_excepthook( ) if sys.excepthook is sys.__excepthook__: traceback.TracebackException.__init__ = ( # type: ignore[assignment] - traceback_exception_init + PatchedTracebackException.__init__ ) traceback.TracebackException.format = ( # type: ignore[assignment] - traceback_exception_format + PatchedTracebackException.format ) traceback.TracebackException.format_exception_only = ( # type: ignore[assignment] - traceback_exception_format_exception_only + PatchedTracebackException.format_exception_only ) sys.excepthook = exceptiongroup_excepthook - PatchedTracebackException = traceback.TracebackException -else: - - class PatchedTracebackException(traceback.TracebackException): - pass - - PatchedTracebackException.__init__ = ( # type: ignore[assignment] - traceback_exception_init - ) - PatchedTracebackException.format = ( # type: ignore[assignment] - traceback_exception_format - ) - PatchedTracebackException.format_exception_only = ( # type: ignore[assignment] - traceback_exception_format_exception_only - ) @singledispatch From 65f80b226d6a58a00a77925c143d1297dbcac2b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 28 Aug 2022 15:45:33 +0300 Subject: [PATCH 4/5] Added more patched traceback functions --- README.rst | 39 ++++-- src/exceptiongroup/__init__.py | 16 ++- src/exceptiongroup/_formatting.py | 37 +++++ tests/test_formatting.py | 219 +++++++++++++++++++++--------- 4 files changed, 228 insertions(+), 83 deletions(-) diff --git a/README.rst b/README.rst index 3a2f62e..48178d9 100644 --- a/README.rst +++ b/README.rst @@ -18,9 +18,14 @@ It contains the following: (installed on import) * An exception hook that handles formatting of exception groups through ``TracebackException`` (installed on import) -* Special versions of the ``traceback.format_exception()`` and - ``traceback.format_exception_only()`` functions that format exception groups correctly - even if monkey patching is disabled or blocked by another custom exception hook +* Special versions of some of the functions from the ``traceback`` module, modified to + correctly handle exception groups even when monkey patching is disabled, or blocked by + another custom exception hook: + + * ``traceback.format_exception()`` + * ``traceback.format_exception_only()`` + * ``traceback.print_exception()`` + * ``traceback.print_exc()`` If this package is imported on Python 3.11 or later, the built-in implementations of the exception group classes are used instead, ``TracebackException`` is not monkey patched @@ -79,17 +84,6 @@ would be written with this backport like this: **NOTE**: Just like with ``except*``, you cannot handle ``BaseExceptionGroup`` or ``ExceptionGroup`` with ``catch()``. -Formatting exception groups -=========================== - -Normally, the monkey patching applied by this library on import will cause exception -groups to be printed properly in tracebacks. But in cases when the monkey patching is -blocked by a third party exception hook, or monkey patching is explicitly disabled, -you can still manually format exceptions using ``exceptiongroup.format_exception()`` or -``exceptiongroup.format_exception_only()``. They work just like their counterparts in -the ``traceback`` module, except that they use a separately patched subclass of -``TracebackException`` to perform the rendering. - Notes on monkey patching ======================== @@ -104,7 +98,24 @@ earlier than 3.11: already present. This hook causes the exception to be formatted using ``traceback.TracebackException`` rather than the built-in rendered. +If ``sys.exceptionhook`` is found to be set to something else than the default when +``exceptiongroup`` is imported, no monkeypatching is done at all. + To prevent the exception hook and patches from being installed, set the environment variable ``EXCEPTIONGROUP_NO_PATCH`` to ``1``. +Formatting exception groups +--------------------------- + +Normally, the monkey patching applied by this library on import will cause exception +groups to be printed properly in tracebacks. But in cases when the monkey patching is +blocked by a third party exception hook, or monkey patching is explicitly disabled, +you can still manually format exceptions using the special versions of the ``traceback`` +functions, like ``format_exception()``, listed at the top of this page. They work just +like their counterparts in the ``traceback`` module, except that they use a separately +patched subclass of ``TracebackException`` to perform the rendering. + +Particularly in cases where a library installs its own exception hook, it is recommended +to use these special versions to do the actual formatting of exceptions/tracebacks. + .. _PEP 654: https://www.python.org/dev/peps/pep-0654/ diff --git a/src/exceptiongroup/__init__.py b/src/exceptiongroup/__init__.py index 95023e4..0e7e02b 100644 --- a/src/exceptiongroup/__init__.py +++ b/src/exceptiongroup/__init__.py @@ -4,6 +4,8 @@ "catch", "format_exception", "format_exception_only", + "print_exception", + "print_exc", ] import os @@ -14,7 +16,12 @@ if sys.version_info < (3, 11): from ._exceptions import BaseExceptionGroup, ExceptionGroup - from ._formatting import format_exception, format_exception_only + from ._formatting import ( + format_exception, + format_exception_only, + print_exc, + print_exception, + ) if os.getenv("EXCEPTIONGROUP_NO_PATCH") != "1": from . import _formatting # noqa: F401 @@ -22,7 +29,12 @@ BaseExceptionGroup.__module__ = __name__ ExceptionGroup.__module__ = __name__ else: - from traceback import format_exception, format_exception_only + from traceback import ( + format_exception, + format_exception_only, + print_exc, + print_exception, + ) BaseExceptionGroup = BaseExceptionGroup ExceptionGroup = ExceptionGroup diff --git a/src/exceptiongroup/_formatting.py b/src/exceptiongroup/_formatting.py index a462f34..2c74162 100644 --- a/src/exceptiongroup/_formatting.py +++ b/src/exceptiongroup/_formatting.py @@ -311,3 +311,40 @@ def _( chain: bool = True, ) -> List[str]: return format_exception(value, limit, chain) + + +@singledispatch +def print_exception( + __exc: BaseException, + limit: Optional[int] = None, + file: Any = None, + chain: bool = True, +) -> None: + if file is None: + file = sys.stderr + + for line in PatchedTracebackException( + type(__exc), __exc, __exc.__traceback__, limit=limit + ).format(chain=chain): + print(line, file=file, end="") + + +@print_exception.register +def _( + __exc: type, + value: BaseException, + tb: TracebackType, + limit: Optional[int] = None, + file: Any = None, + chain: bool = True, +) -> None: + print_exception(value, limit, file, chain) + + +def print_exc( + limit: Optional[int] = None, + file: Any | None = None, + chain: bool = True, +) -> None: + value = sys.exc_info()[1] + print_exception(value, limit, file, chain) diff --git a/tests/test_formatting.py b/tests/test_formatting.py index 640a5de..0270cb9 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -1,12 +1,32 @@ import sys +from typing import NoReturn import pytest +from _pytest.capture import CaptureFixture from _pytest.fixtures import SubRequest from _pytest.monkeypatch import MonkeyPatch from exceptiongroup import ExceptionGroup +def raise_excgroup() -> NoReturn: + exceptions = [] + try: + raise ValueError("foo") + except ValueError as exc: + exceptions.append(exc) + + try: + raise RuntimeError("bar") + except RuntimeError as exc: + exc.__notes__ = ["Note from bar handler"] + exceptions.append(exc) + + exc = ExceptionGroup("test message", exceptions) + exc.add_note("Displays notes attached to the group too") + raise exc + + @pytest.fixture( params=[ pytest.param(True, id="patched"), @@ -33,43 +53,33 @@ def old_argstyle(request: SubRequest) -> bool: return request.param -def test_formatting(capsys): - exceptions = [] - try: - raise ValueError("foo") - except ValueError as exc: - exceptions.append(exc) - +def test_exceptionhook(capsys: CaptureFixture) -> None: try: - raise RuntimeError("bar") - except RuntimeError as exc: - exc.__notes__ = ["Note from bar handler"] - exceptions.append(exc) - - try: - raise ExceptionGroup("test message", exceptions) + raise_excgroup() except ExceptionGroup as exc: - exc.add_note("Displays notes attached to the group too") sys.excepthook(type(exc), exc, exc.__traceback__) - lineno = test_formatting.__code__.co_firstlineno + local_lineno = test_exceptionhook.__code__.co_firstlineno + lineno = raise_excgroup.__code__.co_firstlineno module_prefix = "" if sys.version_info >= (3, 11) else "exceptiongroup." output = capsys.readouterr().err assert output == ( f"""\ + Exception Group Traceback (most recent call last): - | File "{__file__}", line {lineno + 14}, in test_formatting - | raise ExceptionGroup("test message", exceptions) + | File "{__file__}", line {local_lineno + 2}, in test_exceptionhook + | raise_excgroup() + | File "{__file__}", line {lineno + 15}, in raise_excgroup + | raise exc | {module_prefix}ExceptionGroup: test message (2 sub-exceptions) | Displays notes attached to the group too +-+---------------- 1 ---------------- | Traceback (most recent call last): - | File "{__file__}", line {lineno + 3}, in test_formatting + | File "{__file__}", line {lineno + 3}, in raise_excgroup | raise ValueError("foo") | ValueError: foo +---------------- 2 ---------------- | Traceback (most recent call last): - | File "{__file__}", line {lineno + 8}, in test_formatting + | File "{__file__}", line {lineno + 8}, in raise_excgroup | raise RuntimeError("bar") | RuntimeError: bar | Note from bar handler @@ -78,43 +88,34 @@ def test_formatting(capsys): ) -def test_formatting_exception_only(capsys): - exceptions = [] - try: - raise ValueError("foo") - except ValueError as exc: - exceptions.append(exc) - +def test_exceptionhook_format_exception_only(capsys: CaptureFixture) -> None: try: - raise RuntimeError("bar") - except RuntimeError as exc: - exc.__notes__ = ["Note from bar handler"] - exceptions.append(exc) - - try: - raise ExceptionGroup("test message", exceptions) + raise_excgroup() except ExceptionGroup as exc: - exc.add_note("Displays notes attached to the group too") sys.excepthook(type(exc), exc, exc.__traceback__) - lineno = test_formatting_exception_only.__code__.co_firstlineno + local_lineno = test_exceptionhook_format_exception_only.__code__.co_firstlineno + lineno = raise_excgroup.__code__.co_firstlineno module_prefix = "" if sys.version_info >= (3, 11) else "exceptiongroup." output = capsys.readouterr().err assert output == ( f"""\ + Exception Group Traceback (most recent call last): - | File "{__file__}", line {lineno + 14}, in test_formatting_exception_only - | raise ExceptionGroup("test message", exceptions) + | File "{__file__}", line {local_lineno + 2}, in \ +test_exceptionhook_format_exception_only + | raise_excgroup() + | File "{__file__}", line {lineno + 15}, in raise_excgroup + | raise exc | {module_prefix}ExceptionGroup: test message (2 sub-exceptions) | Displays notes attached to the group too +-+---------------- 1 ---------------- | Traceback (most recent call last): - | File "{__file__}", line {lineno + 3}, in test_formatting_exception_only + | File "{__file__}", line {lineno + 3}, in raise_excgroup | raise ValueError("foo") | ValueError: foo +---------------- 2 ---------------- | Traceback (most recent call last): - | File "{__file__}", line {lineno + 8}, in test_formatting_exception_only + | File "{__file__}", line {lineno + 8}, in raise_excgroup | raise RuntimeError("bar") | RuntimeError: bar | Note from bar handler @@ -123,7 +124,7 @@ def test_formatting_exception_only(capsys): ) -def test_formatting_syntax_error(capsys): +def test_formatting_syntax_error(capsys: CaptureFixture) -> None: try: exec("//serser") except SyntaxError as exc: @@ -176,33 +177,34 @@ def test_format_exception( exceptions.append(exc) try: - exc = ExceptionGroup("test message", exceptions) - exc.add_note("Displays notes attached to the group too") - raise exc + raise_excgroup() except ExceptionGroup as exc: if old_argstyle: lines = format_exception(type(exc), exc, exc.__traceback__) else: lines = format_exception(exc) + local_lineno = test_format_exception.__code__.co_firstlineno + lineno = raise_excgroup.__code__.co_firstlineno assert isinstance(lines, list) - lineno = test_format_exception.__code__.co_firstlineno module_prefix = "" if sys.version_info >= (3, 11) else "exceptiongroup." assert "".join(lines) == ( f"""\ + Exception Group Traceback (most recent call last): - | File "{__file__}", line {lineno + 27}, in test_format_exception + | File "{__file__}", line {local_lineno + 25}, in test_format_exception + | raise_excgroup() + | File "{__file__}", line {lineno + 15}, in raise_excgroup | raise exc | {module_prefix}ExceptionGroup: test message (2 sub-exceptions) | Displays notes attached to the group too +-+---------------- 1 ---------------- | Traceback (most recent call last): - | File "{__file__}", line {lineno + 14}, in test_format_exception + | File "{__file__}", line {lineno + 3}, in raise_excgroup | raise ValueError("foo") | ValueError: foo +---------------- 2 ---------------- | Traceback (most recent call last): - | File "{__file__}", line {lineno + 19}, in test_format_exception + | File "{__file__}", line {lineno + 8}, in raise_excgroup | raise RuntimeError("bar") | RuntimeError: bar | Note from bar handler @@ -223,27 +225,110 @@ def test_format_exception_only( from exceptiongroup import format_exception_only - exceptions = [] try: - raise ValueError("foo") - except ValueError as exc: - exceptions.append(exc) + raise_excgroup() + except ExceptionGroup as exc: + if old_argstyle: + output = format_exception_only(type(exc), exc) + else: + output = format_exception_only(exc) + + module_prefix = "" if sys.version_info >= (3, 11) else "exceptiongroup." + assert output == [ + f"{module_prefix}ExceptionGroup: test message (2 sub-exceptions)\n", + "Displays notes attached to the group too\n", + ] + + +def test_print_exception( + patched: bool, old_argstyle: bool, monkeypatch: MonkeyPatch, capsys: CaptureFixture +) -> None: + if not patched: + # Block monkey patching, then force the module to be re-imported + del sys.modules["traceback"] + del sys.modules["exceptiongroup"] + del sys.modules["exceptiongroup._formatting"] + monkeypatch.setattr(sys, "excepthook", lambda *args: sys.__excepthook__(*args)) + + from exceptiongroup import print_exception try: - raise RuntimeError("bar") - except RuntimeError as exc: - exc.__notes__ = ["Note from bar handler"] - exceptions.append(exc) + raise_excgroup() + except ExceptionGroup as exc: + if old_argstyle: + print_exception(type(exc), exc, exc.__traceback__) + else: + print_exception(exc) - exc = ExceptionGroup("test message", exceptions) - exc.add_note("Displays notes attached to the group too") - if old_argstyle: - output = format_exception_only(type(exc), exc) - else: - output = format_exception_only(exc) + local_lineno = test_print_exception.__code__.co_firstlineno + lineno = raise_excgroup.__code__.co_firstlineno + module_prefix = "" if sys.version_info >= (3, 11) else "exceptiongroup." + output = capsys.readouterr().err + assert output == ( + f"""\ + + Exception Group Traceback (most recent call last): + | File "{__file__}", line {local_lineno + 13}, in test_print_exception + | raise_excgroup() + | File "{__file__}", line {lineno + 15}, in raise_excgroup + | raise exc + | {module_prefix}ExceptionGroup: test message (2 sub-exceptions) + | Displays notes attached to the group too + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | File "{__file__}", line {lineno + 3}, in raise_excgroup + | raise ValueError("foo") + | ValueError: foo + +---------------- 2 ---------------- + | Traceback (most recent call last): + | File "{__file__}", line {lineno + 8}, in raise_excgroup + | raise RuntimeError("bar") + | RuntimeError: bar + | Note from bar handler + +------------------------------------ +""" + ) - module_prefix = "" if sys.version_info >= (3, 11) else "exceptiongroup." - assert output == [ - f"{module_prefix}ExceptionGroup: test message (2 sub-exceptions)\n", - "Displays notes attached to the group too\n", - ] + +def test_print_exc( + patched: bool, monkeypatch: MonkeyPatch, capsys: CaptureFixture +) -> None: + if not patched: + # Block monkey patching, then force the module to be re-imported + del sys.modules["traceback"] + del sys.modules["exceptiongroup"] + del sys.modules["exceptiongroup._formatting"] + monkeypatch.setattr(sys, "excepthook", lambda *args: sys.__excepthook__(*args)) + + from exceptiongroup import print_exc + + try: + raise_excgroup() + except ExceptionGroup: + print_exc() + local_lineno = test_print_exc.__code__.co_firstlineno + lineno = raise_excgroup.__code__.co_firstlineno + module_prefix = "" if sys.version_info >= (3, 11) else "exceptiongroup." + output = capsys.readouterr().err + assert output == ( + f"""\ + + Exception Group Traceback (most recent call last): + | File "{__file__}", line {local_lineno + 13}, in test_print_exc + | raise_excgroup() + | File "{__file__}", line {lineno + 15}, in raise_excgroup + | raise exc + | {module_prefix}ExceptionGroup: test message (2 sub-exceptions) + | Displays notes attached to the group too + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | File "{__file__}", line {lineno + 3}, in raise_excgroup + | raise ValueError("foo") + | ValueError: foo + +---------------- 2 ---------------- + | Traceback (most recent call last): + | File "{__file__}", line {lineno + 8}, in raise_excgroup + | raise RuntimeError("bar") + | RuntimeError: bar + | Note from bar handler + +------------------------------------ +""" + ) From 0e971a6764942af753419da40e5b4813214564ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 28 Aug 2022 15:46:43 +0300 Subject: [PATCH 5/5] Updated the changelog --- CHANGES.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 817285c..f28691e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,9 +5,8 @@ This library adheres to `Semantic Versioning 2.0 `_. **UNRELEASED** -- Added custom versions of ``traceback.format_exception()`` and - ``traceback.format_exception_only()`` that work with exception groups even if monkey - patching was disabled or blocked +- Added custom versions of several ``traceback`` functions that work with exception + groups even if monkey patching was disabled or blocked **1.0.0rc8**