From 9b79e7f7d2eaa5640902d62ee14d90e1288a42d6 Mon Sep 17 00:00:00 2001 From: Stefan Scherfke Date: Wed, 16 Apr 2025 22:24:56 +0200 Subject: [PATCH 1/8] tracebacks: Handle ExceptionGroup Fixes: #676 --- docs/api.rst | 2 +- src/structlog/tracebacks.py | 37 ++++++++- tests/test_tracebacks.py | 155 ++++++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 5 deletions(-) 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..32c892a1 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 @@ -92,6 +93,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 @@ -240,6 +243,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, @@ -451,13 +472,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..c6e00c2d 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 + ) + exceptptions = trace.stacks[0].exceptions + assert [ + tracebacks.Trace( + stacks=[ + tracebacks.Stack( + exc_type="ZeroDivisionError", + exc_value="integer division or modulo 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=[], + ) + ] + ), + ] == exceptptions + + @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 (wht 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"] + exceptptions = result[0]["exceptions"] + assert [ + [ + { + "exc_type": "ZeroDivisionError", + "exc_value": "integer division or modulo 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": [], + } + ], + ] == exceptptions + + class TestLogException: """ Higher level integration tests for `Logger.exception()`. From abe863a67a6b187b0ed2eabde906c590432dc4ae Mon Sep 17 00:00:00 2001 From: Stefan Scherfke Date: Wed, 16 Apr 2025 22:32:18 +0200 Subject: [PATCH 2/8] Update docs and CHANGELOG --- CHANGELOG.md | 5 +++++ src/structlog/tracebacks.py | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 772903b3..d25b84bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/ ## [Unreleased](https://github.com/hynek/structlog/compare/25.2.0...HEAD) +- `structlog.tracebacks` handles 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) + ## [25.2.0](https://github.com/hynek/structlog/compare/25.1.0...25.2.0) - 2025-03-11 diff --git a/src/structlog/tracebacks.py b/src/structlog/tracebacks.py index 32c892a1..4a7d2205 100644 --- a/src/structlog/tracebacks.py +++ b/src/structlog/tracebacks.py @@ -85,6 +85,8 @@ class Stack: .. versionchanged:: 25.2.0 Added the *exc_notes* field. + .. versionchanged:: 25.3.0 + Added the *is_group* and *exceptions* fields. """ exc_type: str @@ -228,6 +230,8 @@ def extract( .. versionchanged:: 24.3.0 Added *locals_max_length*, *locals_hide_sunder*, *locals_hide_dunder* and *use_rich* arguments. + .. versionchanged:: 25.3.0 + Handle exception groups. """ stacks: list[Stack] = [] @@ -398,6 +402,9 @@ class ExceptionDictTransformer: .. versionchanged:: 25.1.0 *locals_max_length* and *locals_max_string* may be None to disable truncation. + + .. versionchanged:: 25.3.0 + Handle exception groups. """ def __init__( From b9a1106506f104efa3b32a2befbc3b5d0950757d Mon Sep 17 00:00:00 2001 From: Stefan Scherfke Date: Wed, 28 May 2025 22:54:35 +0200 Subject: [PATCH 3/8] Update tests Exc values for ZeroDivisionError 1 // 0 changed in py314... --- tests/test_tracebacks.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_tracebacks.py b/tests/test_tracebacks.py index c6e00c2d..463014a7 100644 --- a/tests/test_tracebacks.py +++ b/tests/test_tracebacks.py @@ -579,7 +579,7 @@ def test_exception_groups() -> None: lineno = get_next_lineno() async def t1() -> None: - 1 // 0 + 1 / 0 async def t2() -> None: raise ValueError("Blam!") @@ -605,7 +605,7 @@ async def main(): stacks=[ tracebacks.Stack( exc_type="ZeroDivisionError", - exc_value="integer division or modulo by zero", + exc_value="division by zero", exc_notes=[], syntax_error=None, is_cause=False, @@ -941,7 +941,7 @@ def test_json_exception_groups() -> None: lineno = get_next_lineno() async def t1() -> None: - 1 // 0 + 1 / 0 async def t2() -> None: raise ValueError("Blam!") @@ -963,7 +963,7 @@ async def main(): [ { "exc_type": "ZeroDivisionError", - "exc_value": "integer division or modulo by zero", + "exc_value": "division by zero", "exc_notes": [], "syntax_error": None, "is_cause": False, From 7ae1b47d148e1987ecd287df3f4169a083bb10ea Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 29 May 2025 11:23:33 +0200 Subject: [PATCH 4/8] Move changelog entry to correct heading --- CHANGELOG.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ba28bc3..070b4877 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. + `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. @@ -32,10 +38,6 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/ - `structlog.processors.TimeStamper` now again uses timestamps using UTC for custom format strings when `utc=True`. [#713](https://github.com/hynek/structlog/pull/713) -- `structlog.tracebacks` handles 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) ## [25.2.0](https://github.com/hynek/structlog/compare/25.1.0...25.2.0) - 2025-03-11 From 17ca513a780880b011e89c59e0bc192b30df434e Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 29 May 2025 11:31:44 +0200 Subject: [PATCH 5/8] Let's link --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 070b4877..87601113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ 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. +- `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) @@ -39,7 +39,6 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/ [#713](https://github.com/hynek/structlog/pull/713) - ## [25.2.0](https://github.com/hynek/structlog/compare/25.1.0...25.2.0) - 2025-03-11 ### Added From c36b7fe0c7b4272d82df2fc3d580557ea872f68c Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 29 May 2025 11:34:13 +0200 Subject: [PATCH 6/8] Update version --- src/structlog/tracebacks.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/structlog/tracebacks.py b/src/structlog/tracebacks.py index 4a7d2205..949df9cd 100644 --- a/src/structlog/tracebacks.py +++ b/src/structlog/tracebacks.py @@ -85,7 +85,8 @@ class Stack: .. versionchanged:: 25.2.0 Added the *exc_notes* field. - .. versionchanged:: 25.3.0 + + .. versionchanged:: 25.4.0 Added the *is_group* and *exceptions* fields. """ @@ -227,10 +228,12 @@ 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.3.0 + + .. versionchanged:: 25.4.0 Handle exception groups. """ @@ -403,7 +406,7 @@ class ExceptionDictTransformer: *locals_max_length* and *locals_max_string* may be None to disable truncation. - .. versionchanged:: 25.3.0 + .. versionchanged:: 25.4.0 Handle exception groups. """ From b10f5bd9dc314bf0a260fbf9a4da51781dc49826 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 29 May 2025 11:37:12 +0200 Subject: [PATCH 7/8] Fix typo --- tests/test_tracebacks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_tracebacks.py b/tests/test_tracebacks.py index 463014a7..e06c2dda 100644 --- a/tests/test_tracebacks.py +++ b/tests/test_tracebacks.py @@ -599,7 +599,7 @@ async def main(): "unhandled errors in a TaskGroup (2 sub-exceptions)" == trace.stacks[0].exc_value ) - exceptptions = trace.stacks[0].exceptions + exceptions = trace.stacks[0].exceptions assert [ tracebacks.Trace( stacks=[ @@ -643,7 +643,7 @@ async def main(): ) ] ), - ] == exceptptions + ] == exceptions @pytest.mark.parametrize( From f2ddd1e454aac1fb42ed8c0d8793cbeddf9a0699 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 29 May 2025 11:39:40 +0200 Subject: [PATCH 8/8] Another one --- tests/test_tracebacks.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_tracebacks.py b/tests/test_tracebacks.py index e06c2dda..f83f72fb 100644 --- a/tests/test_tracebacks.py +++ b/tests/test_tracebacks.py @@ -934,8 +934,8 @@ def test_json_traceback_value_error( ) 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 (wht a single "stacks" attribute. + 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() @@ -958,7 +958,7 @@ async def main(): result = format_json((type(e), e, e.__traceback__)) assert "ExceptionGroup" == result[0]["exc_type"] - exceptptions = result[0]["exceptions"] + exceptions = result[0]["exceptions"] assert [ [ { @@ -996,7 +996,7 @@ async def main(): "exceptions": [], } ], - ] == exceptptions + ] == exceptions class TestLogException: