From 768dfda6d6eb685fd631d58d895a713918c48c0f Mon Sep 17 00:00:00 2001 From: Kanchan Thapa Date: Tue, 21 Apr 2026 20:02:48 -0500 Subject: [PATCH] Fix __notes__ leaking across chained exceptions (#3960) When rendering a chained exception, Rich previously read __notes__ once from the outermost exception and reused the same list reference on every Stack built inside the while-loop. This caused notes added to one exception to appear on every exception in the chain, which contradicts CPython's native traceback module behavior (notes are only rendered on the specific exception they were added to via PEP 678's add_note()). Move the __notes__ assignment inside the extraction loop so each exception contributes its own notes. Covers __cause__, __context__, and ExceptionGroup chains. Adds test_notes_isolated_per_exception_in_chain covering both __cause__ and __context__ chaining. --- CHANGELOG.md | 6 ++++++ CONTRIBUTORS.md | 1 + rich/traceback.py | 8 +++++-- tests/test_traceback.py | 46 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdd4f15974..f2e4be060a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +- Fixed `__notes__` from the outermost exception being shown on every exception in a chain; notes are now rendered only on the specific exception they were added to https://github.com/Textualize/rich/issues/3960 + ## [14.3.3] - 2026-02-19 ### Fixed diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4d77a0e3ed..d46ab784f0 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -75,6 +75,7 @@ The following people have contributed to the development of Rich: - [Nicolas Simonds](https://github.com/0xDEC0DE) - [Aaron Stephens](https://github.com/aaronst) - [Karolina Surma](https://github.com/befeleme) +- [Kanchan Thapa](https://github.com/kanchanthapa) - [Gabriele N. Tornetta](https://github.com/p403n1x87) - [Nils Vu](https://github.com/nilsvu) - [Arian Mollik Wasi](https://github.com/wasi-master) diff --git a/rich/traceback.py b/rich/traceback.py index 66eaecaae9..d80ad965cc 100644 --- a/rich/traceback.py +++ b/rich/traceback.py @@ -467,8 +467,6 @@ def extract( from rich import _IMPORT_CWD - notes: List[str] = getattr(exc_value, "__notes__", None) or [] - grouped_exceptions: Set[BaseException] = ( set() if _visited_exceptions is None else _visited_exceptions ) @@ -481,6 +479,12 @@ def safe_str(_object: Any) -> str: return "" while True: + # Read __notes__ from the current exception in the chain, not the + # outermost one. Fixes https://github.com/Textualize/rich/issues/3960 + # where notes from the outer exception leaked onto every inner + # exception via the shared list reference. + notes: List[str] = getattr(exc_value, "__notes__", None) or [] + stack = Stack( exc_type=safe_str(exc_type.__name__), exc_value=safe_str(exc_value), diff --git a/tests/test_traceback.py b/tests/test_traceback.py index bcae6920b1..8029980c25 100644 --- a/tests/test_traceback.py +++ b/tests/test_traceback.py @@ -375,6 +375,52 @@ def test_notes() -> None: assert traceback.trace.stacks[0].notes == ["Hello", "World"] +@pytest.mark.skipif( + sys.version_info.minor < 11, reason="Not supported before Python 3.11" +) +def test_notes_isolated_per_exception_in_chain() -> None: + """Regression test for https://github.com/Textualize/rich/issues/3960 + + __notes__ added to one exception in a chain must not leak onto other + exceptions in the chain. Matches CPython's traceback module behavior. + """ + # __cause__ chain (explicit ``raise ... from ...``) + try: + try: + raise ValueError("inner") + except ValueError as exc: + raise RuntimeError("outer") from exc + except RuntimeError as err: + err.add_note("note-on-outer-only") + traceback = Traceback() + + stacks = traceback.trace.stacks + # stacks[0] is the outermost (RuntimeError), stacks[1] is the cause + # (ValueError). The note must appear only on the outer stack. + assert stacks[0].exc_type == "RuntimeError" + assert stacks[0].notes == ["note-on-outer-only"] + assert stacks[1].exc_type == "ValueError" + assert stacks[1].notes == [] + + # __context__ chain (implicit chaining) with a note only on the inner + # exception in source order (the context). The outer should have no notes. + try: + try: + err = ValueError("context-inner") + err.add_note("note-on-context-only") + raise err + except ValueError: + raise RuntimeError("context-outer") + except RuntimeError: + traceback = Traceback() + + stacks = traceback.trace.stacks + assert stacks[0].exc_type == "RuntimeError" + assert stacks[0].notes == [] + assert stacks[1].exc_type == "ValueError" + assert stacks[1].notes == ["note-on-context-only"] + + def test_recursive_exception() -> None: """Regression test for https://github.com/Textualize/rich/issues/3708