Disallow unordered sequences in pytest.approx (#9709)

Fix #9692
This commit is contained in:
Bruno Oliveira 2022-02-24 10:16:35 -03:00 committed by GitHub
parent bcc826d0fb
commit 5f3d94c47e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 45 additions and 18 deletions

View File

@ -0,0 +1,3 @@
:func:`pytest.approx` now raises a :class:`TypeError` when given an unordered sequence (such as :class:`set`).
Note that this implies that custom classes which only implement ``__iter__`` and ``__len__`` are no longer supported as they don't guarantee order.

View File

@ -1,5 +1,6 @@
import math import math
import pprint import pprint
from collections.abc import Collection
from collections.abc import Sized from collections.abc import Sized
from decimal import Decimal from decimal import Decimal
from numbers import Complex from numbers import Complex
@ -8,7 +9,6 @@ from typing import Any
from typing import Callable from typing import Callable
from typing import cast from typing import cast
from typing import Generic from typing import Generic
from typing import Iterable
from typing import List from typing import List
from typing import Mapping from typing import Mapping
from typing import Optional from typing import Optional
@ -306,12 +306,12 @@ class ApproxMapping(ApproxBase):
raise TypeError(msg.format(key, value, pprint.pformat(self.expected))) raise TypeError(msg.format(key, value, pprint.pformat(self.expected)))
class ApproxSequencelike(ApproxBase): class ApproxSequenceLike(ApproxBase):
"""Perform approximate comparisons where the expected value is a sequence of numbers.""" """Perform approximate comparisons where the expected value is a sequence of numbers."""
def __repr__(self) -> str: def __repr__(self) -> str:
seq_type = type(self.expected) seq_type = type(self.expected)
if seq_type not in (tuple, list, set): if seq_type not in (tuple, list):
seq_type = list seq_type = list
return "approx({!r})".format( return "approx({!r})".format(
seq_type(self._approx_scalar(x) for x in self.expected) seq_type(self._approx_scalar(x) for x in self.expected)
@ -515,7 +515,7 @@ class ApproxDecimal(ApproxScalar):
def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase: def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase:
"""Assert that two numbers (or two sets of numbers) are equal to each other """Assert that two numbers (or two ordered sequences of numbers) are equal to each other
within some tolerance. within some tolerance.
Due to the :std:doc:`tutorial/floatingpoint`, numbers that we Due to the :std:doc:`tutorial/floatingpoint`, numbers that we
@ -547,16 +547,11 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase:
>>> 0.1 + 0.2 == approx(0.3) >>> 0.1 + 0.2 == approx(0.3)
True True
The same syntax also works for sequences of numbers:: The same syntax also works for ordered sequences of numbers::
>>> (0.1 + 0.2, 0.2 + 0.4) == approx((0.3, 0.6)) >>> (0.1 + 0.2, 0.2 + 0.4) == approx((0.3, 0.6))
True True
Dictionary *values*::
>>> {'a': 0.1 + 0.2, 'b': 0.2 + 0.4} == approx({'a': 0.3, 'b': 0.6})
True
``numpy`` arrays:: ``numpy`` arrays::
>>> import numpy as np # doctest: +SKIP >>> import numpy as np # doctest: +SKIP
@ -569,6 +564,20 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase:
>>> np.array([0.1, 0.2]) + np.array([0.2, 0.1]) == approx(0.3) # doctest: +SKIP >>> np.array([0.1, 0.2]) + np.array([0.2, 0.1]) == approx(0.3) # doctest: +SKIP
True True
Only ordered sequences are supported, because ``approx`` needs
to infer the relative position of the sequences without ambiguity. This means
``sets`` and other unordered sequences are not supported.
Finally, dictionary *values* can also be compared::
>>> {'a': 0.1 + 0.2, 'b': 0.2 + 0.4} == approx({'a': 0.3, 'b': 0.6})
True
The comparision will be true if both mappings have the same keys and their
respective values match the expected tolerances.
**Tolerances**
By default, ``approx`` considers numbers within a relative tolerance of By default, ``approx`` considers numbers within a relative tolerance of
``1e-6`` (i.e. one part in a million) of its expected value to be equal. ``1e-6`` (i.e. one part in a million) of its expected value to be equal.
This treatment would lead to surprising results if the expected value was This treatment would lead to surprising results if the expected value was
@ -708,12 +717,19 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase:
expected = _as_numpy_array(expected) expected = _as_numpy_array(expected)
cls = ApproxNumpy cls = ApproxNumpy
elif ( elif (
isinstance(expected, Iterable) hasattr(expected, "__getitem__")
and isinstance(expected, Sized) and isinstance(expected, Sized)
# Type ignored because the error is wrong -- not unreachable. # Type ignored because the error is wrong -- not unreachable.
and not isinstance(expected, STRING_TYPES) # type: ignore[unreachable] and not isinstance(expected, STRING_TYPES) # type: ignore[unreachable]
): ):
cls = ApproxSequencelike cls = ApproxSequenceLike
elif (
isinstance(expected, Collection)
# Type ignored because the error is wrong -- not unreachable.
and not isinstance(expected, STRING_TYPES) # type: ignore[unreachable]
):
msg = f"pytest.approx() only supports ordered sequences, but got: {repr(expected)}"
raise TypeError(msg)
else: else:
cls = ApproxScalar cls = ApproxScalar

View File

@ -858,13 +858,21 @@ class TestApprox:
assert approx(expected, rel=5e-7, abs=0) == actual assert approx(expected, rel=5e-7, abs=0) == actual
assert approx(expected, rel=5e-8, abs=0) != actual assert approx(expected, rel=5e-8, abs=0) != actual
def test_generic_sized_iterable_object(self): def test_generic_ordered_sequence(self):
class MySizedIterable: class MySequence:
def __iter__(self): def __getitem__(self, i):
return iter([1, 2, 3, 4]) return [1, 2, 3, 4][i]
def __len__(self): def __len__(self):
return 4 return 4
expected = MySizedIterable() expected = MySequence()
assert [1, 2, 3, 4] == approx(expected) assert [1, 2, 3, 4] == approx(expected, abs=1e-4)
expected_repr = "approx([1 ± 1.0e-06, 2 ± 2.0e-06, 3 ± 3.0e-06, 4 ± 4.0e-06])"
assert repr(approx(expected)) == expected_repr
def test_allow_ordered_sequences_only(self) -> None:
"""pytest.approx() should raise an error on unordered sequences (#9692)."""
with pytest.raises(TypeError, match="only supports ordered sequences"):
assert {1, 2, 3} == approx({1, 2, 3})