Skip to content

Commit f07c136

Browse files
sscherfkehynek
andauthored
tracebacks: Handle ExceptionGroup (#720)
* tracebacks: Handle ExceptionGroup Fixes: #676 * Update docs and CHANGELOG * Update tests Exc values for ZeroDivisionError 1 // 0 changed in py314... * Move changelog entry to correct heading * Let's link * Update version * Fix typo * Another one --------- Co-authored-by: Hynek Schlawack <hs@ox.cx>
1 parent de3dfc8 commit f07c136

File tree

4 files changed

+205
-5
lines changed

4 files changed

+205
-5
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/
2020
- Support for Python 3.14 and Python 3.13.4.
2121
[#723](https://github.com/hynek/structlog/pull/723)
2222

23+
- `structlog.tracebacks` now handles [exception groups](https://docs.python.org/3/library/exceptions.html#exception-groups).
24+
`structlog.tracebacks.Stack` has two new fields, `is_group: bool` and `exceptions: list[Trace]`.
25+
This works similarly to what Rich v14.0.0 does.
26+
[#720](https://github.com/hynek/structlog/pull/720)
27+
28+
2329
### Fixed
2430

2531
- `structlog.processors.ExceptionPrettyPrinter` now respects the *exception_formatter* arguments instead of always using the default formatter.

docs/api.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ API Reference
197197
... 1 / 0
198198
... except ZeroDivisionError:
199199
... log.exception("Cannot compute!")
200-
{"event": "Cannot compute!", "exception": [{"exc_type": "ZeroDivisionError", "exc_value": "division by zero", "exc_notes": [], "syntax_error": null, "is_cause": false, "frames": [{"filename": "<doctest default[3]>", "lineno": 2, "name": "<module>", "locals": {..., "var": "'spam'"}}]}]}
200+
{"event": "Cannot compute!", "exception": [{"exc_type": "ZeroDivisionError", "exc_value": "division by zero", "exc_notes": [], "syntax_error": null, "is_cause": false, "frames": [{"filename": "<doctest default[3]>", "lineno": 2, "name": "<module>", "locals": {..., "var": "'spam'"}}], "is_group": false, "exceptions": []}]}
201201

202202
.. autoclass:: KeyValueRenderer
203203

src/structlog/tracebacks.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import os
1818
import os.path
19+
import sys
1920

2021
from dataclasses import asdict, dataclass, field
2122
from traceback import walk_tb
@@ -84,6 +85,9 @@ class Stack:
8485
8586
.. versionchanged:: 25.2.0
8687
Added the *exc_notes* field.
88+
89+
.. versionchanged:: 25.4.0
90+
Added the *is_group* and *exceptions* fields.
8791
"""
8892

8993
exc_type: str
@@ -92,6 +96,8 @@ class Stack:
9296
syntax_error: SyntaxError_ | None = None
9397
is_cause: bool = False
9498
frames: list[Frame] = field(default_factory=list)
99+
is_group: bool = False
100+
exceptions: list[Trace] = field(default_factory=list)
95101

96102

97103
@dataclass
@@ -222,9 +228,13 @@ def extract(
222228
A Trace instance with structured information about all exceptions.
223229
224230
.. versionadded:: 22.1.0
231+
225232
.. versionchanged:: 24.3.0
226233
Added *locals_max_length*, *locals_hide_sunder*, *locals_hide_dunder*
227234
and *use_rich* arguments.
235+
236+
.. versionchanged:: 25.4.0
237+
Handle exception groups.
228238
"""
229239

230240
stacks: list[Stack] = []
@@ -240,6 +250,24 @@ def extract(
240250
is_cause=is_cause,
241251
)
242252

253+
if sys.version_info >= (3, 11):
254+
if isinstance(exc_value, (BaseExceptionGroup, ExceptionGroup)): # noqa: F821
255+
stack.is_group = True
256+
for exception in exc_value.exceptions:
257+
stack.exceptions.append(
258+
extract(
259+
type(exception),
260+
exception,
261+
exception.__traceback__,
262+
show_locals=show_locals,
263+
locals_max_length=locals_max_length,
264+
locals_max_string=locals_max_string,
265+
locals_hide_dunder=locals_hide_dunder,
266+
locals_hide_sunder=locals_hide_sunder,
267+
use_rich=use_rich,
268+
)
269+
)
270+
243271
if isinstance(exc_value, SyntaxError):
244272
stack.syntax_error = SyntaxError_(
245273
offset=exc_value.offset or 0,
@@ -377,6 +405,9 @@ class ExceptionDictTransformer:
377405
.. versionchanged:: 25.1.0
378406
*locals_max_length* and *locals_max_string* may be None to disable
379407
truncation.
408+
409+
.. versionchanged:: 25.4.0
410+
Handle exception groups.
380411
"""
381412

382413
def __init__(
@@ -451,13 +482,21 @@ def __call__(self, exc_info: ExcInfo) -> list[dict[str, Any]]:
451482
*stack.frames[-half:],
452483
]
453484

454-
stacks = [asdict(stack) for stack in trace.stacks]
455-
for stack_dict in stacks:
485+
return self._as_dict(trace)
486+
487+
def _as_dict(self, trace: Trace) -> list[dict[str, Any]]:
488+
stack_dicts = []
489+
for stack in trace.stacks:
490+
stack_dict = asdict(stack)
456491
for frame_dict in stack_dict["frames"]:
457492
if frame_dict["locals"] is None or any(
458493
frame_dict["filename"].startswith(path)
459494
for path in self.suppress
460495
):
461496
del frame_dict["locals"]
462-
463-
return stacks
497+
if stack.is_group:
498+
stack_dict["exceptions"] = [
499+
self._as_dict(t) for t in stack.exceptions
500+
]
501+
stack_dicts.append(stack_dict)
502+
return stack_dicts

tests/test_tracebacks.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
import asyncio
89
import inspect
910
import json
1011
import sys
@@ -567,6 +568,84 @@ def bar(n):
567568
]
568569

569570

571+
@pytest.mark.skipif(
572+
sys.version_info < (3, 11), reason="Requires Python 3.11 or higher"
573+
)
574+
def test_exception_groups() -> None:
575+
"""
576+
Exception groups are detected and a list of Trace instances is added to
577+
the exception group's Trace.
578+
"""
579+
lineno = get_next_lineno()
580+
581+
async def t1() -> None:
582+
1 / 0
583+
584+
async def t2() -> None:
585+
raise ValueError("Blam!")
586+
587+
async def main():
588+
async with asyncio.TaskGroup() as tg:
589+
tg.create_task(t1())
590+
tg.create_task(t2())
591+
592+
try:
593+
asyncio.run(main())
594+
except Exception as e:
595+
trace = tracebacks.extract(type(e), e, e.__traceback__)
596+
597+
assert "ExceptionGroup" == trace.stacks[0].exc_type
598+
assert (
599+
"unhandled errors in a TaskGroup (2 sub-exceptions)"
600+
== trace.stacks[0].exc_value
601+
)
602+
exceptions = trace.stacks[0].exceptions
603+
assert [
604+
tracebacks.Trace(
605+
stacks=[
606+
tracebacks.Stack(
607+
exc_type="ZeroDivisionError",
608+
exc_value="division by zero",
609+
exc_notes=[],
610+
syntax_error=None,
611+
is_cause=False,
612+
frames=[
613+
tracebacks.Frame(
614+
filename=__file__,
615+
lineno=lineno + 2,
616+
name="t1",
617+
locals=None,
618+
)
619+
],
620+
is_group=False,
621+
exceptions=[],
622+
)
623+
]
624+
),
625+
tracebacks.Trace(
626+
stacks=[
627+
tracebacks.Stack(
628+
exc_type="ValueError",
629+
exc_value="Blam!",
630+
exc_notes=[],
631+
syntax_error=None,
632+
is_cause=False,
633+
frames=[
634+
tracebacks.Frame(
635+
filename=__file__,
636+
lineno=lineno + 5,
637+
name="t2",
638+
locals=None,
639+
)
640+
],
641+
is_group=False,
642+
exceptions=[],
643+
)
644+
]
645+
),
646+
] == exceptions
647+
648+
570649
@pytest.mark.parametrize(
571650
("kwargs", "local_variable"),
572651
[
@@ -623,6 +702,7 @@ def test_json_traceback():
623702
"exc_type": "ZeroDivisionError",
624703
"exc_value": "division by zero",
625704
"exc_notes": [],
705+
"exceptions": [],
626706
"frames": [
627707
{
628708
"filename": __file__,
@@ -631,6 +711,7 @@ def test_json_traceback():
631711
}
632712
],
633713
"is_cause": False,
714+
"is_group": False,
634715
"syntax_error": None,
635716
},
636717
] == result
@@ -657,6 +738,7 @@ def test_json_traceback_with_notes():
657738
"exc_type": "ZeroDivisionError",
658739
"exc_value": "division by zero",
659740
"exc_notes": ["This is a note.", "This is another note."],
741+
"exceptions": [],
660742
"frames": [
661743
{
662744
"filename": __file__,
@@ -665,6 +747,7 @@ def test_json_traceback_with_notes():
665747
}
666748
],
667749
"is_cause": False,
750+
"is_group": False,
668751
"syntax_error": None,
669752
},
670753
] == result
@@ -687,6 +770,7 @@ def test_json_traceback_locals_max_string():
687770
"exc_type": "ZeroDivisionError",
688771
"exc_value": "division by zero",
689772
"exc_notes": [],
773+
"exceptions": [],
690774
"frames": [
691775
{
692776
"filename": __file__,
@@ -700,6 +784,7 @@ def test_json_traceback_locals_max_string():
700784
}
701785
],
702786
"is_cause": False,
787+
"is_group": False,
703788
"syntax_error": None,
704789
},
705790
] == result
@@ -844,6 +929,76 @@ def test_json_traceback_value_error(
844929
tracebacks.ExceptionDictTransformer(**kwargs)
845930

846931

932+
@pytest.mark.skipif(
933+
sys.version_info < (3, 11), reason="Requires Python 3.11 or higher"
934+
)
935+
def test_json_exception_groups() -> None:
936+
"""
937+
When rendered as JSON, the "Trace.stacks" is stripped, so "exceptions" is a
938+
list of lists and not a list of objects (with a single "stacks" attribute.
939+
"""
940+
941+
lineno = get_next_lineno()
942+
943+
async def t1() -> None:
944+
1 / 0
945+
946+
async def t2() -> None:
947+
raise ValueError("Blam!")
948+
949+
async def main():
950+
async with asyncio.TaskGroup() as tg:
951+
tg.create_task(t1())
952+
tg.create_task(t2())
953+
954+
try:
955+
asyncio.run(main())
956+
except Exception as e:
957+
format_json = tracebacks.ExceptionDictTransformer(show_locals=False)
958+
result = format_json((type(e), e, e.__traceback__))
959+
960+
assert "ExceptionGroup" == result[0]["exc_type"]
961+
exceptions = result[0]["exceptions"]
962+
assert [
963+
[
964+
{
965+
"exc_type": "ZeroDivisionError",
966+
"exc_value": "division by zero",
967+
"exc_notes": [],
968+
"syntax_error": None,
969+
"is_cause": False,
970+
"frames": [
971+
{
972+
"filename": __file__,
973+
"lineno": lineno + 2,
974+
"name": "t1",
975+
}
976+
],
977+
"is_group": False,
978+
"exceptions": [],
979+
}
980+
],
981+
[
982+
{
983+
"exc_type": "ValueError",
984+
"exc_value": "Blam!",
985+
"exc_notes": [],
986+
"syntax_error": None,
987+
"is_cause": False,
988+
"frames": [
989+
{
990+
"filename": __file__,
991+
"lineno": lineno + 5,
992+
"name": "t2",
993+
}
994+
],
995+
"is_group": False,
996+
"exceptions": [],
997+
}
998+
],
999+
] == exceptions
1000+
1001+
8471002
class TestLogException:
8481003
"""
8491004
Higher level integration tests for `Logger.exception()`.

0 commit comments

Comments
 (0)