From 5805d58adf5526d7dd0117fc403eee0f8c6bff5a Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 23 Feb 2022 08:25:00 -0300 Subject: [PATCH] Disallow unordered sequences in pytest.approx Fix #9692 --- changelog/9692.improvement.rst | 3 +++ src/_pytest/python_api.py | 40 ++++++++++++++++++++++++---------- testing/python/approx.py | 15 ++++++++----- 3 files changed, 41 insertions(+), 17 deletions(-) create mode 100644 changelog/9692.improvement.rst diff --git a/changelog/9692.improvement.rst b/changelog/9692.improvement.rst new file mode 100644 index 00000000000..ecc57a68c22 --- /dev/null +++ b/changelog/9692.improvement.rst @@ -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. diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 9891946cd1e..ea646811ddf 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -1,5 +1,6 @@ import math import pprint +from collections.abc import Collection from collections.abc import Sized from decimal import Decimal from numbers import Complex @@ -8,7 +9,6 @@ from typing import Callable from typing import cast from typing import Generic -from typing import Iterable from typing import List from typing import Mapping from typing import Optional @@ -306,12 +306,12 @@ def _check_type(self) -> None: 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.""" def __repr__(self) -> str: seq_type = type(self.expected) - if seq_type not in (tuple, list, set): + if seq_type not in (tuple, list): seq_type = list return "approx({!r})".format( 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: - """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. 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) 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)) True - Dictionary *values*:: - - >>> {'a': 0.1 + 0.2, 'b': 0.2 + 0.4} == approx({'a': 0.3, 'b': 0.6}) - True - ``numpy`` arrays:: >>> 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 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 ``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 @@ -708,12 +717,19 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase: expected = _as_numpy_array(expected) cls = ApproxNumpy elif ( - isinstance(expected, Iterable) + hasattr(expected, "__getitem__") and isinstance(expected, Sized) # Type ignored because the error is wrong -- not 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: cls = ApproxScalar diff --git a/testing/python/approx.py b/testing/python/approx.py index eb9bcd874c0..09566c142b3 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -858,13 +858,18 @@ def test_numpy_scalar_with_array(self): assert approx(expected, rel=5e-7, abs=0) == actual assert approx(expected, rel=5e-8, abs=0) != actual - def test_generic_sized_iterable_object(self): - class MySizedIterable: - def __iter__(self): - return iter([1, 2, 3, 4]) + def test_generic_ordered_sequence(self): + class MySequence: + def __getitem__(self, i): + return [1, 2, 3, 4][i] def __len__(self): return 4 - expected = MySizedIterable() + expected = MySequence() assert [1, 2, 3, 4] == approx(expected) + + 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})