From c34dde7a3f582ddb57560ea08cfe00b84be31e16 Mon Sep 17 00:00:00 2001 From: Tadeu Manoel Date: Wed, 14 Mar 2018 15:29:40 -0300 Subject: [PATCH 1/5] Add support for pytest.approx comparisons between array and scalar --- _pytest/python_api.py | 25 +++++++++++++++++++++---- changelog/3312.feature | 1 + testing/python/approx.py | 11 +++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 changelog/3312.feature diff --git a/_pytest/python_api.py b/_pytest/python_api.py index 3dce7f6b4..4b428322a 100644 --- a/_pytest/python_api.py +++ b/_pytest/python_api.py @@ -31,6 +31,10 @@ class ApproxBase(object): or sequences of numbers. """ + # Tell numpy to use our `__eq__` operator instead of its when left side in a numpy array but right side is + # an instance of ApproxBase + __array_ufunc__ = None + def __init__(self, expected, rel=None, abs=None, nan_ok=False): self.expected = expected self.abs = abs @@ -89,7 +93,7 @@ class ApproxNumpy(ApproxBase): except: # noqa raise TypeError("cannot compare '{0}' to numpy.ndarray".format(actual)) - if actual.shape != self.expected.shape: + if not np.isscalar(self.expected) and actual.shape != self.expected.shape: return False return ApproxBase.__eq__(self, actual) @@ -100,8 +104,13 @@ class ApproxNumpy(ApproxBase): # We can be sure that `actual` is a numpy array, because it's # casted in `__eq__` before being passed to `ApproxBase.__eq__`, # which is the only method that calls this one. - for i in np.ndindex(self.expected.shape): - yield actual[i], self.expected[i] + + if np.isscalar(self.expected): + for i in np.ndindex(actual.shape): + yield actual[i], self.expected + else: + for i in np.ndindex(self.expected.shape): + yield actual[i], self.expected[i] class ApproxMapping(ApproxBase): @@ -189,6 +198,8 @@ class ApproxScalar(ApproxBase): Return true if the given value is equal to the expected value within the pre-specified tolerance. """ + if _is_numpy_array(actual): + return actual == ApproxNumpy(self.expected, self.abs, self.rel, self.nan_ok) # Short-circuit exact equality. if actual == self.expected: @@ -308,12 +319,18 @@ def approx(expected, rel=None, abs=None, nan_ok=False): >>> {'a': 0.1 + 0.2, 'b': 0.2 + 0.4} == approx({'a': 0.3, 'b': 0.6}) True - And ``numpy`` arrays:: + ``numpy`` arrays:: >>> import numpy as np # doctest: +SKIP >>> np.array([0.1, 0.2]) + np.array([0.2, 0.4]) == approx(np.array([0.3, 0.6])) # doctest: +SKIP True + And for a ``numpy`` array against a scalar:: + + >>> import numpy as np # doctest: +SKIP + >>> np.array([0.1, 0.2]) + np.array([0.2, 0.1]) == approx(0.3) # doctest: +SKIP + True + 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 diff --git a/changelog/3312.feature b/changelog/3312.feature new file mode 100644 index 000000000..ffb4df8e9 --- /dev/null +++ b/changelog/3312.feature @@ -0,0 +1 @@ +``pytest.approx`` now accepts comparing a numpy array with a scalar. diff --git a/testing/python/approx.py b/testing/python/approx.py index 341e5fcff..b9d28aadb 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -391,3 +391,14 @@ class TestApprox(object): """ with pytest.raises(TypeError): op(1, approx(1, rel=1e-6, abs=1e-12)) + + def test_numpy_array_with_scalar(self): + np = pytest.importorskip('numpy') + + actual = np.array([1 + 1e-7, 1 - 1e-8]) + expected = 1.0 + + assert actual == approx(expected, rel=5e-7, abs=0) + assert actual != approx(expected, rel=5e-8, abs=0) + assert approx(expected, rel=5e-7, abs=0) == actual + assert approx(expected, rel=5e-8, abs=0) != actual From 161d4e5fe4730f46e1fb803595198068b20c94c5 Mon Sep 17 00:00:00 2001 From: Tadeu Manoel Date: Wed, 14 Mar 2018 16:29:04 -0300 Subject: [PATCH 2/5] Add support for pytest.approx comparisons between scalar and array (inverted order) --- _pytest/python_api.py | 15 ++++++++++----- testing/python/approx.py | 11 +++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/_pytest/python_api.py b/_pytest/python_api.py index 4b428322a..af4d77644 100644 --- a/_pytest/python_api.py +++ b/_pytest/python_api.py @@ -88,12 +88,14 @@ class ApproxNumpy(ApproxBase): def __eq__(self, actual): import numpy as np - try: - actual = np.asarray(actual) - except: # noqa - raise TypeError("cannot compare '{0}' to numpy.ndarray".format(actual)) + if not np.isscalar(actual): + try: + actual = np.asarray(actual) + except: # noqa + raise TypeError("cannot compare '{0}' to numpy.ndarray".format(actual)) - if not np.isscalar(self.expected) and actual.shape != self.expected.shape: + if (not np.isscalar(self.expected) and not np.isscalar(actual) + and actual.shape != self.expected.shape): return False return ApproxBase.__eq__(self, actual) @@ -108,6 +110,9 @@ class ApproxNumpy(ApproxBase): if np.isscalar(self.expected): for i in np.ndindex(actual.shape): yield actual[i], self.expected + elif np.isscalar(actual): + for i in np.ndindex(self.expected.shape): + yield actual, self.expected[i] else: for i in np.ndindex(self.expected.shape): yield actual[i], self.expected[i] diff --git a/testing/python/approx.py b/testing/python/approx.py index b9d28aadb..9ca21bdf8 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -402,3 +402,14 @@ class TestApprox(object): assert actual != approx(expected, rel=5e-8, abs=0) assert approx(expected, rel=5e-7, abs=0) == actual assert approx(expected, rel=5e-8, abs=0) != actual + + def test_numpy_scalar_with_array(self): + np = pytest.importorskip('numpy') + + actual = 1.0 + expected = np.array([1 + 1e-7, 1 - 1e-8]) + + assert actual == approx(expected, rel=5e-7, abs=0) + assert actual != approx(expected, rel=5e-8, abs=0) + assert approx(expected, rel=5e-7, abs=0) == actual + assert approx(expected, rel=5e-8, abs=0) != actual From 97f9a8bfdf25e051fbe58b3d645afbc6d9141fbd Mon Sep 17 00:00:00 2001 From: Tadeu Manoel Date: Wed, 14 Mar 2018 17:10:35 -0300 Subject: [PATCH 3/5] Add fixes to make `numpy.approx` array-scalar comparisons work with older numpy versions --- _pytest/python_api.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/_pytest/python_api.py b/_pytest/python_api.py index af4d77644..aa847d649 100644 --- a/_pytest/python_api.py +++ b/_pytest/python_api.py @@ -31,9 +31,9 @@ class ApproxBase(object): or sequences of numbers. """ - # Tell numpy to use our `__eq__` operator instead of its when left side in a numpy array but right side is - # an instance of ApproxBase + # Tell numpy to use our `__eq__` operator instead of its __array_ufunc__ = None + __array_priority__ = 100 def __init__(self, expected, rel=None, abs=None, nan_ok=False): self.expected = expected @@ -73,9 +73,6 @@ class ApproxNumpy(ApproxBase): Perform approximate comparisons for numpy arrays. """ - # Tell numpy to use our `__eq__` operator instead of its. - __array_priority__ = 100 - def __repr__(self): # It might be nice to rewrite this function to account for the # shape of the array... @@ -109,13 +106,13 @@ class ApproxNumpy(ApproxBase): if np.isscalar(self.expected): for i in np.ndindex(actual.shape): - yield actual[i], self.expected + yield np.asscalar(actual[i]), self.expected elif np.isscalar(actual): for i in np.ndindex(self.expected.shape): - yield actual, self.expected[i] + yield actual, np.asscalar(self.expected[i]) else: for i in np.ndindex(self.expected.shape): - yield actual[i], self.expected[i] + yield np.asscalar(actual[i]), np.asscalar(self.expected[i]) class ApproxMapping(ApproxBase): @@ -145,9 +142,6 @@ class ApproxSequence(ApproxBase): Perform approximate comparisons for sequences of numbers. """ - # Tell numpy to use our `__eq__` operator instead of its. - __array_priority__ = 100 - def __repr__(self): seq_type = type(self.expected) if seq_type not in (tuple, list, set): From 42c84f4f30725e42b4ba3e0aa7480549666e2f01 Mon Sep 17 00:00:00 2001 From: Tadeu Manoel Date: Thu, 15 Mar 2018 13:41:58 -0300 Subject: [PATCH 4/5] Add fixes to `numpy.approx` array-scalar comparisons (from PR suggestions) --- _pytest/python_api.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/_pytest/python_api.py b/_pytest/python_api.py index aa847d649..e2c83aeab 100644 --- a/_pytest/python_api.py +++ b/_pytest/python_api.py @@ -76,8 +76,10 @@ class ApproxNumpy(ApproxBase): def __repr__(self): # It might be nice to rewrite this function to account for the # shape of the array... + import numpy as np + return "approx({0!r})".format(list( - self._approx_scalar(x) for x in self.expected)) + self._approx_scalar(x) for x in np.asarray(self.expected))) if sys.version_info[0] == 2: __cmp__ = _cmp_raises_type_error @@ -100,9 +102,11 @@ class ApproxNumpy(ApproxBase): def _yield_comparisons(self, actual): import numpy as np - # We can be sure that `actual` is a numpy array, because it's - # casted in `__eq__` before being passed to `ApproxBase.__eq__`, - # which is the only method that calls this one. + # For both `actual` and `self.expected`, they can independently be + # either a `numpy.array` or a scalar (but both can't be scalar, + # in this case an `ApproxScalar` is used). + # They are treated in `__eq__` before being passed to + # `ApproxBase.__eq__`, which is the only method that calls this one. if np.isscalar(self.expected): for i in np.ndindex(actual.shape): From a754f00ae7e815786ef917654c4106c7f39dad69 Mon Sep 17 00:00:00 2001 From: Tadeu Manoel Date: Fri, 16 Mar 2018 09:01:18 -0300 Subject: [PATCH 5/5] Improve `numpy.approx` array-scalar comparisons So that `self.expected` in ApproxNumpy is always a numpy array. --- _pytest/python_api.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/_pytest/python_api.py b/_pytest/python_api.py index e2c83aeab..9de4dd2a8 100644 --- a/_pytest/python_api.py +++ b/_pytest/python_api.py @@ -87,14 +87,15 @@ class ApproxNumpy(ApproxBase): def __eq__(self, actual): import numpy as np + # self.expected is supposed to always be an array here + if not np.isscalar(actual): try: actual = np.asarray(actual) except: # noqa raise TypeError("cannot compare '{0}' to numpy.ndarray".format(actual)) - if (not np.isscalar(self.expected) and not np.isscalar(actual) - and actual.shape != self.expected.shape): + if not np.isscalar(actual) and actual.shape != self.expected.shape: return False return ApproxBase.__eq__(self, actual) @@ -102,16 +103,11 @@ class ApproxNumpy(ApproxBase): def _yield_comparisons(self, actual): import numpy as np - # For both `actual` and `self.expected`, they can independently be - # either a `numpy.array` or a scalar (but both can't be scalar, - # in this case an `ApproxScalar` is used). - # They are treated in `__eq__` before being passed to - # `ApproxBase.__eq__`, which is the only method that calls this one. + # `actual` can either be a numpy array or a scalar, it is treated in + # `__eq__` before being passed to `ApproxBase.__eq__`, which is the + # only method that calls this one. - if np.isscalar(self.expected): - for i in np.ndindex(actual.shape): - yield np.asscalar(actual[i]), self.expected - elif np.isscalar(actual): + if np.isscalar(actual): for i in np.ndindex(self.expected.shape): yield actual, np.asscalar(self.expected[i]) else: @@ -202,7 +198,7 @@ class ApproxScalar(ApproxBase): the pre-specified tolerance. """ if _is_numpy_array(actual): - return actual == ApproxNumpy(self.expected, self.abs, self.rel, self.nan_ok) + return ApproxNumpy(actual, self.abs, self.rel, self.nan_ok) == self.expected # Short-circuit exact equality. if actual == self.expected: