testresult: correctly apply verbose word markup and avoid crash

The following snippet would have resulted in crash on multiple places since
`_get_verbose_word` expects only string, not a tuple.

```python
    @pytest.hookimpl(tryfirst=True)
    def pytest_report_teststatus(report: pytest.CollectReport | pytest.TestReport, config: pytest.Config):
        if report.when == "call":
            return ("error", "A",  ("AVC", {"bold": True, "red": True}))

        return None
```

```
Traceback (most recent call last):
  File "/home/pbrezina/workspace/sssd/.venv/bin/pytest", line 8, in <module>
    sys.exit(console_main())
             ^^^^^^^^^^^^^^
  File "/home/pbrezina/workspace/pytest/src/_pytest/config/__init__.py", line 207, in console_main
    code = main()
           ^^^^^^
  File "/home/pbrezina/workspace/pytest/src/_pytest/config/__init__.py", line 179, in main
    ret: Union[ExitCode, int] = config.hook.pytest_cmdline_main(
                                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/pbrezina/workspace/sssd/.venv/lib64/python3.11/site-packages/pluggy/_hooks.py", line 513, in __call__
    return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/pbrezina/workspace/sssd/.venv/lib64/python3.11/site-packages/pluggy/_manager.py", line 120, in _hookexec
    return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/pbrezina/workspace/sssd/.venv/lib64/python3.11/site-packages/pluggy/_callers.py", line 139, in _multicall
    raise exception.with_traceback(exception.__traceback__)
  File "/home/pbrezina/workspace/sssd/.venv/lib64/python3.11/site-packages/pluggy/_callers.py", line 103, in _multicall
    res = hook_impl.function(*args)
          ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/pbrezina/workspace/pytest/src/_pytest/main.py", line 333, in pytest_cmdline_main
    return wrap_session(config, _main)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/pbrezina/workspace/pytest/src/_pytest/main.py", line 321, in wrap_session
    config.hook.pytest_sessionfinish(
  File "/home/pbrezina/workspace/sssd/.venv/lib64/python3.11/site-packages/pluggy/_hooks.py", line 513, in __call__
    return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/pbrezina/workspace/sssd/.venv/lib64/python3.11/site-packages/pluggy/_manager.py", line 120, in _hookexec
    return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/pbrezina/workspace/sssd/.venv/lib64/python3.11/site-packages/pluggy/_callers.py", line 139, in _multicall
    raise exception.with_traceback(exception.__traceback__)
  File "/home/pbrezina/workspace/sssd/.venv/lib64/python3.11/site-packages/pluggy/_callers.py", line 122, in _multicall
    teardown.throw(exception)  # type: ignore[union-attr]
    ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/pbrezina/workspace/pytest/src/_pytest/logging.py", line 872, in pytest_sessionfinish
    return (yield)
            ^^^^^
  File "/home/pbrezina/workspace/sssd/.venv/lib64/python3.11/site-packages/pluggy/_callers.py", line 124, in _multicall
    teardown.send(result)  # type: ignore[union-attr]
    ^^^^^^^^^^^^^^^^^^^^^
  File "/home/pbrezina/workspace/pytest/src/_pytest/terminal.py", line 899, in pytest_sessionfinish
    self.config.hook.pytest_terminal_summary(
  File "/home/pbrezina/workspace/sssd/.venv/lib64/python3.11/site-packages/pluggy/_hooks.py", line 513, in __call__
    return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/pbrezina/workspace/sssd/.venv/lib64/python3.11/site-packages/pluggy/_manager.py", line 120, in _hookexec
    return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/pbrezina/workspace/sssd/.venv/lib64/python3.11/site-packages/pluggy/_callers.py", line 139, in _multicall
    raise exception.with_traceback(exception.__traceback__)
  File "/home/pbrezina/workspace/sssd/.venv/lib64/python3.11/site-packages/pluggy/_callers.py", line 124, in _multicall
    teardown.send(result)  # type: ignore[union-attr]
    ^^^^^^^^^^^^^^^^^^^^^
  File "/home/pbrezina/workspace/pytest/src/_pytest/terminal.py", line 923, in pytest_terminal_summary
    self.short_test_summary()
  File "/home/pbrezina/workspace/pytest/src/_pytest/terminal.py", line 1272, in short_test_summary
    action(lines)
  File "/home/pbrezina/workspace/pytest/src/_pytest/terminal.py", line 1205, in show_simple
    line = _get_line_with_reprcrash_message(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/pbrezina/workspace/pytest/src/_pytest/terminal.py", line 1429, in _get_line_with_reprcrash_message
    word = tw.markup(verbose_word, **word_markup)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/pbrezina/workspace/pytest/src/_pytest/_io/terminalwriter.py", line 114, in markup
    text = "".join(f"\x1b[{cod}m" for cod in esc) + text + "\x1b[0m"
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^~~~~~
TypeError: can only concatenate str (not "tuple") to str
```

Signed-off-by: Pavel Březina <pbrezina@redhat.com>
This commit is contained in:
Pavel Březina 2024-06-18 12:09:47 +02:00
parent dbf7dee8c8
commit 41ca4dd44e
4 changed files with 41 additions and 18 deletions

View File

@ -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`.

View File

@ -13,6 +13,7 @@ from typing import Iterator
from typing import Literal from typing import Literal
from typing import Mapping from typing import Mapping
from typing import NoReturn from typing import NoReturn
from typing import Sequence
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionChainRepr
@ -30,6 +31,7 @@ from _pytest._io import TerminalWriter
from _pytest.config import Config from _pytest.config import Config
from _pytest.nodes import Collector from _pytest.nodes import Collector
from _pytest.nodes import Item from _pytest.nodes import Item
from _pytest.outcomes import fail
from _pytest.outcomes import skip from _pytest.outcomes import skip
@ -190,11 +192,26 @@ class BaseReport:
return domain return domain
return None 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( _category, _short, verbose = config.hook.pytest_report_teststatus(
report=self, config=config 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]: def _to_json(self) -> dict[str, Any]:
"""Return the contents of this report as a dict of builtin entries, """Return the contents of this report as a dict of builtin entries,

View File

@ -1206,10 +1206,10 @@ class TerminalReporter:
def show_xfailed(lines: list[str]) -> None: def show_xfailed(lines: list[str]) -> None:
xfailed = self.stats.get("xfailed", []) xfailed = self.stats.get("xfailed", [])
for rep in xfailed: for rep in xfailed:
verbose_word = rep._get_verbose_word(self.config) verbose_word, verbose_markup = rep._get_verbose_word_with_markup(
markup_word = self._tw.markup( self.config, {_color_for_type["warnings"]: True}
verbose_word, **{_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) nodeid = _get_node_id_with_markup(self._tw, self.config, rep)
line = f"{markup_word} {nodeid}" line = f"{markup_word} {nodeid}"
reason = rep.wasxfail reason = rep.wasxfail
@ -1221,10 +1221,10 @@ class TerminalReporter:
def show_xpassed(lines: list[str]) -> None: def show_xpassed(lines: list[str]) -> None:
xpassed = self.stats.get("xpassed", []) xpassed = self.stats.get("xpassed", [])
for rep in xpassed: for rep in xpassed:
verbose_word = rep._get_verbose_word(self.config) verbose_word, verbose_markup = rep._get_verbose_word_with_markup(
markup_word = self._tw.markup( self.config, {_color_for_type["warnings"]: True}
verbose_word, **{_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) nodeid = _get_node_id_with_markup(self._tw, self.config, rep)
line = f"{markup_word} {nodeid}" line = f"{markup_word} {nodeid}"
reason = rep.wasxfail reason = rep.wasxfail
@ -1237,10 +1237,10 @@ class TerminalReporter:
fskips = _folded_skips(self.startpath, skipped) if skipped else [] fskips = _folded_skips(self.startpath, skipped) if skipped else []
if not fskips: if not fskips:
return return
verbose_word = skipped[0]._get_verbose_word(self.config) verbose_word, verbose_markup = skipped[0]._get_verbose_word_with_markup(
markup_word = self._tw.markup( self.config, {_color_for_type["warnings"]: True}
verbose_word, **{_color_for_type["warnings"]: True}
) )
markup_word = self._tw.markup(verbose_word, **verbose_markup)
prefix = "Skipped: " prefix = "Skipped: "
for num, fspath, lineno, reason in fskips: for num, fspath, lineno, reason in fskips:
if reason.startswith(prefix): 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] config: Config, rep: BaseReport, tw: TerminalWriter, word_markup: dict[str, bool]
) -> str: ) -> str:
"""Get summary line for a report, trying to add reprcrash message.""" """Get summary line for a report, trying to add reprcrash message."""
verbose_word = rep._get_verbose_word(config) verbose_word, verbose_markup = rep._get_verbose_word_with_markup(
word = tw.markup(verbose_word, **word_markup) config, word_markup
)
word = tw.markup(verbose_word, **verbose_markup)
node = _get_node_id_with_markup(tw, config, rep) node = _get_node_id_with_markup(tw, config, rep)
line = f"{word} {node}" line = f"{word} {node}"

View File

@ -326,16 +326,17 @@ class TestTerminal:
tr.rewrite("hey", erase=True) tr.rewrite("hey", erase=True)
assert f.getvalue() == "hello" + "\r" + "hey" + (6 * " ") assert f.getvalue() == "hello" + "\r" + "hey" + (6 * " ")
@pytest.mark.parametrize("category", ["foo", "failed", "error", "passed"])
def test_report_teststatus_explicit_markup( def test_report_teststatus_explicit_markup(
self, monkeypatch: MonkeyPatch, pytester: Pytester, color_mapping self, monkeypatch: MonkeyPatch, pytester: Pytester, color_mapping, category: str
) -> None: ) -> None:
"""Test that TerminalReporter handles markup explicitly provided by """Test that TerminalReporter handles markup explicitly provided by
a pytest_report_teststatus hook.""" a pytest_report_teststatus hook."""
monkeypatch.setenv("PY_COLORS", "1") monkeypatch.setenv("PY_COLORS", "1")
pytester.makeconftest( pytester.makeconftest(
""" f"""
def pytest_report_teststatus(report): def pytest_report_teststatus(report):
return 'foo', 'F', ('FOO', {'red': True}) return {category !r}, 'F', ('FOO', {{'red': True}})
""" """
) )
pytester.makepyfile( pytester.makepyfile(
@ -344,7 +345,9 @@ class TestTerminal:
pass pass
""" """
) )
result = pytester.runpytest("-v") result = pytester.runpytest("-v")
assert not result.stderr.lines
result.stdout.fnmatch_lines( result.stdout.fnmatch_lines(
color_mapping.format_for_fnmatch(["*{red}FOO{reset}*"]) color_mapping.format_for_fnmatch(["*{red}FOO{reset}*"])
) )
@ -2385,8 +2388,8 @@ def test_line_with_reprcrash(monkeypatch: MonkeyPatch) -> None:
self.option = Namespace(verbose=0) self.option = Namespace(verbose=0)
class rep: class rep:
def _get_verbose_word(self, *args): def _get_verbose_word_with_markup(self, *args):
return mocked_verbose_word return mocked_verbose_word, {}
class longrepr: class longrepr:
class reprcrash: class reprcrash: