From fca8c782ef68de0dc28cd890b9700b2a9f0057df Mon Sep 17 00:00:00 2001 From: Oleksii Date: Tue, 3 Nov 2026 11:42:46 +0200 Subject: [PATCH 01/12] Add plain-data ExceptionRecord model and ExceptionInfo.record() (#15072) ExceptionInfo.getrepr() and the Repr* classes currently bake display style, path resolution, and syntax markers into pre-rendered strings at capture time, while report serialization reconstructs only a subset of those dataclasses by hand and degrades other representations across process boundaries. Introduce the first increment of #15072 alongside existing code without changing current consumers: - Add frozen, schema-versioned data model classes (Location, SyntaxErrorDetails, FrameRecord, ExceptionRecord) in _pytest._code.record with JSON-compatible to_dict()/from_dict() serialization and a lossless to_rich_trace() adapter. - Model exception chains and BaseExceptionGroup sub-exceptions as a tree (cause, context, children) while breaking reference cycles eagerly at capture time so all downstream traversal and serialization operate on a finite acyclic structure. - Capture raw frame statement lines eagerly within generous bounds (MAX_STATEMENT_LINES, MAX_STATEMENT_LINE_CHARS) anchored at statement_lineno so pathological frames cannot bloat wire payloads while preserving original indentation for PEP 657 byte-offset column spans and render-time deindenting. - Add ExceptionInfo.record() to snapshot full tracebacks, hidden-frame flags, per-entry style overrides, assertion striptext, notes, syntax error details, and RecursionError indices. --- src/_pytest/_code/__init__.py | 10 + src/_pytest/_code/code.py | 332 ++++++++++++++++++++++ src/_pytest/_code/record.py | 502 ++++++++++++++++++++++++++++++++++ testing/code/test_record.py | 405 +++++++++++++++++++++++++++ 4 files changed, 1249 insertions(+) create mode 100644 src/_pytest/_code/record.py create mode 100644 testing/code/test_record.py diff --git a/src/_pytest/_code/__init__.py b/src/_pytest/_code/__init__.py index 7f67a2e3e0a..ea84b5f7bc8 100644 --- a/src/_pytest/_code/__init__.py +++ b/src/_pytest/_code/__init__.py @@ -9,15 +9,25 @@ from .code import getfslineno from .code import Traceback from .code import TracebackEntry +from .record import ExceptionRecord +from .record import FrameRecord +from .record import Location +from .record import RECORD_SCHEMA_VERSION +from .record import SyntaxErrorDetails from .source import getrawcode from .source import Source __all__ = [ + "RECORD_SCHEMA_VERSION", "Code", "ExceptionInfo", + "ExceptionRecord", "Frame", + "FrameRecord", + "Location", "Source", + "SyntaxErrorDetails", "Traceback", "TracebackEntry", "filter_traceback", diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index f6d2f27672c..38ffca7ad69 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -36,6 +36,11 @@ import pluggy import _pytest +from _pytest._code.record import ExceptionRecord +from _pytest._code.record import FrameRecord +from _pytest._code.record import MAX_STATEMENT_LINE_CHARS +from _pytest._code.record import MAX_STATEMENT_LINES +from _pytest._code.record import SyntaxErrorDetails from _pytest._code.source import findsource from _pytest._code.source import getrawcode from _pytest._code.source import getstatementrange_ast @@ -874,6 +879,333 @@ def group_contains( assert (depth is None) or (depth >= 1), msg return self._group_contains(self.value, expected_exception, match, depth) + def record( + self, + *, + showlocals: bool = False, + truncate_locals: bool = True, + funcargs: bool = False, + truncate_args: bool = True, + tbfilter: TracebackFilter = True, + chain: bool = True, + ) -> ExceptionRecord: + """Capture a plain-data :class:`ExceptionRecord` snapshot of this exception.""" + astcache: dict[str | Path, ast.AST] = {} + seen: set[int] = set() + return _record_exception( + self.value, + excinfo=self, + showlocals=showlocals, + truncate_locals=truncate_locals, + funcargs=funcargs, + truncate_args=truncate_args, + tbfilter=tbfilter, + chain=chain, + astcache=astcache, + seen=seen, + ) + + +def _extract_exception_notes(exc: BaseException) -> tuple[str, ...]: + try: + raw_notes = getattr(exc, "__notes__", ()) + except KeyError: + HTTPError = getattr(sys.modules.get("urllib.error", None), "HTTPError", ()) + if sys.version_info < (3, 12) and isinstance(exc, HTTPError): + raw_notes = () + else: # pragma: no cover + raise + if not isinstance(raw_notes, Sequence) or isinstance(raw_notes, str | bytes): + return () + result: list[str] = [] + for note in raw_notes: + try: + result.append(str(note)) + except Exception: + result.append(saferepr(note)) + return tuple(result) + + +def _record_frame( + entry: TracebackEntry, + *, + hidden: bool, + repr_style: Literal["short", "long"] | None, + showlocals: bool, + truncate_locals: bool, + funcargs: bool, + truncate_args: bool, + astcache: dict[str | Path, ast.AST], +) -> FrameRecord: + entry_path = entry.path + if isinstance(entry_path, Path): + abs_str = str(entry_path) + abspath: str | None = abs_str + try: + rel = bestrelpath(Path.cwd(), entry_path) + except OSError: + path = abs_str + else: + path = rel if len(rel) < len(abs_str) else abs_str + else: + path = str(entry_path) + abspath = None + + lineno = entry.lineno + 1 + if sys.version_info >= (3, 11): + fs = entry.get_python_framesummary() + end_lineno = fs.end_lineno + colno = fs.colno + end_colno = fs.end_colno + else: + end_lineno = None + colno = None + end_colno = None + + raw_code = entry.frame.code.raw + function = getattr(raw_code, "co_qualname", raw_code.co_name) + + raw_module: object = None + try: + raw_module = entry.frame.f_globals.get("__name__") + except Exception: + pass + module = raw_module if isinstance(raw_module, str) else None + + source = entry.getsource(astcache) + line_index = entry.relline + if source is None or line_index < 0 or line_index >= len(source.lines): + statement: tuple[str, ...] = () + statement_lineno = lineno + else: + start_0 = entry.getfirstlinesource() + lines_list = source.lines + if len(lines_list) > MAX_STATEMENT_LINES: + window_start = max(0, line_index - (MAX_STATEMENT_LINES - 1)) + window_end = min(len(lines_list), window_start + MAX_STATEMENT_LINES) + lines_list = lines_list[window_start:window_end] + statement_lineno = start_0 + window_start + 1 + else: + statement_lineno = start_0 + 1 + statement = tuple( + line[:MAX_STATEMENT_LINE_CHARS] + "..." + if len(line) > MAX_STATEMENT_LINE_CHARS + else line + for line in lines_list + ) + + locals_dict: dict[str, str] | None = None + if showlocals: + locals_dict = {} + try: + frame_locals = entry.locals + keys = [ + loc + for loc in frame_locals + if isinstance(loc, str) and not loc.startswith("@") + ] + keys.sort() + for name in keys: + value = frame_locals[name] + if name == "__builtins__": + locals_dict[name] = "" + elif truncate_locals: + locals_dict[name] = saferepr(value) + else: + locals_dict[name] = safeformat(value) + except Exception: + pass + + frame_args: tuple[tuple[str, str], ...] | None = None + if funcargs: + args_list: list[tuple[str, str]] = [] + for argname, argvalue in entry.frame.getargs(var=True): + str_repr = ( + saferepr(argvalue) + if truncate_args + else saferepr(argvalue, maxsize=None) + ) + args_list.append((argname, str_repr)) + frame_args = tuple(args_list) + + return FrameRecord( + path=path, + abspath=abspath, + lineno=lineno, + end_lineno=end_lineno, + colno=colno, + end_colno=end_colno, + function=function, + module=module, + statement=statement, + statement_lineno=statement_lineno, + locals=locals_dict, + args=frame_args, + hidden=hidden, + repr_style=repr_style, + ) + + +def _record_exception( + exc: BaseException, + *, + excinfo: ExceptionInfo[BaseException] | None, + showlocals: bool, + truncate_locals: bool, + funcargs: bool, + truncate_args: bool, + tbfilter: TracebackFilter, + chain: bool, + astcache: dict[str | Path, ast.AST], + seen: set[int], +) -> ExceptionRecord: + seen.add(id(exc)) + if excinfo is None and exc.__traceback__ is not None: + excinfo = ExceptionInfo.from_exception(exc) + + exc_type = type(exc) + type_name = getattr(exc_type, "__qualname__", exc_type.__name__) + type_module = getattr(exc_type, "__module__", None) + if not isinstance(type_module, str): + type_module = "" + + syntax_error: SyntaxErrorDetails | None = None + if isinstance(exc, SyntaxError): + syntax_error = SyntaxErrorDetails( + filename=exc.filename, + lineno=exc.lineno, + offset=exc.offset, + text=exc.text, + end_lineno=getattr(exc, "end_lineno", None), + end_offset=getattr(exc, "end_offset", None), + msg=str(exc.msg) if exc.msg is not None else "", + ) + message = syntax_error.msg + elif isinstance(exc, BaseExceptionGroup): + n = len(exc.exceptions) + suffix = "sub-exception" if n == 1 else "sub-exceptions" + message = f"{exc.message} ({n} {suffix})" + else: + try: + message = str(exc) + except Exception: + message = f"" + + notes = _extract_exception_notes(exc) + striptext = excinfo._get_striptext() if excinfo is not None else "" + + frames: tuple[FrameRecord, ...] = () + recursion_index: int | None = None + if excinfo is not None: + full_tb = excinfo.traceback + if callable(tbfilter): + filtered_tb = tbfilter(excinfo) + else: + filtered_tb = full_tb.filter(excinfo) + + kept_by_raw: dict[int, TracebackEntry] = { + id(e._rawentry): e for e in filtered_tb + } + frames = tuple( + _record_frame( + entry, + hidden=id(entry._rawentry) not in kept_by_raw, + repr_style=( + kept_by_raw[id(entry._rawentry)]._repr_style + if id(entry._rawentry) in kept_by_raw + else entry._repr_style + ), + showlocals=showlocals, + truncate_locals=truncate_locals, + funcargs=funcargs, + truncate_args=truncate_args, + astcache=astcache, + ) + for entry in full_tb + ) + if isinstance(exc, RecursionError): + target_tb = filtered_tb if tbfilter is not False else full_tb + try: + recursion_index = target_tb.recursionindex() + except Exception: + recursion_index = None + + children: tuple[ExceptionRecord, ...] = () + if isinstance(exc, BaseExceptionGroup): + child_records: list[ExceptionRecord] = [] + for sub_exc in exc.exceptions: + if id(sub_exc) in seen: + continue + child_records.append( + _record_exception( + sub_exc, + excinfo=None, + showlocals=showlocals, + truncate_locals=truncate_locals, + funcargs=funcargs, + truncate_args=truncate_args, + tbfilter=tbfilter, + chain=chain, + astcache=astcache, + seen=seen, + ) + ) + children = tuple(child_records) + + cause_record: ExceptionRecord | None = None + context_record: ExceptionRecord | None = None + suppress_context = bool(getattr(exc, "__suppress_context__", False)) + if chain: + raw_cause = exc.__cause__ + if raw_cause is not None and id(raw_cause) not in seen: + cause_record = _record_exception( + raw_cause, + excinfo=None, + showlocals=showlocals, + truncate_locals=truncate_locals, + funcargs=funcargs, + truncate_args=truncate_args, + tbfilter=tbfilter, + chain=chain, + astcache=astcache, + seen=seen, + ) + raw_context = exc.__context__ + if ( + raw_context is not None + and not suppress_context + and raw_context is not raw_cause + and id(raw_context) not in seen + ): + context_record = _record_exception( + raw_context, + excinfo=None, + showlocals=showlocals, + truncate_locals=truncate_locals, + funcargs=funcargs, + truncate_args=truncate_args, + tbfilter=tbfilter, + chain=chain, + astcache=astcache, + seen=seen, + ) + + return ExceptionRecord( + type_name=type_name, + type_module=type_module, + message=message, + notes=notes, + syntax_error=syntax_error, + frames=frames, + cause=cause_record, + context=context_record, + suppress_context=suppress_context, + children=children, + sections=(), + recursion_index=recursion_index, + striptext=striptext, + ) + # Type alias for the `tbfilter` setting: # bool: If True, it should be filtered using Traceback.filter() diff --git a/src/_pytest/_code/record.py b/src/_pytest/_code/record.py new file mode 100644 index 00000000000..ef7c3e053a3 --- /dev/null +++ b/src/_pytest/_code/record.py @@ -0,0 +1,502 @@ +from __future__ import annotations + +from collections.abc import Iterator +from collections.abc import Mapping +import dataclasses +from traceback import format_exception_only +from typing import Any +from typing import Final +from typing import Literal +from typing import overload +from typing import TYPE_CHECKING + +from _pytest._io import TerminalWriter + + +if TYPE_CHECKING: + import rich.traceback + + +RECORD_SCHEMA_VERSION: Final[int] = 1 + +# Bounds for eager source statement capture on pathological frames (e.g. +# huge generated functions or module-level tracebacks spanning thousands of +# lines). +MAX_STATEMENT_LINES: Final[int] = 200 +MAX_STATEMENT_LINE_CHARS: Final[int] = 4096 + +CAUSE_MESSAGE: Final[str] = ( + "The above exception was the direct cause of the following exception:" +) +CONTEXT_MESSAGE: Final[str] = ( + "During handling of the above exception, another exception occurred:" +) + + +@dataclasses.dataclass(frozen=True) +class Location: + """A message at a 1-based file location (``:: ``). + + Replaces ``ReprFileLocation`` and unpacks as a ``(path, lineno, message)`` + 3-tuple for compatibility with legacy skip location tuples. + """ + + path: str + lineno: int + message: str + + def __post_init__(self) -> None: + object.__setattr__(self, "path", str(self.path)) + + def __iter__(self) -> Iterator[str | int]: + yield self.path + yield self.lineno + yield self.message + + def __len__(self) -> int: + return 3 + + @overload + def __getitem__(self, index: Literal[0, 2]) -> str: ... + + @overload + def __getitem__(self, index: Literal[1]) -> int: ... + + @overload + def __getitem__(self, index: int) -> str | int: ... + + @overload + def __getitem__(self, index: slice) -> tuple[str | int, ...]: ... + + def __getitem__(self, index: int | slice) -> str | int | tuple[str | int, ...]: + return (self.path, self.lineno, self.message)[index] + + def to_dict(self) -> dict[str, Any]: + return { + "path": self.path, + "lineno": self.lineno, + "message": self.message, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> Location: + return cls( + path=str(data["path"]), + lineno=int(data["lineno"]), + message=str(data["message"]), + ) + + def toterminal(self, tw: TerminalWriter) -> None: + msg = self.message + i = msg.find("\n") + if i != -1: + msg = msg[:i] + tw.write(self.path, bold=True, red=True) + tw.line(f":{self.lineno}: {msg}") + + def __str__(self) -> str: + msg = self.message + i = msg.find("\n") + if i != -1: + msg = msg[:i] + return f"{self.path}:{self.lineno}: {msg}" + + +@dataclasses.dataclass(frozen=True) +class SyntaxErrorDetails: + """Structured details captured from a :class:`SyntaxError` instance.""" + + filename: str | None + lineno: int | None + offset: int | None + text: str | None + end_lineno: int | None = None + end_offset: int | None = None + msg: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "filename": self.filename, + "lineno": self.lineno, + "offset": self.offset, + "text": self.text, + "end_lineno": self.end_lineno, + "end_offset": self.end_offset, + "msg": self.msg, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> SyntaxErrorDetails: + return cls( + filename=data.get("filename"), + lineno=data.get("lineno"), + offset=data.get("offset"), + text=data.get("text"), + end_lineno=data.get("end_lineno"), + end_offset=data.get("end_offset"), + msg=str(data.get("msg", "")), + ) + + def format_only(self, qualified_type_name: str) -> str: + """Format this syntax error using stdlib ``format_exception_only`` rules.""" + err = SyntaxError(self.msg) + err.filename = self.filename + err.lineno = self.lineno + err.offset = self.offset + err.text = self.text + if self.end_lineno is not None: + err.end_lineno = self.end_lineno + if self.end_offset is not None: + err.end_offset = self.end_offset + lines = format_exception_only(SyntaxError, err) + if lines and qualified_type_name != "SyntaxError": + last = lines[-1] + if last.startswith("SyntaxError"): + lines[-1] = qualified_type_name + last.removeprefix("SyntaxError") + return "".join(lines).rstrip() + + +@dataclasses.dataclass(frozen=True) +class FrameRecord: + """Plain-data snapshot of a single traceback frame.""" + + path: str + abspath: str | None + lineno: int + end_lineno: int | None + colno: int | None + end_colno: int | None + function: str + module: str | None + statement: tuple[str, ...] + statement_lineno: int + locals: Mapping[str, str] | None = None + args: tuple[tuple[str, str], ...] | None = None + hidden: bool = False + repr_style: Literal["short", "long"] | None = None + + @property + def short_function_name(self) -> str: + """Function ``co_name`` (last component of ``function`` qualname).""" + return self.function.rsplit(".", 1)[-1] + + def to_dict(self) -> dict[str, Any]: + return { + "path": self.path, + "abspath": self.abspath, + "lineno": self.lineno, + "end_lineno": self.end_lineno, + "colno": self.colno, + "end_colno": self.end_colno, + "function": self.function, + "module": self.module, + "statement": list(self.statement), + "statement_lineno": self.statement_lineno, + "locals": dict(self.locals) if self.locals is not None else None, + "args": [list(item) for item in self.args] + if self.args is not None + else None, + "hidden": self.hidden, + "repr_style": self.repr_style, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> FrameRecord: + raw_locals = data.get("locals") + raw_args = data.get("args") + raw_style = data.get("repr_style") + if raw_style not in ("short", "long", None): + raw_style = None + return cls( + path=str(data["path"]), + abspath=str(data["abspath"]) if data.get("abspath") is not None else None, + lineno=int(data["lineno"]), + end_lineno=int(data["end_lineno"]) + if data.get("end_lineno") is not None + else None, + colno=int(data["colno"]) if data.get("colno") is not None else None, + end_colno=int(data["end_colno"]) + if data.get("end_colno") is not None + else None, + function=str(data["function"]), + module=str(data["module"]) if data.get("module") is not None else None, + statement=tuple(str(line) for line in data.get("statement", ())), + statement_lineno=int(data["statement_lineno"]), + locals={str(k): str(v) for k, v in raw_locals.items()} + if isinstance(raw_locals, Mapping) + else None, + args=tuple((str(k), str(v)) for k, v in raw_args) + if raw_args is not None + else None, + hidden=bool(data.get("hidden", False)), + repr_style=raw_style, + ) + + +@dataclasses.dataclass(frozen=True) +class ExceptionRecord: + """Plain-data, serializable snapshot of an exception, its frames, chain, and group children.""" + + type_name: str + type_module: str + message: str + notes: tuple[str, ...] = () + syntax_error: SyntaxErrorDetails | None = None + frames: tuple[FrameRecord, ...] = () + cause: ExceptionRecord | None = None + context: ExceptionRecord | None = None + suppress_context: bool = False + children: tuple[ExceptionRecord, ...] = () + sections: tuple[tuple[str, str, str], ...] = () + recursion_index: int | None = None + striptext: str = "" + + @property + def short_type_name(self) -> str: + """Unqualified ``__name__`` of the exception type.""" + return self.type_name.rsplit(".", 1)[-1] + + @property + def qualified_type_name(self) -> str: + """Exception type name prefixed with module when not in ``builtins``/``__main__``.""" + if self.type_module in ("builtins", "__main__", "", ""): + return self.type_name + return f"{self.type_module}.{self.type_name}" + + def _single_subexception_leaf(self) -> ExceptionRecord | None: + if len(self.children) != 1: + return None + child = self.children[0] + if child.children: + return child._single_subexception_leaf() + return child + + def exconly(self, *, tryshort: bool = False) -> str: + """Format the exception type, message, syntax error details, and notes.""" + if ( + tryshort + and self.children + and (leaf := self._single_subexception_leaf()) is not None + ): + leaf_repr = ( + f"{leaf.short_type_name}({leaf.message!r})" + if leaf.message + else f"{leaf.short_type_name}()" + ) + return f"{leaf_repr} [single exception in {self.short_type_name}]" + + if self.syntax_error is not None: + base = self.syntax_error.format_only(self.qualified_type_name) + else: + qtype = self.qualified_type_name + if self.message: + base = f"{qtype}: {self.message}" + else: + base = qtype + + if tryshort and self.striptext: + base = base.removeprefix(self.striptext) + + if self.notes: + note_lines: list[str] = [] + for note in self.notes: + note_lines.extend(note.splitlines() or [""]) + return "\n".join([base, *note_lines]).rstrip() + + return base.rstrip() + + @property + def crash(self) -> Location | None: + """Last non-hidden frame location paired with the short exception message.""" + for frame in reversed(self.frames): + if not frame.hidden: + return Location( + path=frame.abspath or frame.path, + lineno=frame.lineno, + message=self.exconly(tryshort=True), + ) + return None + + def walk(self) -> Iterator[ExceptionRecord]: + """Yield this record and all reachable cause, context, and child records in pre-order.""" + yield self + if self.cause is not None: + yield from self.cause.walk() + if self.context is not None and not self.suppress_context: + yield from self.context.walk() + for child in self.children: + yield from child.walk() + + def iter_chain(self) -> Iterator[tuple[ExceptionRecord, str | None]]: + """Yield ``(record, description)`` along the active cause/context chain, oldest first.""" + chain_items: list[tuple[ExceptionRecord, str | None]] = [] + cur: ExceptionRecord | None = self + description: str | None = None + while cur is not None: + chain_items.append((cur, description)) + if cur.children: + break + if cur.cause is not None: + description = CAUSE_MESSAGE + cur = cur.cause + elif cur.context is not None and not cur.suppress_context: + description = CONTEXT_MESSAGE + cur = cur.context + else: + cur = None + chain_items.reverse() + yield from chain_items + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": RECORD_SCHEMA_VERSION, + "type_name": self.type_name, + "type_module": self.type_module, + "message": self.message, + "notes": list(self.notes), + "syntax_error": self.syntax_error.to_dict() + if self.syntax_error is not None + else None, + "frames": [frame.to_dict() for frame in self.frames], + "cause": self.cause.to_dict() if self.cause is not None else None, + "context": self.context.to_dict() if self.context is not None else None, + "suppress_context": self.suppress_context, + "children": [child.to_dict() for child in self.children], + "sections": [list(sec) for sec in self.sections], + "recursion_index": self.recursion_index, + "striptext": self.striptext, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> ExceptionRecord: + version = data.get("schema_version", RECORD_SCHEMA_VERSION) + if version != RECORD_SCHEMA_VERSION: + raise ValueError(f"Unsupported ExceptionRecord schema_version: {version!r}") + + raw_syntax = data.get("syntax_error") + raw_cause = data.get("cause") + raw_context = data.get("context") + raw_recursion = data.get("recursion_index") + + return cls( + type_name=str(data["type_name"]), + type_module=str(data["type_module"]), + message=str(data["message"]), + notes=tuple(str(n) for n in data.get("notes", ())), + syntax_error=SyntaxErrorDetails.from_dict(raw_syntax) + if isinstance(raw_syntax, Mapping) + else None, + frames=tuple( + FrameRecord.from_dict(f) + for f in data.get("frames", ()) + if isinstance(f, Mapping) + ), + cause=cls.from_dict(raw_cause) if isinstance(raw_cause, Mapping) else None, + context=cls.from_dict(raw_context) + if isinstance(raw_context, Mapping) + else None, + suppress_context=bool(data.get("suppress_context", False)), + children=tuple( + cls.from_dict(c) + for c in data.get("children", ()) + if isinstance(c, Mapping) + ), + sections=tuple( + (str(s[0]), str(s[1]), str(s[2])) + for s in data.get("sections", ()) + if len(s) == 3 + ), + recursion_index=int(raw_recursion) if raw_recursion is not None else None, + striptext=str(data.get("striptext", "")), + ) + + def to_rich_trace(self, *, show_hidden: bool = False) -> rich.traceback.Trace: + """Convert this record into a :class:`rich.traceback.Trace`.""" + from rich import pretty + from rich.traceback import _SyntaxError + from rich.traceback import Frame as RichFrame + from rich.traceback import Stack as RichStack + from rich.traceback import Trace as RichTrace + + stacks: list[RichStack] = [] + cur: ExceptionRecord | None = self + is_cause = False + + while cur is not None: + rich_syntax: _SyntaxError | None = None + if cur.syntax_error is not None: + rich_syntax = _SyntaxError( + offset=cur.syntax_error.offset or 0, + filename=cur.syntax_error.filename or "?", + lineno=cur.syntax_error.lineno or 0, + line=cur.syntax_error.text or "", + msg=cur.syntax_error.msg, + notes=list(cur.notes), + ) + + visible_frames = ( + list(cur.frames) + if show_hidden + else [f for f in cur.frames if not f.hidden] + ) + rich_frames: list[RichFrame] = [] + for f in visible_frames: + last_instruction: tuple[tuple[int, int], tuple[int, int]] | None = None + if ( + f.end_lineno is not None + and f.colno is not None + and f.end_colno is not None + ): + last_instruction = ( + (f.lineno, f.colno), + (f.end_lineno, f.end_colno), + ) + line_idx = f.lineno - f.statement_lineno + failing_line = ( + f.statement[line_idx].strip() + if 0 <= line_idx < len(f.statement) + else "" + ) + rich_locals = ( + {k: pretty.Node(value_repr=v) for k, v in f.locals.items()} + if f.locals is not None + else None + ) + rich_frames.append( + RichFrame( + filename=f.abspath or f.path or "?", + lineno=f.lineno, + name=f.function, + line=failing_line, + locals=rich_locals, + last_instruction=last_instruction, + ) + ) + + stack = RichStack( + exc_type=cur.qualified_type_name, + exc_value=cur.message, + syntax_error=rich_syntax, + is_cause=is_cause, + frames=rich_frames, + notes=list(cur.notes), + is_group=bool(cur.children), + exceptions=[ + child.to_rich_trace(show_hidden=show_hidden) + for child in cur.children + ], + ) + stacks.append(stack) + + if cur.children: + break + if cur.cause is not None: + is_cause = True + cur = cur.cause + elif cur.context is not None and not cur.suppress_context: + is_cause = False + cur = cur.context + else: + cur = None + + return RichTrace(stacks=stacks) diff --git a/testing/code/test_record.py b/testing/code/test_record.py new file mode 100644 index 00000000000..f6cb93dc704 --- /dev/null +++ b/testing/code/test_record.py @@ -0,0 +1,405 @@ +from __future__ import annotations + +import io +import json +from pathlib import Path +import pickle +import sys +from typing import Any + +from _pytest._code import Code +from _pytest._code import ExceptionInfo +from _pytest._code import ExceptionRecord +from _pytest._code import Location +from _pytest._code import RECORD_SCHEMA_VERSION +from _pytest._code import Traceback +from _pytest._code.record import CAUSE_MESSAGE +from _pytest._code.record import CONTEXT_MESSAGE +from _pytest._code.record import MAX_STATEMENT_LINE_CHARS +from _pytest._code.record import MAX_STATEMENT_LINES +import pytest + + +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + + +def add_note(err: BaseException, msg: str) -> None: + if sys.version_info < (3, 11): + err.__notes__ = [*getattr(err, "__notes__", []), msg] # type: ignore[attr-defined] + else: + err.add_note(msg) + + +class TestLocation: + def test_unpacking_and_indexing(self) -> None: + loc = Location( + path=Path("foo/bar.py"), # type: ignore[arg-type] + lineno=42, + message="ValueError: bad\ndetail", + ) + assert loc.path == str(Path("foo/bar.py")) + path, lineno, message = loc + assert path == loc.path + assert lineno == 42 + assert message == "ValueError: bad\ndetail" + assert len(loc) == 3 + assert loc[0] == loc.path + assert loc[1] == 42 + assert loc[2] == "ValueError: bad\ndetail" + assert loc[:2] == (loc.path, 42) + assert str(loc) == f"{loc.path}:42: ValueError: bad" + + def test_dict_and_pickle_roundtrip(self) -> None: + loc = Location(path="test_mod.py", lineno=10, message="AssertionError") + data = json.loads(json.dumps(loc.to_dict())) + restored = Location.from_dict(data) + assert restored == loc + assert pickle.loads(pickle.dumps(loc)) == loc + + def test_toterminal(self, tw_mock: Any) -> None: + loc = Location(path="mod.py", lineno=7, message="ValueError: line1\nline2") + loc.toterminal(tw_mock) + assert tw_mock.get_write_msg(0) == "mod.py" + assert tw_mock.lines[1] == ":7: ValueError: line1" + + +class TestRecordCapture: + def test_simple_exception_record(self) -> None: + def inner(x: int) -> int: + y = x + 1 + raise ValueError(f"bad {y}") + + with pytest.raises(ValueError) as excinfo: + inner(4) + + record = excinfo.record(showlocals=True, funcargs=True) + assert record.type_name.endswith("ValueError") + assert record.type_module == "builtins" + assert record.qualified_type_name == "ValueError" + assert record.message == "bad 5" + assert record.exconly() == "ValueError: bad 5" + assert record.crash is not None + assert record.crash.path == __file__ + assert record.crash.message == "ValueError: bad 5" + + last_frame = record.frames[-1] + assert not last_frame.hidden + assert last_frame.short_function_name == "inner" + assert last_frame.module == __name__ + assert last_frame.args == (("x", "4"),) + assert last_frame.locals is not None + assert last_frame.locals["x"] == "4" + assert last_frame.locals["y"] == "5" + assert any("raise ValueError" in line for line in last_frame.statement) + line_idx = last_frame.lineno - last_frame.statement_lineno + assert "raise ValueError" in last_frame.statement[line_idx] + if sys.version_info >= (3, 11): + assert last_frame.end_lineno is not None + assert last_frame.colno is not None + assert last_frame.end_colno is not None + + def test_assertion_striptext_at_record_time(self) -> None: + x = 1 + with pytest.raises(AssertionError) as excinfo: + assert x == 2 + + record = excinfo.record() + assert record.striptext == "AssertionError: " + assert record.exconly(tryshort=True) == "assert 1 == 2" + assert record.exconly(tryshort=False) == "AssertionError: assert 1 == 2" + assert record.crash is not None + assert record.crash.message == "assert 1 == 2" + + with pytest.raises(AssertionError) as explicit_info: + raise AssertionError("custom message") + + explicit_record = explicit_info.record() + assert explicit_record.striptext == "" + assert ( + explicit_record.exconly(tryshort=True) == "AssertionError: custom message" + ) + + def test_hidden_frames_and_repr_style(self) -> None: + def leaf() -> None: + raise RuntimeError("boom") + + def hidden_helper() -> None: + __tracebackhide__ = True + leaf() + + def caller() -> None: + hidden_helper() + + with pytest.raises(RuntimeError) as excinfo: + caller() + + excinfo.traceback = Traceback( + entry.with_repr_style("short") if entry.name == "caller" else entry + for entry in excinfo.traceback + ) + record = excinfo.record() + by_name = {f.short_function_name: f for f in record.frames} + assert by_name["hidden_helper"].hidden is True + assert by_name["leaf"].hidden is False + assert by_name["caller"].hidden is False + assert by_name["caller"].repr_style == "short" + + def test_all_frames_hidden_crash_is_none(self) -> None: + __tracebackhide__ = True + + def hidden_only() -> None: + __tracebackhide__ = True + raise ValueError("hidden") + + try: + hidden_only() + except ValueError as e: + excinfo = ExceptionInfo.from_exception(e) + + record = excinfo.record() + assert all(f.hidden for f in record.frames) + assert record.crash is None + + def test_cause_and_context_chain_and_cycles(self) -> None: + def root() -> None: + raise KeyError("k") + + def mid() -> None: + try: + root() + except KeyError as e: + raise ValueError("v") from e + + def outer() -> None: + try: + mid() + except ValueError: + raise RuntimeError("r") # noqa: B904 + + with pytest.raises(RuntimeError) as excinfo: + outer() + + record = excinfo.record() + assert record.type_name == "RuntimeError" + assert record.context is not None + assert record.context.type_name == "ValueError" + assert record.context.cause is not None + assert record.context.cause.type_name == "KeyError" + + chain_list = list(record.iter_chain()) + assert [r.type_name for r, _ in chain_list] == [ + "KeyError", + "ValueError", + "RuntimeError", + ] + assert chain_list[0][1] == CAUSE_MESSAGE + assert chain_list[1][1] == CONTEXT_MESSAGE + assert chain_list[2][1] is None + + walked = [r.type_name for r in record.walk()] + assert walked == ["RuntimeError", "ValueError", "KeyError"] + + # Cycle safety + e1 = ValueError("1") + e2 = RuntimeError("2") + e1.__cause__ = e2 + e2.__cause__ = e1 + try: + raise e1 + except ValueError: + cyc_info = ExceptionInfo.from_current() + + cyc_record = cyc_info.record() + assert cyc_record.cause is not None + assert cyc_record.cause.cause is None + + def test_suppress_context_and_no_chain(self) -> None: + def fail() -> None: + try: + raise ValueError("inner") + except ValueError: + raise RuntimeError("outer") from None + + with pytest.raises(RuntimeError) as excinfo: + fail() + + rec = excinfo.record() + assert rec.suppress_context is True + assert rec.context is None + assert len(list(rec.iter_chain())) == 1 + + def test_exception_group_children_and_notes(self) -> None: + v_err = ValueError("bad value") + add_note(v_err, "value note") + t_err = TypeError("bad type") + inner_group = ExceptionGroup("inner", [t_err]) + outer_group = ExceptionGroup("outer", [v_err, inner_group]) + add_note(outer_group, "outer note 1") + add_note(outer_group, "outer note 2") + + with pytest.raises(ExceptionGroup) as excinfo: + raise outer_group + + record = excinfo.record() + assert record.notes == ("outer note 1", "outer note 2") + assert len(record.children) == 2 + assert record.children[0].type_name == "ValueError" + assert record.children[0].notes == ("value note",) + assert record.children[1].children[0].type_name == "TypeError" + + # Single sub-exception group short summary format + single_group = ExceptionGroup("wrapped", [ValueError("only one")]) + with pytest.raises(ExceptionGroup) as single_info: + raise single_group + single_rec = single_info.record() + assert ( + single_rec.exconly(tryshort=True) + == "ValueError('only one') [single exception in ExceptionGroup]" + ) + + def test_syntax_error_details(self) -> None: + try: + compile("x = (\n", "broken.py", "exec") + except SyntaxError: + excinfo = ExceptionInfo.from_current() + + record = excinfo.record() + assert record.syntax_error is not None + assert record.syntax_error.filename == "broken.py" + assert record.syntax_error.lineno == 1 + assert "broken.py" in record.exconly() + assert "SyntaxError" in record.exconly() + + def test_recursion_index_recorded_only_for_recursion_error( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + def rec(n: int) -> None: + if n < 5: + n += 1 + rec(n) + + with pytest.raises(RecursionError) as rec_info: + rec(0) + rec_record = rec_info.record() + assert rec_record.recursion_index is not None + + def non_rec() -> None: + raise RuntimeError("normal") + + with pytest.raises(RuntimeError) as norm_info: + non_rec() + monkeypatch.delattr(Traceback, "recursionindex") + norm_record = norm_info.record() + assert norm_record.recursion_index is None + + def test_pathological_statement_bounding(self) -> None: + long_expr = " + ".join(["1"] * (MAX_STATEMENT_LINE_CHARS // 2)) + body_lines = [f" x_{i} = {i}" for i in range(MAX_STATEMENT_LINES + 50)] + body_lines.append(f" y = {long_expr}") + body_lines.append(" raise ValueError(y)") + code_str = "def huge():\n" + "\n".join(body_lines) + "\n" + ns: dict[str, Any] = {} + exec(compile(code_str, "", "exec"), ns) + with pytest.raises(ValueError) as excinfo: + ns["huge"]() + + # Even if source is from a real file, verify FrameRecord bounding logic directly: + from _pytest._code.source import Source + + fake_source = Source(code_str) + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + Code, + "fullsource", + property(lambda self: fake_source), + ) + record = excinfo.record() + + last_frame = record.frames[-1] + assert len(last_frame.statement) <= MAX_STATEMENT_LINES + assert all( + len(line) <= MAX_STATEMENT_LINE_CHARS + 3 for line in last_frame.statement + ) + line_idx = last_frame.lineno - last_frame.statement_lineno + assert 0 <= line_idx < len(last_frame.statement) + assert "raise ValueError(y)" in last_frame.statement[line_idx] + + def test_to_dict_from_dict_and_pickle_roundtrip(self) -> None: + def cause_fn() -> None: + raise KeyError("missing_key") + + def group_fn() -> None: + try: + cause_fn() + except KeyError as e: + eg = ExceptionGroup("multi", [ValueError("v1"), TypeError("t1")]) + add_note(eg, "group note") + raise eg from e + + with pytest.raises(ExceptionGroup) as excinfo: + group_fn() + + record = excinfo.record(showlocals=True, funcargs=True) + payload = record.to_dict() + assert payload["schema_version"] == RECORD_SCHEMA_VERSION + + # Round-trip through JSON text + json_str = json.dumps(payload) + loaded_dict = json.loads(json_str) + restored = ExceptionRecord.from_dict(loaded_dict) + assert restored == record + assert restored.to_dict() == payload + + # Round-trip through pickle + unpickled = pickle.loads(pickle.dumps(record)) + assert unpickled == record + + # Unsupported schema version raises ValueError + bad_payload = dict(payload, schema_version=999) + with pytest.raises(ValueError, match="schema_version"): + ExceptionRecord.from_dict(bad_payload) + + def test_to_rich_trace(self) -> None: + from rich.console import Console + from rich.traceback import Traceback as RichTraceback + + def cause_fn(a: int) -> None: + local_var = a * 2 + raise ValueError(f"cause {local_var}") + + def main_fn() -> None: + try: + cause_fn(21) + except ValueError as e: + err = RuntimeError("top failure") + add_note(err, "top-only note") + raise err from e + + with pytest.raises(RuntimeError) as excinfo: + main_fn() + + record = excinfo.record(showlocals=True) + trace = record.to_rich_trace() + assert len(trace.stacks) == 2 + assert trace.stacks[0].exc_type == "RuntimeError" + assert trace.stacks[0].exc_value == "top failure" + assert trace.stacks[0].notes == ["top-only note"] + assert trace.stacks[1].exc_type == "ValueError" + assert trace.stacks[1].exc_value == "cause 42" + assert trace.stacks[1].is_cause is True + # Outer notes must not leak onto chained stacks + assert trace.stacks[1].notes == [] + + buf = io.StringIO() + console = Console(file=buf, width=100, force_terminal=False, color_system=None) + console.print(RichTraceback(trace=trace, show_locals=True)) + output = buf.getvalue() + assert "ValueError: cause 42" in output + assert ( + "The above exception was the direct cause of the following exception:" + in output + ) + assert "RuntimeError: top failure" in output + assert "top-only note" in output + assert "local_var" in output From 12bbaedfa9ac4d59d429ca43449eb6ddb7013b17 Mon Sep 17 00:00:00 2001 From: Oleksii Date: Tue, 3 Nov 2026 11:59:56 +0200 Subject: [PATCH 02/12] Add `PytestTraceback` renderer over `ExceptionRecord` and `--tb=rich` Introduce `PytestTraceback` as the presentation layer over the pure-data `ExceptionRecord` model so traceback formatting is decoupled from live Python frames and pre-baked `Repr*` strings (Step 2 of #15072). `PytestTraceback` supports all existing traceback styles (`long`, `short`, `auto`, `line`, `no`, `value`, `native`) as well as a new `rich` style powered by `ExceptionRecord.to_rich_trace()`, and implements both pytest's `toterminal(tw)` protocol (`to_repr()` compatibility shim) and Rich's `__rich_console__` protocol. Wire `--tb=rich` through CLI options, `Node._repr_failure_py`, and `ExceptionInfo.getrepr()`. Refs #15072 --- src/_pytest/_code/__init__.py | 4 + src/_pytest/_code/code.py | 32 +- src/_pytest/_code/record.py | 31 +- src/_pytest/_code/render.py | 759 ++++++++++++++++++++++++++++++++++ src/_pytest/nodes.py | 8 +- src/_pytest/terminal.py | 4 +- testing/code/test_excinfo.py | 15 +- testing/code/test_record.py | 129 ++++++ 8 files changed, 969 insertions(+), 13 deletions(-) create mode 100644 src/_pytest/_code/render.py diff --git a/src/_pytest/_code/__init__.py b/src/_pytest/_code/__init__.py index ea84b5f7bc8..b6a3e4615aa 100644 --- a/src/_pytest/_code/__init__.py +++ b/src/_pytest/_code/__init__.py @@ -9,11 +9,13 @@ from .code import getfslineno from .code import Traceback from .code import TracebackEntry +from .code import TracebackStyle from .record import ExceptionRecord from .record import FrameRecord from .record import Location from .record import RECORD_SCHEMA_VERSION from .record import SyntaxErrorDetails +from .render import PytestTraceback from .source import getrawcode from .source import Source @@ -26,10 +28,12 @@ "Frame", "FrameRecord", "Location", + "PytestTraceback", "Source", "SyntaxErrorDetails", "Traceback", "TracebackEntry", + "TracebackStyle", "filter_traceback", "getfslineno", "getrawcode", diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 38ffca7ad69..8bf45d18a58 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -57,7 +57,9 @@ if sys.version_info < (3, 11): from exceptiongroup import BaseExceptionGroup -TracebackStyle = Literal["long", "short", "line", "no", "native", "value", "auto"] +TracebackStyle = Literal[ + "long", "short", "line", "no", "native", "value", "auto", "rich" +] EXCEPTION_OR_MORE = type[BaseException] | tuple[type[BaseException], ...] @@ -776,6 +778,29 @@ def getrepr( reprcrash=self._getreprcrash(), ) + if style == "rich": + from _pytest._code.render import PytestTraceback + + record = self.record( + showlocals=showlocals, + truncate_locals=truncate_locals, + funcargs=funcargs, + truncate_args=truncate_args, + tbfilter=tbfilter, + chain=chain, + ) + return PytestTraceback( + record=record, + style="rich", + showlocals=showlocals, + abspath=abspath, + chain=chain, + truncate_locals=truncate_locals, + truncate_args=truncate_args, + funcargs=funcargs, + show_hidden=tbfilter is False, + ).to_repr() + fmt = ExceptionInfoFormatter( showlocals=showlocals, style=style, @@ -1096,6 +1121,7 @@ def _record_exception( frames: tuple[FrameRecord, ...] = () recursion_index: int | None = None + recursion_error: str | None = None if excinfo is not None: full_tb = excinfo.traceback if callable(tbfilter): @@ -1127,8 +1153,9 @@ def _record_exception( target_tb = filtered_tb if tbfilter is not False else full_tb try: recursion_index = target_tb.recursionindex() - except Exception: + except Exception as e: recursion_index = None + recursion_error = f"{type(e).__name__}: {e!s}" children: tuple[ExceptionRecord, ...] = () if isinstance(exc, BaseExceptionGroup): @@ -1203,6 +1230,7 @@ def _record_exception( children=children, sections=(), recursion_index=recursion_index, + recursion_error=recursion_error, striptext=striptext, ) diff --git a/src/_pytest/_code/record.py b/src/_pytest/_code/record.py index ef7c3e053a3..9bebab52ce2 100644 --- a/src/_pytest/_code/record.py +++ b/src/_pytest/_code/record.py @@ -14,6 +14,9 @@ if TYPE_CHECKING: + from rich.console import Console + from rich.console import ConsoleOptions + from rich.console import RenderResult import rich.traceback @@ -249,6 +252,7 @@ class ExceptionRecord: children: tuple[ExceptionRecord, ...] = () sections: tuple[tuple[str, str, str], ...] = () recursion_index: int | None = None + recursion_error: str | None = None striptext: str = "" @property @@ -334,8 +338,6 @@ def iter_chain(self) -> Iterator[tuple[ExceptionRecord, str | None]]: description: str | None = None while cur is not None: chain_items.append((cur, description)) - if cur.children: - break if cur.cause is not None: description = CAUSE_MESSAGE cur = cur.cause @@ -364,6 +366,7 @@ def to_dict(self) -> dict[str, Any]: "children": [child.to_dict() for child in self.children], "sections": [list(sec) for sec in self.sections], "recursion_index": self.recursion_index, + "recursion_error": self.recursion_error, "striptext": self.striptext, } @@ -377,6 +380,7 @@ def from_dict(cls, data: Mapping[str, Any]) -> ExceptionRecord: raw_cause = data.get("cause") raw_context = data.get("context") raw_recursion = data.get("recursion_index") + raw_recursion_error = data.get("recursion_error") return cls( type_name=str(data["type_name"]), @@ -407,6 +411,9 @@ def from_dict(cls, data: Mapping[str, Any]) -> ExceptionRecord: if len(s) == 3 ), recursion_index=int(raw_recursion) if raw_recursion is not None else None, + recursion_error=str(raw_recursion_error) + if raw_recursion_error is not None + else None, striptext=str(data.get("striptext", "")), ) @@ -488,8 +495,6 @@ def to_rich_trace(self, *, show_hidden: bool = False) -> rich.traceback.Trace: ) stacks.append(stack) - if cur.children: - break if cur.cause is not None: is_cause = True cur = cur.cause @@ -500,3 +505,21 @@ def to_rich_trace(self, *, show_hidden: bool = False) -> rich.traceback.Trace: cur = None return RichTrace(stacks=stacks) + + def toterminal(self, tw: TerminalWriter) -> None: + """Compatibility shim rendering this record to a :class:`TerminalWriter`.""" + from _pytest._code.render import PytestTraceback + + PytestTraceback(self).toterminal(tw) + + def __str__(self) -> str: + from _pytest._code.render import PytestTraceback + + return str(PytestTraceback(self)) + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + from _pytest._code.render import PytestTraceback + + yield from PytestTraceback(self).__rich_console__(console, options) diff --git a/src/_pytest/_code/render.py b/src/_pytest/_code/render.py new file mode 100644 index 00000000000..2a7dba0e5a5 --- /dev/null +++ b/src/_pytest/_code/render.py @@ -0,0 +1,759 @@ +from __future__ import annotations + +import dataclasses +from io import StringIO +from pathlib import Path +import sys +from typing import TYPE_CHECKING + +from _pytest._code.code import _byte_offset_to_character_offset +from _pytest._code.code import ExceptionChainRepr +from _pytest._code.code import ReprEntry +from _pytest._code.code import ReprEntryNative +from _pytest._code.code import ReprExceptionInfo +from _pytest._code.code import ReprFileLocation +from _pytest._code.code import ReprFuncArgs +from _pytest._code.code import ReprLocals +from _pytest._code.code import ReprTraceback +from _pytest._code.code import ReprTracebackNative +from _pytest._code.code import TracebackStyle +from _pytest._code.record import ExceptionRecord +from _pytest._code.record import FrameRecord +from _pytest._code.source import deindent +from _pytest._code.source import Source +from _pytest._io import TerminalWriter +from _pytest.pathlib import bestrelpath + + +if TYPE_CHECKING: + from rich.console import Console + from rich.console import ConsoleOptions + from rich.console import RenderResult + + +_ALL_HIDDEN_MSG = ( + "All traceback entries are hidden. " + "Pass `--full-trace` to see hidden and internal frames." +) + + +def _getindent(source: Source) -> int: + try: + s = str(source.getstatement(len(source) - 1)) + except KeyboardInterrupt: + raise + except BaseException: + try: + s = str(source[-1]) + except KeyboardInterrupt: + raise + except BaseException: + return 0 + return 4 + (len(s) - len(s.lstrip())) + + +def _highlight_arrows_for_line( + *, + line: str, + raw_line: str, + lineno: int | None, + end_lineno: int | None, + colno: int | None, + end_colno: int | None, +) -> list[str]: + if lineno != end_lineno: + return [] + if colno is None or end_colno is None: + return [] + + num_stripped_chars = len(raw_line) - len(line) + start_char_offset = _byte_offset_to_character_offset(raw_line, colno) + end_char_offset = _byte_offset_to_character_offset(raw_line, end_colno) + num_carets = end_char_offset - start_char_offset + if num_carets >= len(line.strip()): + return [] + + highlights = " " + highlights += " " * (start_char_offset - num_stripped_chars + 1) + highlights += "^" * num_carets + return [highlights] + + +def _format_native_record_lines( + rec: ExceptionRecord, + *, + show_hidden: bool, + indent_prefix: str = "", +) -> list[str]: + """Format an :class:`ExceptionRecord` in stdlib native / ExceptionGroup style.""" + lines: list[str] = [] + visible_frames = ( + list(rec.frames) if show_hidden else [f for f in rec.frames if not f.hidden] + ) + + if rec.children: + if visible_frames: + lines.append( + f"{indent_prefix} + Exception Group Traceback (most recent call last):\n" + ) + for f in visible_frames: + fname = f.abspath or f.path + lines.append( + f'{indent_prefix} | File "{fname}", line {f.lineno}, in {f.short_function_name}\n' + ) + line_idx = f.lineno - f.statement_lineno + if 0 <= line_idx < len(f.statement): + raw_line = f.statement[line_idx] + stripped = raw_line.strip() + if stripped: + lines.append(f"{indent_prefix} | {stripped}\n") + if ( + sys.version_info >= (3, 11) + and f.lineno == f.end_lineno + and f.colno is not None + and f.end_colno is not None + ): + carets = _highlight_arrows_for_line( + line=stripped, + raw_line=raw_line, + lineno=f.lineno, + end_lineno=f.end_lineno, + colno=f.colno, + end_colno=f.end_colno, + ) + for c in carets: + lines.append(f"{indent_prefix} | {c}\n") + for exline in rec.exconly(tryshort=False).splitlines(): + lines.append(f"{indent_prefix} | {exline}\n") + + for idx, child in enumerate(rec.children, 1): + header = f"{idx:^18}" if idx >= 10 else f" {idx} ".center(36, "-") + if idx == 1: + lines.append(f"{indent_prefix} +-+{header}\n") + else: + lines.append(f"{indent_prefix} +{header}\n") + child_chain = list(child.iter_chain()) + for chain_rec, chain_desc in child_chain: + lines.extend( + _format_native_record_lines( + chain_rec, + show_hidden=show_hidden, + indent_prefix=indent_prefix + " |", + ) + ) + if chain_desc is not None: + lines.append(f"{indent_prefix} |\n") + lines.append(f"{indent_prefix} | {chain_desc}\n") + lines.append(f"{indent_prefix} |\n") + lines.append(f"{indent_prefix} +------------------------------------\n") + return lines + + body_prefix = f"{indent_prefix} " if indent_prefix else "" + if visible_frames: + lines.append(f"{body_prefix}Traceback (most recent call last):\n") + for f in visible_frames: + fname = f.abspath or f.path + lines.append( + f'{body_prefix} File "{fname}", line {f.lineno}, in {f.short_function_name}\n' + ) + line_idx = f.lineno - f.statement_lineno + if 0 <= line_idx < len(f.statement): + raw_line = f.statement[line_idx] + stripped = raw_line.strip() + if stripped: + lines.append(f"{body_prefix} {stripped}\n") + if ( + sys.version_info >= (3, 11) + and f.lineno == f.end_lineno + and f.colno is not None + and f.end_colno is not None + ): + carets = _highlight_arrows_for_line( + line=stripped, + raw_line=raw_line, + lineno=f.lineno, + end_lineno=f.end_lineno, + colno=f.colno, + end_colno=f.end_colno, + ) + for c in carets: + lines.append(f"{body_prefix}{c}\n") + + for exline in rec.exconly(tryshort=False).splitlines(): + lines.append(f"{body_prefix}{exline}\n") + return lines + + +@dataclasses.dataclass +class PytestTraceback: + """Rich renderable and TerminalWriter renderer over an :class:`ExceptionRecord`.""" + + flow_marker: str = dataclasses.field(default=">", init=False, repr=False) + fail_marker: str = dataclasses.field(default="E", init=False, repr=False) + + record: ExceptionRecord + style: TracebackStyle = "long" + showlocals: bool = False + abspath: bool = False + chain: bool = True + truncate_locals: bool = True + truncate_args: bool = True + funcargs: bool = False + show_hidden: bool = False + sections: list[tuple[str, str, str]] = dataclasses.field( + default_factory=list, repr=False + ) + + def addsection(self, name: str, content: str, sep: str = "-") -> None: + self.sections.append((name, content, sep)) + + def _all_sections(self) -> list[tuple[str, str, str]]: + return [*self.record.sections, *self.sections] + + def _makepath(self, frame: FrameRecord) -> str: + if self.abspath: + return frame.abspath or frame.path + if frame.abspath is not None: + abs_path = Path(frame.abspath) + try: + np = bestrelpath(Path.cwd(), abs_path) + except OSError: + return frame.abspath + if len(np) < len(frame.abspath): + return np + return frame.abspath + return frame.path + + def _make_reprcrash(self, rec: ExceptionRecord) -> ReprFileLocation | None: + loc = rec.crash + if loc is None: + return None + return ReprFileLocation(loc.path, loc.lineno, loc.message) + + def _get_exconly_lines( + self, + rec: ExceptionRecord, + *, + indent: int = 4, + markall: bool = False, + ) -> list[str]: + lines: list[str] = [] + indentstr = " " * indent + exlines = rec.exconly(tryshort=True).split("\n") + failindent = self.fail_marker + indentstr[1:] + for line in exlines: + lines.append(failindent + line) + if not markall: + failindent = indentstr + return lines + + def _format_frame_source( + self, + frame: FrameRecord, + *, + rec_if_last: ExceptionRecord | None, + short: bool, + ) -> list[str]: + lines: list[str] = [] + if not frame.statement: + raw_lines = ["???"] + deindented_lines = ["???"] + line_index = 0 + end_line_index: int | None = None + colno: int | None = None + end_colno: int | None = None + else: + raw_lines = list(frame.statement) + deindented_lines = deindent(raw_lines) + line_index = frame.lineno - frame.statement_lineno + if line_index < 0 or line_index >= len(deindented_lines): + raw_lines = ["???"] + deindented_lines = ["???"] + line_index = 0 + end_line_index = None + colno = None + end_colno = None + else: + end_line_index = ( + frame.end_lineno - frame.statement_lineno + if frame.end_lineno is not None + else None + ) + colno = frame.colno + end_colno = frame.end_colno + + space_prefix = " " + if short: + stripped_line = deindented_lines[line_index].strip() + lines.append(space_prefix + stripped_line) + lines.extend( + _highlight_arrows_for_line( + raw_line=raw_lines[line_index], + line=stripped_line, + lineno=line_index, + end_lineno=end_line_index, + colno=colno, + end_colno=end_colno, + ) + ) + else: + for line in deindented_lines[:line_index]: + lines.append(space_prefix + line) + lines.append(self.flow_marker + " " + deindented_lines[line_index]) + lines.extend( + _highlight_arrows_for_line( + raw_line=raw_lines[line_index], + line=deindented_lines[line_index], + lineno=line_index, + end_lineno=end_line_index, + colno=colno, + end_colno=end_colno, + ) + ) + for line in deindented_lines[line_index + 1 :]: + lines.append(space_prefix + line) + + if rec_if_last is not None: + indent = 4 if short else _getindent(Source(deindented_lines)) + lines.extend( + self._get_exconly_lines(rec_if_last, indent=indent, markall=True) + ) + return lines + + def _repr_frame_entry( + self, + frame: FrameRecord | None, + rec_if_last: ExceptionRecord | None, + effective_style: TracebackStyle, + ) -> ReprEntry: + lines: list[str] = [] + style: TracebackStyle = ( + frame.repr_style + if frame is not None and frame.repr_style is not None + else effective_style + ) + if style == "auto": + style = "long" + + if style in ("short", "long") and frame is not None: + short = style == "short" + reprargs: ReprFuncArgs | None = None + if not short and self.funcargs and frame.args is not None: + reprargs = ReprFuncArgs(list(frame.args)) + + lines.extend( + self._format_frame_source( + frame, + rec_if_last=rec_if_last, + short=short, + ) + ) + if short: + message = f"in {frame.short_function_name}" + else: + message = rec_if_last.short_type_name if rec_if_last is not None else "" + path = self._makepath(frame) + reprfileloc = ReprFileLocation(path, frame.lineno, message) + localsrepr: ReprLocals | None = None + if self.showlocals and frame.locals is not None: + local_lines = [ + f"{name:<10} = {val}" for name, val in frame.locals.items() + ] + localsrepr = ReprLocals(local_lines) + return ReprEntry(lines, reprargs, localsrepr, reprfileloc, style) + elif style == "value": + if rec_if_last is not None: + lines.extend(rec_if_last.message.split("\n")) + return ReprEntry(lines, None, None, None, style) + else: + if rec_if_last is not None: + lines.extend(self._get_exconly_lines(rec_if_last, indent=4)) + return ReprEntry(lines, None, None, None, style) + + def _truncate_recursive_frames( + self, + rec: ExceptionRecord, + frames: list[FrameRecord], + ) -> tuple[list[FrameRecord], str | None]: + max_frames = 10 + if rec.recursion_error is not None: + extraline: str | None = ( + "!!! Recursion error detected, but an error occurred locating the origin of recursion.\n" + " The following exception happened when comparing locals in the stack frame:\n" + f" {rec.recursion_error}\n" + f" Displaying first and last {max_frames} stack frames out of {len(frames)}." + ) + frames = frames[:max_frames] + frames[-max_frames:] + elif rec.recursion_index is not None: + extraline = "!!! Recursion detected (same locals & position)" + frames = frames[: rec.recursion_index + 1] + elif not self.show_hidden and len(frames) > 2 * max_frames: + extraline = ( + "!!! Recursion error detected, but the origin of the recursion " + "could not be located.\n" + f" Displaying first and last {max_frames} stack frames out of {len(frames)}.\n" + " Pass `--full-trace` to see all frames." + ) + frames = frames[:max_frames] + frames[-max_frames:] + else: + extraline = None + return frames, extraline + + def _repr_traceback_for_record(self, rec: ExceptionRecord) -> ReprTraceback: + visible_frames = ( + list(rec.frames) + if self.show_hidden + else [f for f in rec.frames if not f.hidden] + ) + + if rec.short_type_name == "RecursionError" or rec.recursion_index is not None: + visible_frames, extraline = self._truncate_recursive_frames( + rec, visible_frames + ) + else: + extraline = None + + effective_tb_style: TracebackStyle = ( + "long" if self.style == "auto" else self.style + ) + + if not visible_frames: + if extraline is None: + extraline = _ALL_HIDDEN_MSG + entries = [self._repr_frame_entry(None, rec, effective_tb_style)] + return ReprTraceback(entries, extraline, style=effective_tb_style) + + if self.style == "value": + entries = [self._repr_frame_entry(visible_frames[-1], rec, "value")] + return ReprTraceback(entries, None, style="value") + + entries = [] + n_frames = len(visible_frames) + for idx, frame in enumerate(visible_frames): + is_last = idx == n_frames - 1 + entry_default_style = effective_tb_style + if ( + self.style == "auto" + and n_frames > 2 + and 0 < idx < n_frames - 1 + and frame.repr_style is None + ): + entry_default_style = "short" + entries.append( + self._repr_frame_entry( + frame, + rec if is_last else None, + entry_default_style, + ) + ) + return ReprTraceback(entries, extraline, style=effective_tb_style) + + def _iter_chain_records(self) -> list[tuple[ExceptionRecord, str | None]]: + if not self.chain: + return [(self.record, None)] + return list(self.record.iter_chain()) + + def _render_rich_to_lines(self, width: int = 100) -> list[str]: + from rich.console import Console + from rich.traceback import Traceback as RichTraceback + + buf = StringIO() + console = Console( + file=buf, + width=width, + force_terminal=False, + color_system=None, + ) + tb = RichTraceback( + trace=self.record.to_rich_trace(show_hidden=self.show_hidden), + show_locals=self.showlocals, + width=width, + ) + console.print(tb) + return buf.getvalue().splitlines(keepends=True) + + def to_repr(self) -> ReprExceptionInfo | ExceptionChainRepr: + """Build a legacy :class:`ReprExceptionInfo` or :class:`ExceptionChainRepr` from the record.""" + if self.style == "native": + native_lines: list[str] = [] + for rec, desc in self._iter_chain_records(): + native_lines.extend( + _format_native_record_lines(rec, show_hidden=self.show_hidden) + ) + if desc is not None: + native_lines.append(f"\n{desc}\n\n") + res_native = ReprExceptionInfo( + reprtraceback=ReprTracebackNative(native_lines), + reprcrash=self._make_reprcrash(self.record), + ) + for name, content, sep in self._all_sections(): + res_native.addsection(name, content, sep) + return res_native + + if self.style == "rich": + visible_frames = ( + list(self.record.frames) + if self.show_hidden + else [f for f in self.record.frames if not f.hidden] + ) + extraline = ( + _ALL_HIDDEN_MSG if (self.record.frames and not visible_frames) else None + ) + rich_lines = self._render_rich_to_lines() + res_rich = ReprExceptionInfo( + reprtraceback=ReprTracebackNative(rich_lines, extraline=extraline), + reprcrash=self._make_reprcrash(self.record), + ) + for name, content, sep in self._all_sections(): + res_rich.addsection(name, content, sep) + return res_rich + + repr_chain: list[tuple[ReprTraceback, ReprFileLocation | None, str | None]] = [] + for rec, description in self._iter_chain_records(): + if rec.children: + visible = ( + list(rec.frames) + if self.show_hidden + else [f for f in rec.frames if not f.hidden] + ) + extraline = _ALL_HIDDEN_MSG if (rec.frames and not visible) else None + group_lines = _format_native_record_lines( + rec, show_hidden=self.show_hidden + ) + reprtraceback: ReprTraceback = ReprTracebackNative( + group_lines, extraline=extraline + ) + reprcrash = self._make_reprcrash(rec) + elif rec.frames or rec is self.record: + reprtraceback = self._repr_traceback_for_record(rec) + reprcrash = self._make_reprcrash(rec) + else: + no_tb_lines = [ + f"{line}\n" for line in rec.exconly(tryshort=False).splitlines() + ] + reprtraceback = ReprTracebackNative(no_tb_lines) + reprcrash = None + + repr_chain.append((reprtraceback, reprcrash, description)) + + chain_repr = ExceptionChainRepr(repr_chain) + for name, content, sep in self._all_sections(): + chain_repr.addsection(name, content, sep) + return chain_repr + + def toterminal(self, tw: TerminalWriter) -> None: + if self.style == "rich": + from rich.console import Console + from rich.traceback import Traceback as RichTraceback + + visible_frames = ( + list(self.record.frames) + if self.show_hidden + else [f for f in self.record.frames if not f.hidden] + ) + buf = StringIO() + console = Console( + file=buf, + width=tw.fullwidth, + force_terminal=tw.hasmarkup, + no_color=not tw.hasmarkup, + color_system="standard" if tw.hasmarkup else None, + ) + tb = RichTraceback( + trace=self.record.to_rich_trace(show_hidden=self.show_hidden), + show_locals=self.showlocals, + width=tw.fullwidth, + ) + console.print(tb) + tw.write_raw(buf.getvalue()) + if self.record.frames and not visible_frames: + tw.line(_ALL_HIDDEN_MSG) + for name, content, sep in self._all_sections(): + tw.sep(sep, name) + tw.line(content) + return + + if self.style == "line": + crash = self.record.crash + if crash is not None: + tw.line(str(crash)) + else: + tw.line(self.record.exconly(tryshort=True)) + for name, content, sep in self._all_sections(): + tw.sep(sep, name) + tw.line(content) + return + + self.to_repr().toterminal(tw) + + def __str__(self) -> str: + io = StringIO() + tw = TerminalWriter(file=io) + self.toterminal(tw) + return io.getvalue().strip() + + def _render_entry_rich( + self, + entry: ReprEntry | ReprEntryNative, + *, + width: int, + ) -> RenderResult: + from rich.syntax import Syntax + from rich.text import Text + + if isinstance(entry, ReprEntryNative): + yield Text("".join(entry.lines).rstrip("\n")) + return + + if entry.style == "value": + for line in entry.lines: + yield Text(line) + return + + def render_entry_lines() -> RenderResult: + if not entry.lines: + return + fail_prefix = f"{self.fail_marker} " + indent_size = len(fail_prefix) + source_items: list[tuple[str, str]] = [] + failure_lines: list[str] = [] + for idx, line in enumerate(entry.lines): + if line.startswith(fail_prefix): + failure_lines.extend(entry.lines[idx:]) + break + source_items.append((line[:indent_size], line[indent_size:])) + + for indent_str, code_line in source_items: + syntax = Syntax( + code_line, + "python", + theme="ansi_dark", + background_color="default", + ) + highlighted = syntax.highlight(code_line) + highlighted.rstrip() + prefix_style = "bold" if indent_str.startswith(self.flow_marker) else "" + yield Text.assemble((indent_str, prefix_style), highlighted) + + for fline in failure_lines: + yield Text(fline, style="bold red") + + def render_fileloc(loc: ReprFileLocation) -> Text: + msg = loc.message + i = msg.find("\n") + if i != -1: + msg = msg[:i] + return Text.assemble((loc.path, "bold red"), f":{loc.lineno}: {msg}") + + if entry.style == "short": + if entry.reprfileloc is not None: + yield render_fileloc(entry.reprfileloc) + yield from render_entry_lines() + if entry.reprlocals is not None: + for lline in entry.reprlocals.lines: + yield Text(" " + lline) + return + + if entry.reprfuncargs is not None and entry.reprfuncargs.args: + linesofar = "" + for name, value in entry.reprfuncargs.args: + ns = f"{name} = {value}" + if len(ns) + len(linesofar) + 2 > width: + if linesofar: + yield Text(linesofar) + linesofar = ns + else: + linesofar = f"{linesofar}, {ns}" if linesofar else ns + if linesofar: + yield Text(linesofar) + yield Text("") + + yield from render_entry_lines() + + if entry.reprlocals is not None: + yield Text("") + for lline in entry.reprlocals.lines: + yield Text(lline) + + if entry.reprfileloc is not None: + if entry.lines: + yield Text("") + yield render_fileloc(entry.reprfileloc) + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + from rich.text import Text + from rich.traceback import Traceback as RichTraceback + + width = options.max_width or 80 + + if self.style == "rich": + visible_frames = ( + list(self.record.frames) + if self.show_hidden + else [f for f in self.record.frames if not f.hidden] + ) + yield RichTraceback( + trace=self.record.to_rich_trace(show_hidden=self.show_hidden), + show_locals=self.showlocals, + width=width, + ) + if self.record.frames and not visible_frames: + yield Text(_ALL_HIDDEN_MSG) + for name, content, sep in self._all_sections(): + yield Text(f"{sep * 10} {name} {sep * 10}") + yield Text(content) + return + + if self.style == "line": + crash = self.record.crash + if crash is not None: + msg = crash.message.splitlines()[0] if crash.message else "" + yield Text.assemble((crash.path, "bold red"), f":{crash.lineno}: {msg}") + else: + yield Text(self.record.exconly(tryshort=True), style="bold red") + return + + repr_obj = self.to_repr() + chains: Sequence_Chain = ( + repr_obj.chain + if isinstance(repr_obj, ExceptionChainRepr) + else [(repr_obj.reprtraceback, repr_obj.reprcrash, None)] + ) + for reprtraceback, _reprcrash, description in chains: + for i, entry in enumerate(reprtraceback.reprentries): + if entry.style == "long": + yield Text("") + yield from self._render_entry_rich(entry, width=width) + if i < len(reprtraceback.reprentries) - 1: + next_entry = reprtraceback.reprentries[i + 1] + if entry.style == "long" or ( + entry.style == "short" and next_entry.style == "long" + ): + sep_line = ReprTraceback.entrysep * ( + width // len(ReprTraceback.entrysep) + ) + if ( + len(sep_line) + len(ReprTraceback.entrysep.rstrip()) + <= width + ): + sep_line += ReprTraceback.entrysep.rstrip() + yield Text(sep_line) + if reprtraceback.extraline: + yield Text(reprtraceback.extraline) + if description is not None: + yield Text("") + yield Text(description, style="yellow") + + for name, content, sep in repr_obj.sections: + n = max((width - len(name) - 2) // (2 * len(sep)), 1) + fill = sep * n + yield Text(f"{fill} {name} {fill}") + yield Text(content) + + +if TYPE_CHECKING: + from collections.abc import Sequence + + Sequence_Chain = Sequence[tuple[ReprTraceback, ReprFileLocation | None, str | None]] diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 2b510907122..b79fb199500 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -443,7 +443,8 @@ def _repr_failure_py( tbfilter: bool | Callable[[ExceptionInfo[BaseException]], Traceback] if self.config.getoption("fulltrace", False): - style = "long" + if style != "rich": + style = "long" tbfilter = False else: tbfilter = self._traceback_filter @@ -451,8 +452,11 @@ def _repr_failure_py( style = "long" # XXX should excinfo.getrepr record all data and toterminal() process it? if style is None: - if self.config.getoption("tbstyle", "auto") == "short": + tbstyle_opt = self.config.getoption("tbstyle", "auto") + if tbstyle_opt == "short": style = "short" + elif tbstyle_opt == "rich": + style = "rich" else: style = "long" diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 00ef7d3cddb..4f2d9027211 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -234,8 +234,8 @@ def pytest_addoption(parser: Parser) -> None: action="store", dest="tbstyle", default="auto", - choices=["auto", "long", "short", "no", "line", "native"], - help="Traceback print mode (auto/long/short/line/native/no)", + choices=["auto", "long", "short", "no", "line", "native", "rich"], + help="Traceback print mode (auto/long/short/line/native/rich/no)", ) group.addoption( "--xfail-tb", diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 4106a8df671..2fe703bae56 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1358,7 +1358,16 @@ def f(): }, id=f"style={style},showlocals={showlocals},funcargs={funcargs},tbfilter={tbfilter}", ) - for style in ["long", "short", "line", "no", "native", "value", "auto"] + for style in [ + "long", + "short", + "line", + "no", + "native", + "value", + "auto", + "rich", + ] for showlocals in (True, False) for tbfilter in (True, False) for funcargs in (True, False) @@ -2130,7 +2139,7 @@ def test_nested_multiple() -> None: ) -@pytest.mark.parametrize("tbstyle", ("long", "short", "auto", "line", "native")) +@pytest.mark.parametrize("tbstyle", ("long", "short", "auto", "line", "native", "rich")) @pytest.mark.parametrize("group", (True, False), ids=("group", "bare")) def test_all_entries_hidden(pytester: Pytester, tbstyle: str, group: bool) -> None: """Regression test for #10903.""" @@ -2147,7 +2156,7 @@ def test(): result = pytester.runpytest("--tb", tbstyle) assert result.ret == 1 if tbstyle != "line": - result.stdout.fnmatch_lines(["*ValueError: bar"]) + result.stdout.fnmatch_lines(["*ValueError: bar*"]) if tbstyle not in ("line", "native"): result.stdout.fnmatch_lines(["All traceback entries are hidden.*"]) diff --git a/testing/code/test_record.py b/testing/code/test_record.py index f6cb93dc704..3c5f263ccaf 100644 --- a/testing/code/test_record.py +++ b/testing/code/test_record.py @@ -11,12 +11,16 @@ from _pytest._code import ExceptionInfo from _pytest._code import ExceptionRecord from _pytest._code import Location +from _pytest._code import PytestTraceback from _pytest._code import RECORD_SCHEMA_VERSION from _pytest._code import Traceback +from _pytest._code import TracebackStyle from _pytest._code.record import CAUSE_MESSAGE from _pytest._code.record import CONTEXT_MESSAGE from _pytest._code.record import MAX_STATEMENT_LINE_CHARS from _pytest._code.record import MAX_STATEMENT_LINES +from _pytest._io import TerminalWriter +from _pytest.pytester import Pytester import pytest @@ -403,3 +407,128 @@ def main_fn() -> None: assert "RuntimeError: top failure" in output assert "top-only note" in output assert "local_var" in output + + +class TestPytestTraceback: + @pytest.mark.parametrize( + "style", + ("long", "short", "auto", "line", "no", "value", "native", "rich"), + ) + def test_styles_render_and_parity(self, style: TracebackStyle) -> None: + def leaf(x: int) -> None: + y = x + 10 + assert y == 0, "expected zero" + + def caller() -> None: + leaf(5) + + with pytest.raises(AssertionError) as excinfo: + caller() + + record = excinfo.record(showlocals=True, funcargs=True) + tb = PytestTraceback(record, style=style, showlocals=True) + rendered = str(tb) + + if style == "no": + assert "AssertionError: expected zero" in rendered + assert "leaf" not in rendered + elif style == "value": + assert "expected zero" in rendered + elif style == "line": + assert f"{__file__}:" in rendered + assert "AssertionError: expected zero" in rendered + elif style in ("long", "short", "auto", "native", "rich"): + assert "AssertionError" in rendered + assert "expected zero" in rendered + assert "leaf" in rendered + + def test_toterminal_long_matches_excinfo_getrepr(self, tw_mock: Any) -> None: + __tracebackhide__ = True + + def g(x: int) -> None: + raise ValueError(x) + + def f() -> None: + g(3) + + with pytest.raises(ValueError) as excinfo: + f() + + record = excinfo.record() + tb = PytestTraceback(record, style="long") + tb.toterminal(tw_mock) + assert any("def f()" in str(line) for line in tw_mock.lines) + assert any("> g(3)" in str(line) for line in tw_mock.lines) + assert any("E ValueError: 3" in str(line) for line in tw_mock.lines) + + def test_record_toterminal_and_rich_console_protocol(self) -> None: + from rich.console import Console + + def boom() -> None: + raise RuntimeError("protocol test") + + with pytest.raises(RuntimeError) as excinfo: + boom() + + record = excinfo.record() + assert "RuntimeError: protocol test" in str(record) + + buf = io.StringIO() + tw = TerminalWriter(file=buf) + record.toterminal(tw) + assert "RuntimeError: protocol test" in buf.getvalue() + + console_buf = io.StringIO() + console = Console( + file=console_buf, width=88, force_terminal=True, color_system="truecolor" + ) + console.print(record) + assert "RuntimeError: protocol test" in console_buf.getvalue() + + def test_to_repr_compat_shim(self) -> None: + def inner() -> None: + raise KeyError("k") + + def outer() -> None: + try: + inner() + except KeyError as e: + raise ValueError("v") from e + + with pytest.raises(ValueError) as excinfo: + outer() + + record = excinfo.record(showlocals=True, funcargs=True) + tb = PytestTraceback(record, style="long", showlocals=True) + tb.addsection("Captured stdout", "hello world") + repr_obj = tb.to_repr() + assert repr_obj.reprcrash is not None + assert repr_obj.reprcrash.message == "ValueError: v" + assert ("Captured stdout", "hello world", "-") in repr_obj.sections + text = str(repr_obj) + assert "KeyError: 'k'" in text + assert "ValueError: v" in text + assert "The above exception was the direct cause" in text + + def test_tb_rich_cli(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + def helper(val): + local_msg = f"bad {val}" + raise ValueError(local_msg) + + def test_rich_failure(): + helper(42) + """ + ) + result = pytester.runpytest("--tb=rich", "-l") + assert result.ret == 1 + result.stdout.fnmatch_lines( + [ + "*Traceback (most recent call last)*", + "*in test_rich_failure*", + "*in helper*", + "*local_msg = 'bad 42'*", + "*ValueError: bad 42*", + ] + ) From 6a8d280829a0b229773e6866e7dd60db63c120a5 Mon Sep 17 00:00:00 2001 From: Oleksii Date: Tue, 3 Nov 2026 12:24:29 +0200 Subject: [PATCH 03/12] Switch ExceptionInfo.getrepr() and report JSON serialization to record-then-render Previously, ExceptionInfo.getrepr() baked the requested --tb style directly into the constructed Repr* objects, dropping per-frame file locations under --tb=line and --tb=no (#14720) and preventing reporting plugins or cross-process consumers from accessing the full structured traceback when terminal output was suppressed or shortened. Route ExceptionInfo.getrepr() through ExceptionInfo.record() and PytestTraceback.to_repr() across all traceback styles so that exception capture always records the complete structured ExceptionRecord before rendering: - Attach the underlying ExceptionRecord to ExceptionRepr.record and preserve ReprEntry.reprfileloc for all frames even when rendering --tb=line or --tb=no, while keeping terminal output for those styles compact. - Add compatibility properties (reprcrash, reprtraceback, chain) on ExceptionRecord and make FrameRecord hashable when locals are captured so records can serve as xdist collection-error dictionary keys. - Preserve None syntax error messages in SyntaxErrorDetails so bare SyntaxError() instances format identically to stdlib (). - Extend report JSON serialization (_report_to_json / _report_kwargs_from_json) to round-trip ExceptionRecord payloads both when attached to ExceptionRepr and when stored directly as report.longrepr. Fixes #14720 Refs #15072 --- src/_pytest/_code/code.py | 84 +++++++++++++++--------------------- src/_pytest/_code/record.py | 61 ++++++++++++++++++++++++-- src/_pytest/_code/render.py | 46 ++++++++++++++------ src/_pytest/reports.py | 27 ++++++++++-- testing/code/test_excinfo.py | 2 +- testing/code/test_record.py | 54 +++++++++++++++++++++++ testing/test_reports.py | 38 ++++++++++++++++ 7 files changed, 241 insertions(+), 71 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 8bf45d18a58..9a27c41c8bc 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -766,52 +766,27 @@ def getrepr( Added the ``chain`` parameter. """ - if style == "native": - return ReprExceptionInfo( - reprtraceback=ReprTracebackNative( - format_exception( - self.type, - self.value, - self.traceback[0]._rawentry if self.traceback else None, - ) - ), - reprcrash=self._getreprcrash(), - ) + from _pytest._code.render import PytestTraceback - if style == "rich": - from _pytest._code.render import PytestTraceback - - record = self.record( - showlocals=showlocals, - truncate_locals=truncate_locals, - funcargs=funcargs, - truncate_args=truncate_args, - tbfilter=tbfilter, - chain=chain, - ) - return PytestTraceback( - record=record, - style="rich", - showlocals=showlocals, - abspath=abspath, - chain=chain, - truncate_locals=truncate_locals, - truncate_args=truncate_args, - funcargs=funcargs, - show_hidden=tbfilter is False, - ).to_repr() - - fmt = ExceptionInfoFormatter( + record = self.record( showlocals=showlocals, - style=style, - abspath=abspath, - tbfilter=tbfilter, - funcargs=funcargs, truncate_locals=truncate_locals, + funcargs=funcargs, truncate_args=truncate_args, + tbfilter=tbfilter, chain=chain, ) - return fmt.repr_excinfo(self) + return PytestTraceback( + record=record, + style=style, + showlocals=showlocals, + abspath=abspath, + chain=chain, + truncate_locals=truncate_locals, + truncate_args=truncate_args, + funcargs=funcargs, + show_hidden=tbfilter is False, + ).to_repr() def match(self, regexp: str | re.Pattern[str]) -> Literal[True]: """Check whether the regular expression `regexp` matches the string @@ -1103,9 +1078,9 @@ def _record_exception( text=exc.text, end_lineno=getattr(exc, "end_lineno", None), end_offset=getattr(exc, "end_offset", None), - msg=str(exc.msg) if exc.msg is not None else "", + msg=str(exc.msg) if exc.msg is not None else None, ) - message = syntax_error.msg + message = syntax_error.msg or "" elif isinstance(exc, BaseExceptionGroup): n = len(exc.exceptions) suffix = "sub-exception" if n == 1 else "sub-exceptions" @@ -1485,7 +1460,12 @@ def repr_traceback_entry( else: if excinfo: lines.extend(self.get_exconly(excinfo, indent=4)) - return ReprEntry(lines, None, None, None, style) + reprfileloc = None + if entry is not None: + message = (excinfo and excinfo.typename) or "" + path = self._makepath(entry.path) + reprfileloc = ReprFileLocation(path, entry.lineno + 1, message) + return ReprEntry(lines, None, None, reprfileloc, style) def _makepath(self, path: Path | str) -> str: if not self.abspath and isinstance(path, Path): @@ -1665,6 +1645,9 @@ class ExceptionRepr(TerminalRepr): sections: list[tuple[str, str, str]] = dataclasses.field( init=False, default_factory=list ) + record: ExceptionRecord | None = dataclasses.field( + init=False, default=None, repr=False + ) def addsection(self, name: str, content: str, sep: str = "-") -> None: self.sections.append((name, content, sep)) @@ -1843,18 +1826,19 @@ def toterminal(self, tw: TerminalWriter) -> None: self.reprlocals.toterminal(tw, indent=" " * 8) return - if self.reprfuncargs: + if self.style == "long" and self.reprfuncargs: self.reprfuncargs.toterminal(tw) self._write_entry_lines(tw) - if self.reprlocals: - tw.line("") - self.reprlocals.toterminal(tw) - if self.reprfileloc: - if self.lines: + if self.style == "long": + if self.reprlocals: tw.line("") - self.reprfileloc.toterminal(tw) + self.reprlocals.toterminal(tw) + if self.reprfileloc: + if self.lines: + tw.line("") + self.reprfileloc.toterminal(tw) def __str__(self) -> str: return "{}\n{}\n{}".format( diff --git a/src/_pytest/_code/record.py b/src/_pytest/_code/record.py index 9bebab52ce2..5795f513c57 100644 --- a/src/_pytest/_code/record.py +++ b/src/_pytest/_code/record.py @@ -14,11 +14,16 @@ if TYPE_CHECKING: + from collections.abc import Sequence + from rich.console import Console from rich.console import ConsoleOptions from rich.console import RenderResult import rich.traceback + from _pytest._code.code import ReprFileLocation + from _pytest._code.code import ReprTraceback + RECORD_SCHEMA_VERSION: Final[int] = 1 @@ -115,7 +120,7 @@ class SyntaxErrorDetails: text: str | None end_lineno: int | None = None end_offset: int | None = None - msg: str = "" + msg: str | None = None def to_dict(self) -> dict[str, Any]: return { @@ -130,6 +135,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: Mapping[str, Any]) -> SyntaxErrorDetails: + raw_msg = data.get("msg") return cls( filename=data.get("filename"), lineno=data.get("lineno"), @@ -137,12 +143,14 @@ def from_dict(cls, data: Mapping[str, Any]) -> SyntaxErrorDetails: text=data.get("text"), end_lineno=data.get("end_lineno"), end_offset=data.get("end_offset"), - msg=str(data.get("msg", "")), + msg=str(raw_msg) if raw_msg is not None else None, ) def format_only(self, qualified_type_name: str) -> str: """Format this syntax error using stdlib ``format_exception_only`` rules.""" - err = SyntaxError(self.msg) + err = SyntaxError() + if self.msg is not None: + err.msg = self.msg err.filename = self.filename err.lineno = self.lineno err.offset = self.offset @@ -183,6 +191,26 @@ def short_function_name(self) -> str: """Function ``co_name`` (last component of ``function`` qualname).""" return self.function.rsplit(".", 1)[-1] + def __hash__(self) -> int: + return hash( + ( + self.path, + self.abspath, + self.lineno, + self.end_lineno, + self.colno, + self.end_colno, + self.function, + self.module, + self.statement, + self.statement_lineno, + tuple(self.locals.items()) if self.locals is not None else None, + self.args, + self.hidden, + self.repr_style, + ) + ) + def to_dict(self) -> dict[str, Any]: return { "path": self.path, @@ -321,6 +349,31 @@ def crash(self) -> Location | None: ) return None + @property + def reprcrash(self) -> Location | None: + """Compatibility alias for :attr:`crash`.""" + return self.crash + + @property + def reprtraceback(self) -> ReprTraceback: + """Compatibility property returning a rendered :class:`ReprTraceback`.""" + from _pytest._code.render import PytestTraceback + + return PytestTraceback(self).to_repr().reprtraceback + + @property + def chain( + self, + ) -> Sequence[tuple[ReprTraceback, ReprFileLocation | None, str | None]]: + """Compatibility property returning the rendered exception chain.""" + from _pytest._code.code import ExceptionChainRepr + from _pytest._code.render import PytestTraceback + + repr_obj = PytestTraceback(self).to_repr() + if isinstance(repr_obj, ExceptionChainRepr): + return repr_obj.chain + return [(repr_obj.reprtraceback, repr_obj.reprcrash, None)] + def walk(self) -> Iterator[ExceptionRecord]: """Yield this record and all reachable cause, context, and child records in pre-order.""" yield self @@ -437,7 +490,7 @@ def to_rich_trace(self, *, show_hidden: bool = False) -> rich.traceback.Trace: filename=cur.syntax_error.filename or "?", lineno=cur.syntax_error.lineno or 0, line=cur.syntax_error.text or "", - msg=cur.syntax_error.msg, + msg=cur.syntax_error.msg or "", notes=list(cur.notes), ) diff --git a/src/_pytest/_code/render.py b/src/_pytest/_code/render.py index 2a7dba0e5a5..f9dd893c780 100644 --- a/src/_pytest/_code/render.py +++ b/src/_pytest/_code/render.py @@ -74,7 +74,7 @@ def _highlight_arrows_for_line( return [] highlights = " " - highlights += " " * (start_char_offset - num_stripped_chars + 1) + highlights += " " * (start_char_offset - num_stripped_chars) highlights += "^" * num_carets return [highlights] @@ -138,7 +138,7 @@ def _format_native_record_lines( _format_native_record_lines( chain_rec, show_hidden=show_hidden, - indent_prefix=indent_prefix + " |", + indent_prefix=indent_prefix + " |", ) ) if chain_desc is not None: @@ -148,7 +148,7 @@ def _format_native_record_lines( lines.append(f"{indent_prefix} +------------------------------------\n") return lines - body_prefix = f"{indent_prefix} " if indent_prefix else "" + body_prefix = f"{indent_prefix} " if indent_prefix else "" if visible_frames: lines.append(f"{body_prefix}Traceback (most recent call last):\n") for f in visible_frames: @@ -368,7 +368,13 @@ def _repr_frame_entry( else: if rec_if_last is not None: lines.extend(self._get_exconly_lines(rec_if_last, indent=4)) - return ReprEntry(lines, None, None, None, style) + reprfileloc = None + if frame is not None: + message = rec_if_last.short_type_name if rec_if_last is not None else "" + reprfileloc = ReprFileLocation( + self._makepath(frame), frame.lineno, message + ) + return ReprEntry(lines, None, None, reprfileloc, style) def _truncate_recursive_frames( self, @@ -488,6 +494,9 @@ def to_repr(self) -> ReprExceptionInfo | ExceptionChainRepr: ) for name, content, sep in self._all_sections(): res_native.addsection(name, content, sep) + res_native.record = dataclasses.replace( + self.record, sections=tuple(res_native.sections) + ) return res_native if self.style == "rich": @@ -506,6 +515,9 @@ def to_repr(self) -> ReprExceptionInfo | ExceptionChainRepr: ) for name, content, sep in self._all_sections(): res_rich.addsection(name, content, sep) + res_rich.record = dataclasses.replace( + self.record, sections=tuple(res_rich.sections) + ) return res_rich repr_chain: list[tuple[ReprTraceback, ReprFileLocation | None, str | None]] = [] @@ -539,6 +551,9 @@ def to_repr(self) -> ReprExceptionInfo | ExceptionChainRepr: chain_repr = ExceptionChainRepr(repr_chain) for name, content, sep in self._all_sections(): chain_repr.addsection(name, content, sep) + chain_repr.record = dataclasses.replace( + self.record, sections=tuple(chain_repr.sections) + ) return chain_repr def toterminal(self, tw: TerminalWriter) -> None: @@ -654,7 +669,11 @@ def render_fileloc(loc: ReprFileLocation) -> Text: yield Text(" " + lline) return - if entry.reprfuncargs is not None and entry.reprfuncargs.args: + if ( + entry.style == "long" + and entry.reprfuncargs is not None + and entry.reprfuncargs.args + ): linesofar = "" for name, value in entry.reprfuncargs.args: ns = f"{name} = {value}" @@ -670,15 +689,16 @@ def render_fileloc(loc: ReprFileLocation) -> Text: yield from render_entry_lines() - if entry.reprlocals is not None: - yield Text("") - for lline in entry.reprlocals.lines: - yield Text(lline) - - if entry.reprfileloc is not None: - if entry.lines: + if entry.style == "long": + if entry.reprlocals is not None: yield Text("") - yield render_fileloc(entry.reprfileloc) + for lline in entry.reprlocals.lines: + yield Text(lline) + + if entry.reprfileloc is not None: + if entry.lines: + yield Text("") + yield render_fileloc(entry.reprfileloc) def __rich_console__( self, console: Console, options: ConsoleOptions diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 722f71909d4..4dd72c67ec5 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -28,6 +28,7 @@ from _pytest._code.code import ReprLocals from _pytest._code.code import ReprTraceback from _pytest._code.code import TerminalRepr +from _pytest._code.record import ExceptionRecord from _pytest._io import TerminalWriter from _pytest.config import Config from _pytest.nodeid import coerce_node_id @@ -64,7 +65,12 @@ class BaseReport: when: str | None location: tuple[str, int | None, str] | None longrepr: ( - ExceptionInfo[BaseException] | tuple[str, int, str] | str | TerminalRepr | None + ExceptionInfo[BaseException] + | ExceptionRecord + | tuple[str, int, str] + | str + | TerminalRepr + | None ) sections: list[tuple[str, str]] outcome: Literal["passed", "failed", "skipped"] @@ -625,6 +631,10 @@ def serialize_exception_longrepr(rep: BaseReport) -> dict[str, Any]: ) else: result["chain"] = None + if longrepr.record is not None: + result["record"] = dataclasses.replace( + longrepr.record, sections=tuple(longrepr.sections) + ).to_dict() return result d = report.__dict__.copy() @@ -634,7 +644,9 @@ def serialize_exception_longrepr(rep: BaseReport) -> dict[str, Any]: # key that xdist and other consumers expect, and never expose the # internal structured id object on the wire. d["nodeid"] = str(d.pop("_id")) - if hasattr(report.longrepr, "toterminal"): + if isinstance(report.longrepr, ExceptionRecord): + d["longrepr"] = report.longrepr.to_dict() + elif hasattr(report.longrepr, "toterminal"): if hasattr(report.longrepr, "reprtraceback") and hasattr( report.longrepr, "reprcrash" ): @@ -698,7 +710,13 @@ def deserialize_repr_crash(repr_crash_dict: dict[str, Any] | None): return None if ( - reportdict["longrepr"] + isinstance(reportdict["longrepr"], Mapping) + and "schema_version" in reportdict["longrepr"] + and "reprtraceback" not in reportdict["longrepr"] + ): + reportdict["longrepr"] = ExceptionRecord.from_dict(reportdict["longrepr"]) + elif ( + isinstance(reportdict["longrepr"], Mapping) and "reprcrash" in reportdict["longrepr"] and "reprtraceback" in reportdict["longrepr"] ): @@ -729,6 +747,9 @@ def deserialize_repr_crash(repr_crash_dict: dict[str, Any] | None): for section in reportdict["longrepr"]["sections"]: exception_info.addsection(*section) + raw_record = reportdict["longrepr"].get("record") + if isinstance(raw_record, Mapping): + exception_info.record = ExceptionRecord.from_dict(raw_record) reportdict["longrepr"] = exception_info return reportdict diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 2fe703bae56..64a2d914c17 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -2024,7 +2024,7 @@ def test(): result = pytester.runpytest() match_lines = [] if inner_chain in ("another", "from"): - match_lines.append(r"SyntaxError: ") + match_lines.append(r"E\s+SyntaxError: ") match_lines += [ r" + Exception Group Traceback (most recent call last):", diff --git a/testing/code/test_record.py b/testing/code/test_record.py index 3c5f263ccaf..e8c8c6467de 100644 --- a/testing/code/test_record.py +++ b/testing/code/test_record.py @@ -359,6 +359,14 @@ def group_fn() -> None: unpickled = pickle.loads(pickle.dumps(record)) assert unpickled == record + # Record is hashable (even with locals captured) for xdist dict keys + assert isinstance(hash(record), int) + + # Compatibility properties on ExceptionRecord + assert record.reprcrash == record.crash + assert record.reprtraceback is not None + assert len(record.chain) >= 1 + # Unsupported schema version raises ValueError bad_payload = dict(payload, schema_version=999) with pytest.raises(ValueError, match="schema_version"): @@ -532,3 +540,49 @@ def test_rich_failure(): "*ValueError: bad 42*", ] ) + + @pytest.mark.parametrize("tbstyle", ("long", "short", "line", "no")) + def test_tb_style_preserves_frame_locations_and_record_14720( + self, pytester: Pytester, tbstyle: str + ) -> None: + """Regression test for #14720: --tb=line and --tb=no must not lose frame locations or record data.""" + pytester.makepyfile( + """ + def inner(): + raise ValueError("boom") + + def middle(): + inner() + + def test_a(): + middle() + """ + ) + reprec = pytester.inline_run(f"--tb={tbstyle}") + reports = [ + r for r in reprec.getreports("pytest_runtest_logreport") if r.when == "call" + ] + assert len(reports) == 1 + rep = reports[0] + assert rep.failed + longrepr = rep.longrepr + assert longrepr is not None + + # 1. Existing plugins inspecting reprtraceback.reprentries[*].reprfileloc see all 3 located frames + rt = getattr(longrepr, "reprtraceback", None) + assert rt is not None + entries = rt.reprentries + assert len(entries) == 3 + located = [e for e in entries if getattr(e, "reprfileloc", None) is not None] + assert len(located) == 3 + + # 2. The full ExceptionRecord is attached to longrepr regardless of --tb + record = getattr(longrepr, "record", None) + assert isinstance(record, ExceptionRecord) + visible_funcs = [f.short_function_name for f in record.frames if not f.hidden] + assert visible_funcs == ["test_a", "middle", "inner"] + assert all(f.statement for f in record.frames if not f.hidden) + + # 3. Under --tb=line and --tb=no, str(longrepr) stays compact + if tbstyle in ("line", "no"): + assert str(longrepr) == "E ValueError: boom" diff --git a/testing/test_reports.py b/testing/test_reports.py index b476f8bf780..ad0f76e04f5 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -5,6 +5,7 @@ from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionRepr +from _pytest._code.record import ExceptionRecord from _pytest.approx import approx from _pytest.config import Config from _pytest.pytester import Pytester @@ -424,6 +425,43 @@ def check_longrepr(longrepr: object) -> None: assert isinstance(loaded_report.longrepr, ExceptionChainRepr) loaded_report.longrepr.toterminal(tw_mock) + def test_exception_record_serialization_roundtrip( + self, pytester: Pytester, tw_mock + ) -> None: + """Reports preserve ExceptionRecord across JSON serialization both on ExceptionRepr and directly.""" + pytester.makepyfile( + """ + def helper(x): + raise ValueError(f"bad {x}") + + def test_fail(): + helper(7) + """ + ) + reprec = pytester.inline_run("--showlocals") + reports = reprec.getreports("pytest_runtest_logreport") + rep = reports[1] + assert rep.failed + assert isinstance(rep.longrepr, ExceptionRepr) + assert isinstance(rep.longrepr.record, ExceptionRecord) + + # 1. Round-trip with ExceptionRepr carrying .record + data = rep._to_json() + assert "record" in data["longrepr"] + loaded = TestReport._from_json(data) + assert isinstance(loaded.longrepr, ExceptionRepr) + assert loaded.longrepr.record == rep.longrepr.record + + # 2. Round-trip when report.longrepr is an ExceptionRecord directly + record_only = rep.longrepr.record + rep.longrepr = record_only + direct_data = rep._to_json() + assert direct_data["longrepr"] == record_only.to_dict() + loaded_direct = TestReport._from_json(direct_data) + assert isinstance(loaded_direct.longrepr, ExceptionRecord) + assert loaded_direct.longrepr == record_only + loaded_direct.toterminal(tw_mock) + def test_report_prevent_ConftestImportFailure_hiding_exception( self, pytester: Pytester ) -> None: From fded47dc6f87cbc45d0e51a469199aaab929e354 Mon Sep 17 00:00:00 2001 From: Oleksii Date: Tue, 3 Nov 2026 12:52:54 +0200 Subject: [PATCH 04/12] Split ExceptionInfo into capture, data model, and renderers (Steps 1-4 of #15072) Separate exception capture from formatting by introducing a structured, serializable data model (`Location`, `SyntaxErrorDetails`, `FrameRecord`, `ExceptionRecord`, and the `FailureRecord` protocol) alongside a dedicated `PytestTraceback` renderer: - Add `ExceptionInfo.record()` to capture traceback frames, chain topology (`cause`, `context`, `suppress_context`, `children`), bounded source statements with PEP 657 byte spans, and formatted locals into an immutable `ExceptionRecord` tree with `to_dict()`/`from_dict()` and `to_rich_trace()` support. - Introduce `PytestTraceback` to render `ExceptionRecord` across all pytest traceback styles (`auto`, `long`, `short`, `line`, `native`, `value`, `no`) and add `--tb=rich` backed by Rich's `Traceback` renderer. - Switch `ExceptionInfo.getrepr()` and report JSON serialization to a record-then-render pipeline, preserving `longrepr.record` and crash locations even under `--tb=line` and `--tb=no` (fixes #14720). - Migrate `ReprFailDoctest`, `FixtureLookupErrorRepr`, and `CollectErrorRepr` to implement the `FailureRecord` protocol (`sections`, `crash`, `to_dict()`/`from_dict()`, `__rich_console__()`), and replace raw skip 3-tuples on `report.longrepr` with `Location` while maintaining tuple-unpacking and xdist wire compatibility. Refs #15072 Fixes #14720 --- src/_pytest/_code/__init__.py | 2 + src/_pytest/_code/record.py | 50 ++++++++++++-- src/_pytest/_code/render.py | 2 +- src/_pytest/doctest.py | 109 ++++++++++++++++++++++++++++++- src/_pytest/fixtures.py | 102 +++++++++++++++++++++++++++++ src/_pytest/junitxml.py | 3 +- src/_pytest/reports.py | 118 +++++++++++++++++++++++++++++++--- src/_pytest/runner.py | 5 +- src/_pytest/terminal.py | 7 +- testing/test_reports.py | 94 ++++++++++++++++++++++++++- testing/test_runner.py | 5 +- 11 files changed, 466 insertions(+), 31 deletions(-) diff --git a/src/_pytest/_code/__init__.py b/src/_pytest/_code/__init__.py index b6a3e4615aa..3ed537f591e 100644 --- a/src/_pytest/_code/__init__.py +++ b/src/_pytest/_code/__init__.py @@ -11,6 +11,7 @@ from .code import TracebackEntry from .code import TracebackStyle from .record import ExceptionRecord +from .record import FailureRecord from .record import FrameRecord from .record import Location from .record import RECORD_SCHEMA_VERSION @@ -25,6 +26,7 @@ "Code", "ExceptionInfo", "ExceptionRecord", + "FailureRecord", "Frame", "FrameRecord", "Location", diff --git a/src/_pytest/_code/record.py b/src/_pytest/_code/record.py index 5795f513c57..aa5112afd13 100644 --- a/src/_pytest/_code/record.py +++ b/src/_pytest/_code/record.py @@ -8,6 +8,8 @@ from typing import Final from typing import Literal from typing import overload +from typing import Protocol +from typing import runtime_checkable from typing import TYPE_CHECKING from _pytest._io import TerminalWriter @@ -50,13 +52,13 @@ class Location: """ path: str - lineno: int + lineno: int | None message: str def __post_init__(self) -> None: object.__setattr__(self, "path", str(self.path)) - def __iter__(self) -> Iterator[str | int]: + def __iter__(self) -> Iterator[Any]: yield self.path yield self.lineno yield self.message @@ -68,15 +70,17 @@ def __len__(self) -> int: def __getitem__(self, index: Literal[0, 2]) -> str: ... @overload - def __getitem__(self, index: Literal[1]) -> int: ... + def __getitem__(self, index: Literal[1]) -> int | None: ... @overload - def __getitem__(self, index: int) -> str | int: ... + def __getitem__(self, index: int) -> str | int | None: ... @overload - def __getitem__(self, index: slice) -> tuple[str | int, ...]: ... + def __getitem__(self, index: slice) -> tuple[str | int | None, ...]: ... - def __getitem__(self, index: int | slice) -> str | int | tuple[str | int, ...]: + def __getitem__( + self, index: int | slice + ) -> str | int | None | tuple[str | int | None, ...]: return (self.path, self.lineno, self.message)[index] def to_dict(self) -> dict[str, Any]: @@ -88,9 +92,10 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: Mapping[str, Any]) -> Location: + raw_lineno = data.get("lineno") return cls( path=str(data["path"]), - lineno=int(data["lineno"]), + lineno=int(raw_lineno) if raw_lineno is not None else None, message=str(data["message"]), ) @@ -109,6 +114,37 @@ def __str__(self) -> str: msg = msg[:i] return f"{self.path}:{self.lineno}: {msg}" + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + from rich.text import Text + + msg = self.message + i = msg.find("\n") + if i != -1: + msg = msg[:i] + t = Text() + t.append(self.path, style="bold red") + t.append(f":{self.lineno}: {msg}") + yield t + + +@runtime_checkable +class FailureRecord(Protocol): + """Protocol for structured non-exception failure representations.""" + + @property + def sections(self) -> Sequence[tuple[str, str, str]]: ... + + @property + def crash(self) -> Location | None: ... + + def to_dict(self) -> dict[str, Any]: ... + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: ... + @dataclasses.dataclass(frozen=True) class SyntaxErrorDetails: diff --git a/src/_pytest/_code/render.py b/src/_pytest/_code/render.py index f9dd893c780..4d39681b01f 100644 --- a/src/_pytest/_code/render.py +++ b/src/_pytest/_code/render.py @@ -226,7 +226,7 @@ def _makepath(self, frame: FrameRecord) -> str: def _make_reprcrash(self, rec: ExceptionRecord) -> ReprFileLocation | None: loc = rec.crash - if loc is None: + if loc is None or loc.lineno is None: return None return ReprFileLocation(loc.path, loc.lineno, loc.message) diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 04d707113cc..2f26e3dc683 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -7,6 +7,7 @@ from collections.abc import Callable from collections.abc import Generator from collections.abc import Iterable +from collections.abc import Mapping from collections.abc import Sequence from contextlib import contextmanager import functools @@ -26,6 +27,8 @@ from _pytest._code.code import ExceptionInfo from _pytest._code.code import ReprFileLocation from _pytest._code.code import TerminalRepr +from _pytest._code.record import Location +from _pytest._code.record import RECORD_SCHEMA_VERSION from _pytest._io import TerminalWriter from _pytest.approx import approx from _pytest.compat import safe_getattr @@ -46,6 +49,9 @@ if TYPE_CHECKING: import doctest + from rich.console import Console + from rich.console import ConsoleOptions + from rich.console import RenderResult from typing_extensions import Self DOCTEST_REPORT_CHOICE_NONE = "none" @@ -159,15 +165,113 @@ def _is_main_py(path: Path) -> bool: class ReprFailDoctest(TerminalRepr): def __init__( - self, reprlocation_lines: Sequence[tuple[ReprFileLocation, Sequence[str]]] + self, + reprlocation_lines: Sequence[tuple[Location | ReprFileLocation, Sequence[str]]], ) -> None: self.reprlocation_lines = reprlocation_lines + self.sections: list[tuple[str, str, str]] = [] + + def addsection(self, name: str, content: str, sep: str = "-") -> None: + self.sections.append((name, content, sep)) + + @property + def crash(self) -> Location | None: + if not self.reprlocation_lines: + return None + loc, _ = self.reprlocation_lines[-1] + if isinstance(loc, Location): + return loc + return Location(loc.path, loc.lineno, loc.message) + + @property + def reprcrash(self) -> Location | None: + return self.crash + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ReprFailDoctest): + return NotImplemented + return ( + list(self.reprlocation_lines) == list(other.reprlocation_lines) + and self.sections == other.sections + ) + + def __hash__(self) -> int: + return hash( + ( + tuple( + ( + loc + if isinstance(loc, Location) + else Location(loc.path, loc.lineno, loc.message), + tuple(lines), + ) + for loc, lines in self.reprlocation_lines + ), + tuple(self.sections), + ) + ) + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": RECORD_SCHEMA_VERSION, + "type": "ReprFailDoctest", + "reprlocation_lines": [ + { + "location": ( + loc + if isinstance(loc, Location) + else Location(loc.path, loc.lineno, loc.message) + ).to_dict(), + "lines": list(lines), + } + for loc, lines in self.reprlocation_lines + ], + "sections": [list(s) for s in self.sections], + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> ReprFailDoctest: + reprlocation_lines: list[tuple[Location, list[str]]] = [] + for item in data.get("reprlocation_lines", ()): + if isinstance(item, Mapping): + loc = Location.from_dict(item["location"]) + lines = [str(line) for line in item.get("lines", ())] + reprlocation_lines.append((loc, lines)) + inst = cls(reprlocation_lines) + for s in data.get("sections", ()): + if len(s) == 3: + inst.addsection(str(s[0]), str(s[1]), str(s[2])) + return inst + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + from rich.rule import Rule + from rich.text import Text + + for reprlocation, lines in self.reprlocation_lines: + for line in lines: + yield Text(line) + loc = ( + reprlocation + if isinstance(reprlocation, Location) + else Location( + reprlocation.path, reprlocation.lineno, reprlocation.message + ) + ) + yield from loc.__rich_console__(console, options) + for name, content, sep in self.sections: + yield Rule(title=name, characters=sep[:1] or "-") + yield Text(content) def toterminal(self, tw: TerminalWriter) -> None: for reprlocation, lines in self.reprlocation_lines: for line in lines: tw.line(line) reprlocation.toterminal(tw) + for name, content, sep in self.sections: + tw.sep(sep, name) + tw.line(content) class MultipleDoctestFailures(Exception): @@ -349,8 +453,7 @@ def repr_failure( # type: ignore[override] else: lineno = test.lineno + example.lineno + 1 message = type(failure).__name__ - # TODO: ReprFileLocation doesn't expect a None lineno. - reprlocation = ReprFileLocation(filename, lineno, message) # type: ignore[arg-type] + reprlocation = Location(str(filename), lineno, message) checker = _get_checker() report_choice = _get_report_choice(self.config.getoption("doctestreport")) if lineno is not None: diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index e3d6300b599..ae61335dd85 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -38,6 +38,8 @@ from _pytest._code import Source from _pytest._code.code import ExceptionInfoFormatter from _pytest._code.code import TerminalRepr +from _pytest._code.record import Location +from _pytest._code.record import RECORD_SCHEMA_VERSION from _pytest._io import TerminalWriter from _pytest.compat import assert_never from _pytest.compat import deprecated @@ -84,6 +86,10 @@ if TYPE_CHECKING: + from rich.console import Console + from rich.console import ConsoleOptions + from rich.console import RenderResult + from _pytest.python import CallSpec from _pytest.python import Function from _pytest.python import Metafunc @@ -1037,6 +1043,99 @@ def __init__( self.filename = filename self.firstlineno = firstlineno self.argname = argname + self.sections: list[tuple[str, str, str]] = [] + + def addsection(self, name: str, content: str, sep: str = "-") -> None: + self.sections.append((name, content, sep)) + + @property + def crash(self) -> Location | None: + lines = self.errorstring.split("\n") + first_msg = lines[0].strip() if lines else "" + return Location( + path=os.fspath(self.filename), + lineno=self.firstlineno + 1, + message=first_msg, + ) + + @property + def reprcrash(self) -> Location | None: + return self.crash + + def __eq__(self, other: object) -> bool: + if not isinstance(other, FixtureLookupErrorRepr): + return NotImplemented + return ( + os.fspath(self.filename) == os.fspath(other.filename) + and self.firstlineno == other.firstlineno + and list(self.tblines) == list(other.tblines) + and self.errorstring == other.errorstring + and self.argname == other.argname + and self.sections == other.sections + ) + + def __hash__(self) -> int: + return hash( + ( + os.fspath(self.filename), + self.firstlineno, + tuple(self.tblines), + self.errorstring, + self.argname, + tuple(self.sections), + ) + ) + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": RECORD_SCHEMA_VERSION, + "type": "FixtureLookupErrorRepr", + "filename": os.fspath(self.filename), + "firstlineno": self.firstlineno, + "tblines": list(self.tblines), + "errorstring": self.errorstring, + "argname": self.argname, + "sections": [list(s) for s in self.sections], + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> FixtureLookupErrorRepr: + inst = cls( + filename=str(data["filename"]), + firstlineno=int(data["firstlineno"]), + tblines=[str(line) for line in data.get("tblines", ())], + errorstring=str(data["errorstring"]), + argname=str(data["argname"]) if data.get("argname") is not None else None, + ) + for s in data.get("sections", ()): + if len(s) == 3: + inst.addsection(str(s[0]), str(s[1]), str(s[2])) + return inst + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + from rich.rule import Rule + from rich.text import Text + + for tbline in self.tblines: + yield Text(tbline.rstrip()) + lines = self.errorstring.split("\n") + if lines: + yield Text( + f"{ExceptionInfoFormatter.fail_marker} {lines[0].strip()}", + style="red", + ) + for line in lines[1:]: + yield Text( + f"{ExceptionInfoFormatter.flow_marker} {line.strip()}", + style="red", + ) + yield Text("") + yield Text(f"{os.fspath(self.filename)}:{self.firstlineno + 1}") + for name, content, sep in self.sections: + yield Rule(title=name, characters=sep[:1] or "-") + yield Text(content) def toterminal(self, tw: TerminalWriter) -> None: # tw.line("FixtureLookupError: %s" %(self.argname), red=True) @@ -1055,6 +1154,9 @@ def toterminal(self, tw: TerminalWriter) -> None: ) tw.line() tw.line(f"{os.fspath(self.filename)}:{self.firstlineno + 1}") + for name, content, sep in self.sections: + tw.sep(sep, name) + tw.line(content) def call_fixture_func( diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index d33b81a74ac..35da0dceecc 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -22,6 +22,7 @@ from _pytest import timing from _pytest._code.code import ExceptionRepr from _pytest._code.code import ReprFileLocation +from _pytest._code.record import Location from _pytest.compat import assert_never from _pytest.config import Config from _pytest.config import filename_arg @@ -283,7 +284,7 @@ def append_skipped(self, report: TestReport) -> None: skipped = ET.Element("skipped", type="pytest.xfail", message=xfailreason) self.append(skipped) else: - assert isinstance(report.longrepr, tuple) + assert isinstance(report.longrepr, Location | tuple) filename, lineno, skipreason = report.longrepr skipreason = skipreason.removeprefix("Skipped: ") details = f"{filename}:{lineno}: {skipreason}" diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 4dd72c67ec5..6875e0b27d6 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -29,6 +29,9 @@ from _pytest._code.code import ReprTraceback from _pytest._code.code import TerminalRepr from _pytest._code.record import ExceptionRecord +from _pytest._code.record import FailureRecord +from _pytest._code.record import Location +from _pytest._code.record import RECORD_SCHEMA_VERSION from _pytest._io import TerminalWriter from _pytest.config import Config from _pytest.nodeid import coerce_node_id @@ -44,6 +47,9 @@ if TYPE_CHECKING: + from rich.console import Console + from rich.console import ConsoleOptions + from rich.console import RenderResult from typing_extensions import Self from _pytest.runner import CallInfo @@ -67,6 +73,8 @@ class BaseReport: longrepr: ( ExceptionInfo[BaseException] | ExceptionRecord + | FailureRecord + | Location | tuple[str, int, str] | str | TerminalRepr @@ -297,7 +305,7 @@ def _format_failed_longrepr( def _format_exception_group_all_skipped_longrepr( item: Item, excinfo: ExceptionInfo[BaseExceptionGroup[BaseException | BaseExceptionGroup]], -) -> tuple[str, int, str]: +) -> Location: r = excinfo._getreprcrash() assert r is not None, ( "There should always be a traceback entry for skipping a test." @@ -307,10 +315,12 @@ def _format_exception_group_all_skipped_longrepr( ): path, line = item.reportinfo()[:2] assert line is not None - loc = (os.fspath(path), line + 1) + loc_path = os.fspath(path) + loc_lineno = line + 1 default_msg = "skipped" else: - loc = (str(r.path), r.lineno) + loc_path = str(r.path) + loc_lineno = r.lineno default_msg = r.message # Get all unique skip messages. @@ -323,8 +333,7 @@ def _format_exception_group_all_skipped_longrepr( msgs.append(m) reason = "; ".join(msgs) if msgs else default_msg - longrepr = (*loc, reason) - return longrepr + return Location(loc_path, loc_lineno, reason) class TestReport(BaseReport): @@ -354,6 +363,9 @@ def __init__( keywords: Mapping[str, Literal[1]], outcome: Literal["passed", "failed", "skipped"], longrepr: ExceptionInfo[BaseException] + | ExceptionRecord + | FailureRecord + | Location | tuple[str, int, str] | str | TerminalRepr @@ -433,6 +445,9 @@ def from_item_and_call(cls, item: Item, call: CallInfo[None]) -> TestReport: outcome: Literal["passed", "failed", "skipped"] = "passed" longrepr: ( ExceptionInfo[BaseException] + | ExceptionRecord + | FailureRecord + | Location | tuple[str, int, str] | str | TerminalRepr @@ -451,9 +466,9 @@ def from_item_and_call(cls, item: Item, call: CallInfo[None]) -> TestReport: if excinfo.value._use_item_location: path, line = item.reportinfo()[:2] assert line is not None - longrepr = (os.fspath(path), line + 1, r.message) + longrepr = Location(os.fspath(path), line + 1, r.message) else: - longrepr = (str(r.path), r.lineno, r.message) + longrepr = Location(str(r.path), r.lineno, r.message) elif isinstance(excinfo.value, BaseExceptionGroup) and ( excinfo.value.split(skip.Exception)[1] is None ): @@ -507,6 +522,9 @@ def __init__( nodeid: str | NodeId, outcome: Literal["passed", "failed", "skipped"], longrepr: ExceptionInfo[BaseException] + | ExceptionRecord + | FailureRecord + | Location | tuple[str, int, str] | str | TerminalRepr @@ -548,9 +566,59 @@ def __repr__(self) -> str: class CollectErrorRepr(TerminalRepr): def __init__(self, msg: str) -> None: self.longrepr = msg + self.sections: list[tuple[str, str, str]] = [] + + def addsection(self, name: str, content: str, sep: str = "-") -> None: + self.sections.append((name, content, sep)) + + @property + def crash(self) -> Location | None: + return None + + @property + def reprcrash(self) -> Location | None: + return None + + def __eq__(self, other: object) -> bool: + if not isinstance(other, CollectErrorRepr): + return NotImplemented + return self.longrepr == other.longrepr and self.sections == other.sections + + def __hash__(self) -> int: + return hash((self.longrepr, tuple(self.sections))) + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": RECORD_SCHEMA_VERSION, + "type": "CollectErrorRepr", + "longrepr": self.longrepr, + "sections": [list(s) for s in self.sections], + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> CollectErrorRepr: + inst = cls(str(data["longrepr"])) + for s in data.get("sections", ()): + if len(s) == 3: + inst.addsection(str(s[0]), str(s[1]), str(s[2])) + return inst + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + from rich.rule import Rule + from rich.text import Text + + yield Text(self.longrepr, style="red") + for name, content, sep in self.sections: + yield Rule(title=name, characters=sep[:1] or "-") + yield Text(content) def toterminal(self, out: TerminalWriter) -> None: out.line(self.longrepr, red=True) + for name, content, sep in self.sections: + out.sep(sep, name) + out.line(content) def pytest_report_to_serializable( @@ -644,7 +712,13 @@ def serialize_exception_longrepr(rep: BaseReport) -> dict[str, Any]: # key that xdist and other consumers expect, and never expose the # internal structured id object on the wire. d["nodeid"] = str(d.pop("_id")) - if isinstance(report.longrepr, ExceptionRecord): + if isinstance(report.longrepr, Location): + d["longrepr"] = ( + report.longrepr.path, + report.longrepr.lineno, + report.longrepr.message, + ) + elif isinstance(report.longrepr, ExceptionRecord | FailureRecord): d["longrepr"] = report.longrepr.to_dict() elif hasattr(report.longrepr, "toterminal"): if hasattr(report.longrepr, "reprtraceback") and hasattr( @@ -710,11 +784,37 @@ def deserialize_repr_crash(repr_crash_dict: dict[str, Any] | None): return None if ( + isinstance(reportdict["longrepr"], list | tuple) + and len(reportdict["longrepr"]) == 3 + and isinstance(reportdict["longrepr"][0], str) + and isinstance(reportdict["longrepr"][1], int | type(None)) + and isinstance(reportdict["longrepr"][2], str) + ): + reportdict["longrepr"] = Location( + reportdict["longrepr"][0], + reportdict["longrepr"][1], + reportdict["longrepr"][2], + ) + elif ( isinstance(reportdict["longrepr"], Mapping) and "schema_version" in reportdict["longrepr"] and "reprtraceback" not in reportdict["longrepr"] ): - reportdict["longrepr"] = ExceptionRecord.from_dict(reportdict["longrepr"]) + record_type = reportdict["longrepr"].get("type") + if record_type == "ReprFailDoctest": + from _pytest.doctest import ReprFailDoctest + + reportdict["longrepr"] = ReprFailDoctest.from_dict(reportdict["longrepr"]) + elif record_type == "FixtureLookupErrorRepr": + from _pytest.fixtures import FixtureLookupErrorRepr + + reportdict["longrepr"] = FixtureLookupErrorRepr.from_dict( + reportdict["longrepr"] + ) + elif record_type == "CollectErrorRepr": + reportdict["longrepr"] = CollectErrorRepr.from_dict(reportdict["longrepr"]) + else: + reportdict["longrepr"] = ExceptionRecord.from_dict(reportdict["longrepr"]) elif ( isinstance(reportdict["longrepr"], Mapping) and "reprcrash" in reportdict["longrepr"] diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 629796598df..c987919f052 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -25,6 +25,7 @@ from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionInfo from _pytest._code.code import TerminalRepr +from _pytest._code.record import Location from _pytest.config.argparsing import Parser from _pytest.deprecated import check_ispytest from _pytest.nodes import Collector @@ -429,7 +430,7 @@ def collect() -> list[Item | Collector]: call = CallInfo.from_call( collect, "collect", reraise=(KeyboardInterrupt, SystemExit) ) - longrepr: tuple[str, int, str] | str | TerminalRepr | None = None + longrepr: Location | tuple[str, int, str] | str | TerminalRepr | None = None if not call.excinfo: outcome: Literal["passed", "skipped", "failed"] = "passed" else: @@ -443,7 +444,7 @@ def collect() -> list[Item | Collector]: assert isinstance(r_, ExceptionChainRepr), repr(r_) r = r_.reprcrash assert r - longrepr = (str(r.path), r.lineno, r.message) + longrepr = Location(str(r.path), r.lineno, r.message) else: outcome = "failed" errorinfo = collector.repr_failure(call.excinfo) diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 4f2d9027211..70e61f19035 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -36,6 +36,7 @@ from _pytest import timing from _pytest._code import ExceptionInfo from _pytest._code.code import ExceptionRepr +from _pytest._code.record import Location from _pytest._io import TerminalWriter from _pytest._io.wcwidth import wcswidth import _pytest._version @@ -1389,7 +1390,7 @@ def show_skipped_unfolded(lines: list[str]) -> None: for rep in skipped: assert rep.longrepr is not None - assert isinstance(rep.longrepr, tuple), (rep, rep.longrepr) + assert isinstance(rep.longrepr, Location | tuple), (rep, rep.longrepr) assert len(rep.longrepr) == 3, (rep, rep.longrepr) verbose_word, verbose_markup = rep._get_verbose_word_with_markup( @@ -1616,7 +1617,7 @@ def _folded_skips( d: dict[tuple[str, int | None, str], list[CollectReport]] = {} for event in skipped: assert event.longrepr is not None - assert isinstance(event.longrepr, tuple), (event, event.longrepr) + assert isinstance(event.longrepr, Location | tuple), (event, event.longrepr) assert len(event.longrepr) == 3, (event, event.longrepr) fspath, lineno, reason = event.longrepr # For consistency, report all fspaths in relative form. @@ -1721,7 +1722,7 @@ def _get_raw_skip_reason(report: TestReport) -> str: return reason else: assert report.skipped - assert isinstance(report.longrepr, tuple) + assert isinstance(report.longrepr, Location | tuple) _, _, reason = report.longrepr if reason.startswith("Skipped: "): reason = reason[len("Skipped: ") :] diff --git a/testing/test_reports.py b/testing/test_reports.py index ad0f76e04f5..a471b1029bb 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -6,6 +6,8 @@ from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionRepr from _pytest._code.record import ExceptionRecord +from _pytest._code.record import FailureRecord +from _pytest._code.record import Location from _pytest.approx import approx from _pytest.config import Config from _pytest.pytester import Pytester @@ -197,7 +199,7 @@ def test_xfail_imperative(): assert newrep.failed == rep.failed assert newrep.skipped == rep.skipped if newrep.skipped and not hasattr(newrep, "wasxfail"): - assert isinstance(newrep.longrepr, tuple) + assert isinstance(newrep.longrepr, Location) assert len(newrep.longrepr) == 3 assert newrep.outcome == rep.outcome assert newrep.when == rep.when @@ -228,7 +230,8 @@ def test_collectreport_fail(self, pytester: Pytester) -> None: assert newrep.failed == rep.failed assert newrep.skipped == rep.skipped if rep.failed: - assert newrep.longrepr == str(rep.longrepr) + assert newrep.longrepr == rep.longrepr + assert str(newrep.longrepr) == str(rep.longrepr) def test_extended_report_deserialization(self, pytester: Pytester) -> None: """This test came originally from test_remote.py in xdist (ca03269).""" @@ -244,7 +247,8 @@ def test_extended_report_deserialization(self, pytester: Pytester) -> None: assert newrep.failed == rep.failed assert newrep.skipped == rep.skipped if rep.failed: - assert newrep.longrepr == str(rep.longrepr) + assert newrep.longrepr == rep.longrepr + assert str(newrep.longrepr) == str(rep.longrepr) def test_paths_support(self, pytester: Pytester) -> None: """Report attributes which are path-like should become strings.""" @@ -462,6 +466,90 @@ def test_fail(): assert loaded_direct.longrepr == record_only loaded_direct.toterminal(tw_mock) + def test_failure_records_serialization_roundtrip( + self, pytester: Pytester, tw_mock + ) -> None: + """ReprFailDoctest, FixtureLookupErrorRepr, and CollectErrorRepr round-trip structurally.""" + from io import StringIO + + from rich.console import Console + + from _pytest.doctest import ReprFailDoctest + from _pytest.fixtures import FixtureLookupErrorRepr + from _pytest.reports import CollectErrorRepr + + # 1. Doctest failure + pytester.maketxtfile( + test_doc=""" + >>> 1 + 1 + 3 + """ + ) + reprec = pytester.inline_run() + doc_rep = reprec.getreports("pytest_runtest_logreport")[1] + assert isinstance(doc_rep.longrepr, ReprFailDoctest) + assert isinstance(doc_rep.longrepr, FailureRecord) + assert isinstance(doc_rep.longrepr.crash, Location) + assert doc_rep.longrepr.crash.message == "DocTestFailure" + doc_rep.longrepr.addsection("doc-sec", "doc-body", "=") + doc_data = doc_rep._to_json() + assert doc_data["longrepr"]["type"] == "ReprFailDoctest" + doc_loaded = TestReport._from_json(doc_data) + assert isinstance(doc_loaded.longrepr, ReprFailDoctest) + assert doc_loaded.longrepr == doc_rep.longrepr + assert hash(doc_loaded.longrepr) == hash(doc_rep.longrepr) + assert doc_loaded.longreprtext == doc_rep.longreprtext + buf = StringIO() + console = Console(file=buf, force_terminal=False, width=80) + console.print(doc_loaded.longrepr) + assert "DocTestFailure" in buf.getvalue() + + # 2. FixtureLookupErrorRepr + pytester.makepyfile( + test_missing_fix=""" + def test_needs_unknown(nonexistent_fixture): + pass + """ + ) + reprec_fix = pytester.inline_run("test_missing_fix.py") + fix_rep = reprec_fix.getreports("pytest_runtest_logreport")[0] + assert isinstance(fix_rep.longrepr, FixtureLookupErrorRepr) + assert isinstance(fix_rep.longrepr, FailureRecord) + assert isinstance(fix_rep.longrepr.crash, Location) + assert ( + "fixture 'nonexistent_fixture' not found" in fix_rep.longrepr.crash.message + ) + fix_rep.longrepr.addsection("fix-sec", "fix-body", "-") + fix_data = fix_rep._to_json() + assert fix_data["longrepr"]["type"] == "FixtureLookupErrorRepr" + fix_loaded = TestReport._from_json(fix_data) + assert isinstance(fix_loaded.longrepr, FixtureLookupErrorRepr) + assert fix_loaded.longrepr == fix_rep.longrepr + assert hash(fix_loaded.longrepr) == hash(fix_rep.longrepr) + assert fix_loaded.longreprtext == fix_rep.longreprtext + buf_fix = StringIO() + console_fix = Console(file=buf_fix, force_terminal=False, width=80) + console_fix.print(fix_loaded.longrepr) + assert "nonexistent_fixture" in buf_fix.getvalue() + + # 3. CollectErrorRepr + collect_err = CollectErrorRepr("custom collection failure") + collect_err.addsection("col-sec", "col-body", "*") + assert isinstance(collect_err, FailureRecord) + assert collect_err.crash is None + col_rep = CollectReport("test_col.py", "failed", collect_err, None) + col_data = col_rep._to_json() + assert col_data["longrepr"]["type"] == "CollectErrorRepr" + col_loaded = CollectReport._from_json(col_data) + assert isinstance(col_loaded.longrepr, CollectErrorRepr) + assert col_loaded.longrepr == collect_err + assert hash(col_loaded.longrepr) == hash(collect_err) + assert col_loaded.longreprtext == col_rep.longreprtext + buf_col = StringIO() + console_col = Console(file=buf_col, force_terminal=False, width=80) + console_col.print(col_loaded.longrepr) + assert "custom collection failure" in buf_col.getvalue() + def test_report_prevent_ConftestImportFailure_hiding_exception( self, pytester: Pytester ) -> None: diff --git a/testing/test_runner.py b/testing/test_runner.py index 8b59a5ee8c1..6afc41b48e9 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -15,6 +15,7 @@ from _pytest import timing from _pytest._code import ExceptionInfo from _pytest._code.code import ExceptionChainRepr +from _pytest._code.record import Location from _pytest.config import ExitCode from _pytest.monkeypatch import MonkeyPatch from _pytest.outcomes import OutcomeException @@ -1137,7 +1138,7 @@ def test_func(): """ ) _, call_rep, _ = reports - assert isinstance(call_rep.longrepr, tuple) + assert isinstance(call_rep.longrepr, Location) assert "Skipped" in call_rep.longreprtext def test_longreprtext_collect_skip(self, pytester: Pytester) -> None: @@ -1151,7 +1152,7 @@ def test_longreprtext_collect_skip(self, pytester: Pytester) -> None: rec = pytester.inline_run() calls = rec.getcalls("pytest_collectreport") _, call, _ = calls - assert isinstance(call.report.longrepr, tuple) + assert isinstance(call.report.longrepr, Location) assert "Skipped" in call.report.longreprtext def test_longreprtext_failure(self, pytester: Pytester) -> None: From cc8133eebd9faa1f4bd1ba983d7e446fd9f29d1a Mon Sep 17 00:00:00 2001 From: Oleksii Date: Tue, 3 Nov 2026 13:28:22 +0200 Subject: [PATCH 05/12] Render exception groups natively in pytest traceback styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, non-native traceback styles fell back to Python's stdlib `TracebackException.format()` whenever an `ExceptionGroup` or `BaseExceptionGroup` appeared anywhere in the exception chain (#9159). That workaround bypassed pytest's traceback formatting—losing `E ` error prefixes, statement range extraction, locals inspection, and style-specific rendering (`long`, `short`, `auto`, `no`, `value`)—and required special-case filtering on live `TracebackException` instances. With `ExceptionRecord.children` capturing sub-exceptions as part of the recursive exception model, `PytestTraceback` now renders exception groups directly in the active pytest traceback style: - Recursively format each sub-exception (including its own cause/context chain and nested subgroups) using the configured pytest style, boxed with tree prefixes (`| `) and numbered group separators matching stdlib header widths. - Keep full `ExceptionGroup: (N sub-exception[s])` headers on group frames while preserving `tryshort` crash summaries for leaf exceptions. - Preserve the outermost exception's `reprtraceback` and `reprcrash` on `ExceptionChainRepr` when converting or deserializing exception group records. - Delegate `ExceptionInfoFormatter.repr_excinfo()` to `PytestTraceback` and remove the `#9159` `TracebackException` fallback and `_filter_tracebackexception` helper. Part of #15072 (Step 5). --- src/_pytest/_code/code.py | 114 ++++++--------------------- src/_pytest/_code/render.py | 117 ++++++++++++++++++++-------- src/_pytest/reports.py | 2 + testing/code/test_excinfo.py | 16 ++-- testing/code/test_record.py | 78 +++++++++++++++++++ testing/python/fixtures.py | 6 +- testing/test_threadexception.py | 2 +- testing/test_unraisableexception.py | 2 +- 8 files changed, 202 insertions(+), 135 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 9a27c41c8bc..dc4308a7959 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -14,11 +14,8 @@ import re import sys from traceback import extract_tb -from traceback import format_exception from traceback import format_exception_only from traceback import FrameSummary -from traceback import StackSummary -from traceback import TracebackException from types import CodeType from types import FrameType from types import TracebackType @@ -1552,61 +1549,29 @@ def _truncate_recursive_traceback( return traceback, extraline def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainRepr: - repr_chain: list[tuple[ReprTraceback, ReprFileLocation | None, str | None]] = [] - e: BaseException | None = excinfo.value - excinfo_: ExceptionInfo[BaseException] | None = excinfo - description = None - 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. - # See https://github.com/pytest-dev/pytest/issues/9159 - reprtraceback: ReprTraceback | ReprTracebackNative - if isinstance(e, BaseExceptionGroup): - 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 - ) - tb_exc = TracebackException.from_exception(excinfo.value) - _filter_tracebackexception(tb_exc, excinfo.value, self.tbfilter) - reprtraceback = ReprTracebackNative( - list(tb_exc.format()), - extraline=extraline, - ) + from _pytest._code.render import PytestTraceback - else: - reprtraceback = self.repr_traceback(excinfo_) - reprcrash = excinfo_._getreprcrash() - else: - # Fallback to native repr if the exception doesn't have a traceback: - # ExceptionInfo objects require a full traceback to work. - reprtraceback = ReprTracebackNative( - format_exception(type(e), e, None, chain=False) - ) - reprcrash = None - repr_chain.append((reprtraceback, reprcrash, description)) - - if isinstance(e, BaseExceptionGroup) and self.chain: - e = None - elif e.__cause__ is not None and self.chain: - e = e.__cause__ - excinfo_ = ExceptionInfo.from_exception(e) if e.__traceback__ else None - 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 - description = "During handling of the above exception, another exception occurred:" - else: - e = None - repr_chain.reverse() - return ExceptionChainRepr(repr_chain) + record = excinfo.record( + showlocals=self.showlocals, + truncate_locals=self.truncate_locals, + funcargs=self.funcargs, + truncate_args=self.truncate_args, + tbfilter=self.tbfilter, + chain=self.chain, + ) + res = PytestTraceback( + record=record, + style=self.style, + showlocals=self.showlocals, + abspath=self.abspath, + chain=self.chain, + truncate_locals=self.truncate_locals, + truncate_args=self.truncate_args, + funcargs=self.funcargs, + show_hidden=self.tbfilter is False, + ).to_repr() + assert isinstance(res, ExceptionChainRepr) + return res @dataclasses.dataclass(eq=False) @@ -1997,38 +1962,3 @@ def filter_excinfo_traceback( return excinfo.traceback.filter(excinfo) else: return excinfo.traceback - - -def _filter_tracebackexception( - tb_exc: TracebackException, - e: BaseException, - tbfilter: TracebackFilter, -) -> None: - """Filter a ``TracebackException`` in-place, respecting ``__tracebackhide__``. - - This is used to filter native-style tracebacks (currently the only style - used for ``BaseExceptionGroup``) without mutating the original exception - objects. It recurses into exception group sub-exceptions and into - ``__cause__`` / ``__context__`` chains. - - Frames are matched by ``(filename, lineno)``: ``TracebackEntry._rawentry.tb_lineno`` - is 1-based absolute, matching ``FrameSummary.lineno``. - """ - if e.__traceback__ is not None: - excinfo = ExceptionInfo.from_exception(e) - filtered = filter_excinfo_traceback(tbfilter, excinfo) - kept = { - (str(entry.frame.code.path), entry._rawentry.tb_lineno) - for entry in filtered - } - tb_exc.stack = StackSummary.from_list( - [fs for fs in tb_exc.stack if (fs.filename, fs.lineno) in kept] - ) - if isinstance(e, BaseExceptionGroup): - sub_tb_excs = getattr(tb_exc, "exceptions", None) or [] - for sub_tb_exc, sub_e in zip(sub_tb_excs, e.exceptions, strict=True): - _filter_tracebackexception(sub_tb_exc, sub_e, tbfilter) - if tb_exc.__cause__ is not None and e.__cause__ is not None: - _filter_tracebackexception(tb_exc.__cause__, e.__cause__, tbfilter) - if tb_exc.__context__ is not None and e.__context__ is not None: - _filter_tracebackexception(tb_exc.__context__, e.__context__, tbfilter) diff --git a/src/_pytest/_code/render.py b/src/_pytest/_code/render.py index 4d39681b01f..addd15c0233 100644 --- a/src/_pytest/_code/render.py +++ b/src/_pytest/_code/render.py @@ -127,7 +127,7 @@ def _format_native_record_lines( lines.append(f"{indent_prefix} | {exline}\n") for idx, child in enumerate(rec.children, 1): - header = f"{idx:^18}" if idx >= 10 else f" {idx} ".center(36, "-") + header = f" {idx} ".center(35, "-") if idx == 1: lines.append(f"{indent_prefix} +-+{header}\n") else: @@ -239,7 +239,7 @@ def _get_exconly_lines( ) -> list[str]: lines: list[str] = [] indentstr = " " * indent - exlines = rec.exconly(tryshort=True).split("\n") + exlines = rec.exconly(tryshort=not bool(rec.children)).split("\n") failindent = self.fail_marker + indentstr[1:] for line in exlines: lines.append(failindent + line) @@ -405,7 +405,12 @@ def _truncate_recursive_frames( extraline = None return frames, extraline - def _repr_traceback_for_record(self, rec: ExceptionRecord) -> ReprTraceback: + def _repr_traceback_for_record( + self, + rec: ExceptionRecord, + *, + show_all_hidden_msg: bool = True, + ) -> ReprTraceback: visible_frames = ( list(rec.frames) if self.show_hidden @@ -424,7 +429,7 @@ def _repr_traceback_for_record(self, rec: ExceptionRecord) -> ReprTraceback: ) if not visible_frames: - if extraline is None: + if extraline is None and show_all_hidden_msg: extraline = _ALL_HIDDEN_MSG entries = [self._repr_frame_entry(None, rec, effective_tb_style)] return ReprTraceback(entries, extraline, style=effective_tb_style) @@ -454,10 +459,79 @@ def _repr_traceback_for_record(self, rec: ExceptionRecord) -> ReprTraceback: ) return ReprTraceback(entries, extraline, style=effective_tb_style) - def _iter_chain_records(self) -> list[tuple[ExceptionRecord, str | None]]: + def _iter_chain_records( + self, root: ExceptionRecord | None = None + ) -> list[tuple[ExceptionRecord, str | None]]: + target = self.record if root is None else root if not self.chain: - return [(self.record, None)] - return list(self.record.iter_chain()) + return [(target, None)] + return list(target.iter_chain()) + + @staticmethod + def _append_chain_description( + repr_chain: list[tuple[ReprTraceback, ReprFileLocation | None, str | None]], + desc: str, + ) -> None: + tb, crash, existing = repr_chain[-1] + if existing is None: + repr_chain[-1] = (tb, crash, desc) + else: + repr_chain[-1] = (tb, crash, f"{existing}\n\n{desc}") + + def _append_record_chain( + self, + root: ExceptionRecord, + repr_chain: list[tuple[ReprTraceback, ReprFileLocation | None, str | None]], + *, + depth: int = 0, + ) -> tuple[ReprTraceback, ReprFileLocation | None]: + top_tb: ReprTraceback | None = None + top_crash: ReprFileLocation | None = None + for rec, description in self._iter_chain_records(root): + if ( + depth == 0 + and not rec.frames + and rec is not self.record + and not rec.children + ): + no_tb_lines = [ + f"{line}\n" for line in rec.exconly(tryshort=False).splitlines() + ] + reprtraceback: ReprTraceback = ReprTracebackNative(no_tb_lines) + reprcrash = None + else: + reprtraceback = self._repr_traceback_for_record( + rec, + show_all_hidden_msg=(depth == 0), + ) + reprcrash = self._make_reprcrash(rec) + + if rec is root: + top_tb = reprtraceback + top_crash = reprcrash + + repr_chain.append((reprtraceback, reprcrash, None)) + + if rec.children: + group_depth = depth + 1 + indent = " " * group_depth + for idx, child in enumerate(rec.children, 1): + header = f" {idx} ".center(35, "-") + header_line = ( + f"{indent}+-+{header}" if idx == 1 else f"{indent} +{header}" + ) + self._append_chain_description(repr_chain, header_line) + self._append_record_chain(child, repr_chain, depth=group_depth) + self._append_chain_description( + repr_chain, + f"{indent} +------------------------------------", + ) + + if description is not None: + self._append_chain_description(repr_chain, description) + + assert top_tb is not None + return top_tb, top_crash def _render_rich_to_lines(self, width: int = 100) -> list[str]: from rich.console import Console @@ -521,34 +595,11 @@ def to_repr(self) -> ReprExceptionInfo | ExceptionChainRepr: return res_rich repr_chain: list[tuple[ReprTraceback, ReprFileLocation | None, str | None]] = [] - for rec, description in self._iter_chain_records(): - if rec.children: - visible = ( - list(rec.frames) - if self.show_hidden - else [f for f in rec.frames if not f.hidden] - ) - extraline = _ALL_HIDDEN_MSG if (rec.frames and not visible) else None - group_lines = _format_native_record_lines( - rec, show_hidden=self.show_hidden - ) - reprtraceback: ReprTraceback = ReprTracebackNative( - group_lines, extraline=extraline - ) - reprcrash = self._make_reprcrash(rec) - elif rec.frames or rec is self.record: - reprtraceback = self._repr_traceback_for_record(rec) - reprcrash = self._make_reprcrash(rec) - else: - no_tb_lines = [ - f"{line}\n" for line in rec.exconly(tryshort=False).splitlines() - ] - reprtraceback = ReprTracebackNative(no_tb_lines) - reprcrash = None - - repr_chain.append((reprtraceback, reprcrash, description)) + top_tb, top_crash = self._append_record_chain(self.record, repr_chain, depth=0) chain_repr = ExceptionChainRepr(repr_chain) + chain_repr.reprtraceback = top_tb + chain_repr.reprcrash = top_crash for name, content, sep in self._all_sections(): chain_repr.addsection(name, content, sep) chain_repr.record = dataclasses.replace( diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 6875e0b27d6..5cca8179b8d 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -839,6 +839,8 @@ def deserialize_repr_crash(repr_crash_dict: dict[str, Any] | None): exception_info: ExceptionChainRepr | ReprExceptionInfo = ExceptionChainRepr( chain ) + exception_info.reprtraceback = reprtraceback + exception_info.reprcrash = reprcrash else: exception_info = ReprExceptionInfo( reprtraceback=reprtraceback, diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 64a2d914c17..88c48ddd1ca 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -2027,12 +2027,16 @@ def test(): match_lines.append(r"E\s+SyntaxError: ") match_lines += [ - r" + Exception Group Traceback (most recent call last):", - rf" \| {pre_catch}BaseExceptionGroup: Oops \(2 sub-exceptions\)", - r" \| ValueError: From f\(\)", - r" \| BaseException: From g\(\)", - r"=* short test summary info =*", + rf"E\s+{pre_catch}BaseExceptionGroup: Oops \(2 sub-exceptions\)", + r" \+\-\+\-+ 1 \-+.*", + r"E\s+ValueError: From f\(\)", + r" \+\-+ 2 \-+.*", + r"E\s+BaseException: From g\(\)", + r" \+\-+", ] + if outer_chain in ("another", "from"): + match_lines.append(r"E\s+IndexError") + match_lines.append(r"=* short test summary info =*") if outer_chain in ("another", "from"): match_lines.append(r"FAILED test_excgroup.py::test - IndexError") else: @@ -2043,6 +2047,8 @@ def test(): # Check for traceback filtering of pytest internals. result.stdout.no_fnmatch_line("*, line *, in pytest_pyfunc_call") result.stdout.no_fnmatch_line("*, line *, in pytest_runtest_call") + result.stdout.no_fnmatch_line("*in pytest_pyfunc_call*") + result.stdout.no_fnmatch_line("*in pytest_runtest_call*") @pytest.mark.skipif( diff --git a/testing/code/test_record.py b/testing/code/test_record.py index e8c8c6467de..135cbe3ced3 100644 --- a/testing/code/test_record.py +++ b/testing/code/test_record.py @@ -586,3 +586,81 @@ def test_a(): # 3. Under --tb=line and --tb=no, str(longrepr) stays compact if tbstyle in ("line", "no"): assert str(longrepr) == "E ValueError: boom" + + @pytest.mark.parametrize( + "style", + ("long", "short", "auto", "no", "value", "line", "native", "rich"), + ) + def test_exception_group_pytest_styles(self, style: TracebackStyle) -> None: + from rich.console import Console + + def child_a(a_arg: int) -> None: + a_local = a_arg + 1 + assert a_local == 0 + + def child_b() -> None: + raise ValueError("from child_b") + + def nested_group() -> None: + excs: list[Exception] = [] + try: + child_a(10) + except AssertionError as e: + excs.append(e) + try: + child_b() + except ValueError as e: + excs.append(ExceptionGroup("inner_group", [e])) + raise ExceptionGroup("outer_group", excs) + + with pytest.raises(ExceptionGroup) as excinfo: + nested_group() + + record = excinfo.record(showlocals=True, funcargs=True) + tb = PytestTraceback( + record, + style=style, + showlocals=True, + funcargs=True, + ) + rendered = str(tb) + + if style in ("long", "short", "auto"): + assert "ExceptionGroup: outer_group (2 sub-exceptions)" in rendered + assert "ExceptionGroup: inner_group (1 sub-exception)" in rendered + assert "assert 11 == 0" in rendered + assert "ValueError: from child_b" in rendered + assert "+-+---------------- 1 ----------------" in rendered + assert " +---------------- 2 ----------------" in rendered + assert "a_local" in rendered + if style in ("long", "auto"): + assert "a_arg = 10" in rendered + assert "> assert a_local == 0" in rendered + elif style == "short": + assert "in child_a" in rendered + assert "in child_b" in rendered + elif style == "no": + assert "E ExceptionGroup: outer_group (2 sub-exceptions)" in rendered + assert "E assert 11 == 0" in rendered + assert "E ValueError: from child_b" in rendered + assert "in child_a" not in rendered + elif style == "value": + assert "outer_group (2 sub-exceptions)" in rendered + assert "from child_b" in rendered + elif style == "line": + assert "ExceptionGroup: outer_group (2 sub-exceptions)" in rendered + assert "\n" not in rendered + elif style == "native": + assert "+ Exception Group Traceback (most recent call last):" in rendered + assert "ValueError: from child_b" in rendered + elif style == "rich": + assert "outer_group" in rendered + assert "from child_b" in rendered + + # Also verify Rich Console rendering for all styles + console_buf = io.StringIO() + console = Console( + file=console_buf, width=100, force_terminal=False, color_system=None + ) + console.print(tb) + assert "outer_group" in console_buf.getvalue() diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index cb2f1daab63..a04f9e87771 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -1214,11 +1214,11 @@ def test_second(): result.assert_outcomes(passed=2, errors=1) result.stdout.fnmatch_lines( [ - ' | *ExceptionGroup: errors while tearing down fixture "subrequest" of (2 sub-exceptions)', # noqa: E501 + 'E *ExceptionGroup: errors while tearing down fixture "subrequest" of (2 sub-exceptions)', # noqa: E501 " +-+---------------- 1 ----------------", - " | Exception: Error in something fixture", + "E *Exception: Error in something fixture", " +---------------- 2 ----------------", - " | Exception: Error in excepts fixture", + "E *Exception: Error in excepts fixture", " +------------------------------------", ], ) diff --git a/testing/test_threadexception.py b/testing/test_threadexception.py index f4595ec435d..77cf0d3e1ed 100644 --- a/testing/test_threadexception.py +++ b/testing/test_threadexception.py @@ -158,7 +158,7 @@ def test_2(): pass assert result.ret == pytest.ExitCode.TESTS_FAILED result.assert_outcomes(passed=1, failed=1) result.stdout.fnmatch_lines( - [" | *ExceptionGroup: multiple thread exception warnings (2 sub-exceptions)"] + ["E *ExceptionGroup: multiple thread exception warnings (2 sub-exceptions)"] ) diff --git a/testing/test_unraisableexception.py b/testing/test_unraisableexception.py index acc098a8ff8..8c1315d43d1 100644 --- a/testing/test_unraisableexception.py +++ b/testing/test_unraisableexception.py @@ -185,7 +185,7 @@ def test_2(): pass result.assert_outcomes(passed=1, failed=1) result.stdout.fnmatch_lines( [ - " | *ExceptionGroup: multiple unraisable exception warnings (2 sub-exceptions)" + "E *ExceptionGroup: multiple unraisable exception warnings (2 sub-exceptions)" ] ) From 5df14b91f553cd64d709c0542593bc5aca3c289c Mon Sep 17 00:00:00 2001 From: Oleksii Date: Tue, 3 Nov 2026 13:38:32 +0200 Subject: [PATCH 06/12] Share exception matching between ExceptionInfo and raisers with structured failures (Step 6 of #15072) Add _pytest._code.matching with check_type/check_regex primitives and plain-data MatchFailure objects that render to the legacy text or a rich Tree. ExceptionInfo.match, group_contains and RaisesExc/RaisesGroup now use them; fail_reason is derived from the structured failure. Add regression tests for #12175 (striptext captured in the record). --- src/_pytest/_code/code.py | 22 +- src/_pytest/_code/matching.py | 483 ++++++++++++++++++++++++++++++++++ src/_pytest/raises.py | 429 ++++++++++++------------------ testing/code/test_excinfo.py | 18 ++ testing/code/test_matching.py | 282 ++++++++++++++++++++ 5 files changed, 962 insertions(+), 272 deletions(-) create mode 100644 src/_pytest/_code/matching.py create mode 100644 testing/code/test_matching.py diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index dc4308a7959..f1aba927011 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -33,6 +33,7 @@ import pluggy import _pytest +from _pytest._code.matching import check_regex from _pytest._code.record import ExceptionRecord from _pytest._code.record import FrameRecord from _pytest._code.record import MAX_STATEMENT_LINE_CHARS @@ -792,15 +793,9 @@ def match(self, regexp: str | re.Pattern[str]) -> Literal[True]: If it matches `True` is returned, otherwise an `AssertionError` is raised. """ __tracebackhide__ = True - value = stringify_exception(self.value) - msg = ( - f"Regex pattern did not match.\n" - f" Expected regex: {regexp!r}\n" - f" Actual message: {value!r}" - ) - if regexp == value: - msg += "\n Did you mean to `re.escape()` the regex?" - assert re.search(regexp, value), msg + failure = check_regex(regexp, stringify_exception(self.value)) + if failure is not None: + raise AssertionError(failure.to_text()) # Return True to allow for "assert excinfo.match()". return True @@ -827,10 +822,11 @@ def _group_contains( continue if not isinstance(exc, expected_exception): continue - if match is not None: - value = stringify_exception(exc) - if not re.search(match, value): - continue + if ( + match is not None + and check_regex(match, stringify_exception(exc)) is not None + ): + continue return True return False diff --git a/src/_pytest/_code/matching.py b/src/_pytest/_code/matching.py new file mode 100644 index 00000000000..213c31d38f9 --- /dev/null +++ b/src/_pytest/_code/matching.py @@ -0,0 +1,483 @@ +"""Plain-data results of matching an exception against an expectation. + +``ExceptionInfo.match``, ``ExceptionInfo.group_contains``, :class:`pytest.RaisesExc` +and :class:`pytest.RaisesGroup` all answer the same question - "does this exception +satisfy these expectations?" - and used to answer it with ad-hoc strings stitched +together at the point of failure. + +The primitives in this module (:func:`check_type`, :func:`check_regex`) are the one +shared implementation of that check, and they report failures as immutable +:class:`MatchFailure` objects. A failure can be rendered as plain text +(:meth:`MatchFailure.to_text`, byte-for-byte what ``fail_reason`` used to contain) +or as a ``rich`` renderable (:meth:`MatchFailure.__rich_console__`), where mismatches +of nested :class:`pytest.RaisesGroup` become a :class:`rich.tree.Tree`. + +This module deliberately imports nothing from ``_pytest.raises`` or +``_pytest._code.code`` so both can depend on it. +""" + +from __future__ import annotations + +import dataclasses +import re +from re import Pattern +import sys +from textwrap import indent +from typing import ClassVar +from typing import Final +from typing import Literal +from typing import TYPE_CHECKING + + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + +if TYPE_CHECKING: + from rich.console import Console + from rich.console import ConsoleOptions + from rich.console import RenderableType + from rich.console import RenderResult + + +# String patterns default to including the unicode flag. +REGEX_NO_FLAGS: Final[int] = re.compile(r"").flags + + +def match_pattern(match: Pattern[str]) -> str | Pattern[str]: + """Remove redundant ``re.compile`` calls when printing a regex.""" + return match.pattern if match.flags == REGEX_NO_FLAGS else match + + +def backquote(s: str) -> str: + return "`" + s + "`" + + +def exception_type_name( + e: type[BaseException] | tuple[type[BaseException], ...], +) -> str: + if isinstance(e, type): + return e.__name__ + if len(e) == 1: + return e[0].__name__ + return "(" + ", ".join(ee.__name__ for ee in e) + ")" + + +@dataclasses.dataclass(frozen=True) +class MatchFailure: + """Why an exception did not match an expectation. + + Instances are immutable and made of plain strings and nested + :class:`MatchFailure` objects, so they can be stored, compared and + re-rendered without access to the exceptions they describe. + """ + + def to_text(self) -> str: + """The failure as plain text.""" + raise NotImplementedError + + def __str__(self) -> str: + return self.to_text() + + def to_rich(self) -> RenderableType: + """The failure as a ``rich`` renderable.""" + from rich.text import Text + + return Text(self.to_text()) + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield self.to_rich() + + +@dataclasses.dataclass(frozen=True) +class NoException(MatchFailure): + """``None`` was given where an exception was expected.""" + + def to_text(self) -> str: + return "exception is None" + + +@dataclasses.dataclass(frozen=True) +class TypeMismatch(MatchFailure): + """The exception is not an instance of the expected type(s).""" + + actual: str + expected: str + # An exception group was found where a non-group type was expected. + unexpected_nested_group: bool = False + + def to_text(self) -> str: + actual = backquote(self.actual + "()") + expected = backquote(self.expected) + if self.unexpected_nested_group: + return f"Unexpected nested {actual}, expected {expected}" + return f"{actual} is not an instance of {expected}" + + +@dataclasses.dataclass(frozen=True) +class NotAnExceptionGroup(MatchFailure): + """A non-group exception was given to a ``RaisesGroup``.""" + + actual: str + would_match_unwrapped: bool = False + + def to_text(self) -> str: + # Only the type is printed, as the repr would likely be quite long. + text = f"`{self.actual}()` is not an exception group" + if self.would_match_unwrapped: + text += ", but would match with `allow_unwrapped=True`" + return text + + +@dataclasses.dataclass(frozen=True) +class RegexSuggestion: + """A lone sub-exception matched the regex that the enclosing group did not.""" + + expected: str + type_name: str + pattern: str + + +@dataclasses.dataclass(frozen=True) +class RegexMismatch(MatchFailure): + """A regex did not match the stringified exception.""" + + expected: str + actual: str + # e.g. "the `ExceptionGroup()`", to reduce confusion when a group is matched. + subject: str | None = None + # Set if the regex is a fully escaped literal, in which case a diff is shown. + diff: str | None = None + escape_hint: bool = False + suggestion: RegexSuggestion | None = None + + def to_text(self) -> str: + if self.diff is not None: + text = self.diff + else: + subject = f" {self.subject}" if self.subject else "" + text = ( + f"Regex pattern did not match{subject}.\n" + f" Expected regex: {self.expected}\n" + f" Actual message: {self.actual}" + ) + if self.escape_hint: + text += "\n Did you mean to `re.escape()` the regex?" + if self.suggestion is not None: + text += ( + f"\n" + f" but matched the expected `{self.suggestion.expected}`.\n" + f" You might want " + f"`RaisesGroup(RaisesExc({self.suggestion.type_name}, match={self.suggestion.pattern}))`" + ) + return text + + +@dataclasses.dataclass(frozen=True) +class CheckFailure(MatchFailure): + """The ``check`` callable did not return ``True``.""" + + # Empty to suppress repeated printing of the callable when nested. + check_repr: str = "" + # Set when the check was run against an exception group. + on_type: str | None = None + # Set when the check does pass for the lone expected sub-exception. + suggestion: tuple[str, str] | None = None + + def to_text(self) -> str: + check = f" {self.check_repr}" if self.check_repr else "" + text = f"check{check} did not return True" + if self.on_type is not None: + text += f" on the {self.on_type}" + if self.suggestion is not None: + expected, name = self.suggestion + text += ( + f", but did return True for the expected {expected}." + f" You might want RaisesGroup(RaisesExc({name}, check=<...>))" + ) + return text + + +@dataclasses.dataclass(frozen=True) +class Nested(MatchFailure): + """A failure of a nested expectation, labelled with that expectation's repr.""" + + label: str + reason: MatchFailure + + def to_text(self) -> str: + reason = self.reason.to_text() + if reason.startswith("\n"): + return f"\n{self.label}: {indent(reason, ' ')}" + return f"{self.label}: {reason}" + + def to_rich(self) -> RenderableType: + from rich.text import Text + from rich.tree import Tree + + tree = Tree(Text(self.label)) + tree.add(self.reason.to_rich()) + return tree + + +@dataclasses.dataclass(frozen=True) +class FlattenHint(MatchFailure): + """The match would succeed with ``flatten_subgroups=True``.""" + + inner: MatchFailure + + def to_text(self) -> str: + text = self.inner.to_text() + # Only indent if it's a single-line reason. In a multi-line there's already + # indented lines that this does not belong to. + pad = " " if "\n" not in text else "" + return f"{text}\n{pad}Did you mean to use `flatten_subgroups=True`?" + + def to_rich(self) -> RenderableType: + from rich.console import Group + from rich.text import Text + + return Group( + self.inner.to_rich(), Text("Did you mean to use `flatten_subgroups=True`?") + ) + + +def _matched_prefix(matched: int) -> str: + if not matched: + return "" + return f"{matched} matched exception{'s' if matched > 1 else ''}. " + + +@dataclasses.dataclass(frozen=True) +class UnexpectedExceptions(MatchFailure): + """Every expectation was met, but there are raised exceptions left over.""" + + matched: int + actual: tuple[str, ...] + + def to_text(self) -> str: + return ( + f"{_matched_prefix(self.matched)}Unexpected exception(s):" + f" [{', '.join(self.actual)}]" + ) + + +@dataclasses.dataclass(frozen=True) +class TooFewExceptions(MatchFailure): + """Every raised exception was expected, but some expectations are unmet.""" + + matched: int + expected: tuple[str, ...] + + def to_text(self) -> str: + return ( + f"{_matched_prefix(self.matched)}Too few exceptions raised," + f" found no match for: [{', '.join(self.expected)}]" + ) + + +@dataclasses.dataclass(frozen=True) +class PartiallyMatched(MatchFailure): + """Some exceptions were paired up successfully, the remaining pair did not match.""" + + matched: int + inner: MatchFailure + + def to_text(self) -> str: + return f"{_matched_prefix(self.matched)}{self.inner.to_text()}" + + def to_rich(self) -> RenderableType: + from rich.console import Group + from rich.text import Text + + if not self.matched: + return self.inner.to_rich() + return Group(Text(_matched_prefix(self.matched).rstrip()), self.inner.to_rich()) + + +@dataclasses.dataclass(frozen=True) +class PairedWith(MatchFailure): + """``matched`` also satisfies an expectation, but was already paired with ``partner``.""" + + matched: str + partner: str + + def to_text(self) -> str: + return ( + f"It matches {backquote(self.matched)}" + f" which was paired with {backquote(self.partner)}" + ) + + +@dataclasses.dataclass(frozen=True) +class UnmatchedExpected: + """An expectation that no raised exception was paired with.""" + + label: str + paired_elsewhere: tuple[PairedWith, ...] = () + + +@dataclasses.dataclass(frozen=True) +class UnmatchedActual: + """A raised exception that no expectation was paired with. + + ``entries`` holds, for each expectation of interest, either the + reason it did not match or the other exception it was paired with. + """ + + actual: str + entries: tuple[MatchFailure, ...] = () + + +@dataclasses.dataclass(frozen=True) +class PairingDetails(MatchFailure): + """The full table of why expected and raised exceptions could not be paired up.""" + + matched: int + problem: Literal["too_few", "unexpected", "both"] + unmatched_expected: tuple[UnmatchedExpected, ...] + unmatched_actual: tuple[UnmatchedActual, ...] + greedy_hint: bool = False + + _HEADINGS: ClassVar[dict[str, str]] = { + "too_few": "Too few exceptions raised!", + "unexpected": "Unexpected exception(s)!", + "both": "", + } + _GREEDY_HINT: ClassVar[str] = ( + "There exist a possible match when attempting an exhaustive check," + " but RaisesGroup uses a greedy algorithm. " + "Please make your expected exceptions more stringent with `RaisesExc` etc" + " so the greedy algorithm can function." + ) + + def to_text(self) -> str: + indent_1 = " " * 2 + indent_2 = " " * 4 + s = "" + if self.matched: + s += f"\n{_matched_prefix(self.matched)}" + heading = self._HEADINGS[self.problem] + if heading: + s += f"\n{heading}" + + if self.unmatched_expected: + s += "\nThe following expected exceptions did not find a match:" + for exp in self.unmatched_expected: + s += f"\n{indent_1}{exp.label}" + for paired in exp.paired_elsewhere: + s += f"\n{indent_2}{paired.to_text()}" + + if self.unmatched_actual: + s += "\nThe following raised exceptions did not find a match" + for act in self.unmatched_actual: + s += f"\n{indent_1}{act.actual}:" + for entry in act.entries: + if isinstance(entry, PairedWith): + s += f"\n{indent_2}{entry.to_text()}" + else: + text = entry.to_text() + if text[0] != "\n": + s += "\n" + s += indent(text, indent_2) + + if self.greedy_hint: + s += f"\n{self._GREEDY_HINT}" + return s + + def to_rich(self) -> RenderableType: + from rich.text import Text + from rich.tree import Tree + + heading = self._HEADINGS[self.problem] + label = _matched_prefix(self.matched).rstrip() + if heading: + label = f"{label} {heading}".strip() + tree = Tree(Text(label or "Exceptions could not be paired up")) + + if self.unmatched_expected: + branch = tree.add(Text("Expected exceptions without a match", "bold")) + for exp in self.unmatched_expected: + node = branch.add(Text(exp.label)) + for paired in exp.paired_elsewhere: + node.add(paired.to_rich()) + if self.unmatched_actual: + branch = tree.add(Text("Raised exceptions without a match", "bold")) + for act in self.unmatched_actual: + node = branch.add(Text(act.actual)) + for entry in act.entries: + node.add(entry.to_rich()) + if self.greedy_hint: + tree.add(Text(self._GREEDY_HINT, "italic")) + return tree + + +def check_type( + expected_type: type[BaseException] | tuple[type[BaseException], ...] | None, + exception: BaseException, +) -> TypeMismatch | None: + """Check that ``exception`` is an instance of ``expected_type``. + + ``None`` and ``()`` accept any exception. + """ + if expected_type is None or expected_type == (): + return None + + if isinstance(exception, expected_type): + return None + + return TypeMismatch( + actual=exception_type_name(type(exception)), + expected=exception_type_name(expected_type), + unexpected_nested_group=( + isinstance(exception, BaseExceptionGroup) + and isinstance(expected_type, type) + and not issubclass(expected_type, BaseExceptionGroup) + ), + ) + + +def check_regex( + pattern: str | Pattern[str], + actual: str, + *, + rawmatch: str | None = None, + subject: str | None = None, +) -> RegexMismatch | None: + """Check that ``pattern`` is found in ``actual`` using :func:`re.search`. + + :param rawmatch: + If ``pattern`` is a fully escaped literal (anchored with ``^`` and ``$``), + its unescaped text, which makes a failure show a diff. + :param subject: + What was matched, if not a plain exception. Reduces confusion when a + group is matched instead of one of its sub-exceptions. + """ + if re.search(pattern, actual): + return None + + display = pattern if isinstance(pattern, str) else match_pattern(pattern) + diff = None + if rawmatch is not None: + diff = _diff_literal(rawmatch, actual) + return RegexMismatch( + expected=repr(display), + actual=repr(actual), + subject=subject, + diff=diff, + escape_hint=display == actual, + ) + + +def _diff_literal(expected: str, actual: str) -> str: + from _pytest.assertion.compare_text import _diff_text + from _pytest.assertion.highlight import dummy_highlighter + from _pytest.assertion.util import _config + from _pytest.config import Config + + verbose = ( + _config.get_verbosity(Config.VERBOSITY_ASSERTIONS) if _config is not None else 0 + ) + diff = list(_diff_text(expected, actual, dummy_highlighter, verbose)) + return ("\n" if diff[0][0] == "-" else "") + "\n".join(diff) diff --git a/src/_pytest/raises.py b/src/_pytest/raises.py index 898683a85b9..6e67a1b9d46 100644 --- a/src/_pytest/raises.py +++ b/src/_pytest/raises.py @@ -2,10 +2,10 @@ from abc import ABC from abc import abstractmethod +import dataclasses import re from re import Pattern import sys -from textwrap import indent from typing import Any from typing import cast from typing import final @@ -19,6 +19,26 @@ from _pytest._code import ExceptionInfo from _pytest._code.code import stringify_exception +from _pytest._code.matching import check_regex +from _pytest._code.matching import check_type +from _pytest._code.matching import CheckFailure +from _pytest._code.matching import exception_type_name +from _pytest._code.matching import FlattenHint +from _pytest._code.matching import match_pattern +from _pytest._code.matching import MatchFailure +from _pytest._code.matching import Nested +from _pytest._code.matching import NoException +from _pytest._code.matching import NotAnExceptionGroup +from _pytest._code.matching import PairedWith +from _pytest._code.matching import PairingDetails +from _pytest._code.matching import PartiallyMatched +from _pytest._code.matching import REGEX_NO_FLAGS +from _pytest._code.matching import RegexMismatch +from _pytest._code.matching import RegexSuggestion +from _pytest._code.matching import TooFewExceptions +from _pytest._code.matching import UnexpectedExceptions +from _pytest._code.matching import UnmatchedActual +from _pytest._code.matching import UnmatchedExpected from _pytest.outcomes import fail from _pytest.warning_types import PytestWarning @@ -65,10 +85,6 @@ from exceptiongroup import ExceptionGroup -# String patterns default to including the unicode flag. -_REGEX_NO_FLAGS = re.compile(r"").flags - - # pytest.raises helper @overload def raises( @@ -292,11 +308,6 @@ def raises( raises.Exception = fail.Exception # type: ignore[attr-defined] -def _match_pattern(match: Pattern[str]) -> str | Pattern[str]: - """Helper function to remove redundant `re.compile` calls when printing regex""" - return match.pattern if match.flags == _REGEX_NO_FLAGS else match - - def repr_callable(fun: Callable[[BaseExcT_1], bool]) -> str: """Get the repr of a ``check`` parameter. @@ -305,43 +316,6 @@ def repr_callable(fun: Callable[[BaseExcT_1], bool]) -> str: return repr(fun) -def backquote(s: str) -> str: - return "`" + s + "`" - - -def _exception_type_name( - e: type[BaseException] | tuple[type[BaseException], ...], -) -> str: - if isinstance(e, type): - return e.__name__ - if len(e) == 1: - return e[0].__name__ - return "(" + ", ".join(ee.__name__ for ee in e) + ")" - - -def _check_raw_type( - expected_type: type[BaseException] | tuple[type[BaseException], ...] | None, - exception: BaseException, -) -> str | None: - if expected_type is None or expected_type == (): - return None - - if not isinstance( - exception, - expected_type, - ): - actual_type_str = backquote(_exception_type_name(type(exception)) + "()") - expected_type_str = backquote(_exception_type_name(expected_type)) - if ( - isinstance(exception, BaseExceptionGroup) - and isinstance(expected_type, type) - and not issubclass(expected_type, BaseExceptionGroup) - ): - return f"Unexpected nested {actual_type_str}, expected {expected_type_str}" - return f"{actual_type_str} is not an instance of {expected_type_str}" - return None - - def is_fully_escaped(s: str) -> bool: # we know we won't compile with re.VERBOSE, so whitespace doesn't need to be escaped metacharacters = "{}()+.*?^$[]|" @@ -358,9 +332,11 @@ def unescape(s: str) -> str: # These classes conceptually differ from ExceptionInfo in that ExceptionInfo is tied, and # constructed from, a particular exception - whereas these are constructed with expected # exceptions, and later allow matching towards particular exceptions. -# But there's overlap in `ExceptionInfo.match` and `AbstractRaises._check_match`, as with -# `AbstractRaises.matches` and `ExceptionInfo.errisinstance`+`ExceptionInfo.group_contains`. -# The interaction between these classes should perhaps be improved. +# The overlap in what they check (``ExceptionInfo.match`` and ``AbstractRaises._check_match``, +# as with ``AbstractRaises.matches`` and ``ExceptionInfo.errisinstance`` + +# ``ExceptionInfo.group_contains``) is implemented once, by the primitives in +# ``_pytest._code.matching``, which also describe failures as structured +# ``MatchFailure`` objects instead of strings. class AbstractRaises(ABC, Generic[BaseExcT_co]): """ABC with common functionality shared between RaisesExc and RaisesGroup""" @@ -395,7 +371,7 @@ def __init__( # in which case we can do a proper diff on error self.rawmatch: str | None = None if isinstance(match, str) or ( - isinstance(match, Pattern) and match.flags == _REGEX_NO_FLAGS + isinstance(match, Pattern) and match.flags == REGEX_NO_FLAGS ): if isinstance(match, Pattern): match = match.pattern @@ -408,7 +384,9 @@ def __init__( self.rawmatch = unescape(match[1:-1]) self.check = check - self._fail_reason: str | None = None + # Structured reason for the last failed call to ``matches()``; + # ``fail_reason`` is its text rendering. + self._failure: MatchFailure | None = None # used to suppress repeated printing of `repr(self.check)` self._nested: bool = False @@ -458,66 +436,35 @@ def fail_reason(self) -> str | None: """Set after a call to :meth:`matches` to give a human-readable reason for why the match failed. When used as a context manager the string will be printed as the reason for the test failing.""" - return self._fail_reason + return None if self._failure is None else self._failure.to_text() def _check_check( self: AbstractRaises[BaseExcT_1], exception: BaseExcT_1, - ) -> bool: - if self.check is None: - return True - - if self.check(exception): - return True - - check_repr = "" if self._nested else " " + repr_callable(self.check) - self._fail_reason = f"check{check_repr} did not return True" - return False + ) -> CheckFailure | None: + if self.check is None or self.check(exception): + return None - # TODO: harmonize with ExceptionInfo.match - def _check_match(self, e: BaseException) -> bool: - if self.match is None or re.search( - self.match, - stringified_exception := stringify_exception( - e, include_subexception_msg=False - ), - ): - return True + return CheckFailure( + check_repr="" if self._nested else repr_callable(self.check) + ) + def _check_match(self, e: BaseException) -> RegexMismatch | None: + if self.match is None: + return None # if we're matching a group, make sure we're explicit to reduce confusion # if they're trying to match an exception contained within the group - maybe_specify_type = ( - f" the `{_exception_type_name(type(e))}()`" + subject = ( + f"the `{exception_type_name(type(e))}()`" if isinstance(e, BaseExceptionGroup) - else "" + else None ) - if isinstance(self.rawmatch, str): - from _pytest.assertion.compare_text import _diff_text - from _pytest.assertion.highlight import dummy_highlighter - from _pytest.assertion.util import _config - from _pytest.config import Config - - verbose = ( - _config.get_verbosity(Config.VERBOSITY_ASSERTIONS) - if _config is not None - else 0 - ) - diff = list( - _diff_text( - self.rawmatch, stringified_exception, dummy_highlighter, verbose - ) - ) - self._fail_reason = ("\n" if diff[0][0] == "-" else "") + "\n".join(diff) - return False - - self._fail_reason = ( - f"Regex pattern did not match{maybe_specify_type}.\n" - f" Expected regex: {_match_pattern(self.match)!r}\n" - f" Actual message: {stringified_exception!r}" + return check_regex( + self.match, + stringify_exception(e, include_subexception_msg=False), + rawmatch=self.rawmatch, + subject=subject, ) - if _match_pattern(self.match) == stringified_exception: - self._fail_reason += "\n Did you mean to `re.escape()` the regex?" - return False @abstractmethod def matches( @@ -651,35 +598,41 @@ def matches( """ self._just_propagate = False + self._failure = None if exception is None: - self._fail_reason = "exception is None" + self._failure = NoException() return False - if not self._check_type(exception): + failure: MatchFailure | None = check_type(self.expected_exceptions, exception) + if failure is not None: + self._failure = failure self._just_propagate = True return False - if not self._check_match(exception): + failure = self._check_match(exception) + if failure is not None: + self._failure = failure return False - return self._check_check(exception) + # The type was verified above, so the exception can be narrowed. + failure = self._check_check(cast("BaseExcT_co_default", exception)) + if failure is not None: + self._failure = failure + return False + return True def __repr__(self) -> str: parameters = [] if self.expected_exceptions: - parameters.append(_exception_type_name(self.expected_exceptions)) + parameters.append(exception_type_name(self.expected_exceptions)) if self.match is not None: # If no flags were specified, discard the redundant re.compile() here. parameters.append( - f"match={_match_pattern(self.match)!r}", + f"match={match_pattern(self.match)!r}", ) if self.check is not None: parameters.append(f"check={repr_callable(self.check)}") return f"RaisesExc({', '.join(parameters)})" - def _check_type(self, exception: BaseException) -> TypeGuard[BaseExcT_co_default]: - self._fail_reason = _check_raw_type(self.expected_exceptions, exception) - return self._fail_reason is None - def __enter__(self) -> ExceptionInfo[BaseExcT_co_default]: self.excinfo: ExceptionInfo[BaseExcT_co_default] = ExceptionInfo.for_later() return self.excinfo @@ -708,7 +661,7 @@ def __exit__( if not self.matches(exc_val): if self._just_propagate: return False - raise AssertionError(self._fail_reason) from None + raise AssertionError(self.fail_reason) from None # Cast to narrow the exception type now that it's verified.... # even though the TypeGuard in self.matches should be narrowing @@ -1059,7 +1012,7 @@ def __repr__(self) -> str: reqs.append(f"flatten_subgroups={self.flatten_subgroups}") if self.match is not None: # If no flags were specified, discard the redundant re.compile() here. - reqs.append(f"match={_match_pattern(self.match)!r}") + reqs.append(f"match={match_pattern(self.match)!r}") if self.check is not None: reqs.append(f"check={repr_callable(self.check)}") return f"RaisesGroup({', '.join(reqs)})" @@ -1107,16 +1060,16 @@ def matches( assert len(myexc.exceptions) == 1 assert isinstance(myexc.exceptions[0], ValueError) """ - self._fail_reason = None + self._failure = None if exception is None: - self._fail_reason = "exception is None" + self._failure = NoException() return False if not isinstance(exception, BaseExceptionGroup): # we opt to only print type of the exception here, as the repr would # likely be quite long - not_group_msg = f"`{type(exception).__name__}()` is not an exception group" + not_group = NotAnExceptionGroup(type(exception).__name__) if len(self.expected_exceptions) > 1: - self._fail_reason = not_group_msg + self._failure = not_group return False # if we have 1 expected exception, check if it would work even if # allow_unwrapped is not set @@ -1125,48 +1078,40 @@ def matches( return True if res is None: - self._fail_reason = ( - f"{not_group_msg}, but would match with `allow_unwrapped=True`" + self._failure = NotAnExceptionGroup( + type(exception).__name__, would_match_unwrapped=True ) elif self.allow_unwrapped: - self._fail_reason = res + self._failure = res else: - self._fail_reason = not_group_msg + self._failure = not_group return False actual_exceptions: Sequence[BaseException] = exception.exceptions if self.flatten_subgroups: actual_exceptions = self._unroll_exceptions(actual_exceptions) - if not self._check_match(exception): - self._fail_reason = cast(str, self._fail_reason) - old_reason = self._fail_reason + if (regex_failure := self._check_match(exception)) is not None: if ( len(actual_exceptions) == len(self.expected_exceptions) == 1 and isinstance(expected := self.expected_exceptions[0], type) and isinstance(actual := actual_exceptions[0], expected) - and self._check_match(actual) + and self._check_match(actual) is None ): assert self.match is not None, "can't be None if _check_match failed" - assert self._fail_reason is old_reason is not None - self._fail_reason += ( - f"\n" - f" but matched the expected `{self._repr_expected(expected)}`.\n" - f" You might want " - f"`RaisesGroup(RaisesExc({expected.__name__}, match={_match_pattern(self.match)!r}))`" + regex_failure = dataclasses.replace( + regex_failure, + suggestion=RegexSuggestion( + expected=self._repr_expected(expected), + type_name=expected.__name__, + pattern=repr(match_pattern(self.match)), + ), ) - else: - self._fail_reason = old_reason + self._failure = regex_failure return False # do the full check on expected exceptions - if not self._check_exceptions( - exception, - actual_exceptions, - ): - self._fail_reason = cast(str, self._fail_reason) - assert self._fail_reason is not None - old_reason = self._fail_reason + if (failure := self._check_exceptions(actual_exceptions)) is not None: # if we're not expecting a nested structure, and there is one, do a second # pass where we try flattening it if ( @@ -1176,38 +1121,29 @@ def matches( ) and any(isinstance(e, BaseExceptionGroup) for e in actual_exceptions) and self._check_exceptions( - exception, - self._unroll_exceptions(exception.exceptions), + self._unroll_exceptions(exception.exceptions) ) + is None ): - # only indent if it's a single-line reason. In a multi-line there's already - # indented lines that this does not belong to. - indent = " " if "\n" not in self._fail_reason else "" - self._fail_reason = ( - old_reason - + f"\n{indent}Did you mean to use `flatten_subgroups=True`?" - ) - else: - self._fail_reason = old_reason + failure = FlattenHint(failure) + self._failure = failure return False # Only run `self.check` once we know `exception` is of the correct type. - if not self._check_check(exception): - reason = ( - cast(str, self._fail_reason) + f" on the {type(exception).__name__}" - ) + if (check_failure := self._check_check(exception)) is not None: + suggestion = None if ( len(actual_exceptions) == len(self.expected_exceptions) == 1 and isinstance(expected := self.expected_exceptions[0], type) # we explicitly break typing here :) - and self._check_check(actual_exceptions[0]) # type: ignore[arg-type] + and self._check_check(actual_exceptions[0]) is None # type: ignore[arg-type] ): - self._fail_reason = reason + ( - f", but did return True for the expected {self._repr_expected(expected)}." - f" You might want RaisesGroup(RaisesExc({expected.__name__}, check=<...>))" - ) - else: - self._fail_reason = reason + suggestion = (self._repr_expected(expected), expected.__name__) + self._failure = dataclasses.replace( + check_failure, + on_type=type(exception).__name__, + suggestion=suggestion, + ) return False return True @@ -1218,48 +1154,31 @@ def _check_expected( type[BaseException] | RaisesExc[BaseException] | RaisesGroup[BaseException] ), exception: BaseException, - ) -> str | None: + ) -> MatchFailure | None: """Helper method for `RaisesGroup.matches` and `RaisesGroup._check_exceptions` to check one of potentially several expected exceptions.""" if isinstance(expected_type, type): - return _check_raw_type(expected_type, exception) - res = expected_type.matches(exception) - if res: + return check_type(expected_type, exception) + if expected_type.matches(exception): return None - assert expected_type.fail_reason is not None - if expected_type.fail_reason.startswith("\n"): - return f"\n{expected_type!r}: {indent(expected_type.fail_reason, ' ')}" - return f"{expected_type!r}: {expected_type.fail_reason}" + assert expected_type._failure is not None + return Nested(repr(expected_type), expected_type._failure) @staticmethod def _repr_expected(e: type[BaseException] | AbstractRaises[BaseException]) -> str: """Get the repr of an expected type/RaisesExc/RaisesGroup, but we only want the name if it's a type""" if isinstance(e, type): - return _exception_type_name(e) + return exception_type_name(e) return repr(e) - @overload - def _check_exceptions( - self: RaisesGroup[ExcT_1], - _exception: Exception, - actual_exceptions: Sequence[Exception], - ) -> TypeGuard[ExceptionGroup[ExcT_1]]: ... - @overload - def _check_exceptions( - self: RaisesGroup[BaseExcT_1], - _exception: BaseException, - actual_exceptions: Sequence[BaseException], - ) -> TypeGuard[BaseExceptionGroup[BaseExcT_1]]: ... - def _check_exceptions( self, - _exception: BaseException, actual_exceptions: Sequence[BaseException], - ) -> bool: - """Helper method for RaisesGroup.matches that attempts to pair up expected and actual exceptions""" - # The _exception parameter is not used, but necessary for the TypeGuard + ) -> MatchFailure | None: + """Helper method for RaisesGroup.matches that attempts to pair up expected and actual exceptions. + Returns ``None`` if all of them could be paired up.""" # full table with all results results = ResultHolder(self.expected_exceptions, actual_exceptions) @@ -1271,6 +1190,7 @@ def _check_exceptions( matches: dict[int, int] = {} # loop over expected exceptions first to get a more predictable result + res: MatchFailure | None = None for i_exp, expected in enumerate(self.expected_exceptions): for i_rem in remaining_actual: res = self._check_expected(expected, actual_exceptions[i_rem]) @@ -1284,13 +1204,13 @@ def _check_exceptions( # All exceptions matched up successfully if not remaining_actual and not failed_expected: - return True + return None # in case of a single expected and single raised we simplify the output if 1 == len(actual_exceptions) == len(self.expected_exceptions): assert not matches - self._fail_reason = res - return False + assert res is not None + return res # The test case is failing, so we can do a slow and exhaustive check to find # duplicate matches etc that will be helpful in debugging @@ -1302,27 +1222,21 @@ def _check_exceptions( i_exp, i_actual, self._check_expected(expected, actual) ) - successful_str = ( - f"{len(matches)} matched exception{'s' if len(matches) > 1 else ''}. " - if matches - else "" - ) - # all expected were found if not failed_expected and results.no_match_for_actual(remaining_actual): - self._fail_reason = ( - f"{successful_str}Unexpected exception(s):" - f" {[actual_exceptions[i] for i in remaining_actual]!r}" + return UnexpectedExceptions( + len(matches), + tuple(repr(actual_exceptions[i]) for i in remaining_actual), ) - return False # all raised exceptions were expected if not remaining_actual and results.no_match_for_expected(failed_expected): - no_match_for_str = ", ".join( - self._repr_expected(self.expected_exceptions[i]) - for i in failed_expected + return TooFewExceptions( + len(matches), + tuple( + self._repr_expected(self.expected_exceptions[i]) + for i in failed_expected + ), ) - self._fail_reason = f"{successful_str}Too few exceptions raised, found no match for: [{no_match_for_str}]" - return False # if there's only one remaining and one failed, and the unmatched didn't match anything else, # we elect to only print why the remaining and the failed didn't match. @@ -1331,69 +1245,64 @@ def _check_exceptions( and results.no_match_for_actual(remaining_actual) and results.no_match_for_expected(failed_expected) ): - self._fail_reason = f"{successful_str}{results.get_result(failed_expected[0], remaining_actual[0])}" - return False + only = results.get_result(failed_expected[0], remaining_actual[0]) + assert only is not None + return PartiallyMatched(len(matches), only) # there's both expected and raised exceptions without matches - s = "" - if matches: - s += f"\n{successful_str}" - indent_1 = " " * 2 - indent_2 = " " * 4 - + problem: Literal["too_few", "unexpected", "both"] if not remaining_actual: - s += "\nToo few exceptions raised!" + problem = "too_few" elif not failed_expected: - s += "\nUnexpected exception(s)!" - - if failed_expected: - s += "\nThe following expected exceptions did not find a match:" - rev_matches = {v: k for k, v in matches.items()} - for i_failed in failed_expected: - s += ( - f"\n{indent_1}{self._repr_expected(self.expected_exceptions[i_failed])}" - ) - for i_actual, actual in enumerate(actual_exceptions): - if results.get_result(i_failed, i_actual) is None: - # we print full repr of match target - s += ( - f"\n{indent_2}It matches {backquote(repr(actual))} which was paired with " - + backquote( - self._repr_expected( - self.expected_exceptions[rev_matches[i_actual]] - ) - ) + problem = "unexpected" + else: + problem = "both" + + rev_matches = {v: k for k, v in matches.items()} + unmatched_expected = tuple( + UnmatchedExpected( + self._repr_expected(self.expected_exceptions[i_failed]), + tuple( + PairedWith( + repr(actual), + self._repr_expected( + self.expected_exceptions[rev_matches[i_actual]] + ), ) + for i_actual, actual in enumerate(actual_exceptions) + if results.get_result(i_failed, i_actual) is None + ), + ) + for i_failed in failed_expected + ) - if remaining_actual: - s += "\nThe following raised exceptions did not find a match" + unmatched_actual: list[UnmatchedActual] = [] for i_actual in remaining_actual: - s += f"\n{indent_1}{actual_exceptions[i_actual]!r}:" + entries: list[MatchFailure] = [] for i_exp, expected in enumerate(self.expected_exceptions): res = results.get_result(i_exp, i_actual) if i_exp in failed_expected: assert res is not None - if res[0] != "\n": - s += "\n" - s += indent(res, indent_2) + entries.append(res) if res is None: - # we print full repr of match target - s += ( - f"\n{indent_2}It matches {backquote(self._repr_expected(expected))} " - f"which was paired with {backquote(repr(actual_exceptions[matches[i_exp]]))}" + entries.append( + PairedWith( + self._repr_expected(expected), + repr(actual_exceptions[matches[i_exp]]), + ) ) - - if len(self.expected_exceptions) == len(actual_exceptions) and possible_match( - results - ): - s += ( - "\nThere exist a possible match when attempting an exhaustive check," - " but RaisesGroup uses a greedy algorithm. " - "Please make your expected exceptions more stringent with `RaisesExc` etc" - " so the greedy algorithm can function." + unmatched_actual.append( + UnmatchedActual(repr(actual_exceptions[i_actual]), tuple(entries)) ) - self._fail_reason = s - return False + + return PairingDetails( + matched=len(matches), + problem=problem, + unmatched_expected=unmatched_expected, + unmatched_actual=tuple(unmatched_actual), + greedy_hint=len(self.expected_exceptions) == len(actual_exceptions) + and possible_match(results), + ) def __exit__( self, @@ -1418,7 +1327,7 @@ def __exit__( ) if not self.matches(exc_val): - fail(f"Raised exception {group_str} did not match: {self._fail_reason}") + fail(f"Raised exception {group_str} did not match: {self.fail_reason}") # Cast to narrow the exception type now that it's verified.... # even though the TypeGuard in self.matches should be narrowing @@ -1463,14 +1372,16 @@ def __init__( ], actual_exceptions: Sequence[BaseException], ) -> None: - self.results: list[list[str | type[NotChecked] | None]] = [ + self.results: list[list[MatchFailure | type[NotChecked] | None]] = [ [NotChecked for _ in expected_exceptions] for _ in actual_exceptions ] - def set_result(self, expected: int, actual: int, result: str | None) -> None: + def set_result( + self, expected: int, actual: int, result: MatchFailure | None + ) -> None: self.results[actual][expected] = result - def get_result(self, expected: int, actual: int) -> str | None: + def get_result(self, expected: int, actual: int) -> MatchFailure | None: res = self.results[actual][expected] assert res is not NotChecked return res diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 88c48ddd1ca..196de0e399c 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -370,6 +370,24 @@ def test_excinfo_exconly_tryshort_assert() -> None: assert info.exconly(tryshort=True) == "AssertionError: assert 1 == 2" +def test_excinfo_for_later_striptext_is_captured_in_record() -> None: + """The text to strip is part of the record, so an ExceptionInfo that was + created with ``for_later()`` behaves like any other one (#12175).""" + x = 1 + with pytest.raises(AssertionError) as excinfo: + assert x == 2 + record = excinfo.record() + assert record.striptext == "AssertionError: " + assert record.exconly(tryshort=True) == excinfo.exconly(tryshort=True) + assert record.exconly() == excinfo.exconly() + + with pytest.raises(AssertionError) as excinfo: + raise AssertionError("message") + record = excinfo.record() + assert record.striptext == "" + assert record.exconly(tryshort=True) == "AssertionError: message" + + def test_excinfo_exconly(): with pytest.raises(ValueError) as excinfo: h() diff --git a/testing/code/test_matching.py b/testing/code/test_matching.py new file mode 100644 index 00000000000..a69ca41188a --- /dev/null +++ b/testing/code/test_matching.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +import io +import re +import sys + +from _pytest._code import ExceptionInfo +from _pytest._code.matching import check_regex +from _pytest._code.matching import check_type +from _pytest._code.matching import CheckFailure +from _pytest._code.matching import FlattenHint +from _pytest._code.matching import MatchFailure +from _pytest._code.matching import Nested +from _pytest._code.matching import NoException +from _pytest._code.matching import NotAnExceptionGroup +from _pytest._code.matching import PairingDetails +from _pytest._code.matching import PartiallyMatched +from _pytest._code.matching import RegexMismatch +from _pytest._code.matching import TooFewExceptions +from _pytest._code.matching import TypeMismatch +from _pytest._code.matching import UnexpectedExceptions +import pytest +from pytest import RaisesExc +from pytest import RaisesGroup + + +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + + +def render_rich(renderable: object, width: int = 100) -> str: + from rich.console import Console + + buf = io.StringIO() + Console(file=buf, width=width, force_terminal=False, color_system=None).print( + renderable + ) + return buf.getvalue() + + +class TestCheckType: + def test_match_and_accept_all(self) -> None: + assert check_type(ValueError, ValueError()) is None + assert check_type((KeyError, ValueError), ValueError()) is None + # No expected type means any exception is fine. + assert check_type(None, ValueError()) is None + assert check_type((), ValueError()) is None + + def test_mismatch(self) -> None: + failure = check_type(KeyError, ValueError()) + assert failure == TypeMismatch(actual="ValueError", expected="KeyError") + assert failure.to_text() == "`ValueError()` is not an instance of `KeyError`" + + def test_unexpected_nested_group(self) -> None: + failure = check_type(ValueError, ExceptionGroup("", [KeyError()])) + assert failure is not None + assert failure.unexpected_nested_group + assert ( + failure.to_text() + == "Unexpected nested `ExceptionGroup()`, expected `ValueError`" + ) + + +class TestCheckRegex: + def test_match(self) -> None: + assert check_regex("foo", "a foo b") is None + assert check_regex(re.compile("FOO", re.IGNORECASE), "foo") is None + + def test_mismatch_text(self) -> None: + failure = check_regex("foo", "bar") + assert isinstance(failure, RegexMismatch) + assert failure.to_text() == ( + "Regex pattern did not match.\n" + " Expected regex: 'foo'\n" + " Actual message: 'bar'" + ) + + def test_flagless_compiled_pattern_displays_like_str(self) -> None: + assert check_regex(re.compile("foo"), "bar") == check_regex("foo", "bar") + flagged = check_regex(re.compile("foo", re.IGNORECASE), "bar") + assert flagged is not None + assert "re.compile('foo', re.IGNORECASE)" in flagged.to_text() + + def test_escape_hint(self) -> None: + failure = check_regex("a(b)", "a(b)") + assert failure is not None + assert failure.escape_hint + assert failure.to_text().endswith("\n Did you mean to `re.escape()` the regex?") + + def test_subject(self) -> None: + failure = check_regex("foo", "bar", subject="the `ExceptionGroup()`") + assert failure is not None + assert failure.to_text().startswith( + "Regex pattern did not match the `ExceptionGroup()`.\n" + ) + + def test_diff_for_fully_escaped_literal(self) -> None: + failure = check_regex("^foo$", "fob", rawmatch="foo") + assert failure is not None + assert failure.diff is not None + assert failure.to_text() == failure.diff + assert "foo" in failure.diff + assert "fob" in failure.diff + + +class TestExceptionInfoSharesImplementation: + def test_match_message_equals_raisesexc_message(self) -> None: + try: + raise ValueError("bar") + except ValueError as e: + exc = e + excinfo = ExceptionInfo.from_exception(e) + raiser = RaisesExc(ValueError, match="foo") + assert not raiser.matches(exc) + with pytest.raises(AssertionError) as ei: + excinfo.match("foo") + assert str(ei.value) == raiser.fail_reason + + def test_match_with_compiled_pattern(self) -> None: + try: + raise ValueError("bar") + except ValueError as e: + excinfo = ExceptionInfo.from_exception(e) + with pytest.raises(AssertionError) as ei: + excinfo.match(re.compile("foo")) + assert "Expected regex: 'foo'" in str(ei.value) + + def test_group_contains_regex(self) -> None: + try: + raise ExceptionGroup("g", [ValueError("a foo"), KeyError("bar")]) + except ExceptionGroup as e: + excinfo = ExceptionInfo.from_exception(e) + assert excinfo.group_contains(ValueError, match="foo") + assert not excinfo.group_contains(ValueError, match="bar") + assert excinfo.group_contains(KeyError, match="bar") + + +class TestRaiserFailures: + def test_raisesexc_failure_kinds(self) -> None: + raiser = RaisesExc(ValueError, match="foo", check=lambda e: False) + + def failure() -> MatchFailure | None: + # A function, so that mypy does not narrow ``raiser._failure`` across calls. + return raiser._failure + + assert failure() is None + assert raiser.fail_reason is None + + assert not raiser.matches(None) + assert failure() == NoException() + + assert not raiser.matches(KeyError()) + assert isinstance(failure(), TypeMismatch) + + assert not raiser.matches(ValueError("bar")) + assert isinstance(failure(), RegexMismatch) + + assert not raiser.matches(ValueError("foo")) + last = failure() + assert isinstance(last, CheckFailure) + assert raiser.fail_reason == last.to_text() + + def test_failure_is_cleared_on_success(self) -> None: + raiser = RaisesExc(ValueError) + assert not raiser.matches(KeyError()) + assert raiser.fail_reason is not None + assert raiser.matches(ValueError()) + assert raiser._failure is None + assert raiser.fail_reason is None + + def test_group_not_a_group(self) -> None: + raiser = RaisesGroup(ValueError) + assert not raiser.matches(ValueError()) + assert raiser._failure == NotAnExceptionGroup( + "ValueError", would_match_unwrapped=True + ) + + def test_group_too_few_and_unexpected(self) -> None: + raiser = RaisesGroup(ValueError, TypeError) + assert not raiser.matches(ExceptionGroup("", [ValueError()])) + assert raiser._failure == TooFewExceptions(matched=1, expected=("TypeError",)) + + raiser = RaisesGroup(ValueError) + assert not raiser.matches(ExceptionGroup("", [ValueError(), KeyError()])) + assert isinstance(raiser._failure, UnexpectedExceptions) + assert raiser._failure.matched == 1 + + def test_group_single_pair_is_nested_failure(self) -> None: + raiser = RaisesGroup(RaisesExc(ValueError, match="foo")) + assert not raiser.matches(ExceptionGroup("", [ValueError("bar")])) + failure = raiser._failure + assert isinstance(failure, Nested) + assert failure.label == "RaisesExc(ValueError, match='foo')" + assert isinstance(failure.reason, RegexMismatch) + + def test_group_partially_matched(self) -> None: + raiser = RaisesGroup(ValueError, RaisesExc(TypeError, match="foo")) + assert not raiser.matches( + ExceptionGroup("", [ValueError(), TypeError("bar")]), + ) + failure = raiser._failure + assert isinstance(failure, PartiallyMatched) + assert failure.matched == 1 + assert isinstance(failure.inner, Nested) + + def test_group_pairing_details(self) -> None: + raiser = RaisesGroup(ValueError, TypeError) + assert not raiser.matches(ExceptionGroup("", [KeyError(), IndexError()])) + failure = raiser._failure + assert isinstance(failure, PairingDetails) + assert failure.problem == "both" + assert [e.label for e in failure.unmatched_expected] == [ + "ValueError", + "TypeError", + ] + assert [a.actual for a in failure.unmatched_actual] == [ + "KeyError()", + "IndexError()", + ] + assert all( + isinstance(entry, TypeMismatch) + for a in failure.unmatched_actual + for entry in a.entries + ) + + def test_group_flatten_hint(self) -> None: + raiser = RaisesGroup(ValueError) + inner = ExceptionGroup("", [ValueError()]) + assert not raiser.matches(ExceptionGroup("", [inner])) + assert isinstance(raiser._failure, FlattenHint) + + def test_group_check_failure_on_group(self) -> None: + raiser = RaisesGroup(ValueError, check=lambda e: isinstance(e, ValueError)) + assert not raiser.matches(ExceptionGroup("", [ValueError()])) + failure = raiser._failure + assert isinstance(failure, CheckFailure) + assert failure.on_type == "ExceptionGroup" + # The check would have passed for the lone sub-exception. + assert failure.suggestion == ("ValueError", "ValueError") + + raiser = RaisesGroup(ValueError, check=lambda e: False) + assert not raiser.matches(ExceptionGroup("", [ValueError()])) + assert isinstance(raiser._failure, CheckFailure) + assert raiser._failure.suggestion is None + + +class TestRichRendering: + def test_leaf_failure_renders_its_text(self) -> None: + failure = check_regex("foo", "bar") + assert failure is not None + out = render_rich(failure) + assert "Regex pattern did not match." in out + assert "Expected regex: 'foo'" in out + + def test_pairing_details_is_a_tree(self) -> None: + from rich.tree import Tree + + raiser = RaisesGroup(ValueError, RaisesExc(TypeError, match="foo")) + assert not raiser.matches( + ExceptionGroup("", [KeyError(), TypeError("bar"), IndexError()]) + ) + failure = raiser._failure + assert isinstance(failure, MatchFailure) + assert isinstance(failure.to_rich(), Tree) + out = render_rich(failure) + assert "Expected exceptions without a match" in out + assert "Raised exceptions without a match" in out + assert "ValueError" in out + assert "KeyError()" in out + assert "RaisesExc(TypeError, match='foo')" in out + + def test_nested_failure_is_a_tree(self) -> None: + from rich.tree import Tree + + raiser = RaisesGroup(RaisesExc(ValueError, match="foo")) + assert not raiser.matches(ExceptionGroup("", [ValueError("bar")])) + failure = raiser._failure + assert isinstance(failure, Nested) + assert isinstance(failure.to_rich(), Tree) + out = render_rich(failure) + assert "RaisesExc(ValueError, match='foo')" in out + assert "Regex pattern did not match." in out From b090438a4113d95d93a71929331872be4ec4ccfe Mon Sep 17 00:00:00 2001 From: Oleksii Date: Tue, 3 Nov 2026 14:30:35 +0200 Subject: [PATCH 07/12] Render PEP 657 carets across traceback styles (Step 7 of #15072) - Use co_positions() range in Python >= 3.11 when slicing statement range, keeping getstatementrange_ast fallback for Python 3.10 and dynamic frames without line positions. - Capture exact line and column ranges in FrameRecord eagerly at capture time. - Update PytestTraceback to render PEP 657 carets across long, short, auto, and native styles, and underline error spans in rich style. - Strip trailing newlines and handle multibyte UTF-8 offsets in _highlight_arrows_for_line. - Add comprehensive TestPep657Carets suite in testing/code/test_excinfo.py covering position capture, serialization round-trip, all styles, multibyte UTF-8, multiline expressions, and Python 3.10 position-free fallback. --- src/_pytest/_code/code.py | 43 ++--- src/_pytest/_code/render.py | 12 +- src/_pytest/raises.py | 2 +- testing/code/test_excinfo.py | 295 +++++++++++++++++++++++++++++++++++ 4 files changed, 328 insertions(+), 24 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index f1aba927011..24204922cf4 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -20,6 +20,7 @@ from types import FrameType from types import TracebackType from typing import Any +from typing import cast from typing import ClassVar from typing import Final from typing import final @@ -299,21 +300,24 @@ def getsource( source = self.frame.code.fullsource if source is None: return None - key = astnode = None - if astcache is not None: - key = self.frame.code.path - if key is not None: - astnode = astcache.get(key, None) start = self.getfirstlinesource() - try: - astnode, _, end = getstatementrange_ast( - self.lineno, source, astnode=astnode - ) - except SyntaxError: - end = self.lineno + 1 + if sys.version_info >= (3, 11) and self.end_lineno_relative is not None: + end = max(start + self.end_lineno_relative + 1, self.lineno + 1) else: - if key is not None and astcache is not None: - astcache[key] = astnode + key = astnode = None + if astcache is not None: + key = self.frame.code.path + if key is not None: + astnode = astcache.get(key, None) + try: + astnode, _, end = getstatementrange_ast( + self.lineno, source, astnode=astnode + ) + except SyntaxError: + end = self.lineno + 1 + else: + if key is not None and astcache is not None: + astcache[key] = astnode return source[start:end] source = property(getsource) @@ -676,9 +680,10 @@ def _get_single_subexc( ) -> BaseException | None: if len(eg.exceptions) != 1: return None - if isinstance(e := eg.exceptions[0], BaseExceptionGroup): - return _get_single_subexc(e) - return e + first = eg.exceptions[0] + if isinstance(first, BaseExceptionGroup): + return _get_single_subexc(first) + return cast(BaseException, first) if ( tryshort @@ -947,6 +952,8 @@ def _record_frame( lineno = entry.lineno + 1 if sys.version_info >= (3, 11): fs = entry.get_python_framesummary() + if fs.lineno is not None: + lineno = fs.lineno end_lineno = fs.end_lineno colno = fs.colno end_colno = fs.end_colno @@ -1901,9 +1908,9 @@ def getfslineno(obj: object) -> tuple[str | Path, int]: return code.path, code.firstlineno -def _byte_offset_to_character_offset(str, offset): +def _byte_offset_to_character_offset(s: str, offset: int) -> int: """Converts a byte based offset in a string to a code-point.""" - as_utf8 = str.encode("utf-8") + as_utf8 = s.encode("utf-8") return len(as_utf8[:offset].decode("utf-8", errors="replace")) diff --git a/src/_pytest/_code/render.py b/src/_pytest/_code/render.py index addd15c0233..3e326736e63 100644 --- a/src/_pytest/_code/render.py +++ b/src/_pytest/_code/render.py @@ -66,15 +66,17 @@ def _highlight_arrows_for_line( if colno is None or end_colno is None: return [] - num_stripped_chars = len(raw_line) - len(line) - start_char_offset = _byte_offset_to_character_offset(raw_line, colno) - end_char_offset = _byte_offset_to_character_offset(raw_line, end_colno) + raw_clean = raw_line.rstrip("\r\n") + line_clean = line.rstrip("\r\n") + num_stripped_chars = len(raw_clean) - len(line_clean) + start_char_offset = _byte_offset_to_character_offset(raw_clean, colno) + end_char_offset = _byte_offset_to_character_offset(raw_clean, end_colno) num_carets = end_char_offset - start_char_offset - if num_carets >= len(line.strip()): + if num_carets <= 0 or num_carets >= len(line_clean.strip()): return [] highlights = " " - highlights += " " * (start_char_offset - num_stripped_chars) + highlights += " " * max(0, start_char_offset - num_stripped_chars) highlights += "^" * num_carets return [highlights] diff --git a/src/_pytest/raises.py b/src/_pytest/raises.py index 6e67a1b9d46..6c023a927fc 100644 --- a/src/_pytest/raises.py +++ b/src/_pytest/raises.py @@ -1136,7 +1136,7 @@ def matches( len(actual_exceptions) == len(self.expected_exceptions) == 1 and isinstance(expected := self.expected_exceptions[0], type) # we explicitly break typing here :) - and self._check_check(actual_exceptions[0]) is None # type: ignore[arg-type] + and self._check_check(actual_exceptions[0]) is None ): suggestion = (self._repr_expected(expected), expected.__name__) self._failure = dataclasses.replace( diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 196de0e399c..87fa3970266 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -2329,3 +2329,298 @@ def test_check_error_notes_failure( with pytest.raises(AssertionError): with pytest.raises(type(error), match=match): raise error + + +class TestPep657Carets: + """Tests for PEP 657 caret rendering across styles and Python 3.10 fallback.""" + + @pytest.fixture + def importasmod(self, tmp_path: Path, _sys_snapshot): + def importasmod(source: str): + source = textwrap.dedent(source) + modpath = tmp_path.joinpath("mod.py") + tmp_path.joinpath("__init__.py").touch() + modpath.write_text(source, encoding="utf-8") + importlib.invalidate_caches() + return import_path( + modpath, root=tmp_path, consider_namespace_packages=False + ) + + return importasmod + + def test_record_captures_positions(self, importasmod) -> None: + from _pytest._code.record import FrameRecord + + mod = importasmod( + """ + def div_by_zero(): + return 1 / 0 + """ + ) + with pytest.raises(ZeroDivisionError) as excinfo: + mod.div_by_zero() + + rec = excinfo.record() + frame = rec.frames[-1] + assert frame.function.endswith("div_by_zero") + if sys.version_info >= (3, 11): + assert frame.lineno == 3 + assert frame.end_lineno == 3 + assert frame.colno is not None + assert frame.end_colno is not None + assert frame.end_colno > frame.colno + else: + assert frame.end_lineno is None + assert frame.colno is None + assert frame.end_colno is None + + # Round-trip serialization preserves positions + data = frame.to_dict() + restored = FrameRecord.from_dict(data) + assert restored.lineno == frame.lineno + assert restored.end_lineno == frame.end_lineno + assert restored.colno == frame.colno + assert restored.end_colno == frame.end_colno + + @pytest.mark.skipif( + "sys.version_info < (3, 11)", + reason="Column level traceback info added in python 3.11", + ) + def test_carets_short_style(self, importasmod) -> None: + from _pytest._code.render import PytestTraceback + + mod = importasmod( + """ + def div_by_zero(): + return 1 / 0 + def func1(): + return 42 + div_by_zero() + def entry(): + func1() + """ + ) + with pytest.raises(ZeroDivisionError) as excinfo: + mod.entry() + + tb = PytestTraceback(record=excinfo.record(), style="short") + rep = tb.to_repr() + entries = rep.reprtraceback.reprentries + + # entry() call frame: full-line expression suppresses carets + assert len(entries[-3].lines) == 1 + assert entries[-3].lines[0] == " func1()" + + # func1() frame: partial-line expression has carets + assert len(entries[-2].lines) == 2 + assert entries[-2].lines[0] == " return 42 + div_by_zero()" + assert entries[-2].lines[1] == " ^^^^^^^^^^^^^" + + # div_by_zero() frame: partial-line expression has carets + assert len(entries[-1].lines) == 3 + assert entries[-1].lines[0] == " return 1 / 0" + assert entries[-1].lines[1] == " ^^^^^" + assert entries[-1].lines[2].startswith("E ZeroDivisionError") + + @pytest.mark.skipif( + "sys.version_info < (3, 11)", + reason="Column level traceback info added in python 3.11", + ) + def test_carets_long_and_auto_style(self, importasmod) -> None: + from _pytest._code.render import PytestTraceback + + mod = importasmod( + """ + def div_by_zero(): + return 1 / 0 + def func1(): + return 42 + div_by_zero() + def entry(): + func1() + """ + ) + with pytest.raises(ZeroDivisionError) as excinfo: + mod.entry() + + # In long style: intermediate frames show full function with carets + tb_long = PytestTraceback(record=excinfo.record(), style="long") + rep_long = tb_long.to_repr() + entries_long = rep_long.reprtraceback.reprentries + + func1_lines = entries_long[-2].lines + div_idx = next( + i + for i, line_text in enumerate(func1_lines) + if "return 42 + div_by_zero()" in line_text + ) + assert func1_lines[div_idx] == "> return 42 + div_by_zero()" + assert func1_lines[div_idx + 1] == " ^^^^^^^^^^^^^" + + div_lines = entries_long[-1].lines + err_idx = next( + i for i, line_text in enumerate(div_lines) if "return 1 / 0" in line_text + ) + assert div_lines[err_idx] == "> return 1 / 0" + assert div_lines[err_idx + 1] == " ^^^^^" + + # In auto style: intermediate frame is rendered in short style with carets, + # and last frame in long style with carets + tb_auto = PytestTraceback(record=excinfo.record(), style="auto") + rep_auto = tb_auto.to_repr() + entries_auto = rep_auto.reprtraceback.reprentries + + auto_func1_lines = entries_auto[-2].lines + assert auto_func1_lines[0] == " return 42 + div_by_zero()" + assert auto_func1_lines[1] == " ^^^^^^^^^^^^^" + + auto_div_lines = entries_auto[-1].lines + auto_err_idx = next( + i + for i, line_text in enumerate(auto_div_lines) + if "return 1 / 0" in line_text + ) + assert auto_div_lines[auto_err_idx] == "> return 1 / 0" + assert auto_div_lines[auto_err_idx + 1] == " ^^^^^" + + @pytest.mark.skipif( + "sys.version_info < (3, 11)", + reason="Column level traceback info added in python 3.11", + ) + def test_carets_native_style(self, importasmod) -> None: + from _pytest._code.render import PytestTraceback + + mod = importasmod( + """ + def fail(): + return 1 / 0 + """ + ) + with pytest.raises(ZeroDivisionError) as excinfo: + mod.fail() + + tb = PytestTraceback(record=excinfo.record(), style="native") + rep = tb.to_repr() + lines = rep.reprtraceback.reprentries[0].lines + caret_line = next(line_text for line_text in lines if "^^^^^" in line_text) + assert "^^^^^" in caret_line + + @pytest.mark.skipif( + "sys.version_info < (3, 11)", + reason="Column level traceback info added in python 3.11", + ) + def test_carets_rich_style(self, importasmod) -> None: + from rich.console import Console + + from _pytest._code.render import PytestTraceback + + mod = importasmod( + """ + def div_by_zero(): + return 1 / 0 + def func1(): + return 42 + div_by_zero() + def entry(): + func1() + """ + ) + with pytest.raises(ZeroDivisionError) as excinfo: + mod.entry() + + rec = excinfo.record() + rich_trace = rec.to_rich_trace() + last_frame = rich_trace.stacks[0].frames[-1] + assert last_frame.last_instruction is not None + (l1, c1), (l2, c2) = last_frame.last_instruction + assert l1 == l2 + assert c2 > c1 + + tb = PytestTraceback(record=rec, style="rich") + console = Console(width=100, force_terminal=True, color_system="standard") + with console.capture() as capture: + console.print(tb) + output = capture.get() + assert "ZeroDivisionError" in output + assert "\x1b[1;4m" in output or "\x1b[4m" in output + + @pytest.mark.skipif( + "sys.version_info < (3, 11)", + reason="Column level traceback info added in python 3.11", + ) + def test_carets_utf8_multibyte(self, importasmod) -> None: + from _pytest._code.render import PytestTraceback + + mod = importasmod( + """ + def foo(): + x = "日本語" + div_by_zero() + """ + ) + with pytest.raises(NameError) as excinfo: + mod.foo() + + tb = PytestTraceback(record=excinfo.record(), style="short") + rep = tb.to_repr() + lines = rep.reprtraceback.reprentries[-1].lines + assert lines[0] == ' x = "日本語" + div_by_zero()' + assert lines[1] == " ^^^^^^^^^^^" + + def test_carets_fallback_when_positions_missing(self, importasmod) -> None: + import dataclasses + + from _pytest._code.render import PytestTraceback + + mod = importasmod( + """ + def fail(): + return 42 + div_by_zero() + """ + ) + with pytest.raises(NameError) as excinfo: + mod.fail() + + rec = excinfo.record() + frames_no_cols = tuple( + dataclasses.replace(f, colno=None, end_colno=None, end_lineno=None) + for f in rec.frames + ) + rec_no_cols = dataclasses.replace(rec, frames=frames_no_cols) + + tb_short = PytestTraceback(record=rec_no_cols, style="short") + rep_short = tb_short.to_repr() + assert not any( + "^" in line_text + for line_text in rep_short.reprtraceback.reprentries[-1].lines + ) + + tb_long = PytestTraceback(record=rec_no_cols, style="long") + rep_long = tb_long.to_repr() + assert not any( + "^" in line_text + for line_text in rep_long.reprtraceback.reprentries[-1].lines + ) + + @pytest.mark.skipif( + "sys.version_info < (3, 11)", + reason="Column level traceback info added in python 3.11", + ) + def test_carets_multiline_expression_no_carets(self, importasmod) -> None: + from _pytest._code.render import PytestTraceback + + mod = importasmod( + """ + def fail(): + return 1 / ( + 0 + ) + """ + ) + with pytest.raises(ZeroDivisionError) as excinfo: + mod.fail() + + rec = excinfo.record() + f = rec.frames[-1] + assert f.lineno != f.end_lineno + + tb = PytestTraceback(record=rec, style="short") + rep = tb.to_repr() + lines = rep.reprtraceback.reprentries[-1].lines + assert not any("^" in line_text for line_text in lines) From 36a1c7d012c3e9767e069068058814c5cdbab078 Mon Sep 17 00:00:00 2001 From: Oleksii Date: Tue, 3 Nov 2026 16:40:59 +0200 Subject: [PATCH 08/12] Document exception data model as public API and deprecate legacy Repr classes and getrepr() (Step 8 of #15072) - Export ExceptionRecord, FrameRecord, Location, SyntaxErrorDetails, FailureRecord, and PytestTraceback in top-level pytest namespace (__all__). - Add autoclass documentation for data model classes in doc/en/reference/reference.rst. - Deprecate ExceptionInfo.getrepr() and its formatting parameters in favor of ExceptionInfo.record() and PytestTraceback. - Deprecate legacy Repr* classes (TerminalRepr, ExceptionRepr, ExceptionChainRepr, ReprExceptionInfo, ReprTraceback, ReprTracebackNative, ReprEntryNative, ReprEntry, ReprFileLocation, ReprLocals, ReprFuncArgs) and compatibility properties on ExceptionRecord (reprcrash, reprtraceback, chain). - Document deprecations and migration guides in doc/en/deprecations.rst. - Update internal callers to pass _warn_deprecated=False or _ispytest=True to avoid spurious deprecation warnings during test execution. - Add comprehensive deprecation and public API tests in testing/code/test_record.py. --- doc/en/deprecations.rst | 78 ++++++++++++++ doc/en/reference/reference.rst | 42 ++++++++ pyproject.toml | 4 + src/_pytest/_code/code.py | 133 +++++++++++++++++++++--- src/_pytest/_code/record.py | 33 +++++- src/_pytest/_code/render.py | 42 +++++--- src/_pytest/config/__init__.py | 9 +- src/_pytest/deprecated.py | 21 ++++ src/_pytest/nodes.py | 1 + src/_pytest/python.py | 4 +- src/_pytest/reports.py | 16 +-- src/_pytest/terminal.py | 4 +- src/pytest/__init__.py | 12 +++ testing/code/test_record.py | 183 ++++++++++++++++++++++++++++++++- 14 files changed, 535 insertions(+), 47 deletions(-) diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index 328411a9fcc..7ee654fcfba 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -14,6 +14,84 @@ Deprecated Features Below is a complete list of all pytest features which are considered deprecated. Using those features will issue :class:`~pytest.PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters `. +.. _exceptioninfo-getrepr-deprecated: + +``ExceptionInfo.getrepr()`` and formatting parameters +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 10.0 + +``ExceptionInfo.getrepr()`` and its traceback formatting parameters (``showlocals``, +``style``, ``abspath``, ``tbfilter``, ``funcargs``, ``truncate_locals``, +``truncate_args``, ``chain``) are deprecated. + +Previously, ``ExceptionInfo.getrepr()`` combined exception traceback extraction, +filtering, formatting, and string rendering into legacy ``Repr*`` objects. + +Instead, separate capture from rendering: + +1. Obtain a clean, immutable data model via :meth:`ExceptionInfo.record() `: + + .. code-block:: python + + record = excinfo.record() + +2. Render it using :class:`pytest.PytestTraceback`: + + .. code-block:: python + + renderer = pytest.PytestTraceback(record, style="auto") + renderer.toterminal(terminalwriter) + +For legacy plugin compatibility, :meth:`pytest.PytestTraceback.to_repr` can still +convert an :class:`pytest.ExceptionRecord` into a legacy representation. + + +.. _repr-classes-deprecated: + +Legacy ``Repr*`` classes +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 10.0 + +The legacy terminal representation classes: + +* ``TerminalRepr`` +* ``ExceptionRepr`` +* ``ExceptionChainRepr`` +* ``ReprExceptionInfo`` +* ``ReprTraceback`` +* ``ReprTracebackNative`` +* ``ReprEntryNative`` +* ``ReprEntry`` +* ``ReprFileLocation`` +* ``ReprLocals`` +* ``ReprFuncArgs`` + +are deprecated. + +Migrate to the new data model and renderer architecture: + +* Use :class:`pytest.ExceptionRecord` and :class:`pytest.FrameRecord` for inspecting exception information. +* Use :class:`pytest.Location` instead of ``ReprFileLocation``. +* Use the :class:`pytest.FailureRecord` protocol when implementing custom failure representations. +* Use :class:`pytest.PytestTraceback` for terminal and text rendering. + + +.. _exceptionrecord-compat-properties: + +Compatibility properties on ``ExceptionRecord`` (``reprcrash``, ``reprtraceback``, ``chain``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 10.0 + +The properties ``ExceptionRecord.reprcrash``, ``ExceptionRecord.reprtraceback``, and +``ExceptionRecord.chain`` are deprecated: + +* Use :attr:`ExceptionRecord.crash ` instead of ``reprcrash``. +* Use :meth:`ExceptionRecord.iter_chain() ` or :class:`pytest.PytestTraceback` instead of ``chain``. +* Use :class:`pytest.PytestTraceback` to render tracebacks rather than accessing ``reprtraceback``. + .. _terminalreporter-write-line-bytes: Passing ``bytes`` to ``TerminalReporter.write_line()`` diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index b524a2395a1..fcb87e4d8f6 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -996,6 +996,48 @@ ExceptionInfo :members: +ExceptionRecord +~~~~~~~~~~~~~~~ + +.. autoclass:: pytest.ExceptionRecord() + :members: + + +FailureRecord +~~~~~~~~~~~~~ + +.. autoclass:: pytest.FailureRecord() + :members: + + +FrameRecord +~~~~~~~~~~~ + +.. autoclass:: pytest.FrameRecord() + :members: + + +Location +~~~~~~~~ + +.. autoclass:: pytest.Location() + :members: + + +PytestTraceback +~~~~~~~~~~~~~~~ + +.. autoclass:: pytest.PytestTraceback() + :members: + + +SyntaxErrorDetails +~~~~~~~~~~~~~~~~~~ + +.. autoclass:: pytest.SyntaxErrorDetails() + :members: + + ExitCode ~~~~~~~~ diff --git a/pyproject.toml b/pyproject.toml index 8e68acdaca4..8d1046f037d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -480,6 +480,10 @@ filterwarnings = [ # https://github.com/pytest-dev/pytest/issues/2366 # https://github.com/pytest-dev/pytest/pull/13057 'default::pytest.PytestFDWarning', + # Step 8 deprecations (#15072): allow during test suite runs + 'default:(?s).*ExceptionInfo\.getrepr.*:pytest.PytestDeprecationWarning', + 'default:(?s).*is deprecated and will be removed in a future release.*:pytest.PytestDeprecationWarning', + 'default:(?s).*ExceptionRecord\..*is deprecated and will be removed in a future release.*:pytest.PytestDeprecationWarning', # https://github.com/pexpect/pexpect/issues/827 # CPython emits this from os.forkpty() with stacklevel=1, so it is attributed # to stdlib pty (caller of os.forkpty via pty.fork), not ptyprocess. Match on diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 24204922cf4..b738f81ab7a 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -20,7 +20,6 @@ from types import FrameType from types import TracebackType from typing import Any -from typing import cast from typing import ClassVar from typing import Final from typing import final @@ -30,6 +29,7 @@ from typing import SupportsIndex from typing import TypeAlias from typing import TypeVar +from warnings import warn import pluggy @@ -683,7 +683,7 @@ def _get_single_subexc( first = eg.exceptions[0] if isinstance(first, BaseExceptionGroup): return _get_single_subexc(first) - return cast(BaseException, first) + return first if ( tryshort @@ -728,9 +728,16 @@ def getrepr( truncate_locals: bool = True, truncate_args: bool = True, chain: bool = True, + *, + _warn_deprecated: bool = True, ) -> ReprExceptionInfo | ExceptionChainRepr: """Return str()able representation of this exception info. + .. deprecated:: 10.0 + Use :meth:`ExceptionInfo.record` to obtain an :class:`pytest.ExceptionRecord` + and :class:`pytest.PytestTraceback` to render it. + See :ref:`exceptioninfo-getrepr-deprecated`. + The formatting parameters are ineffective if ``style="native"``, since in this case the native formatting is used. @@ -769,6 +776,11 @@ def getrepr( Added the ``chain`` parameter. """ + if _warn_deprecated: + from _pytest.deprecated import GETREPR_DEPRECATED + + warn(GETREPR_DEPRECATED.format(), stacklevel=2) + from _pytest._code.render import PytestTraceback record = self.record( @@ -1580,7 +1592,23 @@ 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.""" + themselves to a terminal. + + .. deprecated:: 10.0 + Use the :class:`pytest.FailureRecord` protocol instead. + See :ref:`repr-classes-deprecated`. + """ + + _ispytest: bool = dataclasses.field(default=False, repr=False, kw_only=True) + + def __post_init__(self) -> None: + if not self._ispytest: + from _pytest.deprecated import REPR_CLASSES_DEPRECATED + + warn( + REPR_CLASSES_DEPRECATED.format(cls=self.__class__.__name__), + stacklevel=3, + ) def __str__(self) -> str: # FYI this is called from pytest-xdist's serialization of exception @@ -1601,6 +1629,10 @@ def toterminal(self, tw: TerminalWriter) -> None: class ExceptionRepr(TerminalRepr): """Base class for exception terminal representations. + .. deprecated:: 10.0 + Use :class:`pytest.ExceptionRecord` and :class:`pytest.PytestTraceback` instead. + See :ref:`repr-classes-deprecated`. + The representation generally contains: - The exception traceback (`reprtraceback`) - The exception message and location (`reprcrash`) @@ -1629,19 +1661,34 @@ 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").""" + exception was the direct cause of the following exception"). + + .. deprecated:: 10.0 + Use :class:`pytest.ExceptionRecord` and :class:`pytest.PytestTraceback` instead. + See :ref:`repr-classes-deprecated`. + """ chain: Sequence[tuple[ReprTraceback, ReprFileLocation | None, str | None]] def __init__( self, chain: Sequence[tuple[ReprTraceback, ReprFileLocation | None, str | None]], + *, + _ispytest: bool = False, ) -> None: + if not _ispytest: + from _pytest.deprecated import REPR_CLASSES_DEPRECATED + + warn( + REPR_CLASSES_DEPRECATED.format(cls=self.__class__.__name__), + stacklevel=2, + ) # reprcrash and reprtraceback of the outermost (the newest) exception # in the chain. super().__init__( reprtraceback=chain[-1][0], reprcrash=chain[-1][1], + _ispytest=True, ) self.chain = chain @@ -1658,7 +1705,12 @@ def toterminal(self, tw: TerminalWriter) -> None: class ReprExceptionInfo(ExceptionRepr): """A single exception with optional extra details (function arguments, function locals, file location) and possible extra line and sections emitted - at the end.""" + at the end. + + .. deprecated:: 10.0 + Use :class:`pytest.ExceptionRecord` and :class:`pytest.PytestTraceback` instead. + See :ref:`repr-classes-deprecated`. + """ def toterminal(self, tw: TerminalWriter) -> None: self.reprtraceback.toterminal(tw) @@ -1668,7 +1720,12 @@ 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.""" + locals, file location) and possible extra line emitted at the end. + + .. deprecated:: 10.0 + Use :class:`pytest.FrameRecord` and :class:`pytest.PytestTraceback` instead. + See :ref:`repr-classes-deprecated`. + """ reprentries: Sequence[ReprEntry | ReprEntryNative] extraline: str | None @@ -1696,20 +1753,44 @@ def toterminal(self, tw: TerminalWriter) -> None: class ReprTracebackNative(ReprTraceback): """A traceback in native style. + .. deprecated:: 10.0 + Use :class:`pytest.ExceptionRecord` with style="native" in :class:`pytest.PytestTraceback` instead. + See :ref:`repr-classes-deprecated`. + 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 = extraline - self.style = "native" + def __init__( + self, + tblines: Sequence[str], + *, + extraline: str | None = None, + _ispytest: bool = False, + ) -> None: + if not _ispytest: + from _pytest.deprecated import REPR_CLASSES_DEPRECATED + + warn( + REPR_CLASSES_DEPRECATED.format(cls=self.__class__.__name__), + stacklevel=2, + ) + super().__init__( + reprentries=[ReprEntryNative(tblines, _ispytest=True)], + extraline=extraline, + style="native", + _ispytest=True, + ) @dataclasses.dataclass(eq=False) class ReprEntryNative(TerminalRepr): """An entry in a traceback in native style. + .. deprecated:: 10.0 + Use :class:`pytest.FrameRecord` instead. + See :ref:`repr-classes-deprecated`. + 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 @@ -1728,7 +1809,12 @@ 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).""" + function locals, source snippet, function's file and line). + + .. deprecated:: 10.0 + Use :class:`pytest.FrameRecord` instead. + See :ref:`repr-classes-deprecated`. + """ lines: Sequence[str] reprfuncargs: ReprFuncArgs | None @@ -1819,6 +1905,10 @@ class ReprFileLocation(TerminalRepr): """A message at a file location, using the `:: ` format that most editors understand. + .. deprecated:: 10.0 + Use :class:`pytest.Location` instead. + See :ref:`repr-classes-deprecated`. + Only the first line of the message is emitted. """ @@ -1828,6 +1918,13 @@ class ReprFileLocation(TerminalRepr): def __post_init__(self) -> None: self.path = str(self.path) + if not self._ispytest: + from _pytest.deprecated import REPR_CLASSES_DEPRECATED + + warn( + REPR_CLASSES_DEPRECATED.format(cls=self.__class__.__name__), + stacklevel=3, + ) def toterminal(self, tw: TerminalWriter) -> None: msg = self.message @@ -1840,7 +1937,12 @@ def toterminal(self, tw: TerminalWriter) -> None: @dataclasses.dataclass(eq=False) class ReprLocals(TerminalRepr): - """Function local variables (pre-formatted).""" + """Function local variables (pre-formatted). + + .. deprecated:: 10.0 + Use :class:`pytest.FrameRecord.locals` instead. + See :ref:`repr-classes-deprecated`. + """ lines: Sequence[str] @@ -1851,7 +1953,12 @@ def toterminal(self, tw: TerminalWriter, indent: str = "") -> None: @dataclasses.dataclass(eq=False) class ReprFuncArgs(TerminalRepr): - """Function arguments - name = value, comma separated.""" + """Function arguments - name = value, comma separated. + + .. deprecated:: 10.0 + Use :class:`pytest.FrameRecord.args` instead. + See :ref:`repr-classes-deprecated`. + """ args: Sequence[tuple[str, object]] diff --git a/src/_pytest/_code/record.py b/src/_pytest/_code/record.py index aa5112afd13..3aabc113872 100644 --- a/src/_pytest/_code/record.py +++ b/src/_pytest/_code/record.py @@ -11,6 +11,7 @@ from typing import Protocol from typing import runtime_checkable from typing import TYPE_CHECKING +from warnings import warn from _pytest._io import TerminalWriter @@ -387,12 +388,29 @@ def crash(self) -> Location | None: @property def reprcrash(self) -> Location | None: - """Compatibility alias for :attr:`crash`.""" + """Compatibility alias for :attr:`crash` (deprecated).""" + from _pytest.deprecated import EXCEPTION_RECORD_REPR_COMPAT_DEPRECATED + + warn( + EXCEPTION_RECORD_REPR_COMPAT_DEPRECATED.format( + attr="reprcrash", alternative="Use ExceptionRecord.crash instead." + ), + stacklevel=2, + ) return self.crash @property def reprtraceback(self) -> ReprTraceback: - """Compatibility property returning a rendered :class:`ReprTraceback`.""" + """Compatibility property returning a rendered :class:`ReprTraceback` (deprecated).""" + from _pytest.deprecated import EXCEPTION_RECORD_REPR_COMPAT_DEPRECATED + + warn( + EXCEPTION_RECORD_REPR_COMPAT_DEPRECATED.format( + attr="reprtraceback", + alternative="Use PytestTraceback(record).to_repr().reprtraceback or render the record directly.", + ), + stacklevel=2, + ) from _pytest._code.render import PytestTraceback return PytestTraceback(self).to_repr().reprtraceback @@ -401,7 +419,16 @@ def reprtraceback(self) -> ReprTraceback: def chain( self, ) -> Sequence[tuple[ReprTraceback, ReprFileLocation | None, str | None]]: - """Compatibility property returning the rendered exception chain.""" + """Compatibility property returning the rendered exception chain (deprecated).""" + from _pytest.deprecated import EXCEPTION_RECORD_REPR_COMPAT_DEPRECATED + + warn( + EXCEPTION_RECORD_REPR_COMPAT_DEPRECATED.format( + attr="chain", + alternative="Use ExceptionRecord.iter_chain() or PytestTraceback instead.", + ), + stacklevel=2, + ) from _pytest._code.code import ExceptionChainRepr from _pytest._code.render import PytestTraceback diff --git a/src/_pytest/_code/render.py b/src/_pytest/_code/render.py index 3e326736e63..c412e01365c 100644 --- a/src/_pytest/_code/render.py +++ b/src/_pytest/_code/render.py @@ -230,7 +230,7 @@ def _make_reprcrash(self, rec: ExceptionRecord) -> ReprFileLocation | None: loc = rec.crash if loc is None or loc.lineno is None: return None - return ReprFileLocation(loc.path, loc.lineno, loc.message) + return ReprFileLocation(loc.path, loc.lineno, loc.message, _ispytest=True) def _get_exconly_lines( self, @@ -341,7 +341,7 @@ def _repr_frame_entry( short = style == "short" reprargs: ReprFuncArgs | None = None if not short and self.funcargs and frame.args is not None: - reprargs = ReprFuncArgs(list(frame.args)) + reprargs = ReprFuncArgs(list(frame.args), _ispytest=True) lines.extend( self._format_frame_source( @@ -355,18 +355,20 @@ def _repr_frame_entry( else: message = rec_if_last.short_type_name if rec_if_last is not None else "" path = self._makepath(frame) - reprfileloc = ReprFileLocation(path, frame.lineno, message) + reprfileloc = ReprFileLocation(path, frame.lineno, message, _ispytest=True) localsrepr: ReprLocals | None = None if self.showlocals and frame.locals is not None: local_lines = [ f"{name:<10} = {val}" for name, val in frame.locals.items() ] - localsrepr = ReprLocals(local_lines) - return ReprEntry(lines, reprargs, localsrepr, reprfileloc, style) + localsrepr = ReprLocals(local_lines, _ispytest=True) + return ReprEntry( + lines, reprargs, localsrepr, reprfileloc, style, _ispytest=True + ) elif style == "value": if rec_if_last is not None: lines.extend(rec_if_last.message.split("\n")) - return ReprEntry(lines, None, None, None, style) + return ReprEntry(lines, None, None, None, style, _ispytest=True) else: if rec_if_last is not None: lines.extend(self._get_exconly_lines(rec_if_last, indent=4)) @@ -374,9 +376,9 @@ def _repr_frame_entry( if frame is not None: message = rec_if_last.short_type_name if rec_if_last is not None else "" reprfileloc = ReprFileLocation( - self._makepath(frame), frame.lineno, message + self._makepath(frame), frame.lineno, message, _ispytest=True ) - return ReprEntry(lines, None, None, reprfileloc, style) + return ReprEntry(lines, None, None, reprfileloc, style, _ispytest=True) def _truncate_recursive_frames( self, @@ -434,11 +436,13 @@ def _repr_traceback_for_record( if extraline is None and show_all_hidden_msg: extraline = _ALL_HIDDEN_MSG entries = [self._repr_frame_entry(None, rec, effective_tb_style)] - return ReprTraceback(entries, extraline, style=effective_tb_style) + return ReprTraceback( + entries, extraline, style=effective_tb_style, _ispytest=True + ) if self.style == "value": entries = [self._repr_frame_entry(visible_frames[-1], rec, "value")] - return ReprTraceback(entries, None, style="value") + return ReprTraceback(entries, None, style="value", _ispytest=True) entries = [] n_frames = len(visible_frames) @@ -459,7 +463,9 @@ def _repr_traceback_for_record( entry_default_style, ) ) - return ReprTraceback(entries, extraline, style=effective_tb_style) + return ReprTraceback( + entries, extraline, style=effective_tb_style, _ispytest=True + ) def _iter_chain_records( self, root: ExceptionRecord | None = None @@ -499,7 +505,9 @@ def _append_record_chain( no_tb_lines = [ f"{line}\n" for line in rec.exconly(tryshort=False).splitlines() ] - reprtraceback: ReprTraceback = ReprTracebackNative(no_tb_lines) + reprtraceback: ReprTraceback = ReprTracebackNative( + no_tb_lines, _ispytest=True + ) reprcrash = None else: reprtraceback = self._repr_traceback_for_record( @@ -565,8 +573,9 @@ def to_repr(self) -> ReprExceptionInfo | ExceptionChainRepr: if desc is not None: native_lines.append(f"\n{desc}\n\n") res_native = ReprExceptionInfo( - reprtraceback=ReprTracebackNative(native_lines), + reprtraceback=ReprTracebackNative(native_lines, _ispytest=True), reprcrash=self._make_reprcrash(self.record), + _ispytest=True, ) for name, content, sep in self._all_sections(): res_native.addsection(name, content, sep) @@ -586,8 +595,11 @@ def to_repr(self) -> ReprExceptionInfo | ExceptionChainRepr: ) rich_lines = self._render_rich_to_lines() res_rich = ReprExceptionInfo( - reprtraceback=ReprTracebackNative(rich_lines, extraline=extraline), + reprtraceback=ReprTracebackNative( + rich_lines, extraline=extraline, _ispytest=True + ), reprcrash=self._make_reprcrash(self.record), + _ispytest=True, ) for name, content, sep in self._all_sections(): res_rich.addsection(name, content, sep) @@ -599,7 +611,7 @@ def to_repr(self) -> ReprExceptionInfo | ExceptionChainRepr: repr_chain: list[tuple[ReprTraceback, ReprFileLocation | None, str | None]] = [] top_tb, top_crash = self._append_record_chain(self.record, repr_chain, depth=0) - chain_repr = ExceptionChainRepr(repr_chain) + chain_repr = ExceptionChainRepr(repr_chain, _ispytest=True) chain_repr.reprtraceback = top_tb chain_repr.reprcrash = top_crash for name, content, sep in self._all_sections(): diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 60ba5f54a89..caa3c8e20e6 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -172,7 +172,7 @@ def _print_import_error(header: str, cause: BaseException, file: TextIO) -> None tw.line(header, red=True) exc_info.traceback = exc_info.traceback.filter(filter_traceback_for_import_failure) exc_repr = ( - exc_info.getrepr(style="short", chain=False) + exc_info.getrepr(style="short", chain=False, _warn_deprecated=False) if exc_info.traceback else exc_info.exconly() ) @@ -1427,7 +1427,10 @@ def notify_exception( else: style = "native" excrepr = excinfo.getrepr( - funcargs=True, showlocals=getattr(option, "showlocals", False), style=style + funcargs=True, + showlocals=getattr(option, "showlocals", False), + style=style, + _warn_deprecated=False, ) res = self.hook.pytest_internalerror(excrepr=excrepr, excinfo=excinfo) if not any(res): @@ -2427,7 +2430,7 @@ def parse_warning_filter( raise except Exception: exc_info = ExceptionInfo.from_current() - exception_text = exc_info.getrepr(style="native") + exception_text = exc_info.getrepr(style="native", _warn_deprecated=False) raise UsageError(error_template.format(error=exception_text)) from None if message and escape: message = re.escape(message) diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index 98b7e112bf6..1cb11f0fb8d 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -170,6 +170,27 @@ "See https://docs.pytest.org/en/stable/deprecations.html#terminalreporter-write-line-bytes", ) +GETREPR_DEPRECATED = UnformattedWarning( + PytestDeprecationWarning, + "ExceptionInfo.getrepr() and its formatting parameters are deprecated and will be removed in a future release.\n" + "Use ExceptionInfo.record() to obtain an immutable ExceptionRecord and PytestTraceback to render it.\n" + "See https://docs.pytest.org/en/stable/deprecations.html#exceptioninfo-getrepr-deprecated", +) + +REPR_CLASSES_DEPRECATED = UnformattedWarning( + PytestDeprecationWarning, + "{cls} is deprecated and will be removed in a future release.\n" + "Use pytest.ExceptionRecord and pytest.PytestTraceback instead.\n" + "See https://docs.pytest.org/en/stable/deprecations.html#repr-classes-deprecated", +) + +EXCEPTION_RECORD_REPR_COMPAT_DEPRECATED = UnformattedWarning( + PytestDeprecationWarning, + "ExceptionRecord.{attr} is deprecated and will be removed in a future release.\n" + "{alternative}\n" + "See https://docs.pytest.org/en/stable/deprecations.html#exceptionrecord-compat-properties", +) + def check_ispytest(ispytest: bool) -> None: if not ispytest: diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index b79fb199500..f3c3d5f1cd4 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -486,6 +486,7 @@ def _repr_failure_py( tbfilter=tbfilter, truncate_locals=truncate_locals, truncate_args=truncate_args, + _warn_deprecated=False, ) def repr_failure( diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 1ce7f71a7bd..eb2ae3b9fe7 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -533,7 +533,7 @@ def importtestmodule( ) except SyntaxError as e: raise nodes.Collector.CollectError( - ExceptionInfo.from_current().getrepr(style="short") + ExceptionInfo.from_current().getrepr(style="short", _warn_deprecated=False) ) from e except ImportPathMismatchError as e: raise nodes.Collector.CollectError( @@ -550,7 +550,7 @@ def importtestmodule( if config.get_verbosity() < 2: exc_info.traceback = exc_info.traceback.filter(filter_traceback) exc_repr = ( - exc_info.getrepr(style="short") + exc_info.getrepr(style="short", _warn_deprecated=False) if exc_info.traceback else exc_info.exconly() ) diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 5cca8179b8d..1df25b6fd4b 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -752,11 +752,11 @@ def deserialize_repr_entry(entry_data): reprfileloc = None reprlocals = None if data["reprfuncargs"]: - reprfuncargs = ReprFuncArgs(**data["reprfuncargs"]) + reprfuncargs = ReprFuncArgs(**data["reprfuncargs"], _ispytest=True) if data["reprfileloc"]: - reprfileloc = ReprFileLocation(**data["reprfileloc"]) + reprfileloc = ReprFileLocation(**data["reprfileloc"], _ispytest=True) if data["reprlocals"]: - reprlocals = ReprLocals(data["reprlocals"]["lines"]) + reprlocals = ReprLocals(data["reprlocals"]["lines"], _ispytest=True) reprentry: ReprEntry | ReprEntryNative = ReprEntry( lines=data["lines"], @@ -764,9 +764,10 @@ def deserialize_repr_entry(entry_data): reprlocals=reprlocals, reprfileloc=reprfileloc, style=data["style"], + _ispytest=True, ) elif entry_type == "ReprEntryNative": - reprentry = ReprEntryNative(data["lines"]) + reprentry = ReprEntryNative(data["lines"], _ispytest=True) else: _report_unserialization_failure(entry_type, TestReport, reportdict) return reprentry @@ -775,11 +776,11 @@ def deserialize_repr_traceback(repr_traceback_dict): repr_traceback_dict["reprentries"] = [ deserialize_repr_entry(x) for x in repr_traceback_dict["reprentries"] ] - return ReprTraceback(**repr_traceback_dict) + return ReprTraceback(**repr_traceback_dict, _ispytest=True) def deserialize_repr_crash(repr_crash_dict: dict[str, Any] | None): if repr_crash_dict is not None: - return ReprFileLocation(**repr_crash_dict) + return ReprFileLocation(**repr_crash_dict, _ispytest=True) else: return None @@ -837,7 +838,7 @@ def deserialize_repr_crash(repr_crash_dict: dict[str, Any] | None): ) ) exception_info: ExceptionChainRepr | ReprExceptionInfo = ExceptionChainRepr( - chain + chain, _ispytest=True ) exception_info.reprtraceback = reprtraceback exception_info.reprcrash = reprcrash @@ -845,6 +846,7 @@ def deserialize_repr_crash(repr_crash_dict: dict[str, Any] | None): exception_info = ReprExceptionInfo( reprtraceback=reprtraceback, reprcrash=reprcrash, + _ispytest=True, ) for section in reportdict["longrepr"]["sections"]: diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 70e61f19035..31925f2c9dc 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -1049,7 +1049,9 @@ def pytest_terminal_summary(self) -> Generator[None]: self.summary_warnings() def pytest_keyboard_interrupt(self, excinfo: ExceptionInfo[BaseException]) -> None: - self._keyboardinterrupt_memo = excinfo.getrepr(funcargs=True) + self._keyboardinterrupt_memo = excinfo.getrepr( + funcargs=True, _warn_deprecated=False + ) def pytest_unconfigure(self) -> None: if self._keyboardinterrupt_memo is not None: diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index ecd075ff5d3..f6b2d54bac2 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -6,6 +6,12 @@ from _pytest import __version__ from _pytest import version_tuple from _pytest._code import ExceptionInfo +from _pytest._code import ExceptionRecord +from _pytest._code import FailureRecord +from _pytest._code import FrameRecord +from _pytest._code import Location +from _pytest._code import PytestTraceback +from _pytest._code import SyntaxErrorDetails from _pytest.approx import Approx from _pytest.approx import approx from _pytest.assertion import register_assert_rewrite @@ -113,15 +119,19 @@ "Directory", "DoctestItem", "ExceptionInfo", + "ExceptionRecord", "ExitCode", + "FailureRecord", "File", "FixtureDef", "FixtureLookupError", "FixtureRequest", + "FrameRecord", "Function", "HookRecorder", "Item", "LineMatcher", + "Location", "LogCaptureFixture", "Mark", "MarkDecorator", @@ -144,6 +154,7 @@ "PytestPluginManager", "PytestRemovedIn10Warning", "PytestReturnNotNoneWarning", + "PytestTraceback", "PytestUnhandledThreadExceptionWarning", "PytestUnknownMarkWarning", "PytestUnraisableExceptionWarning", @@ -159,6 +170,7 @@ "StashKey", "SubtestReport", "Subtests", + "SyntaxErrorDetails", "TempPathFactory", "TempdirFactory", "TerminalReporter", diff --git a/testing/code/test_record.py b/testing/code/test_record.py index 135cbe3ced3..77849766bae 100644 --- a/testing/code/test_record.py +++ b/testing/code/test_record.py @@ -6,6 +6,7 @@ import pickle import sys from typing import Any +import warnings from _pytest._code import Code from _pytest._code import ExceptionInfo @@ -363,9 +364,12 @@ def group_fn() -> None: assert isinstance(hash(record), int) # Compatibility properties on ExceptionRecord - assert record.reprcrash == record.crash - assert record.reprtraceback is not None - assert len(record.chain) >= 1 + with pytest.deprecated_call(match="reprcrash is deprecated"): + assert record.reprcrash == record.crash + with pytest.deprecated_call(match="reprtraceback is deprecated"): + assert record.reprtraceback is not None + with pytest.deprecated_call(match="chain is deprecated"): + assert len(record.chain) >= 1 # Unsupported schema version raises ValueError bad_payload = dict(payload, schema_version=999) @@ -664,3 +668,176 @@ def nested_group() -> None: ) console.print(tb) assert "outer_group" in console_buf.getvalue() + + +class TestPublicApiAndDeprecations: + def test_public_api_exports(self) -> None: + """Verify public API classes are exported in pytest namespace and __all__.""" + expected_exports = [ + "ExceptionRecord", + "FailureRecord", + "FrameRecord", + "Location", + "PytestTraceback", + "SyntaxErrorDetails", + ] + for name in expected_exports: + assert hasattr(pytest, name), f"pytest is missing {name}" + assert name in pytest.__all__, f"{name} is not in pytest.__all__" + + def test_getrepr_deprecated(self) -> None: + """ExceptionInfo.getrepr() emits GETREPR_DEPRECATED warning unless _warn_deprecated=False.""" + try: + raise ValueError("test getrepr deprecation") + except ValueError: + excinfo = ExceptionInfo.from_current() + + with pytest.deprecated_call( + match=r"ExceptionInfo\.getrepr\(\) and its formatting parameters are deprecated" + ): + repr_obj = excinfo.getrepr() + assert repr_obj is not None + + # When _warn_deprecated=False, no warning is emitted + with warnings.catch_warnings(): + warnings.simplefilter("error", pytest.PytestDeprecationWarning) + repr_obj2 = excinfo.getrepr(_warn_deprecated=False) + assert repr_obj2 is not None + + def test_exception_record_compat_properties_deprecated(self) -> None: + """Compatibility properties on ExceptionRecord emit EXCEPTION_RECORD_REPR_COMPAT_DEPRECATED.""" + try: + raise ValueError("test record compat") + except ValueError: + excinfo = ExceptionInfo.from_current() + + record = excinfo.record() + + with pytest.deprecated_call( + match=r"ExceptionRecord\.reprcrash is deprecated and will be removed" + ): + crash = record.reprcrash + assert crash is not None + + with pytest.deprecated_call( + match=r"ExceptionRecord\.reprtraceback is deprecated and will be removed" + ): + tb = record.reprtraceback + assert tb is not None + + with pytest.deprecated_call( + match=r"ExceptionRecord\.chain is deprecated and will be removed" + ): + chain = record.chain + assert len(chain) >= 1 + + def test_repr_classes_deprecated(self) -> None: + """Direct instantiation of legacy Repr* classes emits REPR_CLASSES_DEPRECATED warning.""" + from _pytest._code.code import ExceptionChainRepr + from _pytest._code.code import ExceptionRepr + from _pytest._code.code import ReprEntry + from _pytest._code.code import ReprEntryNative + from _pytest._code.code import ReprExceptionInfo + from _pytest._code.code import ReprFileLocation + from _pytest._code.code import ReprFuncArgs + from _pytest._code.code import ReprLocals + from _pytest._code.code import ReprTraceback + from _pytest._code.code import ReprTracebackNative + from _pytest._code.code import TerminalRepr + + with pytest.deprecated_call( + match=r"TerminalRepr is deprecated and will be removed" + ): + TerminalRepr() + + with pytest.deprecated_call( + match=r"ReprFileLocation is deprecated and will be removed" + ): + floc = ReprFileLocation("foo.py", 10, "boom") + + with pytest.deprecated_call( + match=r"ReprFuncArgs is deprecated and will be removed" + ): + rargs = ReprFuncArgs([("x", 1)]) + + with pytest.deprecated_call( + match=r"ReprLocals is deprecated and will be removed" + ): + rloc = ReprLocals(["x = 1"]) + + with pytest.deprecated_call( + match=r"ReprEntry is deprecated and will be removed" + ): + rentry = ReprEntry(["line"], rargs, rloc, floc, "long") + + with pytest.deprecated_call( + match=r"ReprEntryNative is deprecated and will be removed" + ): + ReprEntryNative(["line\n"]) + + with pytest.deprecated_call( + match=r"ReprTraceback is deprecated and will be removed" + ): + rtb = ReprTraceback([rentry], None, "long") + + with pytest.deprecated_call( + match=r"ReprTracebackNative is deprecated and will be removed" + ): + ReprTracebackNative(["line\n"]) + + with pytest.deprecated_call( + match=r"ReprExceptionInfo is deprecated and will be removed" + ): + ReprExceptionInfo(rtb, floc) + + with pytest.deprecated_call( + match=r"ExceptionRepr is deprecated and will be removed" + ): + ExceptionRepr(rtb, floc) + + with pytest.deprecated_call( + match=r"ExceptionChainRepr is deprecated and will be removed" + ): + ExceptionChainRepr([(rtb, floc, None)]) + + def test_repr_classes_internal_ispytest_suppresses_warning(self) -> None: + """Passing _ispytest=True suppresses deprecation warnings on Repr* classes.""" + from _pytest._code.code import ExceptionChainRepr + from _pytest._code.code import ExceptionRepr + from _pytest._code.code import ReprEntry + from _pytest._code.code import ReprEntryNative + from _pytest._code.code import ReprExceptionInfo + from _pytest._code.code import ReprFileLocation + from _pytest._code.code import ReprFuncArgs + from _pytest._code.code import ReprLocals + from _pytest._code.code import ReprTraceback + from _pytest._code.code import ReprTracebackNative + from _pytest._code.code import TerminalRepr + + with warnings.catch_warnings(): + warnings.simplefilter("error", pytest.PytestDeprecationWarning) + TerminalRepr(_ispytest=True) + floc = ReprFileLocation("foo.py", 10, "boom", _ispytest=True) + rargs = ReprFuncArgs([("x", 1)], _ispytest=True) + rloc = ReprLocals(["x = 1"], _ispytest=True) + rentry = ReprEntry(["line"], rargs, rloc, floc, "long", _ispytest=True) + ReprEntryNative(["line\n"], _ispytest=True) + rtb = ReprTraceback([rentry], None, "long", _ispytest=True) + ReprTracebackNative(["line\n"], _ispytest=True) + ReprExceptionInfo(rtb, floc, _ispytest=True) + ExceptionRepr(rtb, floc, _ispytest=True) + ExceptionChainRepr([(rtb, floc, None)], _ispytest=True) + assert floc.path == "foo.py" + + def test_pytest_traceback_to_repr_does_not_warn(self) -> None: + """PytestTraceback.to_repr() does not emit deprecation warnings for Repr* classes.""" + try: + raise RuntimeError("compat to_repr test") + except RuntimeError: + excinfo = ExceptionInfo.from_current() + + record = excinfo.record() + with warnings.catch_warnings(): + warnings.simplefilter("error", pytest.PytestDeprecationWarning) + legacy_repr = PytestTraceback(record).to_repr() + assert legacy_repr is not None From ba1dbb9a17e027d22086e191f66aaf4a54d2e253 Mon Sep 17 00:00:00 2001 From: Oleksii Date: Tue, 3 Nov 2026 18:48:35 +0200 Subject: [PATCH 09/12] Preserve exception snapshots and render captured source --- src/_pytest/_code/_rich.py | 139 +++++++++++++++++++++++++++++ src/_pytest/_code/code.py | 49 ++++++----- src/_pytest/_code/record.py | 8 +- src/_pytest/_code/render.py | 33 ++++--- src/_pytest/reports.py | 24 +++-- testing/code/test_record.py | 171 ++++++++++++++++++++++++++++++++++++ testing/test_reports.py | 42 +++++++++ testing/test_skipping.py | 18 ++++ 8 files changed, 435 insertions(+), 49 deletions(-) create mode 100644 src/_pytest/_code/_rich.py diff --git a/src/_pytest/_code/_rich.py b/src/_pytest/_code/_rich.py new file mode 100644 index 00000000000..5ff419d6156 --- /dev/null +++ b/src/_pytest/_code/_rich.py @@ -0,0 +1,139 @@ +"""Rich traceback layout backed exclusively by captured frame source.""" + +from __future__ import annotations + +from rich.columns import Columns +from rich.console import ConsoleRenderable +from rich.console import group +from rich.console import RenderResult +from rich.scope import render_scope +from rich.syntax import Syntax +from rich.text import Text +from rich.traceback import Stack +from rich.traceback import Trace +from rich.traceback import Traceback + +from _pytest._code.code import _byte_offset_to_character_offset +from _pytest._code.record import ExceptionRecord +from _pytest._code.record import FrameRecord + + +class RecordedTraceback(Traceback): + def __init__( + self, + record: ExceptionRecord, + *, + show_hidden: bool, + chain: bool, + show_locals: bool, + width: int, + ) -> None: + trace = record.to_rich_trace(show_hidden=show_hidden, chain=chain) + self._chain = chain + super().__init__(trace=trace, show_locals=show_locals, width=width) + self._sources: dict[int, FrameRecord] = {} + self._bind_sources(record, trace, show_hidden=show_hidden) + + def _bind_sources( + self, record: ExceptionRecord, trace: Trace, *, show_hidden: bool + ) -> None: + records = ( + list(reversed(list(record.iter_chain()))) + if self._chain + else [(record, None)] + ) + for (node, _), stack in zip(records, trace.stacks, strict=True): + frames = [f for f in node.frames if show_hidden or not f.hidden] + for frame, source in zip(stack.frames, frames, strict=True): + self._sources[id(frame)] = source + for child, child_trace in zip(node.children, stack.exceptions, strict=True): + self._bind_sources(child, child_trace, show_hidden=show_hidden) + + @group() + def _render_stack(self, stack: Stack) -> RenderResult: + # Rich's default stack renderer reads linecache. Only the surrounding + # layout is reused here; source and positions always come from the record. + excluded = range(0) + if self.max_frames and len(stack.frames) > self.max_frames: + excluded = range( + self.max_frames // 2, len(stack.frames) - self.max_frames // 2 + ) + for index, frame in enumerate(stack.frames): + if index in excluded: + if index == excluded.start: + yield Text( + f"... {len(excluded)} frames hidden ...", + justify="center", + style="traceback.error", + ) + continue + if index: + yield Text("") + yield Text.assemble( + (frame.filename, "pygments.string"), + (f":{frame.lineno}", "pygments.number"), + " in ", + (frame.name, "pygments.function"), + ) + source = self._sources[id(frame)] + parts: list[ConsoleRenderable] = [] + if source.statement and not any( + frame.filename.startswith(path) for path in self.suppress + ): + first = source.statement_lineno + syntax = Syntax( + "\n".join(source.statement), + "python", + theme=self.theme, + line_numbers=True, + start_line=first, + line_range=( + max(1, source.lineno - first + 1 - self.extra_lines), + max(source.lineno, source.end_lineno or source.lineno) + - first + + 1 + + self.extra_lines, + ), + highlight_lines={source.lineno}, + word_wrap=self.word_wrap, + code_width=self.code_width, + indent_guides=self.indent_guides, + dedent=False, + ) + if source.colno is not None and source.end_colno is not None: + last = source.end_lineno or source.lineno + for lineno in range( + max(first, source.lineno), + min(first + len(source.statement) - 1, last) + 1, + ): + line = source.statement[lineno - first] + start = ( + _byte_offset_to_character_offset(line, source.colno) + if lineno == source.lineno + else len(line) - len(line.lstrip()) + ) + end = ( + _byte_offset_to_character_offset(line, source.end_colno) + if lineno == last + else len(line) + ) + syntax.stylize_range( + "traceback.error_range", + (lineno - first + 1, start), + (lineno - first + 1, end), + ) + parts.append(syntax) + if self.show_locals and frame.locals: + parts.append( + render_scope( + frame.locals, + title="locals", + indent_guides=self.indent_guides, + max_length=self.locals_max_length, + max_string=self.locals_max_string, + max_depth=self.locals_max_depth, + overflow=self.locals_overflow, + ) + ) + if parts: + yield Columns(parts, padding=1) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index b738f81ab7a..88e9dab434a 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -715,7 +715,7 @@ def _getreprcrash(self) -> ReprFileLocation | None: if not entry.ishidden(self): path, lineno = entry.frame.code.raw.co_filename, entry.lineno exconly = self.exconly(tryshort=True) - return ReprFileLocation(path, lineno + 1, exconly) + return ReprFileLocation(path, lineno + 1, exconly, _ispytest=True) return None def getrepr( @@ -901,7 +901,7 @@ def record( ) -> ExceptionRecord: """Capture a plain-data :class:`ExceptionRecord` snapshot of this exception.""" astcache: dict[str | Path, ast.AST] = {} - seen: set[int] = set() + ancestors: frozenset[int] = frozenset() return _record_exception( self.value, excinfo=self, @@ -912,7 +912,7 @@ def record( tbfilter=tbfilter, chain=chain, astcache=astcache, - seen=seen, + ancestors=ancestors, ) @@ -1069,9 +1069,10 @@ def _record_exception( tbfilter: TracebackFilter, chain: bool, astcache: dict[str | Path, ast.AST], - seen: set[int], + ancestors: frozenset[int], ) -> ExceptionRecord: - seen.add(id(exc)) + # Each branch has its own ancestry: repeated siblings are not cycles. + ancestors = ancestors | {id(exc)} if excinfo is None and exc.__traceback__ is not None: excinfo = ExceptionInfo.from_exception(exc) @@ -1148,7 +1149,7 @@ def _record_exception( if isinstance(exc, BaseExceptionGroup): child_records: list[ExceptionRecord] = [] for sub_exc in exc.exceptions: - if id(sub_exc) in seen: + if id(sub_exc) in ancestors: continue child_records.append( _record_exception( @@ -1161,7 +1162,7 @@ def _record_exception( tbfilter=tbfilter, chain=chain, astcache=astcache, - seen=seen, + ancestors=ancestors, ) ) children = tuple(child_records) @@ -1171,7 +1172,7 @@ def _record_exception( suppress_context = bool(getattr(exc, "__suppress_context__", False)) if chain: raw_cause = exc.__cause__ - if raw_cause is not None and id(raw_cause) not in seen: + if raw_cause is not None and id(raw_cause) not in ancestors: cause_record = _record_exception( raw_cause, excinfo=None, @@ -1182,14 +1183,14 @@ def _record_exception( tbfilter=tbfilter, chain=chain, astcache=astcache, - seen=seen, + ancestors=ancestors, ) raw_context = exc.__context__ if ( raw_context is not None and not suppress_context and raw_context is not raw_cause - and id(raw_context) not in seen + and id(raw_context) not in ancestors ): context_record = _record_exception( raw_context, @@ -1201,7 +1202,7 @@ def _record_exception( tbfilter=tbfilter, chain=chain, astcache=astcache, - seen=seen, + ancestors=ancestors, ) return ExceptionRecord( @@ -1284,7 +1285,7 @@ def repr_args(self, entry: TracebackEntry) -> ReprFuncArgs | None: else: str_repr = saferepr(argvalue, maxsize=None) args.append((argname, str_repr)) - return ReprFuncArgs(args) + return ReprFuncArgs(args, _ispytest=True) return None def get_source( @@ -1419,7 +1420,7 @@ def repr_locals(self, locals: Mapping[str, object]) -> ReprLocals | None: # self._line("%-10s =\\" % (name,)) # # XXX # pprint.pprint(value, stream=self.excinfowriter) - return ReprLocals(lines) + return ReprLocals(lines, _ispytest=True) return None def repr_traceback_entry( @@ -1462,13 +1463,17 @@ def repr_traceback_entry( message = (excinfo and excinfo.typename) or "" entry_path = entry.path path = self._makepath(entry_path) - reprfileloc = ReprFileLocation(path, entry.lineno + 1, message) + reprfileloc = ReprFileLocation( + path, entry.lineno + 1, message, _ispytest=True + ) localsrepr = self.repr_locals(entry.locals) - return ReprEntry(lines, reprargs, localsrepr, reprfileloc, style) + return ReprEntry( + lines, reprargs, localsrepr, reprfileloc, style, _ispytest=True + ) elif style == "value": if excinfo: lines.extend(str(excinfo.value).split("\n")) - return ReprEntry(lines, None, None, None, style) + return ReprEntry(lines, None, None, None, style, _ispytest=True) else: if excinfo: lines.extend(self.get_exconly(excinfo, indent=4)) @@ -1476,8 +1481,10 @@ def repr_traceback_entry( if entry is not None: message = (excinfo and excinfo.typename) or "" path = self._makepath(entry.path) - reprfileloc = ReprFileLocation(path, entry.lineno + 1, message) - return ReprEntry(lines, None, None, reprfileloc, style) + reprfileloc = ReprFileLocation( + path, entry.lineno + 1, message, _ispytest=True + ) + return ReprEntry(lines, None, None, reprfileloc, style, _ispytest=True) def _makepath(self, path: Path | str) -> str: if not self.abspath and isinstance(path, Path): @@ -1501,18 +1508,18 @@ def repr_traceback(self, excinfo: ExceptionInfo[BaseException]) -> ReprTraceback if extraline is None: 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) + return ReprTraceback(entries, extraline, style=self.style, _ispytest=True) last = traceback[-1] if self.style == "value": entries = [self.repr_traceback_entry(last, excinfo)] - return ReprTraceback(entries, None, style=self.style) + return ReprTraceback(entries, None, style=self.style, _ispytest=True) entries = [ self.repr_traceback_entry(entry, excinfo if last == entry else None) for entry in traceback ] - return ReprTraceback(entries, extraline, style=self.style) + return ReprTraceback(entries, extraline, style=self.style, _ispytest=True) def _truncate_recursive_traceback( self, traceback: Traceback diff --git a/src/_pytest/_code/record.py b/src/_pytest/_code/record.py index 3aabc113872..a92b44fb211 100644 --- a/src/_pytest/_code/record.py +++ b/src/_pytest/_code/record.py @@ -533,7 +533,9 @@ def from_dict(cls, data: Mapping[str, Any]) -> ExceptionRecord: striptext=str(data.get("striptext", "")), ) - def to_rich_trace(self, *, show_hidden: bool = False) -> rich.traceback.Trace: + def to_rich_trace( + self, *, show_hidden: bool = False, chain: bool = True + ) -> rich.traceback.Trace: """Convert this record into a :class:`rich.traceback.Trace`.""" from rich import pretty from rich.traceback import _SyntaxError @@ -605,12 +607,14 @@ def to_rich_trace(self, *, show_hidden: bool = False) -> rich.traceback.Trace: notes=list(cur.notes), is_group=bool(cur.children), exceptions=[ - child.to_rich_trace(show_hidden=show_hidden) + child.to_rich_trace(show_hidden=show_hidden, chain=chain) for child in cur.children ], ) stacks.append(stack) + if not chain: + break if cur.cause is not None: is_cause = True cur = cur.cause diff --git a/src/_pytest/_code/render.py b/src/_pytest/_code/render.py index c412e01365c..377093a9c89 100644 --- a/src/_pytest/_code/render.py +++ b/src/_pytest/_code/render.py @@ -29,6 +29,7 @@ from rich.console import Console from rich.console import ConsoleOptions from rich.console import RenderResult + from rich.traceback import Traceback as RichTraceback _ALL_HIDDEN_MSG = ( @@ -543,9 +544,19 @@ def _append_record_chain( assert top_tb is not None return top_tb, top_crash + def _rich_traceback(self, width: int) -> RichTraceback: + from _pytest._code._rich import RecordedTraceback + + return RecordedTraceback( + self.record, + show_hidden=self.show_hidden, + chain=self.chain, + show_locals=self.showlocals, + width=width, + ) + def _render_rich_to_lines(self, width: int = 100) -> list[str]: from rich.console import Console - from rich.traceback import Traceback as RichTraceback buf = StringIO() console = Console( @@ -554,11 +565,7 @@ def _render_rich_to_lines(self, width: int = 100) -> list[str]: force_terminal=False, color_system=None, ) - tb = RichTraceback( - trace=self.record.to_rich_trace(show_hidden=self.show_hidden), - show_locals=self.showlocals, - width=width, - ) + tb = self._rich_traceback(width) console.print(tb) return buf.getvalue().splitlines(keepends=True) @@ -624,7 +631,6 @@ def to_repr(self) -> ReprExceptionInfo | ExceptionChainRepr: def toterminal(self, tw: TerminalWriter) -> None: if self.style == "rich": from rich.console import Console - from rich.traceback import Traceback as RichTraceback visible_frames = ( list(self.record.frames) @@ -639,11 +645,7 @@ def toterminal(self, tw: TerminalWriter) -> None: no_color=not tw.hasmarkup, color_system="standard" if tw.hasmarkup else None, ) - tb = RichTraceback( - trace=self.record.to_rich_trace(show_hidden=self.show_hidden), - show_locals=self.showlocals, - width=tw.fullwidth, - ) + tb = self._rich_traceback(tw.fullwidth) console.print(tb) tw.write_raw(buf.getvalue()) if self.record.frames and not visible_frames: @@ -769,7 +771,6 @@ def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: from rich.text import Text - from rich.traceback import Traceback as RichTraceback width = options.max_width or 80 @@ -779,11 +780,7 @@ def __rich_console__( if self.show_hidden else [f for f in self.record.frames if not f.hidden] ) - yield RichTraceback( - trace=self.record.to_rich_trace(show_hidden=self.show_hidden), - show_locals=self.showlocals, - width=width, - ) + yield self._rich_traceback(width) if self.record.frames and not visible_frames: yield Text(_ALL_HIDDEN_MSG) for name, content, sep in self._all_sections(): diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 1df25b6fd4b..283c37001da 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -653,18 +653,22 @@ def _report_to_json(report: BaseReport) -> dict[str, Any]: This was originally the serialize_report() function from xdist (ca03269). """ + def repr_dict_factory(items: list[tuple[str, Any]]) -> dict[str, Any]: + # Internal constructor flags are not part of the wire format. + return {key: value for key, value in items if key != "_ispytest"} + def serialize_repr_entry( entry: ReprEntry | ReprEntryNative, ) -> dict[str, Any]: - data = dataclasses.asdict(entry) + data = dataclasses.asdict(entry, dict_factory=repr_dict_factory) for key, value in data.items(): if hasattr(value, "__dict__"): - data[key] = dataclasses.asdict(value) + data[key] = dataclasses.asdict(value, dict_factory=repr_dict_factory) entry_data = {"type": type(entry).__name__, "data": data} return entry_data def serialize_repr_traceback(reprtraceback: ReprTraceback) -> dict[str, Any]: - result = dataclasses.asdict(reprtraceback) + result = dataclasses.asdict(reprtraceback, dict_factory=repr_dict_factory) result["reprentries"] = [ serialize_repr_entry(x) for x in reprtraceback.reprentries ] @@ -674,7 +678,7 @@ def serialize_repr_crash( reprcrash: ReprFileLocation | None, ) -> dict[str, Any] | None: if reprcrash is not None: - return dataclasses.asdict(reprcrash) + return dataclasses.asdict(reprcrash, dict_factory=repr_dict_factory) else: return None @@ -752,9 +756,13 @@ def deserialize_repr_entry(entry_data): reprfileloc = None reprlocals = None if data["reprfuncargs"]: - reprfuncargs = ReprFuncArgs(**data["reprfuncargs"], _ispytest=True) + reprfuncargs = ReprFuncArgs( + **{**data["reprfuncargs"], "_ispytest": True} + ) if data["reprfileloc"]: - reprfileloc = ReprFileLocation(**data["reprfileloc"], _ispytest=True) + reprfileloc = ReprFileLocation( + **{**data["reprfileloc"], "_ispytest": True} + ) if data["reprlocals"]: reprlocals = ReprLocals(data["reprlocals"]["lines"], _ispytest=True) @@ -776,11 +784,11 @@ def deserialize_repr_traceback(repr_traceback_dict): repr_traceback_dict["reprentries"] = [ deserialize_repr_entry(x) for x in repr_traceback_dict["reprentries"] ] - return ReprTraceback(**repr_traceback_dict, _ispytest=True) + return ReprTraceback(**{**repr_traceback_dict, "_ispytest": True}) def deserialize_repr_crash(repr_crash_dict: dict[str, Any] | None): if repr_crash_dict is not None: - return ReprFileLocation(**repr_crash_dict, _ispytest=True) + return ReprFileLocation(**{**repr_crash_dict, "_ispytest": True}) else: return None diff --git a/testing/code/test_record.py b/testing/code/test_record.py index 77849766bae..abc725d1526 100644 --- a/testing/code/test_record.py +++ b/testing/code/test_record.py @@ -2,6 +2,7 @@ import io import json +import linecache from pathlib import Path import pickle import sys @@ -263,6 +264,51 @@ def test_exception_group_children_and_notes(self) -> None: == "ValueError('only one') [single exception in ExceptionGroup]" ) + def test_repeated_group_members(self) -> None: + error = ValueError("shared child") + group = ExceptionGroup( + "outer", [error, error, ExceptionGroup("nested", [error])] + ) + with pytest.raises(ExceptionGroup) as excinfo: + raise group + + record = excinfo.record() + assert len(record.children) == 3 + assert record.children[0] == record.children[1] + assert record.children[2].children == (record.children[0],) + restored = ExceptionRecord.from_dict(json.loads(json.dumps(record.to_dict()))) + assert restored == record + assert ( + str(PytestTraceback(restored, style="no")).count("ValueError: shared child") + == 3 + ) + + def test_group_member_also_used_as_cause(self) -> None: + error = ValueError("shared cause") + with pytest.raises(ExceptionGroup) as excinfo: + raise ExceptionGroup("outer", [error]) from error + + record = excinfo.record() + assert record.cause is not None + assert record.children == (record.cause,) + assert ExceptionRecord.from_dict(record.to_dict()) == record + assert ( + str(PytestTraceback(record, style="no")).count("ValueError: shared cause") + == 2 + ) + + def test_group_child_context_cycle(self) -> None: + child = ValueError("child") + group = ExceptionGroup("outer", [child, child]) + child.__context__ = group + with pytest.raises(ExceptionGroup) as excinfo: + raise group + + record = excinfo.record() + assert len(record.children) == 2 + assert all(child.context is None for child in record.children) + assert ExceptionRecord.from_dict(record.to_dict()) == record + def test_syntax_error_details(self) -> None: try: compile("x = (\n", "broken.py", "exec") @@ -670,6 +716,131 @@ def nested_group() -> None: assert "outer_group" in console_buf.getvalue() +class TestRichSourceSnapshot: + @pytest.mark.parametrize("entrypoint", ["to_repr", "toterminal", "rich_console"]) + @pytest.mark.parametrize("file_change", ["edit", "delete"]) + @pytest.mark.parametrize("roundtrip", [False, True]) + @pytest.mark.parametrize( + "source", + [ + 'def fail():\n return len("é") + 1 / 0 # ORIGINAL_SOURCE\n', + 'def fail():\n return len("é") + (\n 1 / 0 # ORIGINAL_SOURCE\n )\n', + ], + ) + def test_captured_source( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + entrypoint: str, + file_change: str, + roundtrip: bool, + source: str, + ) -> None: + path = tmp_path / "snapshot.py" + path.write_text(source, encoding="utf-8") + namespace: dict[str, Any] = {} + exec(compile(source, str(path), "exec"), namespace) + with pytest.raises(ZeroDivisionError) as excinfo: + namespace["fail"]() + record = excinfo.record() + if roundtrip: + record = ExceptionRecord.from_dict(json.loads(json.dumps(record.to_dict()))) + if file_change == "edit": + path.write_text( + source.replace("ORIGINAL_SOURCE", "REPLACEMENT_SOURCE"), + encoding="utf-8", + ) + else: + path.unlink() + linecache.clearcache() + + def no_live_source(*args: Any, **kwargs: Any) -> Any: + raise AssertionError("renderer must not read linecache") + + renderer = PytestTraceback(record, style="rich") + with monkeypatch.context() as patch: + patch.setattr(linecache, "getlines", no_live_source) + if entrypoint == "to_repr": + rendered = str(renderer.to_repr()) + elif entrypoint == "toterminal": + rendered = str(renderer) + else: + from rich.console import Console + + output = io.StringIO() + Console(file=output, width=100).print(renderer) + rendered = output.getvalue() + assert "ORIGINAL_SOURCE" in rendered + assert "REPLACEMENT_SOURCE" not in rendered + assert "ZeroDivisionError" in rendered + assert "snapshot.py" in rendered + + def test_non_ascii_instruction_highlight(self, tmp_path: Path) -> None: + from rich.console import Console + + path = tmp_path / "unicode.py" + source = 'def fail():\n return "é" + str(1 / 0)\n' + path.write_text(source, encoding="utf-8") + namespace: dict[str, Any] = {} + exec(compile(source, str(path), "exec"), namespace) + with pytest.raises(ZeroDivisionError) as excinfo: + namespace["fail"]() + renderer = PytestTraceback(excinfo.record(), style="rich") + highlighted = "".join( + segment.text + for segment in Console(width=100).render(renderer) + if segment.style is not None and segment.style.underline + ) + assert "1 / 0" in highlighted + assert '"é"' not in highlighted + + +class TestRichChainSelection: + @pytest.mark.parametrize("entrypoint", ["to_repr", "toterminal", "rich_console"]) + @pytest.mark.parametrize("relation", ["cause", "context"]) + def test_chain_selection(self, entrypoint: str, relation: str) -> None: + from rich.console import Console + + hidden = ExceptionRecord("ValueError", "builtins", "HIDDEN_CHAIN_MESSAGE") + child = ExceptionRecord( + "RuntimeError", + "builtins", + "VISIBLE_CHILD", + cause=hidden if relation == "cause" else None, + context=hidden if relation == "context" else None, + ) + record = ExceptionRecord( + "ExceptionGroup", + "builtins", + "VISIBLE_GROUP (1 sub-exception)", + children=(child,), + cause=hidden if relation == "cause" else None, + context=hidden if relation == "context" else None, + ) + original = record.to_dict() + renderer = PytestTraceback(record, style="rich", chain=False) + + def render() -> str: + if entrypoint == "to_repr": + return str(renderer.to_repr()) + if entrypoint == "toterminal": + return str(renderer) + output = io.StringIO() + Console(file=output, width=100).print(renderer) + return output.getvalue() + + rendered = render() + assert "VISIBLE_GROUP" in rendered + assert "VISIBLE_CHILD" in rendered + assert "HIDDEN_CHAIN_MESSAGE" not in rendered + trace = record.to_rich_trace(chain=False) + assert len(trace.stacks) == 1 + assert len(trace.stacks[0].exceptions[0].stacks) == 1 + renderer.chain = True + assert "HIDDEN_CHAIN_MESSAGE" in render() + assert record.to_dict() == original + + class TestPublicApiAndDeprecations: def test_public_api_exports(self) -> None: """Verify public API classes are exported in pytest namespace and __all__.""" diff --git a/testing/test_reports.py b/testing/test_reports.py index a471b1029bb..f6c7c3dcfc9 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Sequence +import json from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionRepr @@ -86,6 +87,47 @@ def test_fail(): # Missing section attribute PR171 assert added_section in a.longrepr.sections + @pytest.mark.parametrize("style", ["long", "short", "line", "no", "native", "rich"]) + @pytest.mark.parametrize("internal_flags", [False, True]) + def test_legacy_repr_constructor_flags( + self, pytester: Pytester, style, internal_flags + ): + reprec = pytester.inline_runsource( + """ + def test_failure(): + value = 0 + try: + raise ValueError("cause") + except ValueError: + assert value + """, + f"--tb={style}", + "--showlocals", + ) + report = reprec.getreports("pytest_runtest_logreport")[1] + wire = json.loads(json.dumps(report._to_json())) + assert '"_ispytest"' not in json.dumps(wire) + + def add_internal_flags(data): + if isinstance(data, dict): + for value in list(data.values()): + add_internal_flags(value) + if ( + "lines" in data + or "reprentries" in data + or "path" in data + or "args" in data + ): + data["_ispytest"] = False + elif isinstance(data, list): + for value in data: + add_internal_flags(value) + + if internal_flags: + add_internal_flags(wire["longrepr"]) + restored = TestReport._from_json(wire) + assert restored.longreprtext == report.longreprtext + def test_to_json_nodeid_wire_shape(self, pytester: Pytester) -> None: """The JSON wire payload must keep a plain top-level string "nodeid" key (never an internal NodeId object / "_id" key) -- pytest-xdist diff --git a/testing/test_skipping.py b/testing/test_skipping.py index fbe196915e8..3fd87c10314 100644 --- a/testing/test_skipping.py +++ b/testing/test_skipping.py @@ -1567,3 +1567,21 @@ def test_exit_reason_only(): ) result = pytester.runpytest(p) result.stdout.fnmatch_lines("*_pytest.outcomes.Exit: foo*") + + +@pytest.mark.parametrize( + "source", + [ + 'def test_skip(): pytest.skip("unavailable")', + 'def test_importorskip(): pytest.importorskip("pytest_missing_review_module")', + 'pytest.skip("collection skip", allow_module_level=True)', + ], +) +def test_skip_reporting_without_internal_deprecations(pytester: Pytester, source): + pytester.makepyfile("import pytest\n" + source) + result = pytester.runpytest_subprocess( + "-W", "error::pytest.PytestDeprecationWarning" + ) + result.assert_outcomes(skipped=1) + assert "INTERNALERROR" not in result.stdout.str() + assert "warnings summary" not in result.stdout.str() From d4708376aa88b519141dd59a9dc5cff22f1e9a10 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:51:20 +0000 Subject: [PATCH 10/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/_code/record.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/_code/record.py b/src/_pytest/_code/record.py index a92b44fb211..b72a6969525 100644 --- a/src/_pytest/_code/record.py +++ b/src/_pytest/_code/record.py @@ -81,7 +81,7 @@ def __getitem__(self, index: slice) -> tuple[str | int | None, ...]: ... def __getitem__( self, index: int | slice - ) -> str | int | None | tuple[str | int | None, ...]: + ) -> str | int | tuple[str | int | None, ...] | None: return (self.path, self.lineno, self.message)[index] def to_dict(self) -> dict[str, Any]: From caba548010a3df308fc1db95201c6bf135406f0c Mon Sep 17 00:00:00 2001 From: Oleksii Date: Tue, 3 Nov 2026 19:39:37 +0200 Subject: [PATCH 11/12] Match exception formatting for unprintable messages --- changelog/15072.deprecation.rst | 6 ++++++ changelog/15072.feature.rst | 8 ++++++++ pyproject.toml | 1 + src/_pytest/_code/code.py | 4 +++- testing/acceptance_test.py | 2 +- testing/code/test_record.py | 28 ++++++++++++++++++++++++- uv.lock | 36 +++++++++++++++++++++++++++++++++ 7 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 changelog/15072.deprecation.rst create mode 100644 changelog/15072.feature.rst diff --git a/changelog/15072.deprecation.rst b/changelog/15072.deprecation.rst new file mode 100644 index 00000000000..7dfb145ea3c --- /dev/null +++ b/changelog/15072.deprecation.rst @@ -0,0 +1,6 @@ +Deprecated :meth:`pytest.ExceptionInfo.getrepr`, the legacy ``Repr*`` terminal +representation classes, and the ``reprcrash``, ``reprtraceback``, and ``chain`` +compatibility properties on :class:`pytest.ExceptionRecord`. + +Use :meth:`pytest.ExceptionInfo.record` to capture exception data and +:class:`pytest.PytestTraceback` to render it. diff --git a/changelog/15072.feature.rst b/changelog/15072.feature.rst new file mode 100644 index 00000000000..53ee6dfd434 --- /dev/null +++ b/changelog/15072.feature.rst @@ -0,0 +1,8 @@ +Added :meth:`pytest.ExceptionInfo.record` and structured exception records with +versioned serialization, captured source, exception chains, and exception-group +children. Traceback records retain frame locations independently of the selected +terminal traceback style. + +Added :class:`pytest.PytestTraceback` and ``--tb=rich`` for rendering captured +records, including source positions and exception groups. Rich 15 or later is now +a runtime dependency. diff --git a/pyproject.toml b/pyproject.toml index 8d1046f037d..d66db9b3ca6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ dependencies = [ "packaging>=24", "pluggy>=1.6,<2", "pygments>=2.15", + "rich>=15", "tomli>=2; python_version<'3.11'", ] # Kept as a published extra because many external repos still install diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 88e9dab434a..f0a0ad12509 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -1102,7 +1102,9 @@ def _record_exception( try: message = str(exc) except Exception: - message = f"" + # Match stdlib traceback formatting, including exceptions whose + # arguments have a broken __repr__. + message = "" notes = _extract_exception_notes(exc) striptext = excinfo._get_striptext() if excinfo is not None else "" diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 3983bd0007d..66af05fad09 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -914,7 +914,7 @@ def test_doctest_id(self, pytester: Pytester) -> None: expected_lines = [ "*= FAILURES =*", "*_ ?doctest? test_doctest_id.txt _*", - "FAILED test_doctest_id.txt::test_doctest_id.txt", + "FAILED test_doctest_id.txt::test_doctest_id.txt - DocTestFailure", "*= 1 failed in*", ] result = pytester.runpytest(testid, "-rf", "--tb=short") diff --git a/testing/code/test_record.py b/testing/code/test_record.py index abc725d1526..b14c9c6a32c 100644 --- a/testing/code/test_record.py +++ b/testing/code/test_record.py @@ -264,6 +264,24 @@ def test_exception_group_children_and_notes(self) -> None: == "ValueError('only one') [single exception in ExceptionGroup]" ) + @pytest.mark.parametrize("exception_type", [ValueError, AssertionError]) + def test_unprintable_exception_message( + self, exception_type: type[Exception] + ) -> None: + class BrokenRepr: + def __repr__(self) -> str: + raise RuntimeError("broken repr") + + with pytest.raises(exception_type) as excinfo: + raise exception_type(BrokenRepr()) + + record = excinfo.record() + assert record.message == "" + assert record.exconly() == excinfo.exconly() + restored = ExceptionRecord.from_dict(json.loads(json.dumps(record.to_dict()))) + assert restored.exconly() == excinfo.exconly() + assert record.exconly() in str(PytestTraceback(restored)) + def test_repeated_group_members(self) -> None: error = ValueError("shared child") group = ExceptionGroup( @@ -690,7 +708,12 @@ def nested_group() -> None: assert "in child_a" in rendered assert "in child_b" in rendered elif style == "no": - assert "E ExceptionGroup: outer_group (2 sub-exceptions)" in rendered + group_type_name = ( + "exceptiongroup.ExceptionGroup" + if sys.version_info < (3, 11) + else "ExceptionGroup" + ) + assert f"E {group_type_name}: outer_group (2 sub-exceptions)" in rendered assert "E assert 11 == 0" in rendered assert "E ValueError: from child_b" in rendered assert "in child_a" not in rendered @@ -775,6 +798,9 @@ def no_live_source(*args: Any, **kwargs: Any) -> Any: assert "ZeroDivisionError" in rendered assert "snapshot.py" in rendered + @pytest.mark.skipif( + sys.version_info < (3, 11), reason="PEP 657 requires Python 3.11 or later" + ) def test_non_ascii_instruction_highlight(self, tmp_path: Path) -> None: from rich.console import Console diff --git a/uv.lock b/uv.lock index 621c4d9ebb1..a4927bf012a 100644 --- a/uv.lock +++ b/uv.lock @@ -300,6 +300,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mock" version = "5.2.0" @@ -514,6 +535,7 @@ dependencies = [ { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, + { name = "rich" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] @@ -559,6 +581,7 @@ requires-dist = [ { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.5" }, { name = "pyyaml", marker = "extra == 'dev'" }, { name = "requests", marker = "extra == 'dev'" }, + { name = "rich", specifier = ">=15" }, { name = "setuptools", marker = "extra == 'dev'" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2" }, { name = "xmlschema", marker = "extra == 'dev'" }, @@ -660,6 +683,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "setuptools" version = "80.9.0" From 3f7345b6ac0517977437c7ce7cc8e4b3ad0a9f81 Mon Sep 17 00:00:00 2001 From: Oleksii Date: Tue, 3 Nov 2026 21:14:31 +0200 Subject: [PATCH 12/12] Improve traceback rendering and document deprecated representations --- doc/en/deprecations.rst | 10 + src/_pytest/_code/code.py | 3 +- src/_pytest/_code/record.py | 25 +- src/_pytest/_code/render.py | 15 +- src/_pytest/fixtures.py | 13 +- src/_pytest/freeze_support.py | 4 +- src/_pytest/raises.py | 4 +- src/_pytest/reports.py | 3 - testing/code/test_matching.py | 57 +++ testing/code/test_record.py | 27 +- testing/code/test_record_rendering.py | 602 ++++++++++++++++++++++++++ 11 files changed, 721 insertions(+), 42 deletions(-) create mode 100644 testing/code/test_record_rendering.py diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index 7ee654fcfba..8d4eb82ecf2 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -77,6 +77,16 @@ Migrate to the new data model and renderer architecture: * Use the :class:`pytest.FailureRecord` protocol when implementing custom failure representations. * Use :class:`pytest.PytestTraceback` for terminal and text rendering. +.. py:class:: _pytest._code.code.ReprTraceback + + Deprecated traceback representation returned by legacy compatibility APIs. + Use :class:`pytest.PytestTraceback` to render an exception record. + +.. py:class:: _pytest._code.code.ReprFileLocation + + Deprecated file location returned by legacy compatibility APIs. + Use :class:`pytest.Location` for structured locations. + .. _exceptionrecord-compat-properties: diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index f0a0ad12509..2620e43e315 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -307,8 +307,7 @@ def getsource( key = astnode = None if astcache is not None: key = self.frame.code.path - if key is not None: - astnode = astcache.get(key, None) + astnode = astcache.get(key, None) try: astnode, _, end = getstatementrange_ast( self.lineno, source, astnode=astnode diff --git a/src/_pytest/_code/record.py b/src/_pytest/_code/record.py index b72a6969525..b152266e639 100644 --- a/src/_pytest/_code/record.py +++ b/src/_pytest/_code/record.py @@ -199,8 +199,7 @@ def format_only(self, qualified_type_name: str) -> str: lines = format_exception_only(SyntaxError, err) if lines and qualified_type_name != "SyntaxError": last = lines[-1] - if last.startswith("SyntaxError"): - lines[-1] = qualified_type_name + last.removeprefix("SyntaxError") + lines[-1] = qualified_type_name + last.removeprefix("SyntaxError") return "".join(lines).rstrip() @@ -401,7 +400,10 @@ def reprcrash(self) -> Location | None: @property def reprtraceback(self) -> ReprTraceback: - """Compatibility property returning a rendered :class:`ReprTraceback` (deprecated).""" + """Compatibility property returning a rendered ``ReprTraceback`` (deprecated). + + :rtype: ``ReprTraceback`` + """ from _pytest.deprecated import EXCEPTION_RECORD_REPR_COMPAT_DEPRECATED warn( @@ -419,7 +421,13 @@ def reprtraceback(self) -> ReprTraceback: def chain( self, ) -> Sequence[tuple[ReprTraceback, ReprFileLocation | None, str | None]]: - """Compatibility property returning the rendered exception chain (deprecated).""" + """Compatibility property returning the rendered exception chain (deprecated). + + Each item contains a legacy ``ReprTraceback``, an optional + ``ReprFileLocation``, and an optional chain description. + + :rtype: ~collections.abc.Sequence + """ from _pytest.deprecated import EXCEPTION_RECORD_REPR_COMPAT_DEPRECATED warn( @@ -433,9 +441,8 @@ def chain( from _pytest._code.render import PytestTraceback repr_obj = PytestTraceback(self).to_repr() - if isinstance(repr_obj, ExceptionChainRepr): - return repr_obj.chain - return [(repr_obj.reprtraceback, repr_obj.reprcrash, None)] + assert isinstance(repr_obj, ExceptionChainRepr) + return repr_obj.chain def walk(self) -> Iterator[ExceptionRecord]: """Yield this record and all reachable cause, context, and child records in pre-order.""" @@ -536,7 +543,7 @@ def from_dict(cls, data: Mapping[str, Any]) -> ExceptionRecord: def to_rich_trace( self, *, show_hidden: bool = False, chain: bool = True ) -> rich.traceback.Trace: - """Convert this record into a :class:`rich.traceback.Trace`.""" + """Convert this record into a ``rich.traceback.Trace``.""" from rich import pretty from rich.traceback import _SyntaxError from rich.traceback import Frame as RichFrame @@ -627,7 +634,7 @@ def to_rich_trace( return RichTrace(stacks=stacks) def toterminal(self, tw: TerminalWriter) -> None: - """Compatibility shim rendering this record to a :class:`TerminalWriter`.""" + """Compatibility shim rendering this record to a ``TerminalWriter``.""" from _pytest._code.render import PytestTraceback PytestTraceback(self).toterminal(tw) diff --git a/src/_pytest/_code/render.py b/src/_pytest/_code/render.py index 377093a9c89..e6b8652e5bd 100644 --- a/src/_pytest/_code/render.py +++ b/src/_pytest/_code/render.py @@ -335,9 +335,6 @@ def _repr_frame_entry( if frame is not None and frame.repr_style is not None else effective_style ) - if style == "auto": - style = "long" - if style in ("short", "long") and frame is not None: short = style == "short" reprargs: ReprFuncArgs | None = None @@ -367,8 +364,8 @@ def _repr_frame_entry( lines, reprargs, localsrepr, reprfileloc, style, _ispytest=True ) elif style == "value": - if rec_if_last is not None: - lines.extend(rec_if_last.message.split("\n")) + assert rec_if_last is not None + lines.extend(rec_if_last.message.split("\n")) return ReprEntry(lines, None, None, None, style, _ispytest=True) else: if rec_if_last is not None: @@ -570,7 +567,7 @@ def _render_rich_to_lines(self, width: int = 100) -> list[str]: return buf.getvalue().splitlines(keepends=True) def to_repr(self) -> ReprExceptionInfo | ExceptionChainRepr: - """Build a legacy :class:`ReprExceptionInfo` or :class:`ExceptionChainRepr` from the record.""" + """Build a legacy ``ReprExceptionInfo`` or ``ExceptionChainRepr`` from the record.""" if self.style == "native": native_lines: list[str] = [] for rec, desc in self._iter_chain_records(): @@ -750,8 +747,7 @@ def render_fileloc(loc: ReprFileLocation) -> Text: linesofar = ns else: linesofar = f"{linesofar}, {ns}" if linesofar else ns - if linesofar: - yield Text(linesofar) + yield Text(linesofar) yield Text("") yield from render_entry_lines() @@ -795,6 +791,9 @@ def __rich_console__( yield Text.assemble((crash.path, "bold red"), f":{crash.lineno}: {msg}") else: yield Text(self.record.exconly(tryshort=True), style="bold red") + for name, content, sep in self._all_sections(): + yield Text(f"{sep * 10} {name} {sep * 10}") + yield Text(content) return repr_obj = self.to_repr() diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index ae61335dd85..684a29a4b51 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1121,16 +1121,15 @@ def __rich_console__( for tbline in self.tblines: yield Text(tbline.rstrip()) lines = self.errorstring.split("\n") - if lines: + yield Text( + f"{ExceptionInfoFormatter.fail_marker} {lines[0].strip()}", + style="red", + ) + for line in lines[1:]: yield Text( - f"{ExceptionInfoFormatter.fail_marker} {lines[0].strip()}", + f"{ExceptionInfoFormatter.flow_marker} {line.strip()}", style="red", ) - for line in lines[1:]: - yield Text( - f"{ExceptionInfoFormatter.flow_marker} {line.strip()}", - style="red", - ) yield Text("") yield Text(f"{os.fspath(self.filename)}:{self.firstlineno + 1}") for name, content, sep in self.sections: diff --git a/src/_pytest/freeze_support.py b/src/_pytest/freeze_support.py index 959ff071d86..61418fa6588 100644 --- a/src/_pytest/freeze_support.py +++ b/src/_pytest/freeze_support.py @@ -24,8 +24,8 @@ def _iter_all_modules( package, recursively. >>> import _pytest - >>> list(_iter_all_modules(_pytest)) - ['_pytest._argcomplete', '_pytest._code.code', ...] + >>> "_pytest._code.code" in _iter_all_modules(_pytest) + True """ import os import pkgutil diff --git a/src/_pytest/raises.py b/src/_pytest/raises.py index 6c023a927fc..f4daa4ebe59 100644 --- a/src/_pytest/raises.py +++ b/src/_pytest/raises.py @@ -1135,8 +1135,8 @@ def matches( if ( len(actual_exceptions) == len(self.expected_exceptions) == 1 and isinstance(expected := self.expected_exceptions[0], type) - # we explicitly break typing here :) - and self._check_check(actual_exceptions[0]) is None + # Probe the predicate on the unwrapped child for a suggestion. + and self._check_check(actual_exceptions[0]) is None # type: ignore[arg-type] ): suggestion = (self._repr_expected(expected), expected.__name__) self._failure = dataclasses.replace( diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 283c37001da..9724b91f5f5 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -661,9 +661,6 @@ def serialize_repr_entry( entry: ReprEntry | ReprEntryNative, ) -> dict[str, Any]: data = dataclasses.asdict(entry, dict_factory=repr_dict_factory) - for key, value in data.items(): - if hasattr(value, "__dict__"): - data[key] = dataclasses.asdict(value, dict_factory=repr_dict_factory) entry_data = {"type": type(entry).__name__, "data": data} return entry_data diff --git a/testing/code/test_matching.py b/testing/code/test_matching.py index a69ca41188a..46d5decd617 100644 --- a/testing/code/test_matching.py +++ b/testing/code/test_matching.py @@ -13,12 +13,15 @@ from _pytest._code.matching import Nested from _pytest._code.matching import NoException from _pytest._code.matching import NotAnExceptionGroup +from _pytest._code.matching import PairedWith from _pytest._code.matching import PairingDetails from _pytest._code.matching import PartiallyMatched from _pytest._code.matching import RegexMismatch from _pytest._code.matching import TooFewExceptions from _pytest._code.matching import TypeMismatch from _pytest._code.matching import UnexpectedExceptions +from _pytest._code.matching import UnmatchedActual +from _pytest._code.matching import UnmatchedExpected import pytest from pytest import RaisesExc from pytest import RaisesGroup @@ -280,3 +283,57 @@ def test_nested_failure_is_a_tree(self) -> None: out = render_rich(failure) assert "RaisesExc(ValueError, match='foo')" in out assert "Regex pattern did not match." in out + + +@pytest.mark.parametrize( + ("failure", "expected"), + [ + (NoException(), ["exception is None"]), + ( + PartiallyMatched(0, TypeMismatch("KeyError", "ValueError")), + ["not an instance"], + ), + ( + PartiallyMatched(2, TypeMismatch("KeyError", "ValueError")), + ["2 matched exceptions.", "not an instance"], + ), + ( + FlattenHint(TypeMismatch("KeyError", "ValueError")), + ["not an instance", "flatten_subgroups=True"], + ), + ( + PairingDetails( + 1, + "too_few", + ( + UnmatchedExpected( + "TypeError", (PairedWith("TypeError()", "Exception"),) + ), + ), + (), + greedy_hint=True, + ), + [ + "Too few exceptions raised!", + "Expected exceptions without a match", + "paired with", + "greedy algorithm", + ], + ), + ( + PairingDetails(0, "unexpected", (), (UnmatchedActual("KeyError()"),)), + [ + "Unexpected exception(s)!", + "Raised exceptions without a match", + "KeyError()", + ], + ), + ], +) +def test_structured_failure_rendering( + failure: MatchFailure, expected: list[str] +) -> None: + assert str(failure) == failure.to_text() + rich_output = " ".join(render_rich(failure).split()) + for text in expected: + assert text in rich_output diff --git a/testing/code/test_record.py b/testing/code/test_record.py index b14c9c6a32c..2ab8213797a 100644 --- a/testing/code/test_record.py +++ b/testing/code/test_record.py @@ -7,6 +7,7 @@ import pickle import sys from typing import Any +from unittest.mock import Mock import warnings from _pytest._code import Code @@ -513,7 +514,8 @@ def caller() -> None: elif style == "line": assert f"{__file__}:" in rendered assert "AssertionError: expected zero" in rendered - elif style in ("long", "short", "auto", "native", "rich"): + else: + assert style in ("long", "short", "auto", "native", "rich") assert "AssertionError" in rendered assert "expected zero" in rendered assert "leaf" in rendered @@ -602,12 +604,14 @@ def test_rich_failure(): result.stdout.fnmatch_lines( [ "*Traceback (most recent call last)*", - "*in test_rich_failure*", - "*in helper*", "*local_msg = 'bad 42'*", "*ValueError: bad 42*", ] ) + # Rich may wrap a function name onto the next line after a long path. + output = " ".join(result.stdout.str().replace("│", "").split()) + assert "in test_rich_failure" in output + assert "in helper" in output @pytest.mark.parametrize("tbstyle", ("long", "short", "line", "no")) def test_tb_style_preserves_frame_locations_and_record_14720( @@ -704,7 +708,8 @@ def nested_group() -> None: if style in ("long", "auto"): assert "a_arg = 10" in rendered assert "> assert a_local == 0" in rendered - elif style == "short": + else: + assert style == "short" assert "in child_a" in rendered assert "in child_b" in rendered elif style == "no": @@ -726,7 +731,8 @@ def nested_group() -> None: elif style == "native": assert "+ Exception Group Traceback (most recent call last):" in rendered assert "ValueError: from child_b" in rendered - elif style == "rich": + else: + assert style == "rich" assert "outer_group" in rendered assert "from child_b" in rendered @@ -777,12 +783,13 @@ def test_captured_source( path.unlink() linecache.clearcache() - def no_live_source(*args: Any, **kwargs: Any) -> Any: - raise AssertionError("renderer must not read linecache") + read_source = Mock( + side_effect=AssertionError("renderer must not read linecache") + ) renderer = PytestTraceback(record, style="rich") with monkeypatch.context() as patch: - patch.setattr(linecache, "getlines", no_live_source) + patch.setattr(linecache, "getlines", read_source) if entrypoint == "to_repr": rendered = str(renderer.to_repr()) elif entrypoint == "toterminal": @@ -793,10 +800,12 @@ def no_live_source(*args: Any, **kwargs: Any) -> Any: output = io.StringIO() Console(file=output, width=100).print(renderer) rendered = output.getvalue() + read_source.assert_not_called() assert "ORIGINAL_SOURCE" in rendered assert "REPLACEMENT_SOURCE" not in rendered assert "ZeroDivisionError" in rendered - assert "snapshot.py" in rendered + # File names can wrap in the middle when temporary paths are long. + assert "snapshot.py" in "".join(rendered.replace("│", "").split()) @pytest.mark.skipif( sys.version_info < (3, 11), reason="PEP 657 requires Python 3.11 or later" diff --git a/testing/code/test_record_rendering.py b/testing/code/test_record_rendering.py new file mode 100644 index 00000000000..3e49485f17c --- /dev/null +++ b/testing/code/test_record_rendering.py @@ -0,0 +1,602 @@ +from __future__ import annotations + +import dataclasses +import io +import json +from pathlib import Path +import sys +from typing import Any + +from rich.console import Console + +from _pytest._code import ExceptionRecord +from _pytest._code import FrameRecord +from _pytest._code import Location +from _pytest._code import PytestTraceback +from _pytest._code import TracebackStyle +from _pytest._code.record import SyntaxErrorDetails +from _pytest._code.source import Source +from _pytest.doctest import ReprFailDoctest +from _pytest.fixtures import FixtureLookupErrorRepr +from _pytest.pytester import Pytester +from _pytest.reports import CollectErrorRepr +import pytest + + +def frame(**overrides: Any) -> FrameRecord: + return dataclasses.replace( + FrameRecord( + path="snapshot.py", + abspath=None, + lineno=2, + end_lineno=2, + colno=11, + end_colno=16, + function="failure", + module="snapshot", + statement=("def failure():", " return 1 / 0"), + statement_lineno=1, + ), + **overrides, + ) + + +def rich_text(value: Any, width: int = 80) -> str: + output = io.StringIO() + Console(file=output, width=width).print(value) + return output.getvalue() + + +def test_location_rich_multiline_message() -> None: + rendered = rich_text(Location("snapshot.py", 2, "first line\nsecond line")) + assert "snapshot.py:2: first line" in rendered + assert "second line" not in rendered + + +def test_syntax_error_roundtrip_and_rich_details() -> None: + with pytest.raises(SyntaxError) as excinfo: + compile("x = (", "broken.py", "exec") + record = excinfo.record() + restored = ExceptionRecord.from_dict(json.loads(json.dumps(record.to_dict()))) + assert restored == record + assert restored.syntax_error is not None + assert restored.syntax_error.format_only("CustomSyntaxError").endswith( + f"CustomSyntaxError: {record.message}" + ) + rich_syntax = restored.to_rich_trace().stacks[0].syntax_error + assert rich_syntax is not None + assert rich_syntax.filename == "broken.py" + assert rich_syntax.line == "x = (\n" + + +def test_syntax_error_details_without_message() -> None: + details = SyntaxErrorDetails(None, None, None, None) + assert SyntaxErrorDetails.from_dict(details.to_dict()) == details + assert details.format_only("SyntaxError") == "SyntaxError: " + + +def test_unknown_frame_style_on_wire() -> None: + data = frame().to_dict() + data["repr_style"] = "unsupported" + restored = FrameRecord.from_dict(data) + assert restored.repr_style is None + assert "return 1 / 0" in str( + PytestTraceback( + ExceptionRecord("ValueError", "builtins", "bad", frames=(restored,)) + ) + ) + + +def test_walk_includes_group_children_and_their_chains() -> None: + cause = ExceptionRecord("KeyError", "builtins", "cause") + child = ExceptionRecord("ValueError", "builtins", "child", cause=cause) + parent = ExceptionRecord("ExceptionGroup", "builtins", "group", children=(child,)) + assert list(parent.walk()) == [parent, child, cause] + + +@pytest.mark.parametrize("style", ["long", "short", "line", "native", "rich"]) +@pytest.mark.parametrize("hidden", [False, True]) +def test_sections_survive_every_rendering_entrypoint( + style: TracebackStyle, hidden: bool +) -> None: + record = ExceptionRecord( + "ValueError", + "builtins", + "bad", + frames=(frame(hidden=hidden),), + sections=(("worker output", "captured at worker", "-"),), + ) + renderer = PytestTraceback(record, style=style) + renderer.addsection("controller output", "captured at controller", "=") + for rendered in [str(renderer), str(renderer.to_repr()), rich_text(renderer)]: + assert "captured at worker" in rendered + assert "captured at controller" in rendered + assert "ValueError: bad" in rendered + if hidden and style in ("rich", "long", "short"): + assert "All traceback entries are hidden" in str(renderer) + assert "All traceback entries are hidden" in rich_text(renderer) + + +def test_line_rendering_without_frames() -> None: + renderer = PytestTraceback( + ExceptionRecord("ValueError", "builtins", "no traceback"), style="line" + ) + renderer.addsection("output", "captured content") + for rendered in [str(renderer), rich_text(renderer)]: + assert "ValueError: no traceback" in rendered + assert "captured content" in rendered + + +@pytest.mark.parametrize("style", ["long", "short"]) +def test_record_with_unavailable_source_range(style: TracebackStyle) -> None: + record = ExceptionRecord( + "ValueError", "builtins", "bad", frames=(frame(lineno=100),) + ) + rendered = str(PytestTraceback(record, style=style)) + assert "???" in rendered + assert "snapshot.py:100" in rendered + + +def test_rendering_without_column_positions() -> None: + record = ExceptionRecord( + "ValueError", "builtins", "bad", frames=(frame(colno=None, end_colno=None),) + ) + rendered = str(PytestTraceback(record)) + assert "return 1 / 0" in rendered + assert "^" not in rendered + + +@pytest.mark.parametrize("recursion_index", [None, 1]) +def test_recorded_recursion_elision(recursion_index: int | None) -> None: + frames = tuple(frame(path=f"frame_{i}.py") for i in range(4)) + record = ExceptionRecord( + "RecursionError", + "builtins", + "recursive call", + frames=frames, + recursion_index=recursion_index, + ) + rendered = str(PytestTraceback(record)) + assert "frame_0.py" in rendered + if recursion_index is None: + assert "frame_3.py" in rendered + else: + assert "frame_3.py" not in rendered + assert "Recursion detected" in rendered + + +def test_rich_frame_limit_preserves_first_and_last_frames() -> None: + record = ExceptionRecord( + "ValueError", + "builtins", + "bad", + frames=tuple(frame(path=f"frame_{i}.py") for i in range(105)), + ) + rendered = str(PytestTraceback(record, style="rich")) + assert "5 frames hidden" in rendered + assert "frame_0.py" in rendered + assert "frame_104.py" in rendered + assert "frame_52.py" not in rendered + + +def test_rich_frame_with_no_source_or_locals() -> None: + record = ExceptionRecord( + "ValueError", "builtins", "bad", frames=(frame(statement=()),) + ) + rendered = str(PytestTraceback(record, style="rich")) + assert "snapshot.py:2" in rendered + assert "ValueError: bad" in rendered + + +def test_native_group_children_keep_their_chains_and_positions() -> None: + cause = ExceptionRecord("KeyError", "builtins", "cause", frames=(frame(),)) + child = ExceptionRecord("ValueError", "builtins", "child", cause=cause) + group = ExceptionRecord( + "ExceptionGroup", "builtins", "group", frames=(frame(),), children=(child,) + ) + rendered = str(PytestTraceback(group, style="native")) + if sys.version_info >= (3, 11): + assert "^^^^^" in rendered + assert "direct cause" in rendered + assert "KeyError: cause" in rendered + assert "ValueError: child" in rendered + + +@pytest.mark.parametrize("statement", [(), ("",)]) +def test_native_group_with_missing_or_blank_source(statement: tuple[str, ...]) -> None: + child = ExceptionRecord("ValueError", "builtins", "child") + group = ExceptionRecord( + "ExceptionGroup", + "builtins", + "group", + frames=(frame(statement=statement, statement_lineno=2),), + children=(child,), + ) + assert "ValueError: child" in str(PytestTraceback(group, style="native")) + + +def test_rich_wide_separator_and_long_arguments() -> None: + record = ExceptionRecord( + "ValueError", + "builtins", + "bad", + frames=( + frame(args=(("short", "1"), ("long", repr("x" * 100)))), + frame(repr_style="short"), + frame(repr_style="long"), + ), + ) + renderer = PytestTraceback(record, funcargs=True) + renderer.addsection("captured", "content") + rendered = rich_text(renderer, width=81) + assert "short = 1" in rendered + assert "long =" in rendered + assert "captured" in rendered + assert "content" in rendered + assert "_ _ _" in rendered + + +def test_rich_location_uses_first_message_line(monkeypatch: pytest.MonkeyPatch) -> None: + from _pytest._code.code import ReprEntry + from _pytest._code.code import ReprFileLocation + + location = ReprFileLocation("file.py", 2, "first\nsecond", _ispytest=True) + renderer = PytestTraceback( + ExceptionRecord("ValueError", "builtins", "bad", frames=(frame(),)) + ) + repr_obj = renderer.to_repr() + entry = repr_obj.reprtraceback.reprentries[-1] + assert isinstance(entry, ReprEntry) + entry.reprfileloc = location + monkeypatch.setattr(renderer, "to_repr", lambda: repr_obj) + rendered = rich_text(renderer) + assert "file.py:2: first" in rendered + assert "second" not in rendered + + +def test_empty_and_legacy_doctest_locations() -> None: + from _pytest._code.code import ReprFileLocation + + assert ReprFailDoctest([]).crash is None + legacy = ReprFileLocation("doctest.txt", 3, "failure", _ispytest=True) + assert ReprFailDoctest([(legacy, ["details"])]).crash == Location( + "doctest.txt", 3, "failure" + ) + + +@pytest.mark.parametrize("kind", ["doctest", "fixture", "collect"]) +def test_failure_records_ignore_incomplete_sections(kind: str) -> None: + record: Any + if kind == "doctest": + record = ReprFailDoctest([(Location("test.txt", 3, "bad"), ["details"])]) + elif kind == "fixture": + record = FixtureLookupErrorRepr("test.py", 2, [], "missing fixture", "missing") + else: + record = CollectErrorRepr("collection failed") + data = record.to_dict() + data["sections"] = [["incomplete"], ["output", "content", "-"]] + if kind == "doctest": + data["reprlocation_lines"].append(None) + restored = type(record).from_dict(data) + assert restored.sections == [("output", "content", "-")] + assert "content" in rich_text(restored) + + +def test_empty_source_indentation_fallback() -> None: + from _pytest._code.render import _getindent + + assert _getindent(Source()) == 0 + + +def test_default_rendering_survives_an_unavailable_working_directory( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from _pytest._code import render + + def unavailable(*args: Any) -> str: + raise OSError("working directory removed") + + record = ExceptionRecord( + "ValueError", "builtins", "bad", frames=(frame(abspath="/tmp/snapshot.py"),) + ) + monkeypatch.setattr(render, "bestrelpath", unavailable) + assert "/tmp/snapshot.py:2" in str(PytestTraceback(record)) + + +@pytest.mark.parametrize( + "notes", [None, {}, "invalid", b"invalid"], ids=["none", "mapping", "str", "bytes"] +) +def test_invalid_exception_notes_are_ignored(notes: Any) -> None: + error = ValueError("bad") + setattr(error, "__notes__", notes) + with pytest.raises(ValueError) as excinfo: + raise error + assert excinfo.record().notes == () + + +def test_unprintable_note_uses_safe_repr() -> None: + class Note: + def __str__(self) -> str: + raise ValueError("broken note") + + def __repr__(self) -> str: + return "" + + error = ValueError("bad") + setattr(error, "__notes__", [Note()]) + with pytest.raises(ValueError) as excinfo: + raise error + assert excinfo.record().notes == ("",) + + +def test_legacy_httperror_missing_notes() -> None: + from email.message import Message + from urllib.error import HTTPError + + class LegacyHTTPError(HTTPError): + def __getattr__(self, name: str) -> Any: + if name == "__notes__": + raise KeyError(name) + return super().__getattr__(name) + + error = LegacyHTTPError("https://example.test", 404, "not found", Message(), None) + assert not hasattr(error, "missing_field") + with pytest.raises(HTTPError) as excinfo: + raise error + if sys.version_info < (3, 12): + assert excinfo.record().notes == () + else: + with pytest.raises(KeyError, match="__notes__"): + excinfo.record() + + +def test_unknown_exception_module(monkeypatch: pytest.MonkeyPatch) -> None: + class UnknownError(Exception): + pass + + monkeypatch.setattr(UnknownError, "__module__", None) + with pytest.raises(UnknownError) as excinfo: + raise UnknownError("bad") + assert excinfo.record().type_module == "" + + +def test_capture_when_relative_path_resolution_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from _pytest._code import code + + with pytest.raises(ValueError) as excinfo: + raise ValueError("bad") + + def unavailable(*args: Any) -> str: + raise OSError("working directory removed") + + monkeypatch.setattr(code, "bestrelpath", unavailable) + assert excinfo.record().frames[-1].path == str(Path(__file__)) + + +def test_capture_keeps_builtins_as_a_placeholder() -> None: + namespace: dict[str, Any] = {} + with pytest.raises(ValueError) as excinfo: + exec('raise ValueError("bad")', namespace) + snapshot = excinfo.record(showlocals=True).frames[-1] + assert snapshot.locals is not None + assert snapshot.locals["__builtins__"] == "" + + +def test_exconly_single_nested_exception_group() -> None: + if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + else: + from builtins import ExceptionGroup + + with pytest.raises(ExceptionGroup) as excinfo: + raise ExceptionGroup("outer", [ExceptionGroup("inner", [ValueError("bad")])]) + assert ( + excinfo.exconly(tryshort=True) + == "ValueError('bad') [single exception in ExceptionGroup]" + ) + + +@pytest.mark.parametrize("metadata", ["globals", "locals", "positions"]) +def test_capture_degrades_when_frame_metadata_is_unavailable( + monkeypatch: pytest.MonkeyPatch, + metadata: str, +) -> None: + from types import SimpleNamespace + + from _pytest._code.code import Frame + from _pytest._code.code import TracebackEntry + + with pytest.raises(ValueError) as excinfo: + raise ValueError("bad") + + def unavailable(*args: Any) -> Any: + raise ValueError("metadata unavailable") + + with monkeypatch.context() as patch: + if metadata == "globals": + patch.setattr(Frame, "f_globals", property(unavailable)) + elif metadata == "locals": + patch.setattr(TracebackEntry, "locals", property(unavailable)) + else: + patch.setattr( + TracebackEntry, + "get_python_framesummary", + lambda entry: SimpleNamespace( + lineno=None, end_lineno=None, colno=None, end_colno=None + ), + ) + record = excinfo.record(showlocals=True, tbfilter=lambda info: info.traceback) + assert record.message == "bad" + last = record.frames[-1] + assert last.lineno == excinfo.traceback[-1].lineno + 1 + if metadata == "globals": + assert last.module is None + elif metadata == "locals": + assert last.locals == {} + else: + assert last.end_lineno is None + assert last.colno is None + + +def test_capture_source_changed_to_invalid_python( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from types import SimpleNamespace + + from _pytest._code import code + from _pytest._code.code import TracebackEntry + + with pytest.raises(ValueError) as excinfo: + raise ValueError("bad") + + def invalid_source(*args: Any, **kwargs: Any) -> Any: + raise SyntaxError("file edited during capture") + + monkeypatch.setattr(code, "getstatementrange_ast", invalid_source) + monkeypatch.setattr( + TracebackEntry, + "get_python_framesummary", + lambda entry: SimpleNamespace( + lineno=None, end_lineno=None, colno=None, end_colno=None + ), + ) + record = excinfo.record() + assert "raise ValueError" in record.frames[-1].statement[-1] + + +def test_source_indentation_preserves_keyboard_interrupt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from _pytest._code.render import _getindent + + def interrupt(*args: Any) -> Any: + raise KeyboardInterrupt + + monkeypatch.setattr(Source, "getstatement", interrupt) + with pytest.raises(KeyboardInterrupt): + _getindent(Source("pass")) + + +def test_source_indentation_fallback_preserves_keyboard_interrupt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from _pytest._code.render import _getindent + + def invalid(*args: Any) -> Any: + raise ValueError("source unavailable") + + def interrupt(*args: Any) -> Any: + raise KeyboardInterrupt + + monkeypatch.setattr(Source, "getstatement", invalid) + monkeypatch.setattr(Source, "__getitem__", interrupt) + with pytest.raises(KeyboardInterrupt): + _getindent(Source("pass")) + + +@pytest.mark.parametrize("style", ["no", "value"]) +@pytest.mark.parametrize("hidden", [False, True]) +def test_legacy_formatter_compact_styles(style: TracebackStyle, hidden: bool) -> None: + from _pytest._code.code import ExceptionInfoFormatter + + __tracebackhide__ = hidden + with pytest.raises(ValueError) as excinfo: + raise ValueError("compact failure") + rendered = str(ExceptionInfoFormatter(style=style).repr_traceback(excinfo)) + assert "compact failure" in rendered + if hidden: + assert "All traceback entries are hidden" in rendered + + +def test_empty_legacy_entry_and_oversized_arguments_render_consistently() -> None: + from rich.console import Group + + from _pytest._code.code import ReprEntry + from _pytest._code.code import ReprFileLocation + from _pytest._code.code import ReprFuncArgs + from _pytest._io import TerminalWriter + + renderer = PytestTraceback(ExceptionRecord("ValueError", "builtins", "bad")) + location = ReprFileLocation("file.py", 2, "failure", _ispytest=True) + arguments = ReprFuncArgs([("long", repr("x" * 100))], _ispytest=True) + entry = ReprEntry([], arguments, None, location, "long", _ispytest=True) + rendered = rich_text(Group(*renderer._render_entry_rich(entry, width=40))) + assert "file.py:2: failure" in rendered + assert "long =" in rendered + assert "file.py:2: failure" in str(entry) + output = io.StringIO() + entry.toterminal(TerminalWriter(output)) + assert "file.py:2: failure" in output.getvalue() + + +@pytest.mark.parametrize("style", ["auto", "short", "rich"]) +@pytest.mark.parametrize("full_trace", [False, True]) +def test_node_default_style_selection( + pytester: Pytester, style: str, full_trace: bool +) -> None: + item = pytester.getitem("def test_func(): pass") + item.config.option.tbstyle = style + item.config.option.fulltrace = full_trace + with pytest.raises(ValueError) as excinfo: + raise ValueError("style selection") + explicit_style: TracebackStyle | None = ( + "rich" if full_trace and style == "rich" else None + ) + rendered = str(item._repr_failure_py(excinfo, style=explicit_style)) + assert "ValueError: style selection" in rendered + if style == "rich": + assert "Traceback (most recent call last)" in rendered + + +def test_legacy_report_roundtrip_without_an_attached_record() -> None: + from _pytest._code.code import ReprExceptionInfo + from _pytest._code.code import ReprTracebackNative + from _pytest.reports import TestReport + + representation = ReprExceptionInfo( + ReprTracebackNative(["ValueError: legacy plugin\n"], _ispytest=True), + None, + _ispytest=True, + ) + report = TestReport( + nodeid="test.py::test", + location=("test.py", 0, "test"), + keywords={}, + outcome="failed", + longrepr=representation, + when="call", + ) + wire = json.loads(json.dumps(report._to_json())) + assert "record" not in wire["longrepr"] + restored = TestReport._from_json(wire) + assert restored.longreprtext == report.longreprtext + + +def test_group_with_a_cyclic_children_property() -> None: + if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + else: + from builtins import ExceptionGroup + + class CyclicGroup(ExceptionGroup[Exception]): + @property + def exceptions(self) -> tuple[Exception, ...]: + return (self,) + + with pytest.raises(CyclicGroup) as excinfo: + raise CyclicGroup("cycle", [ValueError("child")]) + record = excinfo.record() + assert record.children == () + assert ExceptionRecord.from_dict(json.loads(json.dumps(record.to_dict()))) == record + + +@pytest.mark.parametrize("statement", [("", ""), (" ", " ")]) +def test_native_traceback_with_blank_source(statement: tuple[str, ...]) -> None: + record = ExceptionRecord( + "ValueError", "builtins", "bad", frames=(frame(statement=statement),) + ) + rendered = str(PytestTraceback(record, style="native")) + assert "ValueError: bad" in rendered + assert "snapshot.py" in rendered