python_api: let approx() take nonnumeric values (#7710)
Co-authored-by: Bruno Oliveira <nicoddemus@gmail.com>
This commit is contained in:
parent
f324b27d02
commit
91fa11bed0
1
AUTHORS
1
AUTHORS
|
@ -129,6 +129,7 @@ Ilya Konstantinov
|
||||||
Ionuț Turturică
|
Ionuț Turturică
|
||||||
Iwan Briquemont
|
Iwan Briquemont
|
||||||
Jaap Broekhuizen
|
Jaap Broekhuizen
|
||||||
|
Jakob van Santen
|
||||||
Jakub Mitoraj
|
Jakub Mitoraj
|
||||||
Jan Balster
|
Jan Balster
|
||||||
Janne Vanhala
|
Janne Vanhala
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
Use strict equality comparison for nonnumeric types in ``approx`` instead of
|
||||||
|
raising ``TypeError``.
|
||||||
|
This was the undocumented behavior before 3.7, but is now officially a supported feature.
|
|
@ -4,7 +4,7 @@ from collections.abc import Iterable
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from collections.abc import Sized
|
from collections.abc import Sized
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from numbers import Number
|
from numbers import Complex
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
@ -146,7 +146,10 @@ class ApproxMapping(ApproxBase):
|
||||||
)
|
)
|
||||||
|
|
||||||
def __eq__(self, actual) -> bool:
|
def __eq__(self, actual) -> bool:
|
||||||
if set(actual.keys()) != set(self.expected.keys()):
|
try:
|
||||||
|
if set(actual.keys()) != set(self.expected.keys()):
|
||||||
|
return False
|
||||||
|
except AttributeError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return ApproxBase.__eq__(self, actual)
|
return ApproxBase.__eq__(self, actual)
|
||||||
|
@ -161,8 +164,6 @@ class ApproxMapping(ApproxBase):
|
||||||
if isinstance(value, type(self.expected)):
|
if isinstance(value, type(self.expected)):
|
||||||
msg = "pytest.approx() does not support nested dictionaries: key={!r} value={!r}\n full mapping={}"
|
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)))
|
raise TypeError(msg.format(key, value, pprint.pformat(self.expected)))
|
||||||
elif not isinstance(value, Number):
|
|
||||||
raise _non_numeric_type_error(self.expected, at="key={!r}".format(key))
|
|
||||||
|
|
||||||
|
|
||||||
class ApproxSequencelike(ApproxBase):
|
class ApproxSequencelike(ApproxBase):
|
||||||
|
@ -177,7 +178,10 @@ class ApproxSequencelike(ApproxBase):
|
||||||
)
|
)
|
||||||
|
|
||||||
def __eq__(self, actual) -> bool:
|
def __eq__(self, actual) -> bool:
|
||||||
if len(actual) != len(self.expected):
|
try:
|
||||||
|
if len(actual) != len(self.expected):
|
||||||
|
return False
|
||||||
|
except TypeError:
|
||||||
return False
|
return False
|
||||||
return ApproxBase.__eq__(self, actual)
|
return ApproxBase.__eq__(self, actual)
|
||||||
|
|
||||||
|
@ -190,10 +194,6 @@ class ApproxSequencelike(ApproxBase):
|
||||||
if isinstance(x, type(self.expected)):
|
if isinstance(x, type(self.expected)):
|
||||||
msg = "pytest.approx() does not support nested data structures: {!r} at index {}\n full sequence: {}"
|
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)))
|
raise TypeError(msg.format(x, index, pprint.pformat(self.expected)))
|
||||||
elif not isinstance(x, Number):
|
|
||||||
raise _non_numeric_type_error(
|
|
||||||
self.expected, at="index {}".format(index)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ApproxScalar(ApproxBase):
|
class ApproxScalar(ApproxBase):
|
||||||
|
@ -211,16 +211,23 @@ class ApproxScalar(ApproxBase):
|
||||||
For example, ``1.0 ± 1e-6``, ``(3+4j) ± 5e-6 ∠ ±180°``.
|
For example, ``1.0 ± 1e-6``, ``(3+4j) ± 5e-6 ∠ ±180°``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Infinities aren't compared using tolerances, so don't show a
|
# Don't show a tolerance for values that aren't compared using
|
||||||
# tolerance. Need to call abs to handle complex numbers, e.g. (inf + 1j).
|
# tolerances, i.e. non-numerics and infinities. Need to call abs to
|
||||||
if math.isinf(abs(self.expected)):
|
# handle complex numbers, e.g. (inf + 1j).
|
||||||
|
if (not isinstance(self.expected, (Complex, Decimal))) or math.isinf(
|
||||||
|
abs(self.expected)
|
||||||
|
):
|
||||||
return str(self.expected)
|
return str(self.expected)
|
||||||
|
|
||||||
# If a sensible tolerance can't be calculated, self.tolerance will
|
# If a sensible tolerance can't be calculated, self.tolerance will
|
||||||
# raise a ValueError. In this case, display '???'.
|
# raise a ValueError. In this case, display '???'.
|
||||||
try:
|
try:
|
||||||
vetted_tolerance = "{:.1e}".format(self.tolerance)
|
vetted_tolerance = "{:.1e}".format(self.tolerance)
|
||||||
if isinstance(self.expected, complex) and not math.isinf(self.tolerance):
|
if (
|
||||||
|
isinstance(self.expected, Complex)
|
||||||
|
and self.expected.imag
|
||||||
|
and not math.isinf(self.tolerance)
|
||||||
|
):
|
||||||
vetted_tolerance += " ∠ ±180°"
|
vetted_tolerance += " ∠ ±180°"
|
||||||
except ValueError:
|
except ValueError:
|
||||||
vetted_tolerance = "???"
|
vetted_tolerance = "???"
|
||||||
|
@ -239,6 +246,15 @@ class ApproxScalar(ApproxBase):
|
||||||
if actual == self.expected:
|
if actual == self.expected:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# If either type is non-numeric, fall back to strict equality.
|
||||||
|
# NB: we need Complex, rather than just Number, to ensure that __abs__,
|
||||||
|
# __sub__, and __float__ are defined.
|
||||||
|
if not (
|
||||||
|
isinstance(self.expected, (Complex, Decimal))
|
||||||
|
and isinstance(actual, (Complex, Decimal))
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
# Allow the user to control whether NaNs are considered equal to each
|
# Allow the user to control whether NaNs are considered equal to each
|
||||||
# other or not. The abs() calls are for compatibility with complex
|
# other or not. The abs() calls are for compatibility with complex
|
||||||
# numbers.
|
# numbers.
|
||||||
|
@ -409,6 +425,18 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase:
|
||||||
>>> 1 + 1e-8 == approx(1, rel=1e-6, abs=1e-12)
|
>>> 1 + 1e-8 == approx(1, rel=1e-6, abs=1e-12)
|
||||||
True
|
True
|
||||||
|
|
||||||
|
You can also use ``approx`` to compare nonnumeric types, or dicts and
|
||||||
|
sequences containing nonnumeric types, in which case it falls back to
|
||||||
|
strict equality. This can be useful for comparing dicts and sequences that
|
||||||
|
can contain optional values::
|
||||||
|
|
||||||
|
>>> {"required": 1.0000005, "optional": None} == approx({"required": 1, "optional": None})
|
||||||
|
True
|
||||||
|
>>> [None, 1.0000005] == approx([None,1])
|
||||||
|
True
|
||||||
|
>>> ["foo", 1.0000005] == approx([None,1])
|
||||||
|
False
|
||||||
|
|
||||||
If you're thinking about using ``approx``, then you might want to know how
|
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
|
it compares to other good ways of comparing floating-point numbers. All of
|
||||||
these algorithms are based on relative and absolute tolerances and should
|
these algorithms are based on relative and absolute tolerances and should
|
||||||
|
@ -466,6 +494,14 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase:
|
||||||
follows a fixed behavior. `More information...`__
|
follows a fixed behavior. `More information...`__
|
||||||
|
|
||||||
__ https://docs.python.org/3/reference/datamodel.html#object.__ge__
|
__ https://docs.python.org/3/reference/datamodel.html#object.__ge__
|
||||||
|
|
||||||
|
.. versionchanged:: 3.7.1
|
||||||
|
``approx`` raises ``TypeError`` when it encounters a dict value or
|
||||||
|
sequence element of nonnumeric type.
|
||||||
|
|
||||||
|
.. versionchanged:: 6.1.0
|
||||||
|
``approx`` falls back to strict equality for nonnumeric types instead
|
||||||
|
of raising ``TypeError``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Delegate the comparison to a class that knows how to deal with the type
|
# Delegate the comparison to a class that knows how to deal with the type
|
||||||
|
@ -487,8 +523,6 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase:
|
||||||
|
|
||||||
if isinstance(expected, Decimal):
|
if isinstance(expected, Decimal):
|
||||||
cls = ApproxDecimal # type: Type[ApproxBase]
|
cls = ApproxDecimal # type: Type[ApproxBase]
|
||||||
elif isinstance(expected, Number):
|
|
||||||
cls = ApproxScalar
|
|
||||||
elif isinstance(expected, Mapping):
|
elif isinstance(expected, Mapping):
|
||||||
cls = ApproxMapping
|
cls = ApproxMapping
|
||||||
elif _is_numpy_array(expected):
|
elif _is_numpy_array(expected):
|
||||||
|
@ -501,7 +535,7 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase:
|
||||||
):
|
):
|
||||||
cls = ApproxSequencelike
|
cls = ApproxSequencelike
|
||||||
else:
|
else:
|
||||||
raise _non_numeric_type_error(expected, at=None)
|
cls = ApproxScalar
|
||||||
|
|
||||||
return cls(expected, rel, abs, nan_ok)
|
return cls(expected, rel, abs, nan_ok)
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import operator
|
import operator
|
||||||
|
import sys
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
from operator import eq
|
from operator import eq
|
||||||
|
@ -329,6 +330,9 @@ class TestApprox:
|
||||||
assert (1, 2) != approx((1,))
|
assert (1, 2) != approx((1,))
|
||||||
assert (1, 2) != approx((1, 2, 3))
|
assert (1, 2) != approx((1, 2, 3))
|
||||||
|
|
||||||
|
def test_tuple_vs_other(self):
|
||||||
|
assert 1 != approx((1,))
|
||||||
|
|
||||||
def test_dict(self):
|
def test_dict(self):
|
||||||
actual = {"a": 1 + 1e-7, "b": 2 + 1e-8}
|
actual = {"a": 1 + 1e-7, "b": 2 + 1e-8}
|
||||||
# Dictionaries became ordered in python3.6, so switch up the order here
|
# Dictionaries became ordered in python3.6, so switch up the order here
|
||||||
|
@ -346,6 +350,13 @@ class TestApprox:
|
||||||
assert {"a": 1, "b": 2} != approx({"a": 1, "c": 2})
|
assert {"a": 1, "b": 2} != approx({"a": 1, "c": 2})
|
||||||
assert {"a": 1, "b": 2} != approx({"a": 1, "b": 2, "c": 3})
|
assert {"a": 1, "b": 2} != approx({"a": 1, "b": 2, "c": 3})
|
||||||
|
|
||||||
|
def test_dict_nonnumeric(self):
|
||||||
|
assert {"a": 1.0, "b": None} == pytest.approx({"a": 1.0, "b": None})
|
||||||
|
assert {"a": 1.0, "b": 1} != pytest.approx({"a": 1.0, "b": None})
|
||||||
|
|
||||||
|
def test_dict_vs_other(self):
|
||||||
|
assert 1 != approx({"a": 0})
|
||||||
|
|
||||||
def test_numpy_array(self):
|
def test_numpy_array(self):
|
||||||
np = pytest.importorskip("numpy")
|
np = pytest.importorskip("numpy")
|
||||||
|
|
||||||
|
@ -463,20 +474,67 @@ class TestApprox:
|
||||||
["*At index 0 diff: 3 != 4 ± {}".format(expected), "=* 1 failed in *="]
|
["*At index 0 diff: 3 != 4 ± {}".format(expected), "=* 1 failed in *="]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"x, name",
|
||||||
|
[
|
||||||
|
pytest.param([[1]], "data structures", id="nested-list"),
|
||||||
|
pytest.param({"key": {"key": 1}}, "dictionaries", id="nested-dict"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_expected_value_type_error(self, x, name):
|
||||||
|
with pytest.raises(
|
||||||
|
TypeError,
|
||||||
|
match=r"pytest.approx\(\) does not support nested {}:".format(name),
|
||||||
|
):
|
||||||
|
approx(x)
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"x",
|
"x",
|
||||||
[
|
[
|
||||||
pytest.param(None),
|
pytest.param(None),
|
||||||
pytest.param("string"),
|
pytest.param("string"),
|
||||||
pytest.param(["string"], id="nested-str"),
|
pytest.param(["string"], id="nested-str"),
|
||||||
pytest.param([[1]], id="nested-list"),
|
|
||||||
pytest.param({"key": "string"}, id="dict-with-string"),
|
pytest.param({"key": "string"}, id="dict-with-string"),
|
||||||
pytest.param({"key": {"key": 1}}, id="nested-dict"),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_expected_value_type_error(self, x):
|
def test_nonnumeric_okay_if_equal(self, x):
|
||||||
with pytest.raises(TypeError):
|
assert x == approx(x)
|
||||||
approx(x)
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"x",
|
||||||
|
[
|
||||||
|
pytest.param("string"),
|
||||||
|
pytest.param(["string"], id="nested-str"),
|
||||||
|
pytest.param({"key": "string"}, id="dict-with-string"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_nonnumeric_false_if_unequal(self, x):
|
||||||
|
"""For nonnumeric types, x != pytest.approx(y) reduces to x != y"""
|
||||||
|
assert "ab" != approx("abc")
|
||||||
|
assert ["ab"] != approx(["abc"])
|
||||||
|
# in particular, both of these should return False
|
||||||
|
assert {"a": 1.0} != approx({"a": None})
|
||||||
|
assert {"a": None} != approx({"a": 1.0})
|
||||||
|
|
||||||
|
assert 1.0 != approx(None)
|
||||||
|
assert None != approx(1.0) # noqa: E711
|
||||||
|
|
||||||
|
assert 1.0 != approx([None])
|
||||||
|
assert None != approx([1.0]) # noqa: E711
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires ordered dicts")
|
||||||
|
def test_nonnumeric_dict_repr(self):
|
||||||
|
"""Dicts with non-numerics and infinites have no tolerances"""
|
||||||
|
x1 = {"foo": 1.0000005, "bar": None, "foobar": inf}
|
||||||
|
assert (
|
||||||
|
repr(approx(x1))
|
||||||
|
== "approx({'foo': 1.0000005 ± 1.0e-06, 'bar': None, 'foobar': inf})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nonnumeric_list_repr(self):
|
||||||
|
"""Lists with non-numerics and infinites have no tolerances"""
|
||||||
|
x1 = [1.0000005, None, inf]
|
||||||
|
assert repr(approx(x1)) == "approx([1.0000005 ± 1.0e-06, None, inf])"
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"op",
|
"op",
|
||||||
|
|
Loading…
Reference in New Issue