Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

python_api: let approx() take nonnumeric values #7710

Merged
merged 9 commits into from
Sep 28, 2020
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ Ilya Konstantinov
Ionuț Turturică
Iwan Briquemont
Jaap Broekhuizen
Jakob van Santen
Jakub Mitoraj
Jan Balster
Janne Vanhala
Expand Down
3 changes: 3 additions & 0 deletions changelog/7710.improvement.rst
Original file line number Diff line number Diff line change
@@ -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.
66 changes: 50 additions & 16 deletions src/_pytest/python_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from collections.abc import Mapping
from collections.abc import Sized
from decimal import Decimal
from numbers import Number
from numbers import Complex
from types import TracebackType
from typing import Any
from typing import Callable
Expand Down Expand Up @@ -145,7 +145,10 @@ def __repr__(self) -> str:
)

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 ApproxBase.__eq__(self, actual)
Expand All @@ -160,8 +163,6 @@ def _check_type(self) -> None:
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)))
elif not isinstance(value, Number):
raise _non_numeric_type_error(self.expected, at="key={!r}".format(key))


class ApproxSequencelike(ApproxBase):
Expand All @@ -176,7 +177,10 @@ def __repr__(self) -> str:
)

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 ApproxBase.__eq__(self, actual)

Expand All @@ -189,10 +193,6 @@ def _check_type(self) -> None:
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)))
elif not isinstance(x, Number):
raise _non_numeric_type_error(
self.expected, at="index {}".format(index)
)


class ApproxScalar(ApproxBase):
Expand All @@ -210,16 +210,23 @@ def __repr__(self) -> str:
For example, ``1.0 ± 1e-6``, ``(3+4j) ± 5e-6 ∠ ±180°``.
"""

# Infinities aren't compared using tolerances, so don't show a
# tolerance. Need to call abs to handle complex numbers, e.g. (inf + 1j).
if math.isinf(abs(self.expected)):
# Don't show a tolerance for values that aren't compared using
# tolerances, i.e. non-numerics and infinities. Need to call abs to
# handle complex numbers, e.g. (inf + 1j).
if (not isinstance(self.expected, (Complex, Decimal))) or math.isinf(
abs(self.expected)
):
return str(self.expected)

# If a sensible tolerance can't be calculated, self.tolerance will
# raise a ValueError. In this case, display '???'.
try:
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°"
except ValueError:
vetted_tolerance = "???"
Expand All @@ -238,6 +245,15 @@ def __eq__(self, actual) -> bool:
if actual == self.expected:
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
# other or not. The abs() calls are for compatibility with complex
# numbers.
Expand Down Expand Up @@ -408,6 +424,18 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase:
>>> 1 + 1e-8 == approx(1, rel=1e-6, abs=1e-12)
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
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 @@ -465,6 +493,14 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase:
follows a fixed behavior. `More information...`__

__ 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
Expand All @@ -486,8 +522,6 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase:

if isinstance(expected, Decimal):
cls = ApproxDecimal # type: Type[ApproxBase]
elif isinstance(expected, Number):
cls = ApproxScalar
elif isinstance(expected, Mapping):
cls = ApproxMapping
elif _is_numpy_array(expected):
Expand All @@ -500,7 +534,7 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase:
):
cls = ApproxSequencelike
else:
raise _non_numeric_type_error(expected, at=None)
cls = ApproxScalar

return cls(expected, rel, abs, nan_ok)

Expand Down
68 changes: 63 additions & 5 deletions testing/python/approx.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import operator
import sys
from decimal import Decimal
from fractions import Fraction
from operator import eq
Expand Down Expand Up @@ -329,6 +330,9 @@ def test_tuple_wrong_len(self):
assert (1, 2) != approx((1,))
assert (1, 2) != approx((1, 2, 3))

def test_tuple_vs_other(self):
assert 1 != approx((1,))

def test_dict(self):
actual = {"a": 1 + 1e-7, "b": 2 + 1e-8}
# Dictionaries became ordered in python3.6, so switch up the order here
Expand All @@ -346,6 +350,13 @@ def test_dict_wrong_len(self):
assert {"a": 1, "b": 2} != approx({"a": 1, "c": 2})
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):
np = pytest.importorskip("numpy")

Expand Down Expand Up @@ -463,20 +474,67 @@ def test_foo():
["*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(
"x",
[
pytest.param(None),
pytest.param("string"),
pytest.param(["string"], id="nested-str"),
pytest.param([[1]], id="nested-list"),
pytest.param({"key": "string"}, id="dict-with-string"),
pytest.param({"key": {"key": 1}}, id="nested-dict"),
],
)
def test_expected_value_type_error(self, x):
with pytest.raises(TypeError):
approx(x)
def test_nonnumeric_okay_if_equal(self, x):
assert 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(
"op",
Expand Down