From da8a223a8379b75163528facfab5947569d7cab4 Mon Sep 17 00:00:00 2001 From: Jakob van Santen Date: Thu, 3 Sep 2020 17:51:30 +0200 Subject: [PATCH 1/9] python_api: let approx() take nonnumeric values --- src/_pytest/python_api.py | 31 +++++++++++++++---------- testing/python/approx.py | 48 +++++++++++++++++++++++++++++++++++---- 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index a1eb29e1aba..b0b50dcd359 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -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 @@ -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) @@ -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): @@ -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) @@ -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): @@ -238,6 +238,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. @@ -486,8 +495,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): @@ -500,7 +507,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) diff --git a/testing/python/approx.py b/testing/python/approx.py index 194423dc3b0..95a17945c3c 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -329,6 +329,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 @@ -346,6 +349,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") @@ -466,11 +476,7 @@ def test_foo(): @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"), ], ) @@ -478,6 +484,40 @@ def test_expected_value_type_error(self, x): with pytest.raises(TypeError): approx(x) + @pytest.mark.parametrize( + "x", + [ + pytest.param(None), + pytest.param("string"), + pytest.param(["string"], id="nested-str"), + pytest.param({"key": "string"}, id="dict-with-string"), + ], + ) + 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.parametrize( "op", [ From d4d4752a6770ec3096654c95fd90a21d3f5c6497 Mon Sep 17 00:00:00 2001 From: Jakob van Santen Date: Sun, 13 Sep 2020 12:54:04 +0200 Subject: [PATCH 2/9] python_api: safe repr() for ApproxScalar This also eliminates the explicit check for `complex` in __repr__(), as mypy complains that a common subclass of `numbers.Complex` and `complex` would have conflicting method signatures. It's not clear where the conflict comes from, but in any case it's simple (and slightly more Pythonic) to skirt the issue by treating all numbers with a nonzero imaginary component as complex, and considering complex-typed, but real-valued, numbers to be real. --- src/_pytest/python_api.py | 15 +++++++++++---- testing/python/approx.py | 8 ++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index b0b50dcd359..990dac44a84 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -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 = "???" diff --git a/testing/python/approx.py b/testing/python/approx.py index 95a17945c3c..3fdec361deb 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -518,6 +518,14 @@ def test_nonnumeric_false_if_unequal(self, x): assert 1.0 != approx([None]) assert None != approx([1.0]) # noqa: E711 + def test_nonnumeric_repr(self): + """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})" + ) + @pytest.mark.parametrize( "op", [ From d4a7fe1cf81606a4f64fec442c26e12d3c889e8e Mon Sep 17 00:00:00 2001 From: Jakob van Santen Date: Sun, 13 Sep 2020 13:43:16 +0200 Subject: [PATCH 3/9] python_api: add nonnumeric approx example --- src/_pytest/python_api.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 990dac44a84..9d96bacc66d 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -424,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 @@ -481,6 +493,13 @@ 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.0.3 + ``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 From 343d2046817fd0cb776fed69863700ebd1a6bbc2 Mon Sep 17 00:00:00 2001 From: Jakob van Santen Date: Sun, 13 Sep 2020 13:46:05 +0200 Subject: [PATCH 4/9] Update AUTHORS and add changelog entry --- AUTHORS | 1 + changelog/7710.bugfix.rst | 3 +++ 2 files changed, 4 insertions(+) create mode 100644 changelog/7710.bugfix.rst diff --git a/AUTHORS b/AUTHORS index b28e5613389..1c1077d71e7 100644 --- a/AUTHORS +++ b/AUTHORS @@ -129,6 +129,7 @@ Ilya Konstantinov Ionuț Turturică Iwan Briquemont Jaap Broekhuizen +Jakob van Santen Jakub Mitoraj Jan Balster Janne Vanhala diff --git a/changelog/7710.bugfix.rst b/changelog/7710.bugfix.rst new file mode 100644 index 00000000000..2016588f393 --- /dev/null +++ b/changelog/7710.bugfix.rst @@ -0,0 +1,3 @@ +Use strict equality comparison for nonnumeric types in ``approx`` instead of +raising ``TypeError``. +Regressed in pytest 3.7.1. From aa1454e843fb28270d17c8f476924e2a32af0b55 Mon Sep 17 00:00:00 2001 From: Jakob van Santen Date: Sun, 13 Sep 2020 14:04:52 +0200 Subject: [PATCH 5/9] pre-sort dicts for python<3.7 --- testing/python/approx.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testing/python/approx.py b/testing/python/approx.py index 3fdec361deb..b6ccfb321fd 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -520,10 +520,10 @@ def test_nonnumeric_false_if_unequal(self, x): def test_nonnumeric_repr(self): """Non-numerics and infinites have no tolerances""" - x1 = {"foo": 1.0000005, "bar": None, "foobar": inf} + x1 = {"bar": None, "foobar": inf, "foo": 1.0000005} assert ( repr(approx(x1)) - == "approx({'foo': 1.0000005 ± 1.0e-06, 'bar': None, 'foobar': inf})" + == "approx({'bar': None, 'foobar': inf, 'foo': 1.0000005 ± 1.0e-06})" ) @pytest.mark.parametrize( From edd62cce5ac66fa3760e5b3a1bacd0e2ae0183e4 Mon Sep 17 00:00:00 2001 From: Jakob van Santen Date: Sun, 13 Sep 2020 16:23:14 +0200 Subject: [PATCH 6/9] Skip repr test if dicts unordered --- testing/python/approx.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/testing/python/approx.py b/testing/python/approx.py index b6ccfb321fd..2d7911eac0a 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -1,4 +1,5 @@ import operator +import sys from decimal import Decimal from fractions import Fraction from operator import eq @@ -518,12 +519,13 @@ def test_nonnumeric_false_if_unequal(self, x): 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_repr(self): """Non-numerics and infinites have no tolerances""" - x1 = {"bar": None, "foobar": inf, "foo": 1.0000005} + x1 = {"foo": 1.0000005, "bar": None, "foobar": inf} assert ( repr(approx(x1)) - == "approx({'bar': None, 'foobar': inf, 'foo': 1.0000005 ± 1.0e-06})" + == "approx({'foo': 1.0000005 ± 1.0e-06, 'bar': None, 'foobar': inf})" ) @pytest.mark.parametrize( From 9e0f474ec0c64813ab5ee4a960a3d92b4e51dab4 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 19 Sep 2020 09:38:39 -0300 Subject: [PATCH 7/9] Update and rename 7710.bugfix.rst to 7710.feature.rst --- changelog/{7710.bugfix.rst => 7710.feature.rst} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog/{7710.bugfix.rst => 7710.feature.rst} (52%) diff --git a/changelog/7710.bugfix.rst b/changelog/7710.feature.rst similarity index 52% rename from changelog/7710.bugfix.rst rename to changelog/7710.feature.rst index 2016588f393..1bbaf7792ab 100644 --- a/changelog/7710.bugfix.rst +++ b/changelog/7710.feature.rst @@ -1,3 +1,3 @@ Use strict equality comparison for nonnumeric types in ``approx`` instead of raising ``TypeError``. -Regressed in pytest 3.7.1. +This was the undocumented behavior before 3.7, but is now officially a supported feature. From 9b36bb0c742210f810a9cd886288cb22f5288be9 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 19 Sep 2020 09:41:10 -0300 Subject: [PATCH 8/9] Rename 7710.feature.rst to 7710.improvement.rst --- changelog/{7710.feature.rst => 7710.improvement.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog/{7710.feature.rst => 7710.improvement.rst} (100%) diff --git a/changelog/7710.feature.rst b/changelog/7710.improvement.rst similarity index 100% rename from changelog/7710.feature.rst rename to changelog/7710.improvement.rst From 20d1567f18f700266a76f3fe99120a4318c0fe47 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 19 Sep 2020 10:00:55 -0300 Subject: [PATCH 9/9] Small docs and test improvements --- src/_pytest/python_api.py | 3 ++- testing/python/approx.py | 22 +++++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 9d96bacc66d..7cae4230a32 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -497,7 +497,8 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase: .. versionchanged:: 3.7.1 ``approx`` raises ``TypeError`` when it encounters a dict value or sequence element of nonnumeric type. - .. versionchanged:: 6.0.3 + + .. versionchanged:: 6.1.0 ``approx`` falls back to strict equality for nonnumeric types instead of raising ``TypeError``. """ diff --git a/testing/python/approx.py b/testing/python/approx.py index 2d7911eac0a..5f12da37654 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -475,14 +475,17 @@ def test_foo(): ) @pytest.mark.parametrize( - "x", + "x, name", [ - pytest.param([[1]], id="nested-list"), - pytest.param({"key": {"key": 1}}, id="nested-dict"), + 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): - with pytest.raises(TypeError): + 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( @@ -520,14 +523,19 @@ def test_nonnumeric_false_if_unequal(self, x): assert None != approx([1.0]) # noqa: E711 @pytest.mark.skipif(sys.version_info < (3, 7), reason="requires ordered dicts") - def test_nonnumeric_repr(self): - """Non-numerics and infinites have no tolerances""" + 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", [