diff --git a/CHANGELOG.md b/CHANGELOG.md index 0125f807..87601113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/ - Support for Python 3.14 and Python 3.13.4. [#723](https://github.com/hynek/structlog/pull/723) +- `structlog.tracebacks` now handles [exception groups](https://docs.python.org/3/library/exceptions.html#exception-groups). + `structlog.tracebacks.Stack` has two new fields, `is_group: bool` and `exceptions: list[Trace]`. + This works similarly to what Rich v14.0.0 does. + [#720](https://github.com/hynek/structlog/pull/720) + + ### Fixed - `structlog.processors.ExceptionPrettyPrinter` now respects the *exception_formatter* arguments instead of always using the default formatter. diff --git a/docs/api.rst b/docs/api.rst index 3719756a..f13fdbb9 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -197,7 +197,7 @@ API Reference ... 1 / 0 ... except ZeroDivisionError: ... log.exception("Cannot compute!") - {"event": "Cannot compute!", "exception": [{"exc_type": "ZeroDivisionError", "exc_value": "division by zero", "exc_notes": [], "syntax_error": null, "is_cause": false, "frames": [{"filename": "", "lineno": 2, "name": "", "locals": {..., "var": "'spam'"}}]}]} + {"event": "Cannot compute!", "exception": [{"exc_type": "ZeroDivisionError", "exc_value": "division by zero", "exc_notes": [], "syntax_error": null, "is_cause": false, "frames": [{"filename": "", "lineno": 2, "name": "", "locals": {..., "var": "'spam'"}}], "is_group": false, "exceptions": []}]} .. autoclass:: KeyValueRenderer diff --git a/src/structlog/tracebacks.py b/src/structlog/tracebacks.py index a6c6cefc..949df9cd 100644 --- a/src/structlog/tracebacks.py +++ b/src/structlog/tracebacks.py @@ -16,6 +16,7 @@ import os import os.path +import sys from dataclasses import asdict, dataclass, field from traceback import walk_tb @@ -84,6 +85,9 @@ class Stack: .. versionchanged:: 25.2.0 Added the *exc_notes* field. + + .. versionchanged:: 25.4.0 + Added the *is_group* and *exceptions* fields. """ exc_type: str @@ -92,6 +96,8 @@ class Stack: syntax_error: SyntaxError_ | None = None is_cause: bool = False frames: list[Frame] = field(default_factory=list) + is_group: bool = False + exceptions: list[Trace] = field(default_factory=list) @dataclass @@ -222,9 +228,13 @@ def extract( A Trace instance with structured information about all exceptions. .. versionadded:: 22.1.0 + .. versionchanged:: 24.3.0 Added *locals_max_length*, *locals_hide_sunder*, *locals_hide_dunder* and *use_rich* arguments. + + .. versionchanged:: 25.4.0 + Handle exception groups. """ stacks: list[Stack] = [] @@ -240,6 +250,24 @@ def extract( is_cause=is_cause, ) + if sys.version_info >= (3, 11): + if isinstance(exc_value, (BaseExceptionGroup, ExceptionGroup)): # noqa: F821 + stack.is_group = True + for exception in exc_value.exceptions: + stack.exceptions.append( + extract( + type(exception), + exception, + exception.__traceback__, + show_locals=show_locals, + locals_max_length=locals_max_length, + locals_max_string=locals_max_string, + locals_hide_dunder=locals_hide_dunder, + locals_hide_sunder=locals_hide_sunder, + use_rich=use_rich, + ) + ) + if isinstance(exc_value, SyntaxError): stack.syntax_error = SyntaxError_( offset=exc_value.offset or 0, @@ -377,6 +405,9 @@ class ExceptionDictTransformer: .. versionchanged:: 25.1.0 *locals_max_length* and *locals_max_string* may be None to disable truncation. + + .. versionchanged:: 25.4.0 + Handle exception groups. """ def __init__( @@ -451,13 +482,21 @@ def __call__(self, exc_info: ExcInfo) -> list[dict[str, Any]]: *stack.frames[-half:], ] - stacks = [asdict(stack) for stack in trace.stacks] - for stack_dict in stacks: + return self._as_dict(trace) + + def _as_dict(self, trace: Trace) -> list[dict[str, Any]]: + stack_dicts = [] + for stack in trace.stacks: + stack_dict = asdict(stack) for frame_dict in stack_dict["frames"]: if frame_dict["locals"] is None or any( frame_dict["filename"].startswith(path) for path in self.suppress ): del frame_dict["locals"] - - return stacks + if stack.is_group: + stack_dict["exceptions"] = [ + self._as_dict(t) for t in stack.exceptions + ] + stack_dicts.append(stack_dict) + return stack_dicts diff --git a/tests/test_tracebacks.py b/tests/test_tracebacks.py index 0dd1eb80..f83f72fb 100644 --- a/tests/test_tracebacks.py +++ b/tests/test_tracebacks.py @@ -5,6 +5,7 @@ from __future__ import annotations +import asyncio import inspect import json import sys @@ -567,6 +568,84 @@ def bar(n): ] +@pytest.mark.skipif( + sys.version_info < (3, 11), reason="Requires Python 3.11 or higher" +) +def test_exception_groups() -> None: + """ + Exception groups are detected and a list of Trace instances is added to + the exception group's Trace. + """ + lineno = get_next_lineno() + + async def t1() -> None: + 1 / 0 + + async def t2() -> None: + raise ValueError("Blam!") + + async def main(): + async with asyncio.TaskGroup() as tg: + tg.create_task(t1()) + tg.create_task(t2()) + + try: + asyncio.run(main()) + except Exception as e: + trace = tracebacks.extract(type(e), e, e.__traceback__) + + assert "ExceptionGroup" == trace.stacks[0].exc_type + assert ( + "unhandled errors in a TaskGroup (2 sub-exceptions)" + == trace.stacks[0].exc_value + ) + exceptions = trace.stacks[0].exceptions + assert [ + tracebacks.Trace( + stacks=[ + tracebacks.Stack( + exc_type="ZeroDivisionError", + exc_value="division by zero", + exc_notes=[], + syntax_error=None, + is_cause=False, + frames=[ + tracebacks.Frame( + filename=__file__, + lineno=lineno + 2, + name="t1", + locals=None, + ) + ], + is_group=False, + exceptions=[], + ) + ] + ), + tracebacks.Trace( + stacks=[ + tracebacks.Stack( + exc_type="ValueError", + exc_value="Blam!", + exc_notes=[], + syntax_error=None, + is_cause=False, + frames=[ + tracebacks.Frame( + filename=__file__, + lineno=lineno + 5, + name="t2", + locals=None, + ) + ], + is_group=False, + exceptions=[], + ) + ] + ), + ] == exceptions + + @pytest.mark.parametrize( ("kwargs", "local_variable"), [ @@ -623,6 +702,7 @@ def test_json_traceback(): "exc_type": "ZeroDivisionError", "exc_value": "division by zero", "exc_notes": [], + "exceptions": [], "frames": [ { "filename": __file__, @@ -631,6 +711,7 @@ def test_json_traceback(): } ], "is_cause": False, + "is_group": False, "syntax_error": None, }, ] == result @@ -657,6 +738,7 @@ def test_json_traceback_with_notes(): "exc_type": "ZeroDivisionError", "exc_value": "division by zero", "exc_notes": ["This is a note.", "This is another note."], + "exceptions": [], "frames": [ { "filename": __file__, @@ -665,6 +747,7 @@ def test_json_traceback_with_notes(): } ], "is_cause": False, + "is_group": False, "syntax_error": None, }, ] == result @@ -687,6 +770,7 @@ def test_json_traceback_locals_max_string(): "exc_type": "ZeroDivisionError", "exc_value": "division by zero", "exc_notes": [], + "exceptions": [], "frames": [ { "filename": __file__, @@ -700,6 +784,7 @@ def test_json_traceback_locals_max_string(): } ], "is_cause": False, + "is_group": False, "syntax_error": None, }, ] == result @@ -844,6 +929,76 @@ def test_json_traceback_value_error( tracebacks.ExceptionDictTransformer(**kwargs) +@pytest.mark.skipif( + sys.version_info < (3, 11), reason="Requires Python 3.11 or higher" +) +def test_json_exception_groups() -> None: + """ + When rendered as JSON, the "Trace.stacks" is stripped, so "exceptions" is a + list of lists and not a list of objects (with a single "stacks" attribute. + """ + + lineno = get_next_lineno() + + async def t1() -> None: + 1 / 0 + + async def t2() -> None: + raise ValueError("Blam!") + + async def main(): + async with asyncio.TaskGroup() as tg: + tg.create_task(t1()) + tg.create_task(t2()) + + try: + asyncio.run(main()) + except Exception as e: + format_json = tracebacks.ExceptionDictTransformer(show_locals=False) + result = format_json((type(e), e, e.__traceback__)) + + assert "ExceptionGroup" == result[0]["exc_type"] + exceptions = result[0]["exceptions"] + assert [ + [ + { + "exc_type": "ZeroDivisionError", + "exc_value": "division by zero", + "exc_notes": [], + "syntax_error": None, + "is_cause": False, + "frames": [ + { + "filename": __file__, + "lineno": lineno + 2, + "name": "t1", + } + ], + "is_group": False, + "exceptions": [], + } + ], + [ + { + "exc_type": "ValueError", + "exc_value": "Blam!", + "exc_notes": [], + "syntax_error": None, + "is_cause": False, + "frames": [ + { + "filename": __file__, + "lineno": lineno + 5, + "name": "t2", + } + ], + "is_group": False, + "exceptions": [], + } + ], + ] == exceptions + + class TestLogException: """ Higher level integration tests for `Logger.exception()`.