diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 4cf99a77340..7f85bc8093e 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -1187,15 +1187,65 @@ def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainR # See https://github.com/pytest-dev/pytest/issues/9159 reprtraceback: ReprTraceback | ReprTracebackNative if isinstance(e, BaseExceptionGroup): - # don't filter any sub-exceptions since they shouldn't have any internal frames traceback = filter_excinfo_traceback(self.tbfilter, excinfo) - reprtraceback = ReprTracebackNative( - format_exception( - type(excinfo.value), - excinfo.value, - traceback[0]._rawentry if traceback else None, + + patched: list[tuple[BaseException, TracebackType | None]] = [] + + def patch_group(group: BaseExceptionGroup[BaseException]) -> None: + for sub in group.exceptions: + if isinstance(sub, BaseExceptionGroup): + patch_group(sub) + continue + patched.append((sub, sub.__traceback__)) + try: + sub_excinfo = ExceptionInfo.from_exception(sub) + except Exception: + sub.__traceback__ = None + continue + sub_tb = filter_excinfo_traceback( + self.tbfilter, sub_excinfo + ) + if sub_tb: + # Ensure the last frame's tb_next is None + sub_tb[-1]._rawentry.tb_next = None + # Link the filtered frames together + for i in range(len(sub_tb) - 1): + sub_tb[i]._rawentry.tb_next = sub_tb[ + i + 1 + ]._rawentry + sub.__traceback__ = sub_tb[0]._rawentry + else: + sub.__traceback__ = None + + old_group_tb = e.__traceback__ + try: + # Build a filtered traceback chain for the group + if traceback: + # First, ensure the last frame's tb_next is None to prevent + # format_exception from walking into hidden frames + traceback[-1]._rawentry.tb_next = None + # Then link the filtered frames together + for i in range(len(traceback) - 1): + traceback[i]._rawentry.tb_next = traceback[ + i + 1 + ]._rawentry + e.__traceback__ = traceback[0]._rawentry + else: + e.__traceback__ = None + + patch_group(e) + reprtraceback = ReprTracebackNative( + format_exception( + type(excinfo.value), + excinfo.value, + e.__traceback__, + ) ) - ) + finally: + e.__traceback__ = old_group_tb + for sub, old_tb in patched: + sub.__traceback__ = old_tb + if not traceback: reprtraceback.extraline = ( "All traceback entries are hidden. " diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 70499fec893..39bef9269d3 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1963,6 +1963,59 @@ def test(): ) +def test_tracebackhide_in_exceptiongroup_is_respected(pytester: Pytester) -> None: + """Regression test for issue #14036.""" + p = pytester.makepyfile( + """ + def g1(): + __tracebackhide__ = True + str.does_not_exist + + def f3(): + __tracebackhide__ = True + 1 / 0 + + def f2(): + __tracebackhide__ = True + exc = None + try: + f3() + except Exception as e: + exc = e + + exc2 = None + try: + g1() + except Exception as e: + exc2 = e + + raise ExceptionGroup("blah", [exc, exc2]) + + def f1(): + __tracebackhide__ = True + f2() + + def test(): + f1() + """ + ) + result = pytester.runpytest(str(p), "--tb=short") + assert result.ret == 1 + result.stdout.fnmatch_lines( + [ + "*in test*", + "*f1()*", + "*ExceptionGroup: blah (2 sub-exceptions)*", + "*ZeroDivisionError: division by zero*", + "*AttributeError: type object 'str' has no attribute 'does_not_exist'*", + ] + ) + result.stdout.no_fnmatch_line("*in f1*") + result.stdout.no_fnmatch_line("*in f2*") + result.stdout.no_fnmatch_line("*in f3*") + result.stdout.no_fnmatch_line("*in g1*") + + def add_note(err: BaseException, msg: str) -> None: """Adds a note to an exception inplace.""" if sys.version_info < (3, 11):