diff --git a/changelog/6757.feature.rst b/changelog/6757.feature.rst new file mode 100644 index 00000000000..0c154d3d6ff --- /dev/null +++ b/changelog/6757.feature.rst @@ -0,0 +1,3 @@ +Added the :confval:`assertion_text_diff_style` configuration option, allowing +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..db36a5a7206 100644 --- a/doc/en/how-to/output.rst +++ b/doc/en/how-to/output.rst @@ -363,6 +363,13 @@ 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. + + * ``ndiff`` (the default) outputs the differences using inline diff markers. + * ``block`` prints 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. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index a69aa2c7887..ab77da0d226 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 inline diff rendering markers. + * ``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"`` diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index 4b946bc7074..a4530192407 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}" + ), + ) 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. @@ -216,4 +229,5 @@ def pytest_assertrepr_compare( right=right, verbose=config.get_verbosity(Config.VERBOSITY_ASSERTIONS), highlighter=highlighter, + assertion_text_diff_style=util.get_assertion_text_diff_style(config), ) diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index 07918a66284..06b5d7270db 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -21,8 +21,10 @@ from _pytest._io.pprint import PrettyPrinter from _pytest._io.saferepr import saferepr from _pytest._io.saferepr import saferepr_unlimited +from _pytest.compat import assert_never 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 +40,15 @@ # Config object which is assigned during pytest_runtest_protocol. _config: Config | None = None +ASSERTION_TEXT_DIFF_STYLE_INI = "assertion_text_diff_style" +_AssertionTextDiffStyle = Literal["ndiff", "block"] +ASSERTION_TEXT_DIFF_STYLE_NDIFF: Literal["ndiff"] = "ndiff" +ASSERTION_TEXT_DIFF_STYLE_BLOCK: Literal["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 +63,24 @@ def dummy_highlighter(source: str, lexer: Literal["diff", "python"] = "python") return source +def get_assertion_text_diff_style(config: Config) -> _AssertionTextDiffStyle: + style = str(config.getini(ASSERTION_TEXT_DIFF_STYLE_INI)) + match style: + case "ndiff" | "block": + return style + case _: + 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}" + ) + + +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. @@ -180,6 +209,7 @@ def assertrepr_compare( *, verbose: int, highlighter: _HighlightFunc, + assertion_text_diff_style: _AssertionTextDiffStyle, ) -> list[str] | None: """Return specialised explanations for some operators/operands.""" # Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier. @@ -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, + assertion_text_diff_style: _AssertionTextDiffStyle, ) -> 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 @@ -266,7 +312,13 @@ def _compare_eq_any( # field values, not the type or field names. But this branch # intentionally only handles the same-type case, which was often # used in older code bases before dataclasses/attrs were available. - explanation = _compare_eq_cls(left, right, highlighter, verbose) + explanation = _compare_eq_cls( + left, + right, + highlighter, + verbose, + assertion_text_diff_style, + ) elif issequence(left) and issequence(right): explanation = _compare_eq_sequence(left, right, highlighter, verbose) elif isset(left) and isset(right): @@ -281,6 +333,36 @@ def _compare_eq_any( return explanation +def _compare_eq_text( + left: str, + right: str, + highlighter: _HighlightFunc, + verbose: int, + assertion_text_diff_style: _AssertionTextDiffStyle, +) -> list[str]: + match assertion_text_diff_style: + case "block": + return _diff_text_block(left, right) + case "ndiff": + return _diff_text(left, right, highlighter, verbose) + case unreachable: + assert_never(unreachable) + + +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]: @@ -541,7 +623,11 @@ def _compare_eq_dict( def _compare_eq_cls( - left: object, right: object, highlighter: _HighlightFunc, verbose: int + left: object, + right: object, + highlighter: _HighlightFunc, + verbose: int, + assertion_text_diff_style: _AssertionTextDiffStyle, ) -> list[str]: if not has_default_eq(left): return [] @@ -587,7 +673,11 @@ def _compare_eq_cls( explanation += [ indent + line for line in _compare_eq_any( - field_left, field_right, highlighter, verbose + field_left, + field_right, + highlighter, + verbose, + assertion_text_diff_style, ) ] return explanation diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 9a7305a2905..006a7dbd7f2 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: @@ -437,6 +472,18 @@ def test_text_diff(self) -> None: "+ spam", ] + def test_text_diff_ndiff_style(self) -> None: + assert util._compare_eq_text( + "spam", + "eggs", + util.dummy_highlighter, + 0, + util.ASSERTION_TEXT_DIFF_STYLE_NDIFF, + ) == [ + "- eggs", + "+ spam", + ] + def test_text_skipping(self) -> None: lines = callequal("a" * 50 + "spam", "a" * 50 + "eggs") assert lines is not None @@ -458,6 +505,58 @@ 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(self) -> None: + assert callequal( + "spam", + "eggs", + assertion_text_diff_style=util.ASSERTION_TEXT_DIFF_STYLE_BLOCK, + ) == [ + "'spam' == 'eggs'", + "", + "Left:", + " spam", + "", + "Right:", + " eggs", + ] + def test_bytes_diff_normal(self) -> None: """Check special handling for bytes diff (#5260)""" diff = callequal(b"spam", b"eggs") @@ -2184,6 +2283,94 @@ 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_block_for_single_line_strings( + pytester: Pytester, +) -> None: + pytester.makepyfile( + """ + def test_text_diff(): + assert "spam" == "eggs" + """ + ) + 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 spam", + "E Right:", + "E eggs", + ] + ) + result.stdout.no_fnmatch_line("*- eggs*") + + +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"""