diff --git a/AUTHORS b/AUTHORS index 136ffd6c5ee..050e2d89b84 100644 --- a/AUTHORS +++ b/AUTHORS @@ -290,6 +290,7 @@ Lev Maximov Levon Saldamli Lewis Cowles Liam DeVoe +Liu Feiyu Llandy Riveron Del Risco Loic Esteve lovetheguitar diff --git a/changelog/15297.feature.rst b/changelog/15297.feature.rst new file mode 100644 index 00000000000..ca481085e8d --- /dev/null +++ b/changelog/15297.feature.rst @@ -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. diff --git a/doc/en/conf.py b/doc/en/conf.py index 2083e72dfbf..d831c21b457 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -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"), diff --git a/doc/en/example/nonpython.rst b/doc/en/example/nonpython.rst index 9ac2b508ceb..c4fa79d6552 100644 --- a/doc/en/example/nonpython.rst +++ b/doc/en/example/nonpython.rst @@ -105,3 +105,43 @@ interesting to look at the collection tree: ======================== 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. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index b524a2395a1..1c66a0da312 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -996,6 +996,24 @@ ExceptionInfo :members: +Traceback +~~~~~~~~~ + +.. versionadded:: 10.0 + +.. autoclass:: pytest.Traceback() + :members: + + +TracebackEntry +~~~~~~~~~~~~~~ + +.. versionadded:: 10.0 + +.. autoclass:: pytest.TracebackEntry() + :members: + + ExitCode ~~~~~~~~ diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index f6d2f27672c..ee92693cb8c 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -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 diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 2b510907122..43959cf9ce6 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -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 @@ -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? diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index ecd075ff5d3..b02a5d9a771 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -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 @@ -165,6 +167,8 @@ "TestReport", "TestShortLogReport", "Testdir", + "Traceback", + "TracebackEntry", "UsageError", "WarningsRecorder", "__version__", diff --git a/testing/test_nodes.py b/testing/test_nodes.py index e976b9e6f11..abb5a9eeaa6 100644 --- a/testing/test_nodes.py +++ b/testing/test_nodes.py @@ -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] diff --git a/testing/typing_checks.py b/testing/typing_checks.py index 1179f3f5d19..5d333f19214 100644 --- a/testing/typing_checks.py +++ b/testing/typing_checks.py @@ -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.