Skip to content

Commit

Permalink
python_api: handle array-like args in approx()
Browse files Browse the repository at this point in the history
This treats objects that expose an ndarray via the __array__ interface the
same as direct subclasses of ndarray. Fixes pytest-dev#8132.
  • Loading branch information
jvansanten committed Dec 13, 2020
1 parent 7e2e663 commit fd7f260
Show file tree
Hide file tree
Showing 3 changed files with 73 additions and 4 deletions.
10 changes: 10 additions & 0 deletions changelog/8132.bugfix.rst
@@ -0,0 +1,10 @@
Fixed regression in ``approx``: in 6.2.0 ``approx`` no longer raises
``TypeError`` when dealing with non-numeric types, falling back to normal comparison.
Before 6.2.0, array types like tf.DeviceArray fell through to the scalar case,
and happened to compare correctly to a scalar if they had only one element.
After 6.2.0, these types began failing, because they inherited neither from
standard Python number hierarchy nor from ``numpy.ndarray``.

``approx`` now converts arguments to ``numpy.ndarray`` if they expose the array
protocol and are not scalars. This treats array-like objects like numpy arrays,
regardless of size.
37 changes: 33 additions & 4 deletions src/_pytest/python_api.py
Expand Up @@ -15,9 +15,14 @@
from typing import Pattern
from typing import Tuple
from typing import Type
from typing import TYPE_CHECKING
from typing import TypeVar
from typing import Union

if TYPE_CHECKING:
from numpy import ndarray


import _pytest._code
from _pytest.compat import final
from _pytest.compat import STRING_TYPES
Expand Down Expand Up @@ -232,10 +237,11 @@ def __repr__(self) -> str:
def __eq__(self, actual) -> bool:
"""Return whether the given value is equal to the expected value
within the pre-specified tolerance."""
if _is_numpy_array(actual):
asarray = _as_numpy_array(actual)
if asarray is not None:
# Call ``__eq__()`` manually to prevent infinite-recursion with
# numpy<1.13. See #3748.
return all(self.__eq__(a) for a in actual.flat)
return all(self.__eq__(a) for a in asarray.flat)

# Short-circuit exact equality.
if actual == self.expected:
Expand Down Expand Up @@ -521,6 +527,7 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase:
elif isinstance(expected, Mapping):
cls = ApproxMapping
elif _is_numpy_array(expected):
expected = _as_numpy_array(expected)
cls = ApproxNumpy
elif (
isinstance(expected, Iterable)
Expand All @@ -536,18 +543,40 @@ def approx(expected, rel=None, abs=None, nan_ok: bool = False) -> ApproxBase:


def _is_numpy_array(obj: object) -> bool:
"""Return true if the given object is a numpy array.
"""Return true if the given object is implicitly convertible to numpy array.
A special effort is made to avoid importing numpy unless it's really necessary.
"""
import sys

np: Any = sys.modules.get("numpy")
if np is not None:
return isinstance(obj, np.ndarray)
# avoid infinite recursion on numpy scalars, which have __array__
if np.isscalar(obj):
return False
elif isinstance(obj, np.ndarray):
return True
elif hasattr(obj, "__array__") or hasattr("obj", "__array_interface__"):
return True
return False


def _as_numpy_array(obj: object) -> Optional["ndarray"]:
"""Return an ndarray if obj is implicitly convertible, and numpy is already imported."""
import sys

np: Any = sys.modules.get("numpy")
if np is not None:
# avoid infinite recursion on numpy scalars, which have __array__
if np.isscalar(obj):
return None
elif isinstance(obj, np.ndarray):
return obj
elif hasattr(obj, "__array__") or hasattr("obj", "__array_interface__"):
return np.asarray(obj)
return None


# builtin pytest.raises helper

_E = TypeVar("_E", bound=BaseException)
Expand Down
30 changes: 30 additions & 0 deletions testing/python/approx.py
Expand Up @@ -447,6 +447,36 @@ def test_numpy_array_wrong_shape(self):
assert a12 != approx(a21)
assert a21 != approx(a12)

def test_numpy_array_protocol(self):
"""
array-like objects such as tensorflow's DeviceArray are handled like ndarray.
See issue #8132
"""
np = pytest.importorskip("numpy")

class DeviceArray:
def __init__(self, value, size):
self.value = value
self.size = size

def __array__(self):
return self.value * np.ones(self.size)

class DeviceScalar:
def __init__(self, value):
self.value = value

def __array__(self):
return np.array(self.value)

expected = 1
actual = 1 + 1e-6
assert approx(expected) == DeviceArray(actual, size=1)
assert approx(expected) == DeviceArray(actual, size=2)
assert approx(expected) == DeviceScalar(actual)
assert approx(DeviceScalar(expected)) == actual
assert approx(DeviceScalar(expected)) == DeviceScalar(actual)

def test_doctests(self, mocked_doctest_runner) -> None:
import doctest

Expand Down

0 comments on commit fd7f260

Please sign in to comment.