Skip to content

Commit

Permalink
code: handle repr'ing empty tracebacks gracefully
Browse files Browse the repository at this point in the history
By "empty traceback" I mean a traceback all of whose entries have been
filtered/cut/pruned out.

Currently, if an empty traceback needs to be repr'ed, the last entry
before the filtering is used instead (added in
accd962).

Showing a hidden frame is not so good IMO. This commit does the
following instead:

1. Shows details of the exception.
2. Shows a message about how the full trace can be seen.

Example:

```
_____________ test _____________

E   ZeroDivisionError: division by zero
All traceback entries are hidden. Pass `--full-trace` to see hidden and internal frames.
```

Also handles `--tb=native`, though there the `--full-trace` bit is not
shown.

This commit contains some pieces from
431ec6d (which has been reverted).

Helps towards fixing issue # 1904.

Co-authored-by: Felix Hofstätter <Felhof1@hotmail.com>
  • Loading branch information
bluetech and Felhof committed Apr 13, 2023
1 parent eff54ae commit e3b1799
Show file tree
Hide file tree
Showing 5 changed files with 47 additions and 35 deletions.
1 change: 1 addition & 0 deletions AUTHORS
Expand Up @@ -128,6 +128,7 @@ Erik M. Bray
Evan Kepner
Fabien Zarifian
Fabio Zadrozny
Felix Hofstätter
Felix Nieuwenhuizen
Feng Ma
Florian Bruhin
Expand Down
52 changes: 33 additions & 19 deletions src/_pytest/_code/code.py
Expand Up @@ -411,13 +411,14 @@ def filter(
"""
return Traceback(filter(fn, self), self._excinfo)

def getcrashentry(self) -> TracebackEntry:
"""Return last non-hidden traceback entry that lead to the exception of a traceback."""
def getcrashentry(self) -> Optional[TracebackEntry]:
"""Return last non-hidden traceback entry that lead to the exception of
a traceback, or None if all hidden."""
for i in range(-1, -len(self) - 1, -1):
entry = self[i]
if not entry.ishidden():
return entry
return self[-1]
return None

Check warning on line 421 in src/_pytest/_code/code.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/_code/code.py#L421

Added line #L421 was not covered by tests

def recursionindex(self) -> Optional[int]:
"""Return the index of the frame/TracebackEntry where recursion originates if
Expand Down Expand Up @@ -598,9 +599,11 @@ 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()
if entry is None:
return None
path, lineno = entry.frame.code.raw.co_filename, entry.lineno
return ReprFileLocation(path, lineno + 1, exconly)

Expand Down Expand Up @@ -647,7 +650,9 @@ def getrepr(
return ReprExceptionInfo(
reprtraceback=ReprTracebackNative(
traceback.format_exception(
self.type, self.value, self.traceback[0]._rawentry
self.type,
self.value,
self.traceback[0]._rawentry if self.traceback else None,
)
),
reprcrash=self._getreprcrash(),
Expand Down Expand Up @@ -803,12 +808,16 @@ def repr_locals(self, locals: Mapping[str, object]) -> Optional["ReprLocals"]:

def repr_traceback_entry(
self,
entry: TracebackEntry,
entry: Optional[TracebackEntry],
excinfo: Optional[ExceptionInfo[BaseException]] = None,
) -> "ReprEntry":
lines: List[str] = []
style = entry._repr_style if entry._repr_style is not None else self.style
if style in ("short", "long"):
style = (
entry._repr_style
if entry is not None and entry._repr_style is not None
else self.style
)
if style in ("short", "long") and entry is not None:

Check warning on line 820 in src/_pytest/_code/code.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/_code/code.py#L820

Added line #L820 was not covered by tests
source = self._getentrysource(entry)
if source is None:
source = Source("???")
Expand Down Expand Up @@ -857,17 +866,21 @@ def repr_traceback(self, excinfo: ExceptionInfo[BaseException]) -> "ReprTracebac
else:
extraline = None

if not traceback:
if extraline is None:

Check warning on line 870 in src/_pytest/_code/code.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/_code/code.py#L869-L870

Added lines #L869 - L870 were not covered by tests
extraline = "All traceback entries are hidden. Pass `--full-trace` to see hidden and internal frames."
entries = [self.repr_traceback_entry(None, excinfo)]
return ReprTraceback(entries, extraline, style=self.style)

last = traceback[-1]
entries = []
if self.style == "value":
reprentry = self.repr_traceback_entry(last, excinfo)
entries.append(reprentry)
entries = [self.repr_traceback_entry(last, excinfo)]

Check warning on line 877 in src/_pytest/_code/code.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/_code/code.py#L877

Added line #L877 was not covered by tests
return ReprTraceback(entries, None, style=self.style)

for index, entry in enumerate(traceback):
einfo = (last == entry) and excinfo or None
reprentry = self.repr_traceback_entry(entry, einfo)
entries.append(reprentry)
entries = [
self.repr_traceback_entry(entry, excinfo if last == entry else None)
for entry in traceback
]
return ReprTraceback(entries, extraline, style=self.style)

def _truncate_recursive_traceback(
Expand Down Expand Up @@ -924,6 +937,7 @@ def repr_excinfo(
seen: Set[int] = set()
while e is not None and id(e) not in seen:
seen.add(id(e))

if excinfo_:
# Fall back to native traceback as a temporary workaround until
# full support for exception groups added to ExceptionInfo.
Expand All @@ -950,8 +964,8 @@ def repr_excinfo(
traceback.format_exception(type(e), e, None)
)
reprcrash = None

repr_chain += [(reprtraceback, reprcrash, descr)]

if e.__cause__ is not None and self.chain:
e = e.__cause__
excinfo_ = (
Expand Down Expand Up @@ -1042,7 +1056,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)
Expand Down Expand Up @@ -1147,8 +1161,8 @@ def _write_entry_lines(self, tw: TerminalWriter) -> None:

def toterminal(self, tw: TerminalWriter) -> None:
if self.style == "short":
assert self.reprfileloc is not None
self.reprfileloc.toterminal(tw)
if self.reprfileloc:
self.reprfileloc.toterminal(tw)
self._write_entry_lines(tw)
if self.reprlocals:
self.reprlocals.toterminal(tw, indent=" " * 8)
Expand Down
3 changes: 0 additions & 3 deletions src/_pytest/nodes.py
Expand Up @@ -452,10 +452,7 @@ def _repr_failure_py(
if self.config.getoption("fulltrace", False):
style = "long"
else:
tb = _pytest._code.Traceback([excinfo.traceback[-1]])
self._prunetraceback(excinfo)
if len(excinfo.traceback) == 0:
excinfo.traceback = tb
if style == "auto":
style = "long"
# XXX should excinfo.getrepr record all data and toterminal() process it?
Expand Down
3 changes: 3 additions & 0 deletions src/_pytest/reports.py
Expand Up @@ -347,6 +347,9 @@ def from_item_and_call(cls, item: Item, call: "CallInfo[None]") -> "TestReport":
elif isinstance(excinfo.value, skip.Exception):
outcome = "skipped"
r = excinfo._getreprcrash()
assert (

Check warning on line 350 in src/_pytest/reports.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/reports.py#L350

Added line #L350 was not covered by tests
r is not None
), "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
Expand Down
23 changes: 10 additions & 13 deletions testing/code/test_excinfo.py
Expand Up @@ -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
Expand All @@ -309,12 +310,7 @@ def f():
g()

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 excinfo.traceback.getcrashentry() is None


def test_excinfo_exconly():
Expand Down Expand Up @@ -1577,18 +1573,19 @@ def test_exceptiongroup(pytester: Pytester, outer_chain, inner_chain) -> None:
_exceptiongroup_common(pytester, outer_chain, inner_chain, native=False)


def test_all_entries_hidden_doesnt_crash(pytester: Pytester) -> None:
"""Regression test for #10903.
We're not really sure what should be *displayed* here, so this test
just verified that at least it doesn't crash.
"""
@pytest.mark.parametrize("tbstyle", ("long", "short", "auto", "line", "native"))
def test_all_entries_hidden(pytester: Pytester, tbstyle: str) -> None:
"""Regression test for #10903."""
pytester.makepyfile(
"""
def test():
__tracebackhide__ = True
1 / 0
"""
)
result = pytester.runpytest()
result = pytester.runpytest("--tb", tbstyle)
assert result.ret == 1
if tbstyle != "line":
result.stdout.fnmatch_lines(["*ZeroDivisionError: division by zero"])
if tbstyle not in ("line", "native"):
result.stdout.fnmatch_lines(["All traceback entries are hidden.*"])

0 comments on commit e3b1799

Please sign in to comment.