From 431ec6d34ef99d80f90b330876ed6231144a3ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Hofst=C3=A4tter?= Date: Wed, 15 Mar 2023 12:10:25 +0100 Subject: [PATCH] Correctly handle tracebackhide for chained exceptions (#10772) --- AUTHORS | 1 + changelog/1904.bugfix.rst | 1 + src/_pytest/_code/code.py | 27 +++++++++++++++++---------- src/_pytest/reports.py | 4 ++++ testing/code/test_excinfo.py | 6 ++---- testing/test_tracebackhide.py | 25 +++++++++++++++++++++++++ 6 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 changelog/1904.bugfix.rst create mode 100644 testing/test_tracebackhide.py diff --git a/AUTHORS b/AUTHORS index f72932b99c4..05823646188 100644 --- a/AUTHORS +++ b/AUTHORS @@ -128,6 +128,7 @@ Erik M. Bray Evan Kepner Fabien Zarifian Fabio Zadrozny +Felix Hofstätter Felix Nieuwenhuizen Feng Ma Florian Bruhin diff --git a/changelog/1904.bugfix.rst b/changelog/1904.bugfix.rst new file mode 100644 index 00000000000..dc8e0f34217 --- /dev/null +++ b/changelog/1904.bugfix.rst @@ -0,0 +1 @@ +Correctly handle ``__tracebackhide__`` for chained exceptions. diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 44ce8fac91a..e375fb70c44 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -411,13 +411,13 @@ def filter( """ return Traceback(filter(fn, self), self._excinfo) - def getcrashentry(self) -> TracebackEntry: + def getcrashentry(self) -> Optional[TracebackEntry]: """Return last non-hidden traceback entry that lead to the exception of a traceback.""" for i in range(-1, -len(self) - 1, -1): entry = self[i] if not entry.ishidden(): return entry - return self[-1] + return None def recursionindex(self) -> Optional[int]: """Return the index of the frame/TracebackEntry where recursion originates if @@ -602,11 +602,13 @@ def errisinstance( """ return isinstance(self.value, exc) - def _getreprcrash(self) -> "ReprFileLocation": + def _getreprcrash(self) -> Optional["ReprFileLocation"]: exconly = self.exconly(tryshort=True) entry = self.traceback.getcrashentry() - path, lineno = entry.frame.code.raw.co_filename, entry.lineno - return ReprFileLocation(path, lineno + 1, exconly) + if entry: + path, lineno = entry.frame.code.raw.co_filename, entry.lineno + return ReprFileLocation(path, lineno + 1, exconly) + return None def getrepr( self, @@ -942,9 +944,14 @@ def repr_excinfo( ) else: reprtraceback = self.repr_traceback(excinfo_) - reprcrash: Optional[ReprFileLocation] = ( - excinfo_._getreprcrash() if self.style != "value" else None - ) + + # will be None if all traceback entries are hidden + reprcrash: Optional[ReprFileLocation] = excinfo_._getreprcrash() + if reprcrash: + if self.style == "value": + repr_chain += [(reprtraceback, None, descr)] + else: + repr_chain += [(reprtraceback, reprcrash, descr)] else: # Fallback to native repr if the exception doesn't have a traceback: # ExceptionInfo objects require a full traceback to work. @@ -952,8 +959,8 @@ def repr_excinfo( traceback.format_exception(type(e), e, None) ) reprcrash = None + repr_chain += [(reprtraceback, reprcrash, descr)] - repr_chain += [(reprtraceback, reprcrash, descr)] if e.__cause__ is not None and self.chain: e = e.__cause__ excinfo_ = ( @@ -1044,7 +1051,7 @@ def toterminal(self, tw: TerminalWriter) -> None: @dataclasses.dataclass(eq=False) class ReprExceptionInfo(ExceptionRepr): reprtraceback: "ReprTraceback" - reprcrash: "ReprFileLocation" + reprcrash: Optional["ReprFileLocation"] def toterminal(self, tw: TerminalWriter) -> None: self.reprtraceback.toterminal(tw) diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 2e36514eae5..c0a76f92b59 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -347,6 +347,10 @@ def from_item_and_call(cls, item: Item, call: "CallInfo[None]") -> "TestReport": elif isinstance(excinfo.value, skip.Exception): outcome = "skipped" r = excinfo._getreprcrash() + if r is None: + raise ValueError( + "There should always be a traceback entry for skipping a test." + ) if excinfo.value._use_item_location: path, line = item.reportinfo()[:2] assert line is not None diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index e428b9c5ca9..3607501a84e 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -294,6 +294,7 @@ def f(): excinfo = pytest.raises(ValueError, f) tb = excinfo.traceback entry = tb.getcrashentry() + assert entry is not None co = _pytest._code.Code.from_function(h) assert entry.frame.code.path == co.path assert entry.lineno == co.firstlineno + 1 @@ -311,10 +312,7 @@ def f(): excinfo = pytest.raises(ValueError, f) tb = excinfo.traceback entry = tb.getcrashentry() - co = _pytest._code.Code.from_function(g) - assert entry.frame.code.path == co.path - assert entry.lineno == co.firstlineno + 2 - assert entry.frame.code.name == "g" + assert entry is None def test_excinfo_exconly(): diff --git a/testing/test_tracebackhide.py b/testing/test_tracebackhide.py new file mode 100644 index 00000000000..88f9c4fc00e --- /dev/null +++ b/testing/test_tracebackhide.py @@ -0,0 +1,25 @@ +def test_tbh_chained(testdir): + """Ensure chained exceptions whose frames contain "__tracebackhide__" are not shown (#1904).""" + p = testdir.makepyfile( + """ + import pytest + + def f1(): + __tracebackhide__ = True + try: + return f1.meh + except AttributeError: + pytest.fail("fail") + + @pytest.fixture + def fix(): + f1() + + + def test(fix): + pass + """ + ) + result = testdir.runpytest(str(p)) + assert "'function' object has no attribute 'meh'" not in result.stdout.str() + assert result.ret == 1