Skip to content
Open
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
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ Lev Maximov
Levon Saldamli
Lewis Cowles
Liam DeVoe
Liu Feiyu
Llandy Riveron Del Risco
Loic Esteve
lovetheguitar
Expand Down
5 changes: 5 additions & 0 deletions changelog/15297.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Added :meth:`Node.traceback_filter <_pytest.nodes.Node.traceback_filter>` for customizing
failure tracebacks without replacing standard failure formatting, and exported
:class:`pytest.Traceback` and :class:`pytest.TracebackEntry` for type annotations.
Existing ``_traceback_filter`` overrides remain supported; the public method takes
precedence when both are overridden. Default filtering behavior is unchanged.
2 changes: 1 addition & 1 deletion doc/en/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
("py:class", "_pytest.fixtures.FixtureFunctionDefinition"),
("py:class", "_pytest.logging.LogCaptureHandler"),
# Intentionally undocumented/private
("py:class", "_pytest._code.code.Traceback"),
("py:class", "_pytest._code.source.Source"),
("py:class", "_pytest._py.path.LocalPath"),
("py:class", "_pytest.capture.CaptureResult"),
("py:class", "_pytest.compat.NotSetType"),
Expand Down
40 changes: 40 additions & 0 deletions doc/en/example/nonpython.rst
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,43 @@ interesting to look at the collection tree:
<YamlItem ok>

======================== 2 tests collected in 0.12s ========================


Customizing tracebacks
----------------------

To filter the traceback of a custom :class:`pytest.Item` while keeping pytest's
standard failure formatting, override
:meth:`~_pytest.nodes.Node.traceback_filter` instead of ``repr_failure``.
For example, an item which executes tests through a helper module can keep only
frames from its own file:

.. code-block:: python

import pytest


class CustomItem(pytest.Item):
def traceback_filter(
self, excinfo: pytest.ExceptionInfo[BaseException]
) -> pytest.Traceback:
def keep(entry: pytest.TracebackEntry) -> bool:
return entry.path == self.path

return excinfo.traceback.filter(keep)

Adapt the predicate to your test format; the example assumes that executing the
test produces Python frames whose paths match the item's path.
The predicate returns ``True`` for frames to keep. You can also start from
``super().traceback_filter(excinfo)`` to extend the inherited filtering.
The default filtering is unchanged: the base ``Item`` returns the original
traceback, while other node classes, such as ``Function``, provide their own
filtering.

The standard formatter continues to handle pytest's special exceptions and
traceback styles (``--tb``). With ``--full-trace``, filtering is bypassed.
An item that overrides ``repr_failure`` must use the inherited implementation
for this filtering to take effect.

Existing ``_traceback_filter`` overrides continue to work. If an item overrides
both names, ``traceback_filter`` takes precedence.
18 changes: 18 additions & 0 deletions doc/en/reference/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,24 @@ ExceptionInfo
:members:


Traceback
~~~~~~~~~

.. versionadded:: 10.0

.. autoclass:: pytest.Traceback()
:members:


TracebackEntry
~~~~~~~~~~~~~~

.. versionadded:: 10.0

.. autoclass:: pytest.TracebackEntry()
:members:


ExitCode
~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion src/_pytest/_code/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ def filter(
) -> Traceback:
"""Return a Traceback instance with certain items removed.

If the filter is an `ExceptionInfo`, removes all the ``TracebackEntry``s
If the filter is an `ExceptionInfo`, removes all the ``TracebackEntry`` instances
which are hidden (see ishidden() above).

Otherwise, the filter is a function that gets a single argument, a
Expand Down
21 changes: 20 additions & 1 deletion src/_pytest/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,25 @@ def getparent(self, cls: type[_NodeType]) -> _NodeType | None:
return node
return None

def traceback_filter(self, excinfo: ExceptionInfo[BaseException]) -> Traceback:
"""Return the traceback to display for a failure.

Override this method to customize traceback filtering while retaining
pytest's standard failure formatting. It is not called when
``--full-trace`` is used.

The default implementation delegates to ``_traceback_filter`` for
compatibility with existing overrides. If both methods are overridden,
this public method takes precedence. Use ``super().traceback_filter``
to apply the inherited filtering before your own.

:param excinfo: Exception information for the failure being formatted.
:returns: The filtered traceback.

.. versionadded:: 10.0
"""
return self._traceback_filter(excinfo)

def _traceback_filter(self, excinfo: ExceptionInfo[BaseException]) -> Traceback:
return excinfo.traceback

Expand All @@ -446,7 +465,7 @@ def _repr_failure_py(
style = "long"
tbfilter = False
else:
tbfilter = self._traceback_filter
tbfilter = self.traceback_filter
if style == "auto":
style = "long"
# XXX should excinfo.getrepr record all data and toterminal() process it?
Expand Down
4 changes: 4 additions & 0 deletions src/pytest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from _pytest import __version__
from _pytest import version_tuple
from _pytest._code import ExceptionInfo
from _pytest._code import Traceback
from _pytest._code import TracebackEntry
from _pytest.approx import Approx
from _pytest.approx import approx
from _pytest.assertion import register_assert_rewrite
Expand Down Expand Up @@ -165,6 +167,8 @@
"TestReport",
"TestShortLogReport",
"Testdir",
"Traceback",
"TracebackEntry",
"UsageError",
"WarningsRecorder",
"__version__",
Expand Down
138 changes: 138 additions & 0 deletions testing/test_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,144 @@
import pytest


@pytest.mark.parametrize("method", ["traceback_filter", "_traceback_filter", "both"])
@pytest.mark.parametrize("tbstyle", ["auto", "long", "short", "native"])
@pytest.mark.parametrize("fulltrace", [False, True])
@pytest.mark.parametrize("item_type", ["custom", "python"])
def test_custom_item_traceback_filter(
pytester: Pytester, method: str, tbstyle: str, fulltrace: bool, item_type: str
) -> None:
pytester.makeconftest(
"""
import pytest

