diff --git a/changelog/12472.bugfix.rst b/changelog/12472.bugfix.rst new file mode 100644 index 00000000000..f08e9d1f90b --- /dev/null +++ b/changelog/12472.bugfix.rst @@ -0,0 +1 @@ +Fixed a crash when returning category ``"error"`` or ``"failed"`` with a custom test status from :hook:`pytest_report_teststatus` hook -- :user:`pbrezina`. diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 2f39adbfa6f..9e61d7def40 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -13,6 +13,7 @@ from typing import Literal from typing import Mapping from typing import NoReturn +from typing import Sequence from typing import TYPE_CHECKING from _pytest._code.code import ExceptionChainRepr @@ -30,6 +31,7 @@ from _pytest.config import Config from _pytest.nodes import Collector from _pytest.nodes import Item +from _pytest.outcomes import fail from _pytest.outcomes import skip @@ -190,11 +192,26 @@ def head_line(self) -> str | None: return domain return None - def _get_verbose_word(self, config: Config): + def _get_verbose_word_with_markup( + self, config: Config, default_markup: Mapping[str, bool] + ) -> tuple[str, Mapping[str, bool]]: _category, _short, verbose = config.hook.pytest_report_teststatus( report=self, config=config ) - return verbose + + if isinstance(verbose, str): + return verbose, default_markup + + if isinstance(verbose, Sequence) and len(verbose) == 2: + word, markup = verbose + if isinstance(word, str) and isinstance(markup, Mapping): + return word, markup + + fail( + "pytest_report_teststatus() hook (from a plugin) returned " + "an invalid verbose value: {verbose!r}.\nExpected either a string " + "or a tuple of (word, markup)." + ) def _to_json(self) -> dict[str, Any]: """Return the contents of this report as a dict of builtin entries, diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 26c573f583e..f364316e2e2 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -1206,10 +1206,10 @@ def show_simple(lines: list[str], *, stat: str) -> None: def show_xfailed(lines: list[str]) -> None: xfailed = self.stats.get("xfailed", []) for rep in xfailed: - verbose_word = rep._get_verbose_word(self.config) - markup_word = self._tw.markup( - verbose_word, **{_color_for_type["warnings"]: True} + verbose_word, verbose_markup = rep._get_verbose_word_with_markup( + self.config, {_color_for_type["warnings"]: True} ) + markup_word = self._tw.markup(verbose_word, **verbose_markup) nodeid = _get_node_id_with_markup(self._tw, self.config, rep) line = f"{markup_word} {nodeid}" reason = rep.wasxfail @@ -1221,10 +1221,10 @@ def show_xfailed(lines: list[str]) -> None: def show_xpassed(lines: list[str]) -> None: xpassed = self.stats.get("xpassed", []) for rep in xpassed: - verbose_word = rep._get_verbose_word(self.config) - markup_word = self._tw.markup( - verbose_word, **{_color_for_type["warnings"]: True} + verbose_word, verbose_markup = rep._get_verbose_word_with_markup( + self.config, {_color_for_type["warnings"]: True} ) + markup_word = self._tw.markup(verbose_word, **verbose_markup) nodeid = _get_node_id_with_markup(self._tw, self.config, rep) line = f"{markup_word} {nodeid}" reason = rep.wasxfail @@ -1237,10 +1237,10 @@ def show_skipped(lines: list[str]) -> None: fskips = _folded_skips(self.startpath, skipped) if skipped else [] if not fskips: return - verbose_word = skipped[0]._get_verbose_word(self.config) - markup_word = self._tw.markup( - verbose_word, **{_color_for_type["warnings"]: True} + verbose_word, verbose_markup = skipped[0]._get_verbose_word_with_markup( + self.config, {_color_for_type["warnings"]: True} ) + markup_word = self._tw.markup(verbose_word, **verbose_markup) prefix = "Skipped: " for num, fspath, lineno, reason in fskips: if reason.startswith(prefix): @@ -1421,8 +1421,10 @@ def _get_line_with_reprcrash_message( config: Config, rep: BaseReport, tw: TerminalWriter, word_markup: dict[str, bool] ) -> str: """Get summary line for a report, trying to add reprcrash message.""" - verbose_word = rep._get_verbose_word(config) - word = tw.markup(verbose_word, **word_markup) + verbose_word, verbose_markup = rep._get_verbose_word_with_markup( + config, word_markup + ) + word = tw.markup(verbose_word, **verbose_markup) node = _get_node_id_with_markup(tw, config, rep) line = f"{word} {node}" diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 01a84fd8d2c..5e3f631e22b 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -326,16 +326,17 @@ def test_rewrite(self, pytester: Pytester, monkeypatch) -> None: tr.rewrite("hey", erase=True) assert f.getvalue() == "hello" + "\r" + "hey" + (6 * " ") + @pytest.mark.parametrize("category", ["foo", "failed", "error", "passed"]) def test_report_teststatus_explicit_markup( - self, monkeypatch: MonkeyPatch, pytester: Pytester, color_mapping + self, monkeypatch: MonkeyPatch, pytester: Pytester, color_mapping, category: str ) -> None: """Test that TerminalReporter handles markup explicitly provided by a pytest_report_teststatus hook.""" monkeypatch.setenv("PY_COLORS", "1") pytester.makeconftest( - """ + f""" def pytest_report_teststatus(report): - return 'foo', 'F', ('FOO', {'red': True}) + return {category !r}, 'F', ('FOO', {{'red': True}}) """ ) pytester.makepyfile( @@ -344,7 +345,9 @@ def test_foobar(): pass """ ) + result = pytester.runpytest("-v") + assert not result.stderr.lines result.stdout.fnmatch_lines( color_mapping.format_for_fnmatch(["*{red}FOO{reset}*"]) ) @@ -2385,8 +2388,8 @@ def __init__(self): self.option = Namespace(verbose=0) class rep: - def _get_verbose_word(self, *args): - return mocked_verbose_word + def _get_verbose_word_with_markup(self, *args): + return mocked_verbose_word, {} class longrepr: class reprcrash: