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.
Comment on lines +366 to +368
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's clearly present the two options:

Suggested change
: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:`assertion_text_diff_style`: Controls how pytest renders ``str == str`` failures.
* ``ndiff`` (the default) outputs the differences using inline diff markers.
* ``block`` prints multiline string comparisons as separate ``Left:`` and ``Right:`` blocks, which can be easier to read when whitespace or indentation differences dominate.
Note that it is possible to set this option (as any other configuration option) directly in the command line using ``-o assertion_text_diff_style=block``.


: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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* ``ndiff``: use the default inline diff rendering.
* ``ndiff``: use the inline diff rendering markers.

* ``block``: render multiline string comparisons as separate ``Left:`` and ``Right:`` blocks.
Copy link
Copy Markdown
Member

@nicoddemus nicoddemus May 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* ``block``: render multiline string comparisons as separate ``Left:`` and ``Right:`` blocks.
* ``block``: render each string in 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
Comment on lines +226 to +227
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this necessary? Is the config being passed here different from the one already in util._config?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, given this is the only place where util.assertrepr_compare is called, consider just passing the text diff style directly to util.assertrepr_compare.

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,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets remove this default value and force callers to always pass it; it is an internal function, so breaking the API is actually a good thing because it will ensure every caller is actually passing the right thing.

Suggested change
assertion_text_diff_style: str = ASSERTION_TEXT_DIFF_STYLE_NDIFF,
assertion_text_diff_style: str,

) -> 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,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of typing this as str, let's use Literal["ndiff", "block"]. This way we are more explicit, plus we can use match below.

) -> list[str]:
if (
assertion_text_diff_style == ASSERTION_TEXT_DIFF_STYLE_BLOCK
and _is_multiline_text(left, right)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure we should special case being multiline text or not; I think it is reasonable to always honor the block configuration, even for single line texts.

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