Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 97 additions & 39 deletions src/_pytest/_code/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -644,10 +644,13 @@ def __repr__(self) -> str:
def exconly(self, tryshort: bool = False) -> str:
"""Return the exception as a string.

When 'tryshort' resolves to True, and the exception is an
AssertionError, only the actual exception part of the exception
representation is returned (so 'AssertionError: ' is removed from
the beginning).
This is usually a single line "<exception type>: <exception str>", but
may also include additional lines for the exception notes, and detailed
information for SyntaxError's.

:param tryshort:
If true, and the exception is an AssertionError, strip
'AssertionError: ' from the beginning.
"""

def _get_single_subexc(
Expand All @@ -666,7 +669,8 @@ def _get_single_subexc(
):
return f"{subexc!r} [single exception in {type(self.value).__name__}]"

lines = format_exception_only(self.type, self.value)
lines = format_exception_only(self.value)
# The lines already include line separators.
text = "".join(lines)
text = text.rstrip()
if tryshort:
Expand Down Expand Up @@ -705,9 +709,11 @@ def getrepr(
) -> ReprExceptionInfo | ExceptionChainRepr:
"""Return str()able representation of this exception info.

The formatting parameters are ineffective if ``style="native"``,
since in this case the native formatting is used.

:param bool showlocals:
Show locals per traceback entry.
Ignored if ``style=="native"``.

:param str style:
long|short|line|no|native|value traceback style.
Expand All @@ -723,19 +729,19 @@ def getrepr(
variable ``__tracebackhide__ = True``.
* If a callable, delegates the filtering to the callable.

Ignored if ``style`` is ``"native"``.

:param bool funcargs:
Show fixtures ("funcargs" for legacy purposes) per traceback entry.
Show function arguments per traceback entry.

:param bool truncate_locals:
With ``showlocals==True``, make sure locals can be safely represented as strings.
Whether to show a size-limited `repr()` of locals, or a full
pretty-printing.

:param bool truncate_args:
With ``showargs==True``, make sure args can be safely represented as strings.
Whether to show a size-limited truncated `repr()` of function
arguments, or a full pretty-printing.

:param bool chain:
If chained exceptions in Python 3 should be shown.
If chained exceptions should be shown.

.. versionchanged:: 3.9

Expand All @@ -753,7 +759,7 @@ def getrepr(
reprcrash=self._getreprcrash(),
)

fmt = FormattedExcinfo(
fmt = ExceptionInfoFormatter(
showlocals=showlocals,
style=style,
abspath=abspath,
Expand Down Expand Up @@ -864,21 +870,27 @@ def group_contains(


@dataclasses.dataclass
class FormattedExcinfo:
"""Presenting information about failing Functions and Generators."""
class ExceptionInfoFormatter:
"""Helper object to format ExceptionInfo's and individual exception parts
into TerminalRepr's.

See :func:`ExceptionInfo.getrepr` for parameters.
"""

# for traceback entries
flow_marker: ClassVar = ">"
fail_marker: ClassVar = "E"

showlocals: bool = False
# Note: "native" is handled outside of ExceptionInfoFormatter.
style: TracebackStyle = "long"
abspath: bool = True
tbfilter: TracebackFilter = True
funcargs: bool = False
truncate_locals: bool = True
truncate_args: bool = True
chain: bool = True

astcache: dict[str | Path, ast.AST] = dataclasses.field(
default_factory=dict, init=False, repr=False
)
Expand Down Expand Up @@ -1026,6 +1038,8 @@ def get_exconly(
def repr_locals(self, locals: Mapping[str, object]) -> ReprLocals | None:
if self.showlocals:
lines = []
# Variables starting with `@` are helpers injected by assertion
# rewriting, not user variables, so hide them.
keys = [loc for loc in locals if loc[0] != "@"]
keys.sort()
for name in keys:
Expand Down Expand Up @@ -1177,7 +1191,7 @@ def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainR
repr_chain: list[tuple[ReprTraceback, ReprFileLocation | None, str | None]] = []
e: BaseException | None = excinfo.value
excinfo_: ExceptionInfo[BaseException] | None = excinfo
descr = None
description = None
seen: set[int] = set()
while e is not None and id(e) not in seen:
seen.add(id(e))
Expand All @@ -1190,18 +1204,19 @@ def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainR
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)
extraline = (
"All traceback entries are hidden. Pass `--full-trace` to see hidden and internal frames."
if not traceback
else None
)
reprtraceback = ReprTracebackNative(
format_exception(
type(excinfo.value),
excinfo.value,
traceback[0]._rawentry if traceback else None,
)
),
extraline=extraline,
)
if not traceback:
reprtraceback.extraline = (
"All traceback entries are hidden. "
"Pass `--full-trace` to see hidden and internal frames."
)

else:
reprtraceback = self.repr_traceback(excinfo_)
Expand All @@ -1211,18 +1226,18 @@ def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainR
# ExceptionInfo objects require a full traceback to work.
reprtraceback = ReprTracebackNative(format_exception(type(e), e, None))
reprcrash = None
repr_chain += [(reprtraceback, reprcrash, descr)]
repr_chain.append((reprtraceback, reprcrash, description))

if e.__cause__ is not None and self.chain:
e = e.__cause__
excinfo_ = ExceptionInfo.from_exception(e) if e.__traceback__ else None
descr = "The above exception was the direct cause of the following exception:"
description = "The above exception was the direct cause of the following exception:"
elif (
e.__context__ is not None and not e.__suppress_context__ and self.chain
):
e = e.__context__
excinfo_ = ExceptionInfo.from_exception(e) if e.__traceback__ else None
descr = "During handling of the above exception, another exception occurred:"
description = "During handling of the above exception, another exception occurred:"
else:
e = None
repr_chain.reverse()
Expand All @@ -1231,6 +1246,9 @@ def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainR

@dataclasses.dataclass(eq=False)
class TerminalRepr:
"""Base class for terminal representations -- pieces of data that display
themselves to a terminal."""

def __str__(self) -> str:
# FYI this is called from pytest-xdist's serialization of exception
# information.
Expand All @@ -1246,10 +1264,17 @@ def toterminal(self, tw: TerminalWriter) -> None:
raise NotImplementedError()


# This class is abstract -- only subclasses are instantiated.
@dataclasses.dataclass(eq=False)
class ExceptionRepr(TerminalRepr):
# Provided by subclasses.
"""Base class for exception terminal representations.

The representation generally contains:
- The exception traceback (`reprtraceback`)
- The exception message and location (`reprcrash`)
- Separated, titled sections with additional data (pytest core doesn't use
this currently).
"""

reprtraceback: ReprTraceback
reprcrash: ReprFileLocation | None
sections: list[tuple[str, str, str]] = dataclasses.field(
Expand All @@ -1267,6 +1292,9 @@ def toterminal(self, tw: TerminalWriter) -> None:

@dataclasses.dataclass(eq=False)
class ExceptionChainRepr(ExceptionRepr):
"""A chain of exceptions, separated by descriptions (e.g. "The above
exception was the direct cause of the following exception")."""

chain: Sequence[tuple[ReprTraceback, ReprFileLocation | None, str | None]]

def __init__(
Expand All @@ -1282,18 +1310,19 @@ def __init__(
self.chain = chain

def toterminal(self, tw: TerminalWriter) -> None:
for element in self.chain:
element[0].toterminal(tw)
if element[2] is not None:
for reprtraceback, reprcrash, description in self.chain:
reprtraceback.toterminal(tw)
if description is not None:
tw.line("")
tw.line(element[2], yellow=True)
tw.line(description, yellow=True)
super().toterminal(tw)


@dataclasses.dataclass(eq=False)
class ReprExceptionInfo(ExceptionRepr):
reprtraceback: ReprTraceback
reprcrash: ReprFileLocation | None
"""A single exception with optional extra details (function arguments,
function locals, file location) and possible extra line and sections emitted
at the end."""

def toterminal(self, tw: TerminalWriter) -> None:
self.reprtraceback.toterminal(tw)
Expand All @@ -1302,6 +1331,9 @@ def toterminal(self, tw: TerminalWriter) -> None:

@dataclasses.dataclass(eq=False)
class ReprTraceback(TerminalRepr):
"""A traceback with optional extra details (function arguments, function
locals, file location) and possible extra line emitted at the end."""

reprentries: Sequence[ReprEntry | ReprEntryNative]
extraline: str | None
style: TracebackStyle
Expand All @@ -1326,14 +1358,29 @@ def toterminal(self, tw: TerminalWriter) -> None:


class ReprTracebackNative(ReprTraceback):
def __init__(self, tblines: Sequence[str]) -> None:
"""A traceback in native style.

The lines are emitted as is; uses a single entry for the entire native
traceback.
"""

def __init__(self, tblines: Sequence[str], *, extraline: str | None = None) -> None:
self.reprentries = [ReprEntryNative(tblines)]
self.extraline = None
self.extraline = extraline
self.style = "native"


@dataclasses.dataclass(eq=False)
class ReprEntryNative(TerminalRepr):
"""An entry in a traceback in native style.

Emits the lines as is. The lines are assumed to include the line separators.

[Note that we currently use a single "entry" for the entire native
traceback, so this is a bit misleading, but there's no point trying to parse
or split a native traceback.]
"""

lines: Sequence[str]

style: ClassVar[TracebackStyle] = "native"
Expand All @@ -1344,6 +1391,9 @@ def toterminal(self, tw: TerminalWriter) -> None:

@dataclasses.dataclass(eq=False)
class ReprEntry(TerminalRepr):
"""An entry in a traceback with possible extra details (function arguments,
function locals, source snippet, function's file and line)."""

lines: Sequence[str]
reprfuncargs: ReprFuncArgs | None
reprlocals: ReprLocals | None
Expand Down Expand Up @@ -1378,7 +1428,7 @@ def _write_entry_lines(self, tw: TerminalWriter) -> None:
# separate indents and source lines that are not failures: we want to
# highlight the code but not the indentation, which may contain markers
# such as "> assert 0"
fail_marker = f"{FormattedExcinfo.fail_marker} "
fail_marker = f"{ExceptionInfoFormatter.fail_marker} "
indent_size = len(fail_marker)
indents: list[str] = []
source_lines: list[str] = []
Expand Down Expand Up @@ -1429,6 +1479,12 @@ def __str__(self) -> str:

@dataclasses.dataclass(eq=False)
class ReprFileLocation(TerminalRepr):
"""A message at a file location, using the `<path>:<lineno>: <message>`
format that most editors understand.

Only the first line of the message is emitted.
"""

path: str
lineno: int
message: str
Expand All @@ -1437,8 +1493,6 @@ def __post_init__(self) -> None:
self.path = str(self.path)

def toterminal(self, tw: TerminalWriter) -> None:
# Filename and lineno output for each entry, using an output format
# that most editors understand.
msg = self.message
i = msg.find("\n")
if i != -1:
Expand All @@ -1449,15 +1503,19 @@ def toterminal(self, tw: TerminalWriter) -> None:

@dataclasses.dataclass(eq=False)
class ReprLocals(TerminalRepr):
"""Function local variables (pre-formatted)."""

lines: Sequence[str]

def toterminal(self, tw: TerminalWriter, indent="") -> None:
def toterminal(self, tw: TerminalWriter, indent: str = "") -> None:
for line in self.lines:
tw.line(indent + line)


@dataclasses.dataclass(eq=False)
class ReprFuncArgs(TerminalRepr):
"""Function arguments - name = value, comma separated."""

args: Sequence[tuple[str, object]]

def toterminal(self, tw: TerminalWriter) -> None:
Expand Down
6 changes: 3 additions & 3 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from _pytest import nodes
from _pytest._code import getfslineno
from _pytest._code import Source
from _pytest._code.code import FormattedExcinfo
from _pytest._code.code import ExceptionInfoFormatter
from _pytest._code.code import TerminalRepr
from _pytest._io import TerminalWriter
from _pytest.compat import assert_never
Expand Down Expand Up @@ -944,12 +944,12 @@ def toterminal(self, tw: TerminalWriter) -> None:
lines = self.errorstring.split("\n")
if lines:
tw.line(
f"{FormattedExcinfo.fail_marker} {lines[0].strip()}",
f"{ExceptionInfoFormatter.fail_marker} {lines[0].strip()}",
red=True,
)
for line in lines[1:]:
tw.line(
f"{FormattedExcinfo.flow_marker} {line.strip()}",
f"{ExceptionInfoFormatter.flow_marker} {line.strip()}",
red=True,
)
tw.line()
Expand Down
4 changes: 2 additions & 2 deletions src/_pytest/skipping.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ def evaluate_condition(item: Item, mark: Mark, condition: object) -> tuple[bool,
msglines = [
f"Error evaluating {mark.name!r} condition",
" " + condition,
*traceback.format_exception_only(type(exc), exc),
*traceback.format_exception_only(exc),
]
fail("\n".join(msglines), pytrace=False)

Expand All @@ -140,7 +140,7 @@ def evaluate_condition(item: Item, mark: Mark, condition: object) -> tuple[bool,
except Exception as exc:
msglines = [
f"Error evaluating {mark.name!r} condition as a boolean",
*traceback.format_exception_only(type(exc), exc),
*traceback.format_exception_only(exc),
]
fail("\n".join(msglines), pytrace=False)

Expand Down
Loading
Loading