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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelog/15072.deprecation.rst
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions changelog/15072.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
88 changes: 88 additions & 0 deletions doc/en/deprecations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,94 @@ 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 <warnings>`.

.. _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() <pytest.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.

.. 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:

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 <pytest.ExceptionRecord.crash>` instead of ``reprcrash``.
* Use :meth:`ExceptionRecord.iter_chain() <pytest.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()``
Expand Down
42 changes: 42 additions & 0 deletions doc/en/reference/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~~

Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -480,6 +481,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
Expand Down
16 changes: 16 additions & 0 deletions src/_pytest/_code/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,33 @@
from .code import getfslineno
from .code import Traceback
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
from .record import SyntaxErrorDetails
from .render import PytestTraceback
from .source import getrawcode
from .source import Source


__all__ = [
"RECORD_SCHEMA_VERSION",
"Code",
"ExceptionInfo",
"ExceptionRecord",
"FailureRecord",
"Frame",
"FrameRecord",
"Location",
"PytestTraceback",
"Source",
"SyntaxErrorDetails",
"Traceback",
"TracebackEntry",
"TracebackStyle",
"filter_traceback",
"getfslineno",
"getrawcode",
Expand Down
139 changes: 139 additions & 0 deletions src/_pytest/_code/_rich.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading