Skip to content
3 changes: 3 additions & 0 deletions changelog/6757.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Added the :confval:`assertion_text_diff_style` configuration option, allowing
multiline string equality failures to be rendered as separate ``Left:`` and
``Right:`` blocks instead of ``ndiff`` output.
4 changes: 4 additions & 0 deletions doc/en/how-to/output.rst
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,10 @@ This is done by setting a verbosity level in the configuration file for the spec
``pytest --no-header`` with a value of ``2`` would have the same output as the previous example, but each test inside
the file is shown by a single character in the output.

:confval:`assertion_text_diff_style`: Controls how pytest renders ``str == str`` failures. The default ``ndiff`` output
keeps the existing inline diff markers. Setting it to ``block`` prints multiline string comparisons as separate
``Left:`` and ``Right:`` blocks, which can be easier to read when whitespace or indentation differences dominate.

:confval:`verbosity_test_cases`: Controls how verbose the test execution output should be when pytest is executed.
Running ``pytest --no-header`` with a value of ``2`` would have the same output as the first verbosity example, but each
test inside the file gets its own line in the output.
Expand Down
26 changes: 26 additions & 0 deletions doc/en/reference/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2699,6 +2699,32 @@ passed multiple times. The expected format is ``name=value``. For example::
A special value of ``"auto"`` can be used to explicitly use the global verbosity level.


.. confval:: assertion_text_diff_style
:type: ``str``
:default: ``"ndiff"``

Set how pytest renders diffs for string equality assertions.

Supported values are:

* ``ndiff``: use the default inline diff rendering.
* ``block``: render multiline string comparisons as separate ``Left:`` and ``Right:`` blocks.

.. tab:: toml

.. code-block:: toml

[pytest]
assertion_text_diff_style = "block"

.. tab:: ini

.. code-block:: ini

[pytest]
assertion_text_diff_style = block


.. confval:: verbosity_subtests
:type: ``str``
:default: ``"auto"``
Expand Down
32 changes: 25 additions & 7 deletions src/_pytest/assertion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ def pytest_addoption(parser: Parser) -> None:
default=None,
help=("Set threshold of CHARS after which truncation will take effect"),
)
parser.addini(
"assertion_text_diff_style",
default=util.ASSERTION_TEXT_DIFF_STYLE_NDIFF,
help=(
"Choose how pytest renders diffs for string equality assertions: "
f"{util.ASSERTION_TEXT_DIFF_STYLE_NDIFF} or "
f"{util.ASSERTION_TEXT_DIFF_STYLE_BLOCK} for multiline strings"
),
)

Config._add_verbosity_ini(
parser,
Expand All @@ -68,6 +77,10 @@ def pytest_addoption(parser: Parser) -> None:
)


def pytest_configure(config: Config) -> None:
util.validate_assertion_text_diff_style(config)


