From 0008a01d0516f59f1cba919e6ce3339972f39740 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 18 Jun 2025 18:52:01 +0100 Subject: [PATCH 1/7] detect recursion in Exception Groups --- rich/traceback.py | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/rich/traceback.py b/rich/traceback.py index b2cc63040..ce798422e 100644 --- a/rich/traceback.py +++ b/rich/traceback.py @@ -418,6 +418,7 @@ def extract( locals_max_string: int = LOCALS_MAX_STRING, locals_hide_dunder: bool = True, locals_hide_sunder: bool = False, + _grouped_exceptions: set[BaseException] | None = None, ) -> Trace: """Extract traceback information. @@ -443,6 +444,10 @@ def extract( notes: List[str] = getattr(exc_value, "__notes__", None) or [] + grouped_exceptions: set[BaseException] = ( + set() if _grouped_exceptions is None else _grouped_exceptions + ) + def safe_str(_object: Any) -> str: """Don't allow exceptions from __str__ to propagate.""" try: @@ -462,6 +467,11 @@ def safe_str(_object: Any) -> str: if isinstance(exc_value, (BaseExceptionGroup, ExceptionGroup)): stack.is_group = True for exception in exc_value.exceptions: + if exception in grouped_exceptions: + stack.is_group = False + continue + # stack.is_group = False + grouped_exceptions.add(exception) stack.exceptions.append( Traceback.extract( type(exception), @@ -471,6 +481,7 @@ def safe_str(_object: Any) -> str: locals_max_length=locals_max_length, locals_hide_dunder=locals_hide_dunder, locals_hide_sunder=locals_hide_sunder, + _grouped_exceptions=grouped_exceptions, ) ) @@ -561,23 +572,24 @@ def get_locals( if frame_summary.f_locals.get("_rich_traceback_guard", False): del stack.frames[:] - cause = getattr(exc_value, "__cause__", None) - if cause: - exc_type = cause.__class__ - exc_value = cause - # __traceback__ can be None, e.g. for exceptions raised by the - # 'multiprocessing' module - traceback = cause.__traceback__ - is_cause = True - continue + if not grouped_exceptions: + cause = getattr(exc_value, "__cause__", None) + if cause and cause is not exc_value: + exc_type = cause.__class__ + exc_value = cause + # __traceback__ can be None, e.g. for exceptions raised by the + # 'multiprocessing' module + traceback = cause.__traceback__ + is_cause = True + continue - cause = exc_value.__context__ - if cause and not getattr(exc_value, "__suppress_context__", False): - exc_type = cause.__class__ - exc_value = cause - traceback = cause.__traceback__ - is_cause = False - continue + cause = exc_value.__context__ + if cause and not getattr(exc_value, "__suppress_context__", False): + exc_type = cause.__class__ + exc_value = cause + traceback = cause.__traceback__ + is_cause = False + continue # No cover, code is reached but coverage doesn't recognize it. break # pragma: no cover From 65a04ccdb251fe0b9b6f7adc66760cebef72ffa8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 19 Jun 2025 09:12:51 +0100 Subject: [PATCH 2/7] name change --- rich/traceback.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/rich/traceback.py b/rich/traceback.py index ce798422e..d8b2c62fc 100644 --- a/rich/traceback.py +++ b/rich/traceback.py @@ -418,7 +418,7 @@ def extract( locals_max_string: int = LOCALS_MAX_STRING, locals_hide_dunder: bool = True, locals_hide_sunder: bool = False, - _grouped_exceptions: set[BaseException] | None = None, + _visited_exceptions: set[BaseException] | None = None, ) -> Trace: """Extract traceback information. @@ -445,7 +445,7 @@ def extract( notes: List[str] = getattr(exc_value, "__notes__", None) or [] grouped_exceptions: set[BaseException] = ( - set() if _grouped_exceptions is None else _grouped_exceptions + set() if _visited_exceptions is None else _visited_exceptions ) def safe_str(_object: Any) -> str: @@ -470,7 +470,6 @@ def safe_str(_object: Any) -> str: if exception in grouped_exceptions: stack.is_group = False continue - # stack.is_group = False grouped_exceptions.add(exception) stack.exceptions.append( Traceback.extract( @@ -481,7 +480,7 @@ def safe_str(_object: Any) -> str: locals_max_length=locals_max_length, locals_hide_dunder=locals_hide_dunder, locals_hide_sunder=locals_hide_sunder, - _grouped_exceptions=grouped_exceptions, + _visited_exceptions=grouped_exceptions, ) ) @@ -574,7 +573,7 @@ def get_locals( if not grouped_exceptions: cause = getattr(exc_value, "__cause__", None) - if cause and cause is not exc_value: + if cause is not None and cause is not exc_value: exc_type = cause.__class__ exc_value = cause # __traceback__ can be None, e.g. for exceptions raised by the @@ -584,7 +583,9 @@ def get_locals( continue cause = exc_value.__context__ - if cause and not getattr(exc_value, "__suppress_context__", False): + if cause is not None and not getattr( + exc_value, "__suppress_context__", False + ): exc_type = cause.__class__ exc_value = cause traceback = cause.__traceback__ From 4a61aee0150c14c09d773a222a653c8e27543803 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 19 Jun 2025 09:21:31 +0100 Subject: [PATCH 3/7] test for infinite loop --- tests/test_traceback.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_traceback.py b/tests/test_traceback.py index bc9bc91e9..bcae6920b 100644 --- a/tests/test_traceback.py +++ b/tests/test_traceback.py @@ -373,3 +373,27 @@ def test_notes() -> None: traceback = Traceback() assert traceback.trace.stacks[0].notes == ["Hello", "World"] + + +def test_recursive_exception() -> None: + """Regression test for https://github.com/Textualize/rich/issues/3708 + + Test this doesn't create an infinite loop. + + """ + console = Console() + + def foo() -> None: + try: + raise RuntimeError("Hello") + except Exception as e: + raise e from e + + def bar() -> None: + try: + foo() + except Exception as e: + assert e is e.__cause__ + console.print_exception(show_locals=True) + + bar() From 409cfe7a2656be327b3096ae7baa0f6e4b92a11a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 19 Jun 2025 09:25:41 +0100 Subject: [PATCH 4/7] CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb1476956..490857b35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed `typing_extensions` from runtime dependencies https://github.com/Textualize/rich/pull/3763 - Live objects (including Progress) may now be nested https://github.com/Textualize/rich/pull/3768 +### Fixed + +- Fixed extraction of recursive exceptions https://github.com/Textualize/rich/pull/3772 + ## [14.0.0] - 2025-03-30 ### Added From a9516af6569a2ff960c81cfa4ddf953e9babfb38 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 19 Jun 2025 09:30:31 +0100 Subject: [PATCH 5/7] no need for this --- rich/traceback.py | 1 - 1 file changed, 1 deletion(-) diff --git a/rich/traceback.py b/rich/traceback.py index d8b2c62fc..a5227d733 100644 --- a/rich/traceback.py +++ b/rich/traceback.py @@ -468,7 +468,6 @@ def safe_str(_object: Any) -> str: stack.is_group = True for exception in exc_value.exceptions: if exception in grouped_exceptions: - stack.is_group = False continue grouped_exceptions.add(exception) stack.exceptions.append( From 1dcc3f6a6716daafbe3d1a0bce42d8c84dfee130 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 19 Jun 2025 11:00:47 +0100 Subject: [PATCH 6/7] typing fix --- rich/traceback.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rich/traceback.py b/rich/traceback.py index a5227d733..1ed8e67d2 100644 --- a/rich/traceback.py +++ b/rich/traceback.py @@ -14,6 +14,7 @@ List, Optional, Sequence, + Set, Tuple, Type, Union, @@ -418,7 +419,7 @@ def extract( locals_max_string: int = LOCALS_MAX_STRING, locals_hide_dunder: bool = True, locals_hide_sunder: bool = False, - _visited_exceptions: set[BaseException] | None = None, + _visited_exceptions: Optional[set[BaseException]] = None, ) -> Trace: """Extract traceback information. @@ -444,7 +445,7 @@ def extract( notes: List[str] = getattr(exc_value, "__notes__", None) or [] - grouped_exceptions: set[BaseException] = ( + grouped_exceptions: Set[BaseException] = ( set() if _visited_exceptions is None else _visited_exceptions ) From b96cd22883640a2e7865ec00551c188f59331ca4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 19 Jun 2025 11:03:24 +0100 Subject: [PATCH 7/7] typing fix --- rich/traceback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rich/traceback.py b/rich/traceback.py index 1ed8e67d2..25d399a7a 100644 --- a/rich/traceback.py +++ b/rich/traceback.py @@ -419,7 +419,7 @@ def extract( locals_max_string: int = LOCALS_MAX_STRING, locals_hide_dunder: bool = True, locals_hide_sunder: bool = False, - _visited_exceptions: Optional[set[BaseException]] = None, + _visited_exceptions: Optional[Set[BaseException]] = None, ) -> Trace: """Extract traceback information.