Implement suggestions from code review.
- Avoid importing numpy unless necessary. - Mention numpy arrays and dictionaries in the docs. - Add numpy to the list of tox dependencies. - Don't unnecessarily copy arrays or allocate empty space for them. - Use code from compat.py rather than writing py2/3 versions of things myself. - Avoid reimplementing __repr__ for built-in types. - Add an option to consider NaN == NaN, because sometimes people use NaN to mean "missing data".
This commit is contained in:
parent
89292f08dc
commit
8badb47db6
|
@ -125,6 +125,7 @@ if sys.version_info[:2] == (2, 6):
|
|||
if _PY3:
|
||||
import codecs
|
||||
imap = map
|
||||
izip = zip
|
||||
STRING_TYPES = bytes, str
|
||||
UNICODE_TYPES = str,
|
||||
|
||||
|
@ -160,7 +161,7 @@ else:
|
|||
STRING_TYPES = bytes, str, unicode
|
||||
UNICODE_TYPES = unicode,
|
||||
|
||||
from itertools import imap # NOQA
|
||||
from itertools import imap, izip # NOQA
|
||||
|
||||
def _escape_strings(val):
|
||||
"""In py2 bytes and str are the same type, so return if it's a bytes
|
||||
|
|
|
@ -3,7 +3,7 @@ import sys
|
|||
|
||||
import py
|
||||
|
||||
from _pytest.compat import isclass
|
||||
from _pytest.compat import isclass, izip
|
||||
from _pytest.runner import fail
|
||||
import _pytest._code
|
||||
|
||||
|
@ -15,15 +15,14 @@ class ApproxBase(object):
|
|||
or sequences of numbers.
|
||||
"""
|
||||
|
||||
def __init__(self, expected, rel=None, abs=None):
|
||||
def __init__(self, expected, rel=None, abs=None, nan_ok=False):
|
||||
self.expected = expected
|
||||
self.abs = abs
|
||||
self.rel = rel
|
||||
self.nan_ok = nan_ok
|
||||
|
||||
def __repr__(self):
|
||||
return ', '.join(
|
||||
repr(self._approx_scalar(x))
|
||||
for x in self._yield_expected())
|
||||
raise NotImplementedError
|
||||
|
||||
def __eq__(self, actual):
|
||||
return all(
|
||||
|
@ -36,14 +35,7 @@ class ApproxBase(object):
|
|||
return not (actual == self)
|
||||
|
||||
def _approx_scalar(self, x):
|
||||
return ApproxScalar(x, rel=self.rel, abs=self.abs)
|
||||
|
||||
def _yield_expected(self, actual):
|
||||
"""
|
||||
Yield all the expected values associated with this object. This is
|
||||
used to implement the `__repr__` method.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
return ApproxScalar(x, rel=self.rel, abs=self.abs, nan_ok=self.nan_ok)
|
||||
|
||||
def _yield_comparisons(self, actual):
|
||||
"""
|
||||
|
@ -53,53 +45,64 @@ class ApproxBase(object):
|
|||
raise NotImplementedError
|
||||
|
||||
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
|
||||
class ApproxNumpy(ApproxBase, np.ndarray):
|
||||
class ApproxNumpyBase(ApproxBase):
|
||||
"""
|
||||
Perform approximate comparisons for numpy arrays.
|
||||
|
||||
This class must inherit from numpy.ndarray in order to allow the approx
|
||||
to be on either side of the `==` operator. The reason for this has to
|
||||
do with how python decides whether to call `a.__eq__()` or `b.__eq__()`
|
||||
when it encounters `a == b`.
|
||||
This class should not be used directly. Instead, it should be used to make
|
||||
a subclass that also inherits from `np.ndarray`, e.g.::
|
||||
|
||||
If `a` and `b` are not related by inheritance, `a` gets priority. So
|
||||
as long as `a.__eq__` is defined, it will be called. Because most
|
||||
implementations of `a.__eq__` end up calling `b.__eq__`, this detail
|
||||
usually doesn't matter. However, `numpy.ndarray.__eq__` raises an
|
||||
error complaining that "the truth value of an array with more than
|
||||
one element is ambiguous. Use a.any() or a.all()" when compared with a
|
||||
custom class, so `b.__eq__` never gets called.
|
||||
import numpy as np
|
||||
ApproxNumpy = type('ApproxNumpy', (ApproxNumpyBase, np.ndarray), {})
|
||||
|
||||
This bizarre invocation is necessary because the object doing the
|
||||
approximate comparison must inherit from `np.ndarray`, or it will only work
|
||||
on the left side of the `==` operator. But importing numpy is relatively
|
||||
expensive, so we also want to avoid that unless we actually have a numpy
|
||||
array to compare.
|
||||
|
||||
The reason why the approx object needs to inherit from `np.ndarray` has to
|
||||
do with how python decides whether to call `a.__eq__()` or `b.__eq__()`
|
||||
when it parses `a == b`. If `a` and `b` are not related by inheritance,
|
||||
`a` gets priority. So as long as `a.__eq__` is defined, it will be called.
|
||||
Because most implementations of `a.__eq__` end up calling `b.__eq__`, this
|
||||
detail usually doesn't matter. However, `np.ndarray.__eq__` treats the
|
||||
approx object as a scalar and builds a new array by comparing it to each
|
||||
item in the original array. `b.__eq__` is called to compare against each
|
||||
individual element in the array, but it has no way (that I can see) to
|
||||
prevent the return value from being an boolean array, and boolean arrays
|
||||
can't be used with assert because "the truth value of an array with more
|
||||
than one element is ambiguous."
|
||||
|
||||
The trick is that the priority rules change if `a` and `b` are related
|
||||
by inheritance. Specifically, `b.__eq__` gets priority if `b` is a
|
||||
subclass of `a`. So we can guarantee that `ApproxNumpy.__eq__` gets
|
||||
called by inheriting from `numpy.ndarray`.
|
||||
subclass of `a`. So by inheriting from `np.ndarray`, we can guarantee that
|
||||
`ApproxNumpy.__eq__` gets called no matter which side of the `==` operator
|
||||
it appears on.
|
||||
"""
|
||||
|
||||
def __new__(cls, expected, rel=None, abs=None):
|
||||
def __new__(cls, expected, rel=None, abs=None, nan_ok=False):
|
||||
"""
|
||||
Numpy uses __new__ (rather than __init__) to initialize objects.
|
||||
|
||||
The `expected` argument must be a numpy array. This should be
|
||||
ensured by the approx() delegator function.
|
||||
"""
|
||||
assert isinstance(expected, np.ndarray)
|
||||
obj = super(ApproxNumpy, cls).__new__(cls, expected.shape)
|
||||
obj.__init__(expected, rel, abs)
|
||||
obj = super(ApproxNumpyBase, cls).__new__(cls, ())
|
||||
obj.__init__(expected, rel, abs, nan_ok)
|
||||
return obj
|
||||
|
||||
def __repr__(self):
|
||||
# It might be nice to rewrite this function to account for the
|
||||
# shape of the array...
|
||||
return '[' + ApproxBase.__repr__(self) + ']'
|
||||
return repr(list(
|
||||
self._approx_scalar(x) for x in self.expected))
|
||||
|
||||
def __eq__(self, actual):
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
actual = np.array(actual)
|
||||
actual = np.asarray(actual)
|
||||
except:
|
||||
raise ValueError("cannot cast '{0}' to numpy.ndarray".format(actual))
|
||||
|
||||
|
@ -108,11 +111,9 @@ try:
|
|||
|
||||
return ApproxBase.__eq__(self, actual)
|
||||
|
||||
def _yield_expected(self):
|
||||
for x in self.expected:
|
||||
yield x
|
||||
|
||||
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.
|
||||
|
@ -120,9 +121,6 @@ try:
|
|||
yield actual[i], self.expected[i]
|
||||
|
||||
|
||||
except ImportError:
|
||||
np = None
|
||||
|
||||
class ApproxMapping(ApproxBase):
|
||||
"""
|
||||
Perform approximate comparisons for mappings where the values are numbers
|
||||
|
@ -130,8 +128,9 @@ class ApproxMapping(ApproxBase):
|
|||
"""
|
||||
|
||||
def __repr__(self):
|
||||
item = lambda k, v: "'{0}': {1}".format(k, self._approx_scalar(v))
|
||||
return '{' + ', '.join(item(k,v) for k,v in self.expected.items()) + '}'
|
||||
return repr({
|
||||
k: self._approx_scalar(v)
|
||||
for k,v in self.expected.items()})
|
||||
|
||||
def __eq__(self, actual):
|
||||
if actual.keys() != self.expected.keys():
|
||||
|
@ -150,19 +149,19 @@ class ApproxSequence(ApproxBase):
|
|||
"""
|
||||
|
||||
def __repr__(self):
|
||||
open, close = '()' if isinstance(self.expected, tuple) else '[]'
|
||||
return open + ApproxBase.__repr__(self) + close
|
||||
seq_type = type(self.expected)
|
||||
if seq_type not in (tuple, list, set):
|
||||
seq_type = list
|
||||
return repr(seq_type(
|
||||
self._approx_scalar(x) for x in self.expected))
|
||||
|
||||
def __eq__(self, actual):
|
||||
if len(actual) != len(self.expected):
|
||||
return False
|
||||
return ApproxBase.__eq__(self, actual)
|
||||
|
||||
def _yield_expected(self):
|
||||
return iter(self.expected)
|
||||
|
||||
def _yield_comparisons(self, actual):
|
||||
return zip(actual, self.expected)
|
||||
return izip(actual, self.expected)
|
||||
|
||||
|
||||
class ApproxScalar(ApproxBase):
|
||||
|
@ -202,19 +201,17 @@ class ApproxScalar(ApproxBase):
|
|||
Return true if the given value is equal to the expected value within
|
||||
the pre-specified tolerance.
|
||||
"""
|
||||
from numbers import Number
|
||||
|
||||
# Give a good error message we get values to compare that aren't
|
||||
# numbers, rather than choking on them later on.
|
||||
if not isinstance(actual, Number):
|
||||
raise ValueError("approx can only compare numbers, not '{0}'".format(actual))
|
||||
if not isinstance(self.expected, Number):
|
||||
raise ValueError("approx can only compare numbers, not '{0}'".format(self.expected))
|
||||
|
||||
# Short-circuit exact equality.
|
||||
if actual == self.expected:
|
||||
return True
|
||||
|
||||
# Allow the user to control whether NaNs are considered equal to each
|
||||
# other or not. The abs() calls are for compatibility with complex
|
||||
# numbers.
|
||||
if math.isnan(abs(self.expected)):
|
||||
return self.nan_ok and math.isnan(abs(actual))
|
||||
|
||||
# Infinity shouldn't be approximately equal to anything but itself, but
|
||||
# if there's a relative tolerance, it will be infinite and infinity
|
||||
# will seem approximately equal to everything. The equal-to-itself
|
||||
|
@ -270,7 +267,7 @@ class ApproxScalar(ApproxBase):
|
|||
|
||||
|
||||
|
||||
def approx(expected, rel=None, abs=None):
|
||||
def approx(expected, rel=None, abs=None, nan_ok=False):
|
||||
"""
|
||||
Assert that two numbers (or two sets of numbers) are equal to each other
|
||||
within some tolerance.
|
||||
|
@ -306,23 +303,35 @@ def approx(expected, rel=None, abs=None):
|
|||
>>> 0.1 + 0.2 == approx(0.3)
|
||||
True
|
||||
|
||||
The same syntax also works on sequences of numbers::
|
||||
The same syntax also works for sequences of numbers::
|
||||
|
||||
>>> (0.1 + 0.2, 0.2 + 0.4) == approx((0.3, 0.6))
|
||||
True
|
||||
|
||||
Dictionary *values*::
|
||||
|
||||
>>> {'a': 0.1 + 0.2, 'b': 0.2 + 0.4} == approx({'a': 0.3, 'b': 0.6})
|
||||
True
|
||||
|
||||
And ``numpy`` arrays::
|
||||
|
||||
>>> np.array([0.1, 0.2]) + np.array([0.2, 0.4]) == approx(np.array([0.3, 0.6]))
|
||||
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
|
||||
``0.0``, because nothing but ``0.0`` itself is relatively close to ``0.0``.
|
||||
To handle this case less surprisingly, ``approx`` also considers numbers
|
||||
within an absolute tolerance of ``1e-12`` of its expected value to be
|
||||
equal. Infinite numbers are another special case. They are only
|
||||
considered equal to themselves, regardless of the relative tolerance. Both
|
||||
the relative and absolute tolerances can be changed by passing arguments to
|
||||
the ``approx`` constructor::
|
||||
equal. Infinity and NaN are special cases. Infinity is only considered
|
||||
equal to itself, regardless of the relative tolerance. NaN is not
|
||||
considered equal to anything by default, but you can make it be equal to
|
||||
itself by setting the ``nan_ok`` argument to True. (This is meant to
|
||||
facilitate comparing arrays that use NaN to mean "no data".)
|
||||
|
||||
Both the relative and absolute tolerances can be changed by passing
|
||||
arguments to the ``approx`` constructor::
|
||||
|
||||
>>> 1.0001 == approx(1)
|
||||
False
|
||||
|
@ -387,10 +396,7 @@ def approx(expected, rel=None, abs=None):
|
|||
"""
|
||||
|
||||
from collections import Mapping, Sequence
|
||||
try:
|
||||
String = basestring # python2
|
||||
except NameError:
|
||||
String = str, bytes # python3
|
||||
from _pytest.compat import STRING_TYPES as String
|
||||
|
||||
# Delegate the comparison to a class that knows how to deal with the type
|
||||
# of the expected value (e.g. int, float, list, dict, numpy.array, etc).
|
||||
|
@ -406,8 +412,11 @@ def approx(expected, rel=None, abs=None):
|
|||
# (i.e. dict). The old code accepted mapping types, but would only compare
|
||||
# their keys, which is probably not what most people would expect.
|
||||
|
||||
if np and isinstance(expected, np.ndarray):
|
||||
cls = ApproxNumpy
|
||||
if _is_numpy_array(expected):
|
||||
# Create the delegate class on the fly. This allow us to inherit from
|
||||
# ``np.ndarray`` while still not importing numpy unless we need to.
|
||||
import numpy as np
|
||||
cls = type('ApproxNumpy', (ApproxNumpyBase, np.ndarray), {})
|
||||
elif isinstance(expected, Mapping):
|
||||
cls = ApproxMapping
|
||||
elif isinstance(expected, Sequence) and not isinstance(expected, String):
|
||||
|
@ -415,7 +424,25 @@ def approx(expected, rel=None, abs=None):
|
|||
else:
|
||||
cls = ApproxScalar
|
||||
|
||||
return cls(expected, rel, abs)
|
||||
return cls(expected, rel, abs, nan_ok)
|
||||
|
||||
|
||||
def _is_numpy_array(obj):
|
||||
"""
|
||||
Return true if the given object is a numpy array. Make a special effort to
|
||||
avoid importing numpy unless it's really necessary.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
for cls in inspect.getmro(type(obj)):
|
||||
if cls.__module__ == 'numpy':
|
||||
try:
|
||||
import numpy as np
|
||||
return isinstance(obj, np.ndarray)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# builtin pytest.raises helper
|
||||
|
@ -555,6 +582,7 @@ def raises(expected_exception, *args, **kwargs):
|
|||
return _pytest._code.ExceptionInfo()
|
||||
fail(message)
|
||||
|
||||
|
||||
raises.Exception = fail.Exception
|
||||
|
||||
class RaisesContext(object):
|
||||
|
|
|
@ -218,21 +218,18 @@ class TestApprox(object):
|
|||
|
||||
def test_expecting_nan(self):
|
||||
examples = [
|
||||
(nan, nan),
|
||||
(-nan, -nan),
|
||||
(nan, -nan),
|
||||
(0.0, nan),
|
||||
(inf, nan),
|
||||
(eq, nan, nan),
|
||||
(eq, -nan, -nan),
|
||||
(eq, nan, -nan),
|
||||
(ne, 0.0, nan),
|
||||
(ne, inf, nan),
|
||||
]
|
||||
for a, x in examples:
|
||||
# If there is a relative tolerance and the expected value is NaN,
|
||||
# the actual tolerance is a NaN, which should be an error.
|
||||
with pytest.raises(ValueError):
|
||||
a != approx(x, rel=inf)
|
||||
for op, a, x in examples:
|
||||
# Nothing is equal to NaN by default.
|
||||
assert a != approx(x)
|
||||
|
||||
# You can make comparisons against NaN by not specifying a relative
|
||||
# tolerance, so only an absolute tolerance is calculated.
|
||||
assert a != approx(x, abs=inf)
|
||||
# If ``nan_ok=True``, then NaN is equal to NaN.
|
||||
assert op(a, approx(x, nan_ok=True))
|
||||
|
||||
def test_int(self):
|
||||
within_1e6 = [
|
||||
|
@ -310,8 +307,9 @@ class TestApprox(object):
|
|||
|
||||
def test_dict(self):
|
||||
actual = {'a': 1 + 1e-7, 'b': 2 + 1e-8}
|
||||
expected = {'b': 2, 'a': 1} # Dictionaries became ordered in python3.6,
|
||||
# so make sure the order doesn't matter
|
||||
# Dictionaries became ordered in python3.6, so switch up the order here
|
||||
# to make sure it doesn't matter.
|
||||
expected = {'b': 2, 'a': 1}
|
||||
|
||||
# Return false if any element is outside the tolerance.
|
||||
assert actual == approx(expected, rel=5e-7, abs=0)
|
||||
|
@ -325,10 +323,7 @@ class TestApprox(object):
|
|||
assert {'a': 1, 'b': 2} != approx({'a': 1, 'b': 2, 'c': 3})
|
||||
|
||||
def test_numpy_array(self):
|
||||
try:
|
||||
import numpy as np
|
||||
except ImportError:
|
||||
pytest.skip("numpy not installed")
|
||||
np = pytest.importorskip('numpy')
|
||||
|
||||
actual = np.array([1 + 1e-7, 2 + 1e-8])
|
||||
expected = np.array([1, 2])
|
||||
|
@ -339,30 +334,27 @@ class TestApprox(object):
|
|||
assert approx(expected, rel=5e-7, abs=0) == expected
|
||||
assert approx(expected, rel=5e-8, abs=0) != actual
|
||||
|
||||
def test_numpy_array_wrong_shape(self):
|
||||
try:
|
||||
import numpy as np
|
||||
except ImportError:
|
||||
pytest.skip("numpy not installed")
|
||||
# Should be able to compare lists with numpy arrays.
|
||||
assert list(actual) == approx(expected, rel=5e-7, abs=0)
|
||||
assert list(actual) != approx(expected, rel=5e-8, abs=0)
|
||||
assert actual == approx(list(expected), rel=5e-7, abs=0)
|
||||
assert actual != approx(list(expected), rel=5e-8, abs=0)
|
||||
|
||||
def test_numpy_array_wrong_shape(self):
|
||||
np = pytest.importorskip('numpy')
|
||||
|
||||
import numpy as np
|
||||
a12 = np.array([[1, 2]])
|
||||
a21 = np.array([[1],[2]])
|
||||
|
||||
assert a12 != approx(a21)
|
||||
assert a21 != approx(a12)
|
||||
|
||||
def test_non_number(self):
|
||||
with pytest.raises(ValueError):
|
||||
1 == approx("1")
|
||||
with pytest.raises(ValueError):
|
||||
"1" == approx(1)
|
||||
|
||||
def test_doctests(self):
|
||||
np = pytest.importorskip('numpy')
|
||||
parser = doctest.DocTestParser()
|
||||
test = parser.get_doctest(
|
||||
approx.__doc__,
|
||||
{'approx': approx},
|
||||
{'approx': approx ,'np': np},
|
||||
approx.__name__,
|
||||
None, None,
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue