diff --git a/changelog/6757.feature.rst b/changelog/6757.feature.rst new file mode 100644 index 00000000000..667ef543908 --- /dev/null +++ b/changelog/6757.feature.rst @@ -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. diff --git a/doc/en/how-to/output.rst b/doc/en/how-to/output.rst index a594fcb3aab..127d776dfad 100644 --- a/doc/en/how-to/output.rst +++ b/doc/en/how-to/output.rst @@ -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. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index a69aa2c7887..9fd55c58e47 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -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"`` diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index 4b946bc7074..5f9b583988a 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -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, @@ -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. @@ -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 diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index 07918a66284..15d83fd09fe 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -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 @@ -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: @@ -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. @@ -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 = ( @@ -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) @@ -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 @@ -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]: diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 9a7305a2905..29f8b18d56e 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -19,7 +19,11 @@ import pytest -def mock_config(verbose: int = 0, assertion_override: int | None = None): +def mock_config( + verbose: int = 0, + assertion_override: int | None = None, + assertion_text_diff_style: str = util.ASSERTION_TEXT_DIFF_STYLE_NDIFF, +): class TerminalWriter: def _highlight(self, source, lexer="python"): return source @@ -44,6 +48,11 @@ def get_verbosity(self, verbosity_type: str | None = None) -> int: raise KeyError(f"Not mocked out: {verbosity_type}") + def getini(self, name: str) -> str: + if name == util.ASSERTION_TEXT_DIFF_STYLE_INI: + return assertion_text_diff_style + raise KeyError(f"Not mocked out: {name}") + return Config() @@ -81,6 +90,12 @@ def test_get_unsupported_type_error(self): with pytest.raises(KeyError): config.get_verbosity("--- NOT A VERBOSITY LEVEL ---") + def test_getini_unsupported_error(self): + config = mock_config() + + with pytest.raises(KeyError, match="Not mocked out: --- NOT AN INI ---"): + config.getini("--- NOT AN INI ---") + class TestImportHookInstallation: @pytest.mark.parametrize("initial_conftest", [True, False]) @@ -410,13 +425,33 @@ def test_check(list): result.stdout.fnmatch_lines(["*test_hello*FAIL*", "*test_check*PASS*"]) -def callop(op: str, left: Any, right: Any, verbose: int = 0) -> list[str] | None: - config = mock_config(verbose=verbose) +def callop( + op: str, + left: Any, + right: Any, + verbose: int = 0, + assertion_text_diff_style: str = util.ASSERTION_TEXT_DIFF_STYLE_NDIFF, +) -> list[str] | None: + config = mock_config( + verbose=verbose, + assertion_text_diff_style=assertion_text_diff_style, + ) return plugin.pytest_assertrepr_compare(config, op, left, right) -def callequal(left: Any, right: Any, verbose: int = 0) -> list[str] | None: - return callop("==", left, right, verbose) +def callequal( + left: Any, + right: Any, + verbose: int = 0, + assertion_text_diff_style: str = util.ASSERTION_TEXT_DIFF_STYLE_NDIFF, +) -> list[str] | None: + return callop( + "==", + left, + right, + verbose, + assertion_text_diff_style=assertion_text_diff_style, + ) class TestAssert_reprcompare: @@ -458,6 +493,55 @@ def test_multiline_text_diff(self) -> None: assert "- eggs" in diff assert "+ spam" in diff + def test_multiline_text_diff_block(self) -> None: + assert callequal( + "foo\nspam\nbar", + "foo\neggs\nbar", + assertion_text_diff_style=util.ASSERTION_TEXT_DIFF_STYLE_BLOCK, + ) == [ + r"'foo\nspam\nbar' == 'foo\neggs\nbar'", + "", + "Left:", + " foo", + " spam", + " bar", + "", + "Right:", + " foo", + " eggs", + " bar", + ] + + def test_multiline_text_diff_block_preserves_blank_lines(self) -> None: + assert callequal( + "\nfoo\n", + "\nbar", + assertion_text_diff_style=util.ASSERTION_TEXT_DIFF_STYLE_BLOCK, + ) == [ + r"'\nfoo\n' == '\nbar'", + "", + "Left:", + " ", + " foo", + " ", + "", + "Right:", + " ", + " bar", + ] + + def test_single_line_text_diff_block_falls_back_to_ndiff(self) -> None: + assert callequal( + "spam", + "eggs", + assertion_text_diff_style=util.ASSERTION_TEXT_DIFF_STYLE_BLOCK, + ) == [ + "'spam' == 'eggs'", + "", + "- eggs", + "+ spam", + ] + def test_bytes_diff_normal(self) -> None: """Check special handling for bytes diff (#5260)""" diff = callequal(b"spam", b"eggs") @@ -2184,6 +2268,65 @@ def test_long_text_fail(): ) +def test_assertion_text_diff_style_block_for_multiline_strings( + pytester: Pytester, +) -> None: + pytester.makepyfile( + r""" + actual = "alpha\n beta\n" + expected = "alpha\n beta" + + def test_text_diff(): + assert actual == expected + """ + ) + pytester.makeini( + f""" + [pytest] + assertion_text_diff_style = {util.ASSERTION_TEXT_DIFF_STYLE_BLOCK} + """ + ) + + result = pytester.runpytest("-vv") + + result.stdout.fnmatch_lines( + [ + "E Left:", + "E alpha", + "E beta", + "E ", + "E Right:", + "E alpha", + "E beta", + ] + ) + result.stdout.no_fnmatch_line("*? -*") + + +def test_assertion_text_diff_style_invalid(pytester: Pytester) -> None: + pytester.makepyfile( + """ + def test_ok(): + pass + """ + ) + pytester.makeini( + """ + [pytest] + assertion_text_diff_style = side-by-side + """ + ) + + result = pytester.runpytest() + + assert result.ret == pytest.ExitCode.USAGE_ERROR + result.stderr.fnmatch_lines( + [ + "*ERROR: assertion_text_diff_style must be one of 'ndiff', 'block'; got 'side-by-side'" + ] + ) + + def test_full_output_vvv(pytester: Pytester) -> None: pytester.makepyfile( r"""