From e7203972aba7373ef3985b44c8ba6be1e310c437 Mon Sep 17 00:00:00 2001 From: Hamza Mobeen Date: Thu, 23 Apr 2026 12:15:17 +0100 Subject: [PATCH 1/5] Add datetime/timedelta support to pytest.approx (#8395) Closes #8395 Co-authored-by: Antigravity --- AUTHORS | 1 + changelog/8395.feature.rst | 1 + src/_pytest/python_api.py | 101 ++++++++++++++++++++- testing/python/approx.py | 176 +++++++++++++++++++++++++++++++++++++ 4 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 changelog/8395.feature.rst diff --git a/AUTHORS b/AUTHORS index c33cf5fafbd..030078438f3 100644 --- a/AUTHORS +++ b/AUTHORS @@ -194,6 +194,7 @@ Grig Gheorghiu Grigorii Eremeev (budulianin) Guido Wesdorp Guoqiang Zhang +Hamza Mobeen Harald Armin Massa Harshna Henk-Jaap Wagenaar diff --git a/changelog/8395.feature.rst b/changelog/8395.feature.rst new file mode 100644 index 00000000000..b7b8b959b03 --- /dev/null +++ b/changelog/8395.feature.rst @@ -0,0 +1 @@ +Added support for :class:`~datetime.datetime` and :class:`~datetime.timedelta` comparisons with :func:`pytest.approx`. An explicit ``abs`` tolerance as a :class:`~datetime.timedelta` is required; relative tolerance is not supported for time-based comparisons -- by :user:`hamza-mobeen`. diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 9e2e1826a4f..90404140971 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -1,10 +1,13 @@ # mypy: allow-untyped-defs from __future__ import annotations +import builtins from collections.abc import Collection from collections.abc import Mapping from collections.abc import Sequence from collections.abc import Sized +from datetime import datetime +from datetime import timedelta from decimal import Decimal import math from numbers import Complex @@ -558,10 +561,87 @@ def __repr__(self) -> str: return f"{self.expected} ± {tol_str}" +class ApproxTimedelta(ApproxBase): + """Perform approximate comparisons where the expected value is a + datetime or timedelta. + + Requires an explicit absolute tolerance as a timedelta. + Relative tolerance is not supported for time-based comparisons. + """ + + def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: + __tracebackhide__ = True + if rel is not None: + raise TypeError( + "pytest.approx() does not support relative tolerance for " + "datetime/timedelta comparisons. Use abs=timedelta(...) instead." + ) + if nan_ok: + raise TypeError( + "pytest.approx() does not support nan_ok for " + "datetime/timedelta comparisons." + ) + if abs is None: + raise TypeError( + "pytest.approx() requires an absolute tolerance for " + "datetime/timedelta comparisons: " + "e.g. approx(expected, abs=timedelta(seconds=1))" + ) + if not isinstance(abs, timedelta): + raise TypeError( + f"absolute tolerance for datetime/timedelta must be a " + f"timedelta, got {type(abs).__name__}" + ) + # Store the timedelta tolerance directly. + self.expected = expected + self._tolerance = abs + # Call grandparent init to set up basic state without _check_type. + self.abs = abs + self.rel = None + self.nan_ok = False + + def __repr__(self) -> str: + return f"{self.expected} ± {self._tolerance}" + + def __eq__(self, actual) -> bool: + try: + return bool(builtins.abs(self.expected - actual) <= self._tolerance) + except (TypeError, OverflowError): + return False + + __hash__ = None + + def __ne__(self, actual) -> bool: + return not (actual == self) + + def __bool__(self): + __tracebackhide__ = True + raise AssertionError( + "approx() is not supported in a boolean context.\n" + "Did you mean: `assert a == approx(b)`?" + ) + + def _yield_comparisons(self, actual): + yield actual, self.expected + + def _repr_compare(self, other_side: Any) -> list[str]: + try: + abs_diff = builtins.abs(self.expected - other_side) + except (TypeError, OverflowError): + abs_diff = "N/A" + return [ + "comparison failed", + f"Obtained: {other_side}", + f"Expected: {self.expected} ± {self._tolerance}", + f"Absolute difference: {abs_diff}", + f"Tolerance: {self._tolerance}", + ] + + def approx( expected: Any, rel: float | Decimal | None = None, - abs: float | Decimal | None = None, + abs: float | Decimal | timedelta | None = None, nan_ok: bool = False, ) -> ApproxBase: """Assert that two numbers (or two ordered sequences of numbers) are equal to each other @@ -677,6 +757,23 @@ def approx( >>> ["foo", 1.0000005] == approx([None,1]) False + **datetime and timedelta** + + You can also use ``approx`` to compare :class:`~datetime.datetime` and + :class:`~datetime.timedelta` objects by specifying an absolute tolerance + as a :class:`~datetime.timedelta`:: + + >>> from datetime import datetime, timedelta + >>> dt1 = datetime(2024, 1, 1, 12, 0, 0) + >>> dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000) + >>> dt1 == approx(dt2, abs=timedelta(seconds=1)) + True + + Note that ``rel`` is not supported for datetime/timedelta comparisons, + and ``abs`` must be explicitly provided as a ``timedelta`` object. + + .. versionadded:: 8.4 + If you're thinking about using ``approx``, then you might want to know how it compares to other good ways of comparing floating-point numbers. All of these algorithms are based on relative and absolute tolerances and should @@ -785,6 +882,8 @@ def approx( elif isinstance(expected, Collection) and not isinstance(expected, str | bytes): msg = f"pytest.approx() only supports ordered sequences, but got: {expected!r}" raise TypeError(msg) + elif isinstance(expected, (datetime, timedelta)): + cls = ApproxTimedelta else: cls = ApproxScalar diff --git a/testing/python/approx.py b/testing/python/approx.py index bfbb59fb61d..42f3bbd25c4 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -1105,6 +1105,182 @@ def test_approx_on_unordered_mapping_matching(): result.assert_outcomes(passed=1) +class TestApproxDatetime: + """Tests for datetime/timedelta support in approx (issue #8395).""" + + def test_datetime_exactly_equal(self): + from datetime import datetime, timedelta + + dt = datetime(2024, 1, 1, 12, 0, 0) + assert dt == approx(dt, abs=timedelta(seconds=1)) + + def test_datetime_within_tolerance(self): + from datetime import datetime, timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000) # +0.5s + assert dt1 == approx(dt2, abs=timedelta(seconds=1)) + + def test_datetime_outside_tolerance(self): + from datetime import datetime, timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 2) # +2s + assert dt1 != approx(dt2, abs=timedelta(seconds=1)) + + def test_datetime_negative_difference(self): + from datetime import datetime, timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 1) + dt2 = datetime(2024, 1, 1, 12, 0, 0) # dt2 < dt1 + assert dt1 == approx(dt2, abs=timedelta(seconds=2)) + assert dt1 != approx(dt2, abs=timedelta(milliseconds=500)) + + def test_timedelta_within_tolerance(self): + from datetime import timedelta + + td1 = timedelta(seconds=100) + td2 = timedelta(seconds=100.5) + assert td1 == approx(td2, abs=timedelta(seconds=1)) + + def test_timedelta_outside_tolerance(self): + from datetime import timedelta + + td1 = timedelta(seconds=100) + td2 = timedelta(seconds=102) + assert td1 != approx(td2, abs=timedelta(seconds=1)) + + def test_requires_abs(self): + from datetime import datetime + + with pytest.raises(TypeError, match="requires an absolute tolerance"): + approx(datetime(2024, 1, 1)) + + def test_rejects_rel(self): + from datetime import datetime, timedelta + + with pytest.raises(TypeError, match="does not support relative tolerance"): + approx(datetime(2024, 1, 1), rel=0.1, abs=timedelta(seconds=1)) + + def test_abs_must_be_timedelta(self): + from datetime import datetime + + with pytest.raises(TypeError, match="must be a timedelta"): + approx(datetime(2024, 1, 1), abs=1.0) + + def test_rejects_nan_ok(self): + from datetime import datetime, timedelta + + with pytest.raises(TypeError, match="does not support nan_ok"): + approx(datetime(2024, 1, 1), abs=timedelta(seconds=1), nan_ok=True) + + def test_datetime_repr(self): + from datetime import datetime, timedelta + + dt = datetime(2024, 1, 1, 12, 0, 0) + result = repr(approx(dt, abs=timedelta(seconds=1))) + assert "2024-01-01 12:00:00" in result + assert "0:00:01" in result + + def test_timedelta_repr(self): + from datetime import timedelta + + td = timedelta(seconds=100) + result = repr(approx(td, abs=timedelta(seconds=1))) + assert "0:01:40" in result # 100 seconds + assert "0:00:01" in result # 1 second tolerance + + def test_datetime_symmetry(self): + """approx comparison should work on both sides of ==.""" + from datetime import datetime, timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000) + tol = timedelta(seconds=1) + assert dt1 == approx(dt2, abs=tol) + assert approx(dt2, abs=tol) == dt1 + + def test_datetime_ne_operator(self): + from datetime import datetime, timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 5) + tol = timedelta(seconds=1) + assert dt1 != approx(dt2, abs=tol) + assert not (dt1 == approx(dt2, abs=tol)) + + def test_datetime_with_timezone(self): + from datetime import datetime, timedelta, timezone + + tz = timezone.utc + dt1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=tz) + dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000, tzinfo=tz) + assert dt1 == approx(dt2, abs=timedelta(seconds=1)) + + def test_datetime_error_message(self): + from datetime import datetime, timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 5) # 5 seconds off + with pytest.raises(AssertionError, match="comparison failed"): + assert dt1 == approx(dt2, abs=timedelta(seconds=1)) + + def test_timedelta_zero(self): + from datetime import timedelta + + td1 = timedelta(seconds=0) + td2 = timedelta(seconds=0) + assert td1 == approx(td2, abs=timedelta(seconds=1)) + + def test_datetime_boundary_exact(self): + """Test that values exactly at the tolerance boundary are equal.""" + from datetime import datetime, timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 1) # exactly 1 second + assert dt1 == approx(dt2, abs=timedelta(seconds=1)) + + def test_datetime_microsecond_tolerance(self): + from datetime import datetime, timedelta + + dt1 = datetime(2024, 1, 1, 12, 0, 0, 0) + dt2 = datetime(2024, 1, 1, 12, 0, 0, 100) # +100 microseconds + assert dt1 == approx(dt2, abs=timedelta(microseconds=200)) + assert dt1 != approx(dt2, abs=timedelta(microseconds=50)) + + def test_bool_context_raises(self): + from datetime import datetime, timedelta + + with pytest.raises(AssertionError, match="boolean context"): + bool(approx(datetime(2024, 1, 1), abs=timedelta(seconds=1))) + + def test_wrong_type_comparison(self): + """Comparing a datetime approx with a non-datetime should return False.""" + from datetime import datetime, timedelta + + assert 42 != approx(datetime(2024, 1, 1), abs=timedelta(seconds=1)) + assert "string" != approx(datetime(2024, 1, 1), abs=timedelta(seconds=1)) + + def test_yield_comparisons(self): + """Test that _yield_comparisons yields (actual, expected) pairs.""" + from datetime import datetime, timedelta + + dt = datetime(2024, 1, 1, 12, 0, 0) + a = approx(dt, abs=timedelta(seconds=1)) + actual = datetime(2024, 1, 1, 12, 0, 0, 500000) + pairs = list(a._yield_comparisons(actual)) + assert pairs == [(actual, dt)] + + def test_repr_compare_with_incompatible_type(self): + """_repr_compare handles TypeError when actual is not a datetime.""" + from datetime import datetime, timedelta + + a = approx(datetime(2024, 1, 1), abs=timedelta(seconds=1)) + result = a._repr_compare("not a datetime") + assert "comparison failed" in result[0] + assert "N/A" in result[3] + + class MyVec3: # incomplete """sequence like""" From 5a2a014b628e39f16bcb80707bb075511f639964 Mon Sep 17 00:00:00 2001 From: Hamza Mobeen Date: Mon, 27 Apr 2026 10:58:18 +0100 Subject: [PATCH 2/5] Add block text diffs --- changelog/6757.feature.rst | 3 + doc/en/how-to/output.rst | 4 + doc/en/reference/reference.rst | 26 +++++ src/_pytest/assertion/__init__.py | 13 +++ src/_pytest/assertion/util.py | 80 ++++++++++++++- testing/test_assertion.py | 156 +++++++++++++++++++++++++++++- 6 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 changelog/6757.feature.rst 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 22f3ca8e258..3b26d83cb2f 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. diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index f35d83a6fe4..70d41010698 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -22,6 +22,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 @@ -37,6 +38,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: @@ -51,6 +60,20 @@ def dummy_highlighter(source: str, lexer: Literal["diff", "python"] = "python") return source +def get_assertion_text_diff_style(config: Config) -> str: + style = 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. @@ -180,6 +203,7 @@ def assertrepr_compare( ) -> list[str] | None: """Return specialised explanations for some operators/operands.""" verbose = config.get_verbosity(Config.VERBOSITY_ASSERTIONS) + assertion_text_diff_style = get_assertion_text_diff_style(config) # Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier. # See issue #3246. @@ -208,7 +232,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 +276,21 @@ def assertrepr_compare( def _compare_eq_any( - left: Any, right: Any, highlighter: _HighlightFunc, verbose: int = 0 + left: Any, + right: Any, + 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 @@ -282,6 +322,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 d68fd0b1fba..c511b638534 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 @@ -38,6 +42,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() @@ -404,13 +413,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: @@ -452,6 +481,64 @@ 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") @@ -2178,6 +2265,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""" From 2a40e3883520a1803e8e0a202e89804934328c3b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:36:08 +0000 Subject: [PATCH 3/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/assertion/util.py | 4 +- testing/python/approx.py | 54 +++++++++++++------- testing/test_assertion.py | 95 ++++++++++++++++------------------- 3 files changed, 82 insertions(+), 71 deletions(-) diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index 70d41010698..1038a882f92 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -63,7 +63,9 @@ def dummy_highlighter(source: str, lexer: Literal["diff", "python"] = "python") def get_assertion_text_diff_style(config: Config) -> str: style = 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) + 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}" ) diff --git a/testing/python/approx.py b/testing/python/approx.py index 42f3bbd25c4..0aa0b6182c2 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -1109,27 +1109,31 @@ class TestApproxDatetime: """Tests for datetime/timedelta support in approx (issue #8395).""" def test_datetime_exactly_equal(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt = datetime(2024, 1, 1, 12, 0, 0) assert dt == approx(dt, abs=timedelta(seconds=1)) def test_datetime_within_tolerance(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt1 = datetime(2024, 1, 1, 12, 0, 0) dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000) # +0.5s assert dt1 == approx(dt2, abs=timedelta(seconds=1)) def test_datetime_outside_tolerance(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt1 = datetime(2024, 1, 1, 12, 0, 0) dt2 = datetime(2024, 1, 1, 12, 0, 2) # +2s assert dt1 != approx(dt2, abs=timedelta(seconds=1)) def test_datetime_negative_difference(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt1 = datetime(2024, 1, 1, 12, 0, 1) dt2 = datetime(2024, 1, 1, 12, 0, 0) # dt2 < dt1 @@ -1157,7 +1161,8 @@ def test_requires_abs(self): approx(datetime(2024, 1, 1)) def test_rejects_rel(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta with pytest.raises(TypeError, match="does not support relative tolerance"): approx(datetime(2024, 1, 1), rel=0.1, abs=timedelta(seconds=1)) @@ -1169,13 +1174,15 @@ def test_abs_must_be_timedelta(self): approx(datetime(2024, 1, 1), abs=1.0) def test_rejects_nan_ok(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta with pytest.raises(TypeError, match="does not support nan_ok"): approx(datetime(2024, 1, 1), abs=timedelta(seconds=1), nan_ok=True) def test_datetime_repr(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt = datetime(2024, 1, 1, 12, 0, 0) result = repr(approx(dt, abs=timedelta(seconds=1))) @@ -1191,8 +1198,9 @@ def test_timedelta_repr(self): assert "0:00:01" in result # 1 second tolerance def test_datetime_symmetry(self): - """approx comparison should work on both sides of ==.""" - from datetime import datetime, timedelta + """Approx comparison should work on both sides of ==.""" + from datetime import datetime + from datetime import timedelta dt1 = datetime(2024, 1, 1, 12, 0, 0) dt2 = datetime(2024, 1, 1, 12, 0, 0, 500000) @@ -1201,7 +1209,8 @@ def test_datetime_symmetry(self): assert approx(dt2, abs=tol) == dt1 def test_datetime_ne_operator(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt1 = datetime(2024, 1, 1, 12, 0, 0) dt2 = datetime(2024, 1, 1, 12, 0, 5) @@ -1210,7 +1219,9 @@ def test_datetime_ne_operator(self): assert not (dt1 == approx(dt2, abs=tol)) def test_datetime_with_timezone(self): - from datetime import datetime, timedelta, timezone + from datetime import datetime + from datetime import timedelta + from datetime import timezone tz = timezone.utc dt1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=tz) @@ -1218,7 +1229,8 @@ def test_datetime_with_timezone(self): assert dt1 == approx(dt2, abs=timedelta(seconds=1)) def test_datetime_error_message(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt1 = datetime(2024, 1, 1, 12, 0, 0) dt2 = datetime(2024, 1, 1, 12, 0, 5) # 5 seconds off @@ -1234,14 +1246,16 @@ def test_timedelta_zero(self): def test_datetime_boundary_exact(self): """Test that values exactly at the tolerance boundary are equal.""" - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt1 = datetime(2024, 1, 1, 12, 0, 0) dt2 = datetime(2024, 1, 1, 12, 0, 1) # exactly 1 second assert dt1 == approx(dt2, abs=timedelta(seconds=1)) def test_datetime_microsecond_tolerance(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt1 = datetime(2024, 1, 1, 12, 0, 0, 0) dt2 = datetime(2024, 1, 1, 12, 0, 0, 100) # +100 microseconds @@ -1249,21 +1263,24 @@ def test_datetime_microsecond_tolerance(self): assert dt1 != approx(dt2, abs=timedelta(microseconds=50)) def test_bool_context_raises(self): - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta with pytest.raises(AssertionError, match="boolean context"): bool(approx(datetime(2024, 1, 1), abs=timedelta(seconds=1))) def test_wrong_type_comparison(self): """Comparing a datetime approx with a non-datetime should return False.""" - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta assert 42 != approx(datetime(2024, 1, 1), abs=timedelta(seconds=1)) assert "string" != approx(datetime(2024, 1, 1), abs=timedelta(seconds=1)) def test_yield_comparisons(self): """Test that _yield_comparisons yields (actual, expected) pairs.""" - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta dt = datetime(2024, 1, 1, 12, 0, 0) a = approx(dt, abs=timedelta(seconds=1)) @@ -1273,7 +1290,8 @@ def test_yield_comparisons(self): def test_repr_compare_with_incompatible_type(self): """_repr_compare handles TypeError when actual is not a datetime.""" - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta a = approx(datetime(2024, 1, 1), abs=timedelta(seconds=1)) result = a._repr_compare("not a datetime") diff --git a/testing/test_assertion.py b/testing/test_assertion.py index c511b638534..373748eeb6a 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -482,62 +482,53 @@ def test_multiline_text_diff(self) -> None: 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", - ] - ) + 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", - ] - ) + 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", - ] - ) + 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)""" From ef9017990c6b25dd0054367a28c2a68b03d1a967 Mon Sep 17 00:00:00 2001 From: Hamza Mobeen Date: Mon, 27 Apr 2026 11:49:59 +0100 Subject: [PATCH 4/5] Fix pre-commit lint and mypy errors --- src/_pytest/assertion/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index 1038a882f92..c9078f5f6d3 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -61,7 +61,7 @@ def dummy_highlighter(source: str, lexer: Literal["diff", "python"] = "python") def get_assertion_text_diff_style(config: Config) -> str: - style = config.getini(ASSERTION_TEXT_DIFF_STYLE_INI) + 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 From 499aaa9fe374cee0365cfdfd97edbf1d638109cb Mon Sep 17 00:00:00 2001 From: Hamza Mobeen Date: Mon, 27 Apr 2026 12:16:18 +0100 Subject: [PATCH 5/5] Add test to cover getini KeyError in mock_config --- testing/test_assertion.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 373748eeb6a..b392c119a48 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -84,6 +84,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])