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:
Kale Kundert 2017-06-15 09:03:31 -07:00
parent 89292f08dc
commit 8badb47db6
No known key found for this signature in database
GPG Key ID: C6238221D17CAFAE
4 changed files with 174 additions and 152 deletions

View File

@ -125,6 +125,7 @@ if sys.version_info[:2] == (2, 6):
if _PY3: if _PY3:
import codecs import codecs
imap = map imap = map
izip = zip
STRING_TYPES = bytes, str STRING_TYPES = bytes, str
UNICODE_TYPES = str, UNICODE_TYPES = str,
@ -160,7 +161,7 @@ else:
STRING_TYPES = bytes, str, unicode STRING_TYPES = bytes, str, unicode
UNICODE_TYPES = unicode, UNICODE_TYPES = unicode,
from itertools import imap # NOQA from itertools import imap, izip # NOQA
def _escape_strings(val): def _escape_strings(val):
"""In py2 bytes and str are the same type, so return if it's a bytes """In py2 bytes and str are the same type, so return if it's a bytes

View File

@ -3,7 +3,7 @@ import sys
import py import py
from _pytest.compat import isclass from _pytest.compat import isclass, izip
from _pytest.runner import fail from _pytest.runner import fail
import _pytest._code import _pytest._code
@ -15,15 +15,14 @@ class ApproxBase(object):
or sequences of numbers. 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.expected = expected
self.abs = abs self.abs = abs
self.rel = rel self.rel = rel
self.nan_ok = nan_ok
def __repr__(self): def __repr__(self):
return ', '.join( raise NotImplementedError
repr(self._approx_scalar(x))
for x in self._yield_expected())
def __eq__(self, actual): def __eq__(self, actual):
return all( return all(
@ -36,14 +35,7 @@ class ApproxBase(object):
return not (actual == self) return not (actual == self)
def _approx_scalar(self, x): def _approx_scalar(self, x):
return ApproxScalar(x, rel=self.rel, abs=self.abs) return ApproxScalar(x, rel=self.rel, abs=self.abs, nan_ok=self.nan_ok)
def _yield_expected(self, actual):
"""
Yield all the expected values associated with this object. This is
used to implement the `__repr__` method.
"""
raise NotImplementedError
def _yield_comparisons(self, actual): def _yield_comparisons(self, actual):
""" """
@ -53,53 +45,64 @@ class ApproxBase(object):
raise NotImplementedError raise NotImplementedError
class ApproxNumpyBase(ApproxBase):
try:
import numpy as np
class ApproxNumpy(ApproxBase, np.ndarray):
""" """
Perform approximate comparisons for numpy arrays. Perform approximate comparisons for numpy arrays.
This class must inherit from numpy.ndarray in order to allow the approx This class should not be used directly. Instead, it should be used to make
to be on either side of the `==` operator. The reason for this has to a subclass that also inherits from `np.ndarray`, e.g.::
do with how python decides whether to call `a.__eq__()` or `b.__eq__()`
when it encounters `a == b`.
If `a` and `b` are not related by inheritance, `a` gets priority. So import numpy as np
as long as `a.__eq__` is defined, it will be called. Because most ApproxNumpy = type('ApproxNumpy', (ApproxNumpyBase, np.ndarray), {})
implementations of `a.__eq__` end up calling `b.__eq__`, this detail
usually doesn't matter. However, `numpy.ndarray.__eq__` raises an This bizarre invocation is necessary because the object doing the
error complaining that "the truth value of an array with more than approximate comparison must inherit from `np.ndarray`, or it will only work
one element is ambiguous. Use a.any() or a.all()" when compared with a on the left side of the `==` operator. But importing numpy is relatively
custom class, so `b.__eq__` never gets called. 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 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 by inheritance. Specifically, `b.__eq__` gets priority if `b` is a
subclass of `a`. So we can guarantee that `ApproxNumpy.__eq__` gets subclass of `a`. So by inheriting from `np.ndarray`, we can guarantee that
called by inheriting from `numpy.ndarray`. `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. Numpy uses __new__ (rather than __init__) to initialize objects.
The `expected` argument must be a numpy array. This should be The `expected` argument must be a numpy array. This should be
ensured by the approx() delegator function. ensured by the approx() delegator function.
""" """
assert isinstance(expected, np.ndarray) obj = super(ApproxNumpyBase, cls).__new__(cls, ())
obj = super(ApproxNumpy, cls).__new__(cls, expected.shape) obj.__init__(expected, rel, abs, nan_ok)
obj.__init__(expected, rel, abs)
return obj return obj
def __repr__(self): def __repr__(self):
# It might be nice to rewrite this function to account for the # It might be nice to rewrite this function to account for the
# shape of the array... # shape of the array...
return '[' + ApproxBase.__repr__(self) + ']' return repr(list(
self._approx_scalar(x) for x in self.expected))
def __eq__(self, actual): def __eq__(self, actual):
import numpy as np
try: try:
actual = np.array(actual) actual = np.asarray(actual)
except: except:
raise ValueError("cannot cast '{0}' to numpy.ndarray".format(actual)) raise ValueError("cannot cast '{0}' to numpy.ndarray".format(actual))
@ -108,11 +111,9 @@ try:
return ApproxBase.__eq__(self, actual) return ApproxBase.__eq__(self, actual)
def _yield_expected(self):
for x in self.expected:
yield x
def _yield_comparisons(self, actual): def _yield_comparisons(self, actual):
import numpy as np
# We can be sure that `actual` is a numpy array, because it's # We can be sure that `actual` is a numpy array, because it's
# casted in `__eq__` before being passed to `ApproxBase.__eq__`, # casted in `__eq__` before being passed to `ApproxBase.__eq__`,
# which is the only method that calls this one. # which is the only method that calls this one.
@ -120,9 +121,6 @@ try:
yield actual[i], self.expected[i] yield actual[i], self.expected[i]
except ImportError:
np = None
class ApproxMapping(ApproxBase): class ApproxMapping(ApproxBase):
""" """
Perform approximate comparisons for mappings where the values are numbers Perform approximate comparisons for mappings where the values are numbers
@ -130,8 +128,9 @@ class ApproxMapping(ApproxBase):
""" """
def __repr__(self): def __repr__(self):
item = lambda k, v: "'{0}': {1}".format(k, self._approx_scalar(v)) return repr({
return '{' + ', '.join(item(k,v) for k,v in self.expected.items()) + '}' k: self._approx_scalar(v)
for k,v in self.expected.items()})
def __eq__(self, actual): def __eq__(self, actual):
if actual.keys() != self.expected.keys(): if actual.keys() != self.expected.keys():
@ -150,19 +149,19 @@ class ApproxSequence(ApproxBase):
""" """
def __repr__(self): def __repr__(self):
open, close = '()' if isinstance(self.expected, tuple) else '[]' seq_type = type(self.expected)
return open + ApproxBase.__repr__(self) + close 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): def __eq__(self, actual):
if len(actual) != len(self.expected): if len(actual) != len(self.expected):
return False return False
return ApproxBase.__eq__(self, actual) return ApproxBase.__eq__(self, actual)
def _yield_expected(self):
return iter(self.expected)
def _yield_comparisons(self, actual): def _yield_comparisons(self, actual):
return zip(actual, self.expected) return izip(actual, self.expected)
class ApproxScalar(ApproxBase): class ApproxScalar(ApproxBase):
@ -202,19 +201,17 @@ class ApproxScalar(ApproxBase):
Return true if the given value is equal to the expected value within Return true if the given value is equal to the expected value within
the pre-specified tolerance. 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. # Short-circuit exact equality.
if actual == self.expected: if actual == self.expected:
return True 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 # Infinity shouldn't be approximately equal to anything but itself, but
# if there's a relative tolerance, it will be infinite and infinity # if there's a relative tolerance, it will be infinite and infinity
# will seem approximately equal to everything. The equal-to-itself # 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 Assert that two numbers (or two sets of numbers) are equal to each other
within some tolerance. within some tolerance.
@ -306,23 +303,35 @@ def approx(expected, rel=None, abs=None):
>>> 0.1 + 0.2 == approx(0.3) >>> 0.1 + 0.2 == approx(0.3)
True 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)) >>> (0.1 + 0.2, 0.2 + 0.4) == approx((0.3, 0.6))
True True
Dictionary *values*::
>>> {'a': 0.1 + 0.2, 'b': 0.2 + 0.4} == approx({'a': 0.3, 'b': 0.6}) >>> {'a': 0.1 + 0.2, 'b': 0.2 + 0.4} == approx({'a': 0.3, 'b': 0.6})
True 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 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. ``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 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``. ``0.0``, because nothing but ``0.0`` itself is relatively close to ``0.0``.
To handle this case less surprisingly, ``approx`` also considers numbers To handle this case less surprisingly, ``approx`` also considers numbers
within an absolute tolerance of ``1e-12`` of its expected value to be within an absolute tolerance of ``1e-12`` of its expected value to be
equal. Infinite numbers are another special case. They are only equal. Infinity and NaN are special cases. Infinity is only considered
considered equal to themselves, regardless of the relative tolerance. Both equal to itself, regardless of the relative tolerance. NaN is not
the relative and absolute tolerances can be changed by passing arguments to considered equal to anything by default, but you can make it be equal to
the ``approx`` constructor:: 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) >>> 1.0001 == approx(1)
False False
@ -387,10 +396,7 @@ def approx(expected, rel=None, abs=None):
""" """
from collections import Mapping, Sequence from collections import Mapping, Sequence
try: from _pytest.compat import STRING_TYPES as String
String = basestring # python2
except NameError:
String = str, bytes # python3
# Delegate the comparison to a class that knows how to deal with the type # 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). # 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 # (i.e. dict). The old code accepted mapping types, but would only compare
# their keys, which is probably not what most people would expect. # their keys, which is probably not what most people would expect.
if np and isinstance(expected, np.ndarray): if _is_numpy_array(expected):
cls = ApproxNumpy # 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): elif isinstance(expected, Mapping):
cls = ApproxMapping cls = ApproxMapping
elif isinstance(expected, Sequence) and not isinstance(expected, String): elif isinstance(expected, Sequence) and not isinstance(expected, String):
@ -415,7 +424,25 @@ def approx(expected, rel=None, abs=None):
else: else:
cls = ApproxScalar 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 # builtin pytest.raises helper
@ -555,6 +582,7 @@ def raises(expected_exception, *args, **kwargs):
return _pytest._code.ExceptionInfo() return _pytest._code.ExceptionInfo()
fail(message) fail(message)
raises.Exception = fail.Exception raises.Exception = fail.Exception
class RaisesContext(object): class RaisesContext(object):

View File

@ -218,21 +218,18 @@ class TestApprox(object):
def test_expecting_nan(self): def test_expecting_nan(self):
examples = [ examples = [
(nan, nan), (eq, nan, nan),
(-nan, -nan), (eq, -nan, -nan),
(nan, -nan), (eq, nan, -nan),
(0.0, nan), (ne, 0.0, nan),
(inf, nan), (ne, inf, nan),
] ]
for a, x in examples: for op, a, x in examples:
# If there is a relative tolerance and the expected value is NaN, # Nothing is equal to NaN by default.
# the actual tolerance is a NaN, which should be an error. assert a != approx(x)
with pytest.raises(ValueError):
a != approx(x, rel=inf)
# You can make comparisons against NaN by not specifying a relative # If ``nan_ok=True``, then NaN is equal to NaN.
# tolerance, so only an absolute tolerance is calculated. assert op(a, approx(x, nan_ok=True))
assert a != approx(x, abs=inf)
def test_int(self): def test_int(self):
within_1e6 = [ within_1e6 = [
@ -310,8 +307,9 @@ class TestApprox(object):
def test_dict(self): def test_dict(self):
actual = {'a': 1 + 1e-7, 'b': 2 + 1e-8} actual = {'a': 1 + 1e-7, 'b': 2 + 1e-8}
expected = {'b': 2, 'a': 1} # Dictionaries became ordered in python3.6, # Dictionaries became ordered in python3.6, so switch up the order here
# so make sure the order doesn't matter # to make sure it doesn't matter.
expected = {'b': 2, 'a': 1}
# Return false if any element is outside the tolerance. # Return false if any element is outside the tolerance.
assert actual == approx(expected, rel=5e-7, abs=0) 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}) assert {'a': 1, 'b': 2} != approx({'a': 1, 'b': 2, 'c': 3})
def test_numpy_array(self): def test_numpy_array(self):
try: np = pytest.importorskip('numpy')
import numpy as np
except ImportError:
pytest.skip("numpy not installed")
actual = np.array([1 + 1e-7, 2 + 1e-8]) actual = np.array([1 + 1e-7, 2 + 1e-8])
expected = np.array([1, 2]) 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-7, abs=0) == expected
assert approx(expected, rel=5e-8, abs=0) != actual assert approx(expected, rel=5e-8, abs=0) != actual
def test_numpy_array_wrong_shape(self): # Should be able to compare lists with numpy arrays.
try: assert list(actual) == approx(expected, rel=5e-7, abs=0)
import numpy as np assert list(actual) != approx(expected, rel=5e-8, abs=0)
except ImportError: assert actual == approx(list(expected), rel=5e-7, abs=0)
pytest.skip("numpy not installed") 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]]) a12 = np.array([[1, 2]])
a21 = np.array([[1],[2]]) a21 = np.array([[1],[2]])
assert a12 != approx(a21) assert a12 != approx(a21)
assert a21 != approx(a12) 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): def test_doctests(self):
np = pytest.importorskip('numpy')
parser = doctest.DocTestParser() parser = doctest.DocTestParser()
test = parser.get_doctest( test = parser.get_doctest(
approx.__doc__, approx.__doc__,
{'approx': approx}, {'approx': approx ,'np': np},
approx.__name__, approx.__name__,
None, None, None, None,
) )

View File

@ -26,6 +26,7 @@ deps=
nose nose
mock mock
requests requests
numpy
[testenv:py26] [testenv:py26]
commands= pytest --lsof -rfsxX {posargs:testing} commands= pytest --lsof -rfsxX {posargs:testing}