Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ Grig Gheorghiu
Grigorii Eremeev (budulianin)
Guido Wesdorp
Guoqiang Zhang
Hamza Mobeen
Harald Armin Massa
Harshna
Henk-Jaap Wagenaar
Expand Down
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.
1 change: 1 addition & 0 deletions changelog/8395.feature.rst
Original file line number Diff line number Diff line change
@@ -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`.
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.

: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.
* ``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"``
Expand Down
13 changes: 13 additions & 0 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
82 changes: 79 additions & 3 deletions src/_pytest/assertion/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -51,6 +60,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 @@ -180,6 +205,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.
Expand Down Expand Up @@ -208,7 +234,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 +278,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

Expand Down Expand Up @@ -282,6 +324,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]:
Expand Down
101 changes: 100 additions & 1 deletion src/_pytest/python_api.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading