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