From 29127b5ca4d13445e3f957f7addeb08f6ae65c0e Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 23 Jun 2026 14:28:19 +0300 Subject: [PATCH 1/7] approx: remove useless piece of code I don't think it has any effect (always False, checked two lines above). Probably a copy/paste error. --- src/_pytest/python_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index d7f4c2dcef8..beac9544d94 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -411,7 +411,7 @@ def __repr__(self) -> str: if ( isinstance(self.expected, bool) or (not isinstance(self.expected, Complex | Decimal)) - or math.isinf(abs(self.expected) or isinstance(self.expected, bool)) + or math.isinf(abs(self.expected)) ): return str(self.expected) From 1d7c81714735ba5a8df5d1f11ba41e9a7a7bba94 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 23 Jun 2026 14:34:40 +0300 Subject: [PATCH 2/7] approx: consistently handle numpy bool Consistently use `_is_bool` check which handles `numpy.bool` in addition to `bool`, instead of `isinstance(..., bool)`. --- src/_pytest/python_api.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index beac9544d94..9f05da6e1c2 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -409,7 +409,7 @@ def __repr__(self) -> str: # tolerances, i.e. non-numerics and infinities. Need to call abs to # handle complex numbers, e.g. (inf + 1j). if ( - isinstance(self.expected, bool) + _is_bool(self.expected) or (not isinstance(self.expected, Complex | Decimal)) or math.isinf(abs(self.expected)) ): @@ -437,15 +437,6 @@ def __repr__(self) -> str: def __eq__(self, actual) -> bool: """Return whether the given value is equal to the expected value within the pre-specified tolerance.""" - - def is_bool(val: Any) -> bool: - # Check if `val` is a native bool or numpy bool. - if isinstance(val, bool): - return True - if np := sys.modules.get("numpy"): - return isinstance(val, np.bool_) - return False - asarray = _as_numpy_array(actual) if asarray is not None: # Call ``__eq__()`` manually to prevent infinite-recursion with @@ -453,7 +444,7 @@ def is_bool(val: Any) -> bool: return all(self.__eq__(a) for a in asarray.flat) # Short-circuit exact equality, except for bool and np.bool_ - if is_bool(self.expected) and not is_bool(actual): + if _is_bool(self.expected) and not _is_bool(actual): return False elif actual == self.expected: return True @@ -462,7 +453,7 @@ def is_bool(val: Any) -> bool: # NB: we need Complex, rather than just Number, to ensure that __abs__, # __sub__, and __float__ are defined. Also, consider bool to be # non-numeric, even though it has the required arithmetic. - if is_bool(self.expected) or not ( + if _is_bool(self.expected) or not ( isinstance(self.expected, Complex | Decimal) and isinstance(actual, Complex | Decimal) ): @@ -920,3 +911,12 @@ def _as_numpy_array(obj: object) -> ndarray | None: elif hasattr(obj, "__array__") or hasattr(obj, "__array_interface__"): return np.asarray(obj) return None + + +def _is_bool(val: Any) -> bool: + # Check if `val` is a native bool or numpy bool. + if isinstance(val, bool): + return True + if np := sys.modules.get("numpy"): + return isinstance(val, np.bool_) + return False From f3a92199fe963425c2ae4d059dda343cd0d89b24 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 20 Jun 2026 23:01:56 +0300 Subject: [PATCH 3/7] approx: inline `_check_type` into `__init__` The code is more direct this way, and more amenable to typing (in follow up commits). --- src/_pytest/python_api.py | 44 ++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 9f05da6e1c2..98c223915ab 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -59,12 +59,10 @@ class ApproxBase: __array_priority__ = 100 def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: - __tracebackhide__ = True self.expected = expected self.abs = abs self.rel = rel self.nan_ok = nan_ok - self._check_type() def __repr__(self) -> str: raise NotImplementedError @@ -107,14 +105,6 @@ def _yield_comparisons(self, actual): """ raise NotImplementedError - def _check_type(self) -> None: - """Raise a TypeError if the expected value is not a valid type.""" - # This is only a concern if the expected value is a sequence. In every - # other case, the approx() function ensures that the expected value has - # a numeric type. For this reason, the default is to do nothing. The - # classes that deal with sequences should reimplement this method to - # raise if there are any non-numeric elements in the sequence. - def _recursive_sequence_map(f, x): """Recursively map a function over a sequence of arbitrary depth""" @@ -235,6 +225,16 @@ class ApproxMapping(ApproxBase): """Perform approximate comparisons where the expected value is a mapping with numeric values (the keys can be anything).""" + def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: + __tracebackhide__ = True + + for key, value in expected.items(): + if isinstance(value, type(expected)): + msg = "pytest.approx() does not support nested dictionaries: key={!r} value={!r}\n full mapping={}" + raise TypeError(msg.format(key, value, pprint.pformat(expected))) + + super().__init__(expected, rel=rel, abs=abs, nan_ok=nan_ok) + def __repr__(self) -> str: return f"approx({ ({k: self._approx_scalar(v) for k, v in self.expected.items()})!r})" @@ -310,17 +310,20 @@ def _yield_comparisons(self, actual): for k in self.expected.keys(): yield actual[k], self.expected[k] - def _check_type(self) -> None: - __tracebackhide__ = True - for key, value in self.expected.items(): - if isinstance(value, type(self.expected)): - msg = "pytest.approx() does not support nested dictionaries: key={!r} value={!r}\n full mapping={}" - raise TypeError(msg.format(key, value, pprint.pformat(self.expected))) - class ApproxSequenceLike(ApproxBase): """Perform approximate comparisons where the expected value is a sequence of numbers.""" + def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: + __tracebackhide__ = True + + for index, x in enumerate(expected): + if isinstance(x, type(expected)): + msg = "pytest.approx() does not support nested data structures: {!r} at index {}\n full sequence: {}" + raise TypeError(msg.format(x, index, pprint.pformat(expected))) + + super().__init__(expected, rel=rel, abs=abs, nan_ok=nan_ok) + def __repr__(self) -> str: seq_type = type(self.expected) if seq_type not in (tuple, list): @@ -383,13 +386,6 @@ def __eq__(self, actual) -> bool: def _yield_comparisons(self, actual): return zip(actual, self.expected, strict=True) - def _check_type(self) -> None: - __tracebackhide__ = True - for index, x in enumerate(self.expected): - if isinstance(x, type(self.expected)): - msg = "pytest.approx() does not support nested data structures: {!r} at index {}\n full sequence: {}" - raise TypeError(msg.format(x, index, pprint.pformat(self.expected))) - class ApproxScalar(ApproxBase): """Perform approximate comparisons where the expected value is a single number.""" From 5085bb197238fe52e06d5ad35bc7a1fdee27ab70 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 23 Jun 2026 15:01:31 +0300 Subject: [PATCH 4/7] approx: inline `set_default` Not really helpful and annoying to type. --- src/_pytest/python_api.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 98c223915ab..731014a70e8 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -483,13 +483,11 @@ def tolerance(self): This could be either an absolute tolerance or a relative tolerance, depending on what the user specified or which would be larger. """ - - def set_default(x, default): - return x if x is not None else default - # Figure out what the absolute tolerance should be. ``self.abs`` is # either None or a value specified by the user. - absolute_tolerance = set_default(self.abs, self.DEFAULT_ABSOLUTE_TOLERANCE) + absolute_tolerance = ( + self.abs if self.abs is not None else self.DEFAULT_ABSOLUTE_TOLERANCE + ) if absolute_tolerance < 0: raise ValueError( @@ -509,8 +507,8 @@ def set_default(x, default): # we've made sure the user didn't ask for an absolute tolerance only, # because we don't want to raise errors about the relative tolerance if # we aren't even going to use it. - relative_tolerance = set_default( - self.rel, self.DEFAULT_RELATIVE_TOLERANCE + relative_tolerance = ( + self.rel if self.rel is not None else self.DEFAULT_RELATIVE_TOLERANCE ) * abs(self.expected) if relative_tolerance < 0: From 8fb64dae5eabdecb0682d5290612219532b7e071 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 20 Jun 2026 23:04:57 +0300 Subject: [PATCH 5/7] approx: improve typing This (tries to) improve the typing of the approx code in preparation of exposing a type for its return value. The main change is to make `ApproxBase` generic in the expected value type, so that `self.expected` can have a correct type. This is not so easy... --- src/_pytest/python_api.py | 151 ++++++++++++++++++++++++++++---------- 1 file changed, 111 insertions(+), 40 deletions(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 731014a70e8..a512e30b299 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -1,6 +1,7 @@ # mypy: allow-untyped-defs from __future__ import annotations +import abc import builtins from collections.abc import Collection from collections.abc import Mapping @@ -14,11 +15,20 @@ import pprint import sys from typing import Any +from typing import Generic +from typing import SupportsAbs from typing import TYPE_CHECKING +from typing import TypeGuard +from typing import TypeVar if TYPE_CHECKING: from numpy import ndarray +else: + ndarray = object + + +# builtin pytest.approx helper def _compare_approx( @@ -47,10 +57,11 @@ def _compare_approx( return explanation -# builtin pytest.approx helper +T = TypeVar("T") +ExpectedT = TypeVar("ExpectedT", covariant=True) -class ApproxBase: +class ApproxBase(abc.ABC, Generic[ExpectedT]): """Provide shared utilities for making approximate comparisons between numbers or sequences of numbers.""" @@ -58,16 +69,23 @@ class ApproxBase: __array_ufunc__ = None __array_priority__ = 100 - def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: + def __init__( + self, + expected: ExpectedT, + rel: float | Decimal | timedelta | None, + abs: float | Decimal | timedelta | None, + nan_ok: bool, + ) -> None: self.expected = expected self.abs = abs self.rel = rel self.nan_ok = nan_ok + @abc.abstractmethod def __repr__(self) -> str: raise NotImplementedError - def _repr_compare(self, other_side: Any) -> list[str]: + def _repr_compare(self, other_side) -> list[str]: return [ "comparison failed", f"Obtained: {other_side}", @@ -88,17 +106,17 @@ def __bool__(self): # Ignore type because of https://github.com/python/mypy/issues/4266. __hash__ = None # type: ignore - def __ne__(self, actual) -> bool: + def __ne__(self, actual: object) -> bool: return not (actual == self) - def _approx_scalar(self, x) -> ApproxBase: + def _approx_scalar(self, x) -> ApproxScalar[Any] | ApproxTimedelta: if isinstance(x, Decimal): return ApproxDecimal(x, rel=self.rel, abs=self.abs, nan_ok=self.nan_ok) if isinstance(x, (datetime, timedelta)): return ApproxTimedelta(x, rel=self.rel, abs=self.abs, nan_ok=self.nan_ok) return ApproxScalar(x, rel=self.rel, abs=self.abs, nan_ok=self.nan_ok) - def _yield_comparisons(self, actual): + def _yield_comparisons(self, actual: object): """Yield all the pairs of numbers to be compared. This is used to implement the `__eq__` method. @@ -117,7 +135,7 @@ def _recursive_sequence_map(f, x): return f(x) -class ApproxNumpy(ApproxBase): +class ApproxNumpy(ApproxBase[ndarray]): """Perform approximate comparisons where the expected value is numpy array.""" def __repr__(self) -> str: @@ -221,11 +239,17 @@ def _yield_comparisons(self, actual): yield actual[i].item(), self.expected[i].item() -class ApproxMapping(ApproxBase): +class ApproxMapping(ApproxBase[Mapping[Any, Any]]): """Perform approximate comparisons where the expected value is a mapping with numeric values (the keys can be anything).""" - def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: + def __init__( + self, + expected: Mapping[Any, Any], + rel: float | Decimal | timedelta | None, + abs: float | Decimal | timedelta | None, + nan_ok: bool, + ) -> None: __tracebackhide__ = True for key, value in expected.items(): @@ -267,7 +291,9 @@ def _repr_compare(self, other_side: Mapping[object, float]) -> list[str]: if approx_value.expected is not None and other_value is not None: try: max_abs_diff = max( - max_abs_diff, abs(approx_value.expected - other_value) + max_abs_diff, + # TODO: The type error here seems correct. + abs(approx_value.expected - other_value), # type: ignore[operator] ) if approx_value.expected == 0.0: max_rel_diff = math.inf @@ -275,7 +301,8 @@ def _repr_compare(self, other_side: Mapping[object, float]) -> list[str]: max_rel_diff = max( max_rel_diff, abs( - (approx_value.expected - other_value) + # TODO: The type error here seems correct. + (approx_value.expected - other_value) # type: ignore[operator] / approx_value.expected ), ) @@ -311,10 +338,16 @@ def _yield_comparisons(self, actual): yield actual[k], self.expected[k] -class ApproxSequenceLike(ApproxBase): +class ApproxSequenceLike(ApproxBase[Sequence[Any]]): """Perform approximate comparisons where the expected value is a sequence of numbers.""" - def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: + def __init__( + self, + expected: Sequence[Any], + rel: float | Decimal | timedelta | None, + abs: float | Decimal | timedelta | None, + nan_ok: bool, + ) -> None: __tracebackhide__ = True for index, x in enumerate(expected): @@ -387,13 +420,41 @@ def _yield_comparisons(self, actual): return zip(actual, self.expected, strict=True) -class ApproxScalar(ApproxBase): +class ApproxScalar(ApproxBase[ExpectedT]): """Perform approximate comparisons where the expected value is a single number.""" # Using Real should be better than this Union, but not possible yet: # https://github.com/python/typeshed/pull/3108 DEFAULT_ABSOLUTE_TOLERANCE: float | Decimal = 1e-12 DEFAULT_RELATIVE_TOLERANCE: float | Decimal = 1e-6 + rel: float | Decimal | None + abs: float | Decimal | None + + def __init__( + self, + expected: ExpectedT, + rel: float | Decimal | timedelta | None, + abs: float | Decimal | timedelta | None, + nan_ok: bool, + ) -> None: + __tracebackhide__ = True + if rel is not None: + if not isinstance(rel, (int, float, Decimal)): + raise TypeError( + f"relative tolerance for a scalar value must be an int, float or Decimal, " + f"got {type(rel).__name__}" + ) + if not isinstance(expected, SupportsAbs): + raise TypeError( + f"expected value must support abs(...) when relative tolerance is used, " + f"got {type(expected).__name__}" + ) + if abs is not None and not isinstance(abs, (int, float, Decimal)): + raise TypeError( + f"absolute tolerance for a scalar value must be an int, float or Decimal, " + f"got {type(abs).__name__}" + ) + super().__init__(expected, rel=rel, abs=abs, nan_ok=nan_ok) def __repr__(self) -> str: """Return a string communicating both the expected value and the @@ -507,9 +568,11 @@ def tolerance(self): # we've made sure the user didn't ask for an absolute tolerance only, # because we don't want to raise errors about the relative tolerance if # we aren't even going to use it. - relative_tolerance = ( - self.rel if self.rel is not None else self.DEFAULT_RELATIVE_TOLERANCE - ) * abs(self.expected) + rel = self.rel if self.rel is not None else self.DEFAULT_RELATIVE_TOLERANCE + # expected is SupportAbs, checked in __init__. + # The typing here is not exact... + abs_expected: ExpectedT = abs(self.expected) # type: ignore[arg-type] + relative_tolerance: float | Decimal = rel * abs_expected # type: ignore[operator] if relative_tolerance < 0: raise ValueError( @@ -522,20 +585,20 @@ def tolerance(self): return max(relative_tolerance, absolute_tolerance) -class ApproxDecimal(ApproxScalar): +class ApproxDecimal(ApproxScalar[Decimal]): """Perform approximate comparisons where the expected value is a Decimal.""" DEFAULT_ABSOLUTE_TOLERANCE = Decimal("1e-12") DEFAULT_RELATIVE_TOLERANCE = Decimal("1e-6") def __repr__(self) -> str: - if isinstance(self.rel, float): - rel = Decimal.from_float(self.rel) + if isinstance(self.rel, (float, int)): + rel: Decimal | None = Decimal.from_float(self.rel) else: rel = self.rel - if isinstance(self.abs, float): - abs_ = Decimal.from_float(self.abs) + if isinstance(self.abs, (float, int)): + abs_: Decimal | None = Decimal.from_float(self.abs) else: abs_ = self.abs @@ -548,7 +611,7 @@ def __repr__(self) -> str: return f"{self.expected} ± {tol_str}" -class ApproxTimedelta(ApproxBase): +class ApproxTimedelta(ApproxBase[datetime | timedelta]): """Perform approximate comparisons where the expected value is a datetime or timedelta. @@ -556,7 +619,13 @@ class ApproxTimedelta(ApproxBase): Relative tolerance is not supported for datetime comparisons. """ - def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: + def __init__( + self, + expected: datetime | timedelta, + rel: float | Decimal | timedelta | None, + abs: float | Decimal | timedelta | None, + nan_ok: bool, + ) -> None: __tracebackhide__ = True if isinstance(expected, datetime) and rel is not None: raise TypeError( @@ -595,9 +664,14 @@ def __init__(self, expected, rel=None, abs=None, nan_ok: bool = False) -> None: # Compute the effective tolerance. abs_tolerance is a timedelta, rel * expected # gives a timedelta (timedelta * float works in Python). abs_tolerance = abs - rel_tolerance = rel * builtins.abs(expected) if rel is not None else None + if rel is None: + rel_tolerance = None + else: + # Checked above. + assert not isinstance(expected, datetime) + rel_tolerance = rel * builtins.abs(expected) if abs_tolerance is not None and rel_tolerance is not None: - tolerance = max(abs_tolerance, rel_tolerance) + tolerance: timedelta | None = max(abs_tolerance, rel_tolerance) else: tolerance = abs_tolerance if abs_tolerance is not None else rel_tolerance super().__init__(expected, rel=rel, abs=tolerance, nan_ok=False) @@ -611,7 +685,7 @@ def __eq__(self, actual) -> bool: except (TypeError, OverflowError): return False - def _yield_comparisons(self, actual): + def _yield_comparisons(self, actual: object): yield actual, self.expected def _repr_compare(self, other_side: Any) -> list[str]: @@ -629,11 +703,11 @@ def _repr_compare(self, other_side: Any) -> list[str]: def approx( - expected: Any, + expected: T, rel: float | Decimal | timedelta | None = None, abs: float | Decimal | timedelta | None = None, nan_ok: bool = False, -) -> ApproxBase: +) -> ApproxBase[T]: """Assert that two numbers (or two ordered sequences of numbers) are equal to each other within some tolerance. @@ -863,26 +937,23 @@ def approx( __tracebackhide__ = True if isinstance(expected, Decimal): - cls: type[ApproxBase] = ApproxDecimal + return ApproxDecimal(expected, rel=rel, abs=abs, nan_ok=nan_ok) # type: ignore[return-value] elif isinstance(expected, Mapping): - cls = ApproxMapping + return ApproxMapping(expected, rel=rel, abs=abs, nan_ok=nan_ok) # type: ignore[return-value] elif (np_array := _as_numpy_array(expected)) is not None: - expected = np_array - cls = ApproxNumpy + return ApproxNumpy(np_array, rel=rel, abs=abs, nan_ok=nan_ok) elif _is_sequence_like(expected): - cls = ApproxSequenceLike + return ApproxSequenceLike(expected, rel=rel, abs=abs, nan_ok=nan_ok) # type: ignore[return-value] 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 + return ApproxTimedelta(expected, rel=rel, abs=abs, nan_ok=nan_ok) # type: ignore[return-value] else: - cls = ApproxScalar - - return cls(expected, rel, abs, nan_ok) + return ApproxScalar(expected, rel=rel, abs=abs, nan_ok=nan_ok) -def _is_sequence_like(expected: object) -> bool: +def _is_sequence_like(expected: object) -> TypeGuard[Sequence[Any]]: return ( hasattr(expected, "__getitem__") and isinstance(expected, Sized) From fa85a996c32edecbfb43f2696e715665b6e50259 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 23 Jun 2026 19:27:15 +0300 Subject: [PATCH 6/7] approx: expose `pytest.Approx` as the return type of `pytest.approx` Fix #14164. --- changelog/14164.improvement.rst | 2 ++ doc/en/reference/reference.rst | 5 +++++ src/_pytest/assertion/_compare_any.py | 6 +++--- src/_pytest/python_api.py | 31 ++++++++++++++++++--------- src/pytest/__init__.py | 2 ++ 5 files changed, 33 insertions(+), 13 deletions(-) create mode 100644 changelog/14164.improvement.rst diff --git a/changelog/14164.improvement.rst b/changelog/14164.improvement.rst new file mode 100644 index 00000000000..25acae10c50 --- /dev/null +++ b/changelog/14164.improvement.rst @@ -0,0 +1,2 @@ +Exposed :class:`pytest.Approx` as the type of the return value of :func:`pytest.approx()`. +This can be used in type annotations and ``isinstance`` checks. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index ea7eff68f5d..644b78668a7 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -951,6 +951,11 @@ Objects Objects accessible from :ref:`fixtures ` or :ref:`hooks ` or importable from ``pytest``. +Approx +~~~~~~ + +.. autoclass:: pytest.Approx() + :members: CallInfo ~~~~~~~~ diff --git a/src/_pytest/assertion/_compare_any.py b/src/_pytest/assertion/_compare_any.py index 9e577683736..179b92642a3 100644 --- a/src/_pytest/assertion/_compare_any.py +++ b/src/_pytest/assertion/_compare_any.py @@ -44,12 +44,12 @@ def _compare_eq_any( assertion_text_diff_style, ) else: - from _pytest.python_api import ApproxBase + from _pytest.python_api import Approx # Although the common order should be obtained == approx(...), allow both ways. - if isinstance(right, ApproxBase): + if isinstance(right, Approx): yield from right._repr_compare(left) - elif isinstance(left, ApproxBase): + elif isinstance(left, Approx): yield from left._repr_compare(right) elif type(left) is type(right) and ( isdatacls(left) or isattrs(left) or isnamedtuple(left) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index a512e30b299..41895dc3ca9 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -61,9 +61,16 @@ def _compare_approx( ExpectedT = TypeVar("ExpectedT", covariant=True) -class ApproxBase(abc.ABC, Generic[ExpectedT]): - """Provide shared utilities for making approximate comparisons between - numbers or sequences of numbers.""" +class Approx(abc.ABC, Generic[ExpectedT]): + """The return type of :func:`pytest.approx`. + + ``Approx`` objects support (approximate) equality comparisons and ``repr``, + and can also be used with ``isinstance(..., pytest.Approx)``. + + .. versionadded:: 9.2 + """ + + __module__ = "pytest" # Tell numpy to use our `__eq__` operator instead of its. __array_ufunc__ = None @@ -76,9 +83,13 @@ def __init__( abs: float | Decimal | timedelta | None, nan_ok: bool, ) -> None: + #: The expected value passed. self.expected = expected - self.abs = abs + #: The relative tolerance. self.rel = rel + #: The absolute tolerance. + self.abs = abs + #: Whether NaNs compare equal to NaN. self.nan_ok = nan_ok @abc.abstractmethod @@ -135,7 +146,7 @@ def _recursive_sequence_map(f, x): return f(x) -class ApproxNumpy(ApproxBase[ndarray]): +class ApproxNumpy(Approx[ndarray]): """Perform approximate comparisons where the expected value is numpy array.""" def __repr__(self) -> str: @@ -239,7 +250,7 @@ def _yield_comparisons(self, actual): yield actual[i].item(), self.expected[i].item() -class ApproxMapping(ApproxBase[Mapping[Any, Any]]): +class ApproxMapping(Approx[Mapping[Any, Any]]): """Perform approximate comparisons where the expected value is a mapping with numeric values (the keys can be anything).""" @@ -338,7 +349,7 @@ def _yield_comparisons(self, actual): yield actual[k], self.expected[k] -class ApproxSequenceLike(ApproxBase[Sequence[Any]]): +class ApproxSequenceLike(Approx[Sequence[Any]]): """Perform approximate comparisons where the expected value is a sequence of numbers.""" def __init__( @@ -420,7 +431,7 @@ def _yield_comparisons(self, actual): return zip(actual, self.expected, strict=True) -class ApproxScalar(ApproxBase[ExpectedT]): +class ApproxScalar(Approx[ExpectedT]): """Perform approximate comparisons where the expected value is a single number.""" # Using Real should be better than this Union, but not possible yet: @@ -611,7 +622,7 @@ def __repr__(self) -> str: return f"{self.expected} ± {tol_str}" -class ApproxTimedelta(ApproxBase[datetime | timedelta]): +class ApproxTimedelta(Approx[datetime | timedelta]): """Perform approximate comparisons where the expected value is a datetime or timedelta. @@ -707,7 +718,7 @@ def approx( rel: float | Decimal | timedelta | None = None, abs: float | Decimal | timedelta | None = None, nan_ok: bool = False, -) -> ApproxBase[T]: +) -> Approx[T]: """Assert that two numbers (or two ordered sequences of numbers) are equal to each other within some tolerance. diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 7bce1d55ae3..ae438ee0c49 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -60,6 +60,7 @@ from _pytest.python import Metafunc from _pytest.python import Module from _pytest.python import Package +from _pytest.python_api import Approx from _pytest.python_api import approx from _pytest.raises import raises from _pytest.raises import RaisesExc @@ -98,6 +99,7 @@ __all__ = [ "HIDDEN_PARAM", + "Approx", "Cache", "CallInfo", "CaptureFixture", From 62c1be610ba9a24b8cc3b081960f71f0b4cac5ed Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 23 Jun 2026 19:36:55 +0300 Subject: [PATCH 7/7] Rename `python_api.py` to `approx.py` The `python_api.py` file used to also contain the implementation of `raises`, but that was moved to its own file a while ago. Now `python_api.py` only houses the `approx` code, so let's rename it to the more obvious and less generic `approx.py`. --- src/_pytest/{python_api.py => approx.py} | 5 ++--- src/_pytest/assertion/_compare_any.py | 2 +- src/_pytest/doctest.py | 2 +- src/pytest/__init__.py | 4 ++-- testing/python/approx.py | 2 +- testing/test_reports.py | 2 +- 6 files changed, 8 insertions(+), 9 deletions(-) rename src/_pytest/{python_api.py => approx.py} (99%) diff --git a/src/_pytest/python_api.py b/src/_pytest/approx.py similarity index 99% rename from src/_pytest/python_api.py rename to src/_pytest/approx.py index 41895dc3ca9..cf364ee178c 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/approx.py @@ -1,3 +1,5 @@ +"""The implementation of pytest.approx().""" + # mypy: allow-untyped-defs from __future__ import annotations @@ -28,9 +30,6 @@ ndarray = object -# builtin pytest.approx helper - - def _compare_approx( full_object: object, message_data: Sequence[tuple[str, str, str]], diff --git a/src/_pytest/assertion/_compare_any.py b/src/_pytest/assertion/_compare_any.py index 179b92642a3..a1e729187a7 100644 --- a/src/_pytest/assertion/_compare_any.py +++ b/src/_pytest/assertion/_compare_any.py @@ -44,7 +44,7 @@ def _compare_eq_any( assertion_text_diff_style, ) else: - from _pytest.python_api import Approx + from _pytest.approx import Approx # Although the common order should be obtained == approx(...), allow both ways. if isinstance(right, Approx): diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index b1f365109ba..c5b8d09b09d 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -27,6 +27,7 @@ from _pytest._code.code import ReprFileLocation from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter +from _pytest.approx import approx from _pytest.compat import safe_getattr from _pytest.config import Config from _pytest.config.argparsing import Parser @@ -38,7 +39,6 @@ from _pytest.outcomes import skip from _pytest.pathlib import fnmatch_ex from _pytest.python import Module -from _pytest.python_api import approx from _pytest.warning_types import PytestWarning diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index ae438ee0c49..6ff39f05a45 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -6,6 +6,8 @@ from _pytest import __version__ from _pytest import version_tuple from _pytest._code import ExceptionInfo +from _pytest.approx import Approx +from _pytest.approx import approx from _pytest.assertion import register_assert_rewrite from _pytest.cacheprovider import Cache from _pytest.capture import CaptureFixture @@ -60,8 +62,6 @@ from _pytest.python import Metafunc from _pytest.python import Module from _pytest.python import Package -from _pytest.python_api import Approx -from _pytest.python_api import approx from _pytest.raises import raises from _pytest.raises import RaisesExc from _pytest.raises import RaisesGroup diff --git a/testing/python/approx.py b/testing/python/approx.py index 88d46cbb755..9c375dc4d51 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -13,8 +13,8 @@ from operator import ne import re +from _pytest.approx import _recursive_sequence_map from _pytest.pytester import Pytester -from _pytest.python_api import _recursive_sequence_map import pytest from pytest import approx diff --git a/testing/test_reports.py b/testing/test_reports.py index b81371587d9..23c5968bb52 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -5,9 +5,9 @@ from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionRepr +from _pytest.approx import approx from _pytest.config import Config from _pytest.pytester import Pytester -from _pytest.python_api import approx from _pytest.reports import CollectReport from _pytest.reports import TestReport import pytest