Skip to content

Commit 5a2a014

Browse files
committed
Add block text diffs
1 parent e720397 commit 5a2a014

6 files changed

Lines changed: 274 additions & 8 deletions

File tree

changelog/6757.feature.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Added the :confval:`assertion_text_diff_style` configuration option, allowing
2+
multiline string equality failures to be rendered as separate ``Left:`` and
3+
``Right:`` blocks instead of ``ndiff`` output.

doc/en/how-to/output.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,10 @@ This is done by setting a verbosity level in the configuration file for the spec
363363
``pytest --no-header`` with a value of ``2`` would have the same output as the previous example, but each test inside
364364
the file is shown by a single character in the output.
365365

366+
:confval:`assertion_text_diff_style`: Controls how pytest renders ``str == str`` failures. The default ``ndiff`` output
367+
keeps the existing inline diff markers. Setting it to ``block`` prints multiline string comparisons as separate
368+
``Left:`` and ``Right:`` blocks, which can be easier to read when whitespace or indentation differences dominate.
369+
366370
:confval:`verbosity_test_cases`: Controls how verbose the test execution output should be when pytest is executed.
367371
Running ``pytest --no-header`` with a value of ``2`` would have the same output as the first verbosity example, but each
368372
test inside the file gets its own line in the output.

doc/en/reference/reference.rst

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2699,6 +2699,32 @@ passed multiple times. The expected format is ``name=value``. For example::
26992699
A special value of ``"auto"`` can be used to explicitly use the global verbosity level.
27002700

27012701

2702+
.. confval:: assertion_text_diff_style
2703+
:type: ``str``
2704+
:default: ``"ndiff"``
2705+
2706+
Set how pytest renders diffs for string equality assertions.
2707+
2708+
Supported values are:
2709+
2710+
* ``ndiff``: use the default inline diff rendering.
2711+
* ``block``: render multiline string comparisons as separate ``Left:`` and ``Right:`` blocks.
2712+
2713+
.. tab:: toml
2714+
2715+
.. code-block:: toml
2716+
2717+
[pytest]
2718+
assertion_text_diff_style = "block"
2719+
2720+
.. tab:: ini
2721+
2722+
.. code-block:: ini
2723+
2724+
[pytest]
2725+
assertion_text_diff_style = block
2726+
2727+
27022728
.. confval:: verbosity_subtests
27032729
:type: ``str``
27042730
:default: ``"auto"``

src/_pytest/assertion/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ def pytest_addoption(parser: Parser) -> None:
5757
default=None,
5858
help=("Set threshold of CHARS after which truncation will take effect"),
5959
)
60+
parser.addini(
61+
"assertion_text_diff_style",
62+
default=util.ASSERTION_TEXT_DIFF_STYLE_NDIFF,
63+
help=(
64+
"Choose how pytest renders diffs for string equality assertions: "
65+
f"{util.ASSERTION_TEXT_DIFF_STYLE_NDIFF} or "
66+
f"{util.ASSERTION_TEXT_DIFF_STYLE_BLOCK} for multiline strings"
67+
),
68+
)
6069

6170
Config._add_verbosity_ini(
6271
parser,
@@ -68,6 +77,10 @@ def pytest_addoption(parser: Parser) -> None:
6877
)
6978

7079

80+
def pytest_configure(config: Config) -> None:
81+
util.validate_assertion_text_diff_style(config)
82+
83+
7184
def register_assert_rewrite(*names: str) -> None:
7285
"""Register one or more module names to be rewritten on import.
7386

src/_pytest/assertion/util.py

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from _pytest._io.saferepr import saferepr_unlimited
2323
from _pytest.compat import running_on_ci
2424
from _pytest.config import Config
25+
from _pytest.config import UsageError
2526

2627

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

41+
ASSERTION_TEXT_DIFF_STYLE_INI = "assertion_text_diff_style"
42+
ASSERTION_TEXT_DIFF_STYLE_NDIFF = "ndiff"
43+
ASSERTION_TEXT_DIFF_STYLE_BLOCK = "block"
44+
ASSERTION_TEXT_DIFF_STYLE_CHOICES = (
45+
ASSERTION_TEXT_DIFF_STYLE_NDIFF,
46+
ASSERTION_TEXT_DIFF_STYLE_BLOCK,
47+
)
48+
4049

4150
class _HighlightFunc(Protocol):
4251
def __call__(self, source: str, lexer: Literal["diff", "python"] = "python") -> str:
@@ -51,6 +60,20 @@ def dummy_highlighter(source: str, lexer: Literal["diff", "python"] = "python")
5160
return source
5261

5362

63+
def get_assertion_text_diff_style(config: Config) -> str:
64+
style = config.getini(ASSERTION_TEXT_DIFF_STYLE_INI)
65+
if style not in ASSERTION_TEXT_DIFF_STYLE_CHOICES:
66+
choices = ", ".join(repr(choice) for choice in ASSERTION_TEXT_DIFF_STYLE_CHOICES)
67+
raise UsageError(
68+
f"{ASSERTION_TEXT_DIFF_STYLE_INI} must be one of {choices}; got {style!r}"
69+
)
70+
return style
71+
72+
73+
def validate_assertion_text_diff_style(config: Config) -> None:
74+
get_assertion_text_diff_style(config)
75+
76+
5477
def format_explanation(explanation: str) -> str:
5578
r"""Format an explanation.
5679
@@ -180,6 +203,7 @@ def assertrepr_compare(
180203
) -> list[str] | None:
181204
"""Return specialised explanations for some operators/operands."""
182205
verbose = config.get_verbosity(Config.VERBOSITY_ASSERTIONS)
206+
assertion_text_diff_style = get_assertion_text_diff_style(config)
183207

184208
# Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier.
185209
# See issue #3246.
@@ -208,7 +232,13 @@ def assertrepr_compare(
208232
explanation = None
209233
try:
210234
if op == "==":
211-
explanation = _compare_eq_any(left, right, highlighter, verbose)
235+
explanation = _compare_eq_any(
236+
left,
237+
right,
238+
highlighter,
239+
verbose,
240+
assertion_text_diff_style,
241+
)
212242
elif op == "not in":
213243
if istext(left) and istext(right):
214244
explanation = _notin_text(left, right, verbose)
@@ -246,11 +276,21 @@ def assertrepr_compare(
246276

247277

248278
def _compare_eq_any(
249-
left: Any, right: Any, highlighter: _HighlightFunc, verbose: int = 0
279+
left: Any,
280+
right: Any,
281+
highlighter: _HighlightFunc,
282+
verbose: int = 0,
283+
assertion_text_diff_style: str = ASSERTION_TEXT_DIFF_STYLE_NDIFF,
250284
) -> list[str]:
251285
explanation = []
252286
if istext(left) and istext(right):
253-
explanation = _diff_text(left, right, highlighter, verbose)
287+
explanation = _compare_eq_text(
288+
left,
289+
right,
290+
highlighter,
291+
verbose,
292+
assertion_text_diff_style,
293+
)
254294
else:
255295
from _pytest.python_api import ApproxBase
256296

@@ -282,6 +322,40 @@ def _compare_eq_any(
282322
return explanation
283323

284324

325+
def _compare_eq_text(
326+
left: str,
327+
right: str,
328+
highlighter: _HighlightFunc,
329+
verbose: int,
330+
assertion_text_diff_style: str,
331+
) -> list[str]:
332+
if (
333+
assertion_text_diff_style == ASSERTION_TEXT_DIFF_STYLE_BLOCK
334+
and _is_multiline_text(left, right)
335+
and not (left.isspace() or right.isspace())
336+
):
337+
return _diff_text_block(left, right)
338+
return _diff_text(left, right, highlighter, verbose)
339+
340+
341+
def _is_multiline_text(*texts: str) -> bool:
342+
return any("\n" in text or "\r" in text for text in texts)
343+
344+
345+
def _diff_text_block(left: str, right: str) -> list[str]:
346+
return [
347+
"Left:",
348+
*_format_text_block_lines(left),
349+
"",
350+
"Right:",
351+
*_format_text_block_lines(right),
352+
]
353+
354+
355+
def _format_text_block_lines(text: str) -> list[str]:
356+
return [f" {line}" for line in text.split("\n")]
357+
358+
285359
def _diff_text(
286360
left: str, right: str, highlighter: _HighlightFunc, verbose: int = 0
287361
) -> list[str]:

0 commit comments

Comments
 (0)