def pytest_pycollect_makeitem(collector, name, obj):
if name == "test_failure":
return CustomItem.from_parent(collector, name=name)

def pytest_collect_file(file_path, parent):
if file_path.suffix == ".custom":
return CustomFile.from_parent(parent, path=file_path)

class CustomFile(pytest.File):
def collect(self):
yield CustomItem.from_parent(self, name="custom")

def leaf():
raise ValueError("leaf failure")

class CustomItem(BASE):
def runtest(self):
leaf()

def filter_failure(self, excinfo):
assert not self.config.getoption("fulltrace")
return excinfo.traceback.filter(lambda entry: entry.name == "leaf")
""".replace(
"BASE", "pytest.Function" if item_type == "python" else "pytest.Item"
)
)
with (pytester.path / "conftest.py").open("a", encoding="utf-8") as f:
if method == "both":
f.write(
"\n traceback_filter = filter_failure\n"
" def _traceback_filter(self, excinfo):\n"
" raise AssertionError('private override must not run')\n"
)
else:
f.write(f"\n {method} = filter_failure\n")
if item_type == "custom":
pytester.makefile(".custom", test_failure="")
else:
pytester.makepyfile("def test_failure(): pass")
args = [f"--tb={tbstyle}"]
if fulltrace:
args.append("--full-trace")
result = pytester.runpytest(*args)
result.assert_outcomes(failed=1)
result.stdout.fnmatch_lines(["*ValueError: leaf failure*"])
unfiltered = fulltrace or (item_type == "python" and tbstyle == "native")
assert (
any(re.fullmatch(r"\s*>?\s*leaf\(\)\s*", line) for line in result.stdout.lines)
== unfiltered
)


def test_public_traceback_types() -> None:
with pytest.raises(ValueError, match="failure") as excinfo:
raise ValueError("failure")
assert isinstance(excinfo.traceback, pytest.Traceback)
assert isinstance(excinfo.traceback[-1], pytest.TracebackEntry)


def test_traceback_filter_special_failures(pytester: Pytester) -> None:
pytester.makeconftest(
"""
import pytest

def pytest_pycollect_makeitem(collector, name, obj):
if name.startswith("test_"):
return CustomFunction.from_parent(collector, name=name)

class CustomFunction(pytest.Function):
def traceback_filter(self, excinfo):
assert not isinstance(excinfo.value, pytest.FixtureLookupError)
return super().traceback_filter(excinfo)[-1:]
"""
)
pytester.makepyfile(
"""
import pytest

def test_missing_fixture(missing_fixture):
pass

def test_no_traceback():
pytest.fail("message only", pytrace=False)
"""
)
result = pytester.runpytest()
result.assert_outcomes(failed=1, errors=1)
result.stdout.fnmatch_lines(
["*fixture 'missing_fixture' not found*", "message only"]
)
result.stdout.no_fnmatch_line("*pytest.fail(*")


@pytest.mark.parametrize("fulltrace", [False, True])
def test_custom_collector_traceback_filter(pytester: Pytester, fulltrace: bool) -> None:
pytester.makeconftest(
"""
import pytest

def pytest_collect_file(file_path, parent):
if file_path.suffix == ".custom":
return CustomFile.from_parent(parent, path=file_path)

def leaf():
raise ValueError("collection failure")

class CustomFile(pytest.File):
def collect(self):
leaf()

def traceback_filter(self, excinfo):
assert not self.config.getoption("fulltrace")
return super().traceback_filter(excinfo).filter(
lambda entry: entry.name == "leaf"
)
"""
)
pytester.makefile(".custom", test_failure="")
result = pytester.runpytest("--full-trace") if fulltrace else pytester.runpytest()
result.assert_outcomes(errors=1)
result.stdout.fnmatch_lines(["*ValueError: collection failure*"])
assert (
any(re.fullmatch(r"\s*>?\s*leaf\(\)\s*", line) for line in result.stdout.lines)
== fulltrace
)


def test_node_from_parent_disallowed_arguments() -> None:
with pytest.raises(TypeError, match="session is"):
nodes.Node.from_parent(None, session=None) # type: ignore[arg-type]
Expand Down
18 changes: 18 additions & 0 deletions testing/typing_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,24 @@
from pytest import MonkeyPatch
from pytest import ScopeName
from pytest import TestReport
from pytest import Traceback
from pytest import TracebackEntry


def check_traceback_filter(
item: pytest.Item, excinfo: pytest.ExceptionInfo[BaseException]
) -> None:
def keep(entry: TracebackEntry) -> bool:
return entry.path == item.path

assert_type(item.traceback_filter(excinfo), Traceback)
assert_type(excinfo.traceback.filter(keep), Traceback)

class CustomItem(pytest.Item):
def traceback_filter(
self, excinfo: pytest.ExceptionInfo[BaseException]
) -> Traceback:
return super().traceback_filter(excinfo).filter(keep)


# Issue #7488.
Expand Down
Loading