def register_assert_rewrite(*names: str) -> None:
"""Register one or more module names to be rewritten on import.

Expand Down Expand Up @@ -210,10 +223,15 @@ def pytest_assertrepr_compare(
else:
# Keep it plaintext when not using terminalrepoterer (#14377).
highlighter = util.dummy_highlighter
return util.assertrepr_compare(
op=op,
left=left,
right=right,
verbose=config.get_verbosity(Config.VERBOSITY_ASSERTIONS),
highlighter=highlighter,
)
saved_config = util._config
util._config = config
try:
return util.assertrepr_compare(
op=op,
left=left,
right=right,
verbose=config.get_verbosity(Config.VERBOSITY_ASSERTIONS),
highlighter=highlighter,
)
finally:
util._config = saved_config
86 changes: 83 additions & 3 deletions src/_pytest/assertion/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from _pytest._io.saferepr import saferepr_unlimited
from _pytest.compat import running_on_ci
from _pytest.config import Config
from _pytest.config import UsageError


# The _reprcompare attribute on the util module is used by the new assertion
Expand All @@ -38,6 +39,14 @@
# Config object which is assigned during pytest_runtest_protocol.
_config: Config | None = None

ASSERTION_TEXT_DIFF_STYLE_INI = "assertion_text_diff_style"
ASSERTION_TEXT_DIFF_STYLE_NDIFF = "ndiff"
ASSERTION_TEXT_DIFF_STYLE_BLOCK = "block"
ASSERTION_TEXT_DIFF_STYLE_CHOICES = (
ASSERTION_TEXT_DIFF_STYLE_NDIFF,
ASSERTION_TEXT_DIFF_STYLE_BLOCK,
)


class _HighlightFunc(Protocol):
def __call__(self, source: str, lexer: Literal["diff", "python"] = "python") -> str:
Expand All @@ -52,6 +61,22 @@ def dummy_highlighter(source: str, lexer: Literal["diff", "python"] = "python")
return source


def get_assertion_text_diff_style(config: Config) -> str:
style = str(config.getini(ASSERTION_TEXT_DIFF_STYLE_INI))
if style not in ASSERTION_TEXT_DIFF_STYLE_CHOICES:
choices = ", ".join(
repr(choice) for choice in ASSERTION_TEXT_DIFF_STYLE_CHOICES
)
raise UsageError(
f"{ASSERTION_TEXT_DIFF_STYLE_INI} must be one of {choices}; got {style!r}"
)
return style


def validate_assertion_text_diff_style(config: Config) -> None:
get_assertion_text_diff_style(config)


def format_explanation(explanation: str) -> str:
r"""Format an explanation.

Expand Down Expand Up @@ -182,6 +207,11 @@ def assertrepr_compare(
highlighter: _HighlightFunc,
) -> list[str] | None:
"""Return specialised explanations for some operators/operands."""
assertion_text_diff_style = (
get_assertion_text_diff_style(_config)
if _config is not None
else ASSERTION_TEXT_DIFF_STYLE_NDIFF
)
# Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier.
# See issue #3246.
use_ascii = (
Expand All @@ -208,7 +238,13 @@ def assertrepr_compare(
explanation = None
try:
if op == "==":
explanation = _compare_eq_any(left, right, highlighter, verbose)
explanation = _compare_eq_any(
left,
right,
highlighter,
verbose,
assertion_text_diff_style,
)
elif op == "not in":
if istext(left) and istext(right):
explanation = _notin_text(left, right, verbose)
Expand Down Expand Up @@ -246,11 +282,21 @@ def assertrepr_compare(


def _compare_eq_any(
left: object, right: object, highlighter: _HighlightFunc, verbose: int = 0
left: object,
right: object,
highlighter: _HighlightFunc,
verbose: int = 0,
assertion_text_diff_style: str = ASSERTION_TEXT_DIFF_STYLE_NDIFF,
) -> list[str]:
explanation = []
if istext(left) and istext(right):
explanation = _diff_text(left, right, highlighter, verbose)
explanation = _compare_eq_text(
left,
right,
highlighter,
verbose,
assertion_text_diff_style,
)
else:
from _pytest.python_api import ApproxBase

Expand Down Expand Up @@ -281,6 +327,40 @@ def _compare_eq_any(
return explanation


def _compare_eq_text(
left: str,
right: str,
highlighter: _HighlightFunc,
verbose: int,
assertion_text_diff_style: str,
) -> list[str]:
if (
assertion_text_diff_style == ASSERTION_TEXT_DIFF_STYLE_BLOCK
and _is_multiline_text(left, right)
and not (left.isspace() or right.isspace())
):
return _diff_text_block(left, right)
return _diff_text(left, right, highlighter, verbose)


def _is_multiline_text(*texts: str) -> bool:
return any("\n" in text or "\r" in text for text in texts)


def _diff_text_block(left: str, right: str) -> list[str]:
return [
"Left:",
*_format_text_block_lines(left),
"",
"Right:",
*_format_text_block_lines(right),
]


def _format_text_block_lines(text: str) -> list[str]:
return [f" {line}" for line in text.split("\n")]


def _diff_text(
left: str, right: str, highlighter: _HighlightFunc, verbose: int = 0
) -> list[str]:
Expand Down
Loading
Loading