From 9f3122fec68488f2fdd41da40eef227372168ad4 Mon Sep 17 00:00:00 2001 From: Kale Kundert Date: Sun, 11 Jun 2017 19:27:41 -0700 Subject: [PATCH 01/12] Add support for numpy arrays (and dicts) to approx. This fixes #1994. It turned out to require a lot of refactoring because subclassing numpy.ndarray was necessary to coerce python into calling the right `__eq__` operator. --- _pytest/python.py | 432 ++++++++++++++++++++++++++------------- testing/python/approx.py | 131 +++++++++--- 2 files changed, 400 insertions(+), 163 deletions(-) diff --git a/_pytest/python.py b/_pytest/python.py index 06f74ce4b8a..ea2cfd3c61c 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -1117,7 +1117,6 @@ def raises(expected_exception, *args, **kwargs): ... Failed: Expecting ZeroDivisionError - .. note:: When using ``pytest.raises`` as a context manager, it's worthwhile to @@ -1150,7 +1149,6 @@ def raises(expected_exception, *args, **kwargs): >>> with raises(ValueError, match=r'must be \d+$'): ... raise ValueError("value must be 42") - Or you can specify a callable by passing a to-be-called lambda:: >>> raises(ZeroDivisionError, lambda: 1/0) @@ -1230,10 +1228,8 @@ def raises(expected_exception, *args, **kwargs): return _pytest._code.ExceptionInfo() fail(message) - raises.Exception = fail.Exception - class RaisesContext(object): def __init__(self, expected_exception, message, match_expr): self.expected_exception = expected_exception @@ -1265,9 +1261,271 @@ def __exit__(self, *tp): return suppress_exception + # builtin pytest.approx helper -class approx(object): +class ApproxBase(object): + """ + Provide shared utilities for making approximate comparisons between numbers + or sequences of numbers. + """ + + def __init__(self, expected, rel=None, abs=None): + self.expected = expected + self.abs = abs + self.rel = rel + + def __repr__(self): + return ', '.join( + repr(self._approx_scalar(x)) + for x in self._yield_expected()) + + def __eq__(self, actual): + return all( + a == self._approx_scalar(x) + for a, x in self._yield_comparisons(actual)) + + __hash__ = None + + def __ne__(self, actual): + 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 + + def _yield_comparisons(self, actual): + """ + Yield all the pairs of numbers to be compared. This is used to + implement the `__eq__` method. + """ + raise NotImplementedError + + + +try: + import numpy as np + + class ApproxNumpy(ApproxBase, np.ndarray): + """ + 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`. + + 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. + + 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`. + """ + + def __new__(cls, expected, rel=None, abs=None): + """ + 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) + 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) + ']' + + def __eq__(self, actual): + try: + actual = np.array(actual) + except: + raise ValueError("cannot cast '{0}' to numpy.ndarray".format(actual)) + + if actual.shape != self.expected.shape: + return False + + return ApproxBase.__eq__(self, actual) + + def _yield_expected(self): + for x in self.expected: + yield x + + def _yield_comparisons(self, actual): + # 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] + + +except ImportError: + np = None + +class ApproxMapping(ApproxBase): + """ + Perform approximate comparisons for mappings where the values are numbers + (the keys can be anything). + """ + + 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()) + '}' + + def __eq__(self, actual): + if actual.keys() != self.expected.keys(): + return False + + return ApproxBase.__eq__(self, actual) + + def _yield_comparisons(self, actual): + for k in self.expected.keys(): + yield actual[k], self.expected[k] + + +class ApproxSequence(ApproxBase): + """ + Perform approximate comparisons for sequences of numbers. + """ + + def __repr__(self): + open, close = '()' if isinstance(self.expected, tuple) else '[]' + return open + ApproxBase.__repr__(self) + close + + 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) + + +class ApproxScalar(ApproxBase): + """ + Perform approximate comparisons for single numbers only. + """ + + def __repr__(self): + """ + Return a string communicating both the expected value and the tolerance + for the comparison being made, e.g. '1.0 +- 1e-6'. Use the unicode + plus/minus symbol if this is python3 (it's too hard to get right for + python2). + """ + if isinstance(self.expected, complex): + return str(self.expected) + + # Infinities aren't compared using tolerances, so don't show a + # tolerance. + if math.isinf(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) + except ValueError: + vetted_tolerance = '???' + + if sys.version_info[0] == 2: + return '{0} +- {1}'.format(self.expected, vetted_tolerance) + else: + return u'{0} \u00b1 {1}'.format(self.expected, vetted_tolerance) + + def __eq__(self, actual): + """ + 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 + + # 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 + # case would have been short circuited above, so here we can just + # return false if the expected value is infinite. The abs() call is + # for compatibility with complex numbers. + if math.isinf(abs(self.expected)): + return False + + # Return true if the two numbers are within the tolerance. + return abs(self.expected - actual) <= self.tolerance + + __hash__ = None + + @property + def tolerance(self): + """ + Return the tolerance for the comparison. This could be either an + absolute tolerance or a relative tolerance, depending on what the user + specified or which would be larger. + """ + set_default = lambda x, default: x if x is not None else default + + # Figure out what the absolute tolerance should be. ``self.abs`` is + # either None or a value specified by the user. + absolute_tolerance = set_default(self.abs, 1e-12) + + if absolute_tolerance < 0: + raise ValueError("absolute tolerance can't be negative: {}".format(absolute_tolerance)) + if math.isnan(absolute_tolerance): + raise ValueError("absolute tolerance can't be NaN.") + + # If the user specified an absolute tolerance but not a relative one, + # just return the absolute tolerance. + if self.rel is None: + if self.abs is not None: + return absolute_tolerance + + # Figure out what the relative tolerance should be. ``self.rel`` is + # either None or a value specified by the user. This is done after + # we've made sure the user didn't ask for an absolute tolerance only, + # because we don't want to raise errors about the relative tolerance if + # we aren't even going to use it. + relative_tolerance = set_default(self.rel, 1e-6) * abs(self.expected) + + if relative_tolerance < 0: + raise ValueError("relative tolerance can't be negative: {}".format(absolute_tolerance)) + if math.isnan(relative_tolerance): + raise ValueError("relative tolerance can't be NaN.") + + # Return the larger of the relative and absolute tolerances. + return max(relative_tolerance, absolute_tolerance) + + + +def approx(expected, rel=None, abs=None): """ Assert that two numbers (or two sets of numbers) are equal to each other within some tolerance. @@ -1307,6 +1565,8 @@ class approx(object): >>> (0.1 + 0.2, 0.2 + 0.4) == approx((0.3, 0.6)) True + >>> {'a': 0.1 + 0.2, 'b': 0.2 + 0.4} == approx({'a': 0.3, 'b': 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. @@ -1380,139 +1640,37 @@ class approx(object): special case that you explicitly specify an absolute tolerance but not a relative tolerance, only the absolute tolerance is considered. """ + + from collections import Mapping, Sequence + try: + String = basestring # python2 + except NameError: + String = str, bytes # python3 + + # 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). + # + # This architecture is really driven by the need to support numpy arrays. + # The only way to override `==` for arrays without requiring that approx be + # the left operand is to inherit the approx object from `numpy.ndarray`. + # But that can't be a general solution, because it requires (1) numpy to be + # installed and (2) the expected value to be a numpy array. So the general + # solution is to delegate each type of expected value to a different class. + # + # This has the advantage that it made it easy to support mapping types + # (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 + elif isinstance(expected, Mapping): + cls = ApproxMapping + elif isinstance(expected, Sequence) and not isinstance(expected, String): + cls = ApproxSequence + else: + cls = ApproxScalar - def __init__(self, expected, rel=None, abs=None): - self.expected = expected - self.abs = abs - self.rel = rel - - def __repr__(self): - return ', '.join(repr(x) for x in self.expected) - - def __eq__(self, actual): - from collections import Iterable - if not isinstance(actual, Iterable): - actual = [actual] - if len(actual) != len(self.expected): - return False - return all(a == x for a, x in zip(actual, self.expected)) - - __hash__ = None - - def __ne__(self, actual): - return not (actual == self) - - @property - def expected(self): - # Regardless of whether the user-specified expected value is a number - # or a sequence of numbers, return a list of ApproxNotIterable objects - # that can be compared against. - from collections import Iterable - approx_non_iter = lambda x: ApproxNonIterable(x, self.rel, self.abs) - if isinstance(self._expected, Iterable): - return [approx_non_iter(x) for x in self._expected] - else: - return [approx_non_iter(self._expected)] - - @expected.setter - def expected(self, expected): - self._expected = expected - - -class ApproxNonIterable(object): - """ - Perform approximate comparisons for single numbers only. - - In other words, the ``expected`` attribute for objects of this class must - be some sort of number. This is in contrast to the ``approx`` class, where - the ``expected`` attribute can either be a number of a sequence of numbers. - This class is responsible for making comparisons, while ``approx`` is - responsible for abstracting the difference between numbers and sequences of - numbers. Although this class can stand on its own, it's only meant to be - used within ``approx``. - """ - - def __init__(self, expected, rel=None, abs=None): - self.expected = expected - self.abs = abs - self.rel = rel - - def __repr__(self): - if isinstance(self.expected, complex): - return str(self.expected) - - # Infinities aren't compared using tolerances, so don't show a - # tolerance. - if math.isinf(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) - except ValueError: - vetted_tolerance = '???' - - if sys.version_info[0] == 2: - return '{0} +- {1}'.format(self.expected, vetted_tolerance) - else: - return u'{0} \u00b1 {1}'.format(self.expected, vetted_tolerance) - - def __eq__(self, actual): - # Short-circuit exact equality. - if actual == self.expected: - return True - - # 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 - # case would have been short circuited above, so here we can just - # return false if the expected value is infinite. The abs() call is - # for compatibility with complex numbers. - if math.isinf(abs(self.expected)): - return False - - # Return true if the two numbers are within the tolerance. - return abs(self.expected - actual) <= self.tolerance - - __hash__ = None - - def __ne__(self, actual): - return not (actual == self) - - @property - def tolerance(self): - set_default = lambda x, default: x if x is not None else default - - # Figure out what the absolute tolerance should be. ``self.abs`` is - # either None or a value specified by the user. - absolute_tolerance = set_default(self.abs, 1e-12) - - if absolute_tolerance < 0: - raise ValueError("absolute tolerance can't be negative: {}".format(absolute_tolerance)) - if math.isnan(absolute_tolerance): - raise ValueError("absolute tolerance can't be NaN.") - - # If the user specified an absolute tolerance but not a relative one, - # just return the absolute tolerance. - if self.rel is None: - if self.abs is not None: - return absolute_tolerance - - # Figure out what the relative tolerance should be. ``self.rel`` is - # either None or a value specified by the user. This is done after - # we've made sure the user didn't ask for an absolute tolerance only, - # because we don't want to raise errors about the relative tolerance if - # we aren't even going to use it. - relative_tolerance = set_default(self.rel, 1e-6) * abs(self.expected) - - if relative_tolerance < 0: - raise ValueError("relative tolerance can't be negative: {}".format(absolute_tolerance)) - if math.isnan(relative_tolerance): - raise ValueError("relative tolerance can't be NaN.") - - # Return the larger of the relative and absolute tolerances. - return max(relative_tolerance, absolute_tolerance) + return cls(expected, rel, abs) # diff --git a/testing/python/approx.py b/testing/python/approx.py index d7063e2150f..8b9605a00bb 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -9,7 +9,6 @@ from fractions import Fraction inf, nan = float('inf'), float('nan') - class MyDocTestRunner(doctest.DocTestRunner): def __init__(self): @@ -29,12 +28,19 @@ def test_repr_string(self): if sys.version_info[:2] == (2, 6): tol1, tol2, infr = '???', '???', '???' assert repr(approx(1.0)) == '1.0 {pm} {tol1}'.format(pm=plus_minus, tol1=tol1) - assert repr(approx([1.0, 2.0])) == '1.0 {pm} {tol1}, 2.0 {pm} {tol2}'.format(pm=plus_minus, tol1=tol1, tol2=tol2) + assert repr(approx([1.0, 2.0])) == '[1.0 {pm} {tol1}, 2.0 {pm} {tol2}]'.format(pm=plus_minus, tol1=tol1, tol2=tol2) + assert repr(approx((1.0, 2.0))) == '(1.0 {pm} {tol1}, 2.0 {pm} {tol2})'.format(pm=plus_minus, tol1=tol1, tol2=tol2) assert repr(approx(inf)) == 'inf' assert repr(approx(1.0, rel=nan)) == '1.0 {pm} ???'.format(pm=plus_minus) assert repr(approx(1.0, rel=inf)) == '1.0 {pm} {infr}'.format(pm=plus_minus, infr=infr) assert repr(approx(1.0j, rel=inf)) == '1j' + # Dictionaries aren't ordered, so we need to check both orders. + assert repr(approx({'a': 1.0, 'b': 2.0})) in ( + "{{'a': 1.0 {pm} {tol1}, 'b': 2.0 {pm} {tol2}}}".format(pm=plus_minus, tol1=tol1, tol2=tol2), + "{{'b': 2.0 {pm} {tol2}, 'a': 1.0 {pm} {tol1}}}".format(pm=plus_minus, tol1=tol1, tol2=tol2), + ) + def test_operator_overloading(self): assert 1 == approx(1, rel=1e-6, abs=1e-12) assert not (1 != approx(1, rel=1e-6, abs=1e-12)) @@ -228,30 +234,6 @@ def test_expecting_nan(self): # tolerance, so only an absolute tolerance is calculated. assert a != approx(x, abs=inf) - def test_expecting_sequence(self): - within_1e8 = [ - (1e8 + 1e0, 1e8), - (1e0 + 1e-8, 1e0), - (1e-8 + 1e-16, 1e-8), - ] - actual, expected = zip(*within_1e8) - assert actual == approx(expected, rel=5e-8, abs=0.0) - - def test_expecting_sequence_wrong_len(self): - assert [1, 2] != approx([1]) - assert [1, 2] != approx([1,2,3]) - - def test_complex(self): - within_1e6 = [ - ( 1.000001 + 1.0j, 1.0 + 1.0j), - (1.0 + 1.000001j, 1.0 + 1.0j), - (-1.000001 + 1.0j, -1.0 + 1.0j), - (1.0 - 1.000001j, 1.0 - 1.0j), - ] - for a, x in within_1e6: - assert a == approx(x, rel=5e-6, abs=0) - assert a != approx(x, rel=5e-7, abs=0) - def test_int(self): within_1e6 = [ (1000001, 1000000), @@ -260,6 +242,8 @@ def test_int(self): for a, x in within_1e6: assert a == approx(x, rel=5e-6, abs=0) assert a != approx(x, rel=5e-7, abs=0) + assert approx(x, rel=5e-6, abs=0) == a + assert approx(x, rel=5e-7, abs=0) != a def test_decimal(self): within_1e6 = [ @@ -269,6 +253,8 @@ def test_decimal(self): for a, x in within_1e6: assert a == approx(x, rel=Decimal('5e-6'), abs=0) assert a != approx(x, rel=Decimal('5e-7'), abs=0) + assert approx(x, rel=Decimal('5e-6'), abs=0) == a + assert approx(x, rel=Decimal('5e-7'), abs=0) != a def test_fraction(self): within_1e6 = [ @@ -278,6 +264,99 @@ def test_fraction(self): for a, x in within_1e6: assert a == approx(x, rel=5e-6, abs=0) assert a != approx(x, rel=5e-7, abs=0) + assert approx(x, rel=5e-6, abs=0) == a + assert approx(x, rel=5e-7, abs=0) != a + + def test_complex(self): + within_1e6 = [ + ( 1.000001 + 1.0j, 1.0 + 1.0j), + (1.0 + 1.000001j, 1.0 + 1.0j), + (-1.000001 + 1.0j, -1.0 + 1.0j), + (1.0 - 1.000001j, 1.0 - 1.0j), + ] + for a, x in within_1e6: + assert a == approx(x, rel=5e-6, abs=0) + assert a != approx(x, rel=5e-7, abs=0) + assert approx(x, rel=5e-6, abs=0) == a + assert approx(x, rel=5e-7, abs=0) != a + + def test_list(self): + actual = [1 + 1e-7, 2 + 1e-8] + expected = [1, 2] + + # Return false if any element is outside the tolerance. + 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 + + def test_list_wrong_len(self): + assert [1, 2] != approx([1]) + assert [1, 2] != approx([1,2,3]) + + def test_tuple(self): + actual = (1 + 1e-7, 2 + 1e-8) + expected = (1, 2) + + # Return false if any element is outside the tolerance. + 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 + + def test_tuple_wrong_len(self): + assert (1, 2) != approx((1,)) + assert (1, 2) != approx((1,2,3)) + + 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 + + # Return false if any element is outside the tolerance. + 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 + + def test_dict_wrong_len(self): + assert {'a': 1, 'b': 2} != approx({'a': 1}) + assert {'a': 1, 'b': 2} != approx({'a': 1, 'c': 2}) + 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") + + actual = np.array([1 + 1e-7, 2 + 1e-8]) + expected = np.array([1, 2]) + + # Return false if any element is outside the tolerance. + 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) == 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") + + 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): parser = doctest.DocTestParser() From 89292f08dc73ef9d20a3488098b07d794a4b3248 Mon Sep 17 00:00:00 2001 From: Kale Kundert Date: Sun, 11 Jun 2017 19:51:21 -0700 Subject: [PATCH 02/12] Add a changelog entry. --- changelog/1994.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/1994.feature diff --git a/changelog/1994.feature b/changelog/1994.feature new file mode 100644 index 00000000000..f3c596e63b1 --- /dev/null +++ b/changelog/1994.feature @@ -0,0 +1 @@ +Add support for numpy arrays (and dicts) to approx. From 8badb47db60d43966a56cfe5f0630e38358c309a Mon Sep 17 00:00:00 2001 From: Kale Kundert Date: Thu, 15 Jun 2017 09:03:31 -0700 Subject: [PATCH 03/12] 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". --- _pytest/compat.py | 3 +- _pytest/python_api.py | 270 +++++++++++++++++++++------------------ testing/python/approx.py | 54 ++++---- tox.ini | 1 + 4 files changed, 175 insertions(+), 153 deletions(-) diff --git a/_pytest/compat.py b/_pytest/compat.py index 8c200af5fe5..0554efeb704 100644 --- a/_pytest/compat.py +++ b/_pytest/compat.py @@ -125,6 +125,7 @@ def isclass(object): if _PY3: import codecs imap = map + izip = zip STRING_TYPES = bytes, str UNICODE_TYPES = str, @@ -160,7 +161,7 @@ def _escape_strings(val): 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 diff --git a/_pytest/python_api.py b/_pytest/python_api.py index 029276c9599..a2942b74288 100644 --- a/_pytest/python_api.py +++ b/_pytest/python_api.py @@ -3,7 +3,7 @@ import py -from _pytest.compat import isclass +from _pytest.compat import isclass, izip from _pytest.runner import fail import _pytest._code @@ -11,19 +11,18 @@ class ApproxBase(object): """ - Provide shared utilities for making approximate comparisons between numbers + Provide shared utilities for making approximate comparisons between 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.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,109 +35,109 @@ def __ne__(self, actual): 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): """ - Yield all the pairs of numbers to be compared. This is used to + Yield all the pairs of numbers to be compared. This is used to implement the `__eq__` method. """ raise NotImplementedError +class ApproxNumpyBase(ApproxBase): + """ + Perform approximate comparisons for numpy arrays. + + This class should not be used directly. Instead, it should be used to make + a subclass that also inherits from `np.ndarray`, e.g.:: + + 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 by inheriting from `np.ndarray`, we can guarantee that + `ApproxNumpy.__eq__` gets called no matter which side of the `==` operator + it appears on. + """ -try: - import numpy as np - - class ApproxNumpy(ApproxBase, np.ndarray): + def __new__(cls, expected, rel=None, abs=None, nan_ok=False): """ - 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`. - - 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. - - 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`. + 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. """ + obj = super(ApproxNumpyBase, cls).__new__(cls, ()) + obj.__init__(expected, rel, abs, nan_ok) + return obj - def __new__(cls, expected, rel=None, abs=None): - """ - 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) - 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) + ']' - - def __eq__(self, actual): - try: - actual = np.array(actual) - except: - raise ValueError("cannot cast '{0}' to numpy.ndarray".format(actual)) + def __repr__(self): + # It might be nice to rewrite this function to account for the + # shape of the array... + return repr(list( + self._approx_scalar(x) for x in self.expected)) - if actual.shape != self.expected.shape: - return False + def __eq__(self, actual): + import numpy as np - return ApproxBase.__eq__(self, actual) + try: + actual = np.asarray(actual) + except: + raise ValueError("cannot cast '{0}' to numpy.ndarray".format(actual)) - def _yield_expected(self): - for x in self.expected: - yield x + if actual.shape != self.expected.shape: + return False - def _yield_comparisons(self, actual): - # 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] + return ApproxBase.__eq__(self, actual) + + 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 i in np.ndindex(self.expected.shape): + yield actual[i], self.expected[i] -except ImportError: - np = None class ApproxMapping(ApproxBase): """ - Perform approximate comparisons for mappings where the values are numbers + Perform approximate comparisons for mappings where the values are numbers (the keys can be anything). """ 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(): return False return ApproxBase.__eq__(self, actual) - + def _yield_comparisons(self, actual): for k in self.expected.keys(): yield actual[k], self.expected[k] @@ -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): @@ -172,9 +171,9 @@ class ApproxScalar(ApproxBase): def __repr__(self): """ - Return a string communicating both the expected value and the tolerance - for the comparison being made, e.g. '1.0 +- 1e-6'. Use the unicode - plus/minus symbol if this is python3 (it's too hard to get right for + Return a string communicating both the expected value and the tolerance + for the comparison being made, e.g. '1.0 +- 1e-6'. Use the unicode + plus/minus symbol if this is python3 (it's too hard to get right for python2). """ if isinstance(self.expected, complex): @@ -199,22 +198,20 @@ def __repr__(self): def __eq__(self, actual): """ - 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. """ - 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 @@ -232,8 +229,8 @@ def __eq__(self, actual): @property def tolerance(self): """ - Return the tolerance for the comparison. This could be either an - absolute tolerance or a relative tolerance, depending on what the user + Return the tolerance for the comparison. This could be either an + absolute tolerance or a relative tolerance, depending on what the user specified or which would be larger. """ set_default = lambda x, default: x if x is not None else default @@ -270,7 +267,7 @@ def tolerance(self): -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 @@ -385,29 +394,29 @@ def approx(expected, rel=None, abs=None): special case that you explicitly specify an absolute tolerance but not a relative tolerance, only the absolute tolerance is considered. """ - + 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). + # 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). # - # This architecture is really driven by the need to support numpy arrays. - # The only way to override `==` for arrays without requiring that approx be - # the left operand is to inherit the approx object from `numpy.ndarray`. - # But that can't be a general solution, because it requires (1) numpy to be - # installed and (2) the expected value to be a numpy array. So the general + # This architecture is really driven by the need to support numpy arrays. + # The only way to override `==` for arrays without requiring that approx be + # the left operand is to inherit the approx object from `numpy.ndarray`. + # But that can't be a general solution, because it requires (1) numpy to be + # installed and (2) the expected value to be a numpy array. So the general # solution is to delegate each type of expected value to a different class. # - # This has the advantage that it made it easy to support mapping types - # (i.e. dict). The old code accepted mapping types, but would only compare + # This has the advantage that it made it easy to support mapping types + # (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): diff --git a/testing/python/approx.py b/testing/python/approx.py index 8b9605a00bb..d67500b15a3 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -218,21 +218,18 @@ def test_expecting_inf(self): 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 @@ def test_tuple_wrong_len(self): 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 @@ def test_dict_wrong_len(self): 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 @@ def test_numpy_array(self): assert approx(expected, rel=5e-7, abs=0) == expected assert approx(expected, rel=5e-8, abs=0) != actual + # 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): - try: - import numpy as np - except ImportError: - pytest.skip("numpy not installed") + 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, ) diff --git a/tox.ini b/tox.ini index b73deca7d32..188b073da7f 100644 --- a/tox.ini +++ b/tox.ini @@ -26,6 +26,7 @@ deps= nose mock requests + numpy [testenv:py26] commands= pytest --lsof -rfsxX {posargs:testing} From b41852c93b2f7e5f3dae60d09077d83d368aa730 Mon Sep 17 00:00:00 2001 From: Kale Kundert Date: Thu, 15 Jun 2017 14:52:39 -0700 Subject: [PATCH 04/12] Use `autofunction` to document approx. It used to be a class, but it's a function now. --- doc/en/builtin.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/en/builtin.rst b/doc/en/builtin.rst index 26dbd44cb3e..af0dd9a744d 100644 --- a/doc/en/builtin.rst +++ b/doc/en/builtin.rst @@ -38,7 +38,7 @@ Examples at :ref:`assertraises`. Comparing floating point numbers -------------------------------- -.. autoclass:: approx +.. autofunction:: approx Raising a specific test outcome -------------------------------------- From 50769557e820e3b58d00d2727c9355d6d8dd6833 Mon Sep 17 00:00:00 2001 From: Kale Kundert Date: Thu, 15 Jun 2017 14:53:27 -0700 Subject: [PATCH 05/12] Skip the numpy doctests. They seem like more trouble that they're worth. --- _pytest/python_api.py | 3 ++- testing/python/approx.py | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/_pytest/python_api.py b/_pytest/python_api.py index a2942b74288..0c0a6bb7401 100644 --- a/_pytest/python_api.py +++ b/_pytest/python_api.py @@ -315,7 +315,8 @@ def approx(expected, rel=None, abs=None, nan_ok=False): And ``numpy`` arrays:: - >>> np.array([0.1, 0.2]) + np.array([0.2, 0.4]) == approx(np.array([0.3, 0.6])) + >>> 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 By default, ``approx`` considers numbers within a relative tolerance of diff --git a/testing/python/approx.py b/testing/python/approx.py index d67500b15a3..3005a7bbe76 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -350,11 +350,10 @@ def test_numpy_array_wrong_shape(self): assert a21 != approx(a12) def test_doctests(self): - np = pytest.importorskip('numpy') parser = doctest.DocTestParser() test = parser.get_doctest( approx.__doc__, - {'approx': approx ,'np': np}, + {'approx': approx}, approx.__name__, None, None, ) From 5d2496862a12b0c05896ae6dac04d09f26eaa74b Mon Sep 17 00:00:00 2001 From: Kale Kundert Date: Thu, 15 Jun 2017 18:41:13 -0700 Subject: [PATCH 06/12] Only test numpy with py27 and py35. Travis was not successfully installing numpy with python<=2.6, python<=3.3, or PyPy. I decided that it didn't make sense to use numpy for all the tests, so instead I made new testing environments specifically for numpy. --- .travis.yml | 3 +++ appveyor.yml | 2 ++ tox.ini | 13 +++++++++++-- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 0a71e7dc1d7..29647f9bcb9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,13 +16,16 @@ env: - TOXENV=py33 - TOXENV=py34 - TOXENV=py35 + - TOXENV=py36 - TOXENV=pypy - TOXENV=py27-pexpect - TOXENV=py27-xdist - TOXENV=py27-trial + - TOXENV=py27-numpy - TOXENV=py35-pexpect - TOXENV=py35-xdist - TOXENV=py35-trial + - TOXENV=py35-numpy - TOXENV=py27-nobyte - TOXENV=doctesting - TOXENV=freeze diff --git a/appveyor.yml b/appveyor.yml index cc72b4b7053..abf033b4cca 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -20,9 +20,11 @@ environment: - TOXENV: "py27-pexpect" - TOXENV: "py27-xdist" - TOXENV: "py27-trial" + - TOXENV: "py27-numpy" - TOXENV: "py35-pexpect" - TOXENV: "py35-xdist" - TOXENV: "py35-trial" + - TOXENV: "py35-numpy" - TOXENV: "py27-nobyte" - TOXENV: "doctesting" - TOXENV: "freeze" diff --git a/tox.ini b/tox.ini index 188b073da7f..9b5cdc64a86 100644 --- a/tox.ini +++ b/tox.ini @@ -12,7 +12,7 @@ envlist= py36 py37 pypy - {py27,py35}-{pexpect,xdist,trial} + {py27,py35}-{pexpect,xdist,trial,numpy} py27-nobyte doctesting freeze @@ -26,7 +26,6 @@ deps= nose mock requests - numpy [testenv:py26] commands= pytest --lsof -rfsxX {posargs:testing} @@ -111,6 +110,16 @@ deps={[testenv:py27-trial]deps} commands= pytest -ra {posargs:testing/test_unittest.py} +[testenv:py27-numpy] +deps=numpy +commands= + pytest -rfsxX {posargs:testing/python/approx.py} + +[testenv:py35-numpy] +deps=numpy +commands= + pytest -rfsxX {posargs:testing/python/approx.py} + [testenv:docs] skipsdist=True usedevelop=True From 4d02863b161108d022b5840f1a8e4b58a75ed088 Mon Sep 17 00:00:00 2001 From: Kale Kundert Date: Thu, 15 Jun 2017 18:56:09 -0700 Subject: [PATCH 07/12] Remove a dict-comprehension. Not compatible with python26. --- _pytest/python_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/_pytest/python_api.py b/_pytest/python_api.py index 0c0a6bb7401..91605aa550c 100644 --- a/_pytest/python_api.py +++ b/_pytest/python_api.py @@ -128,9 +128,9 @@ class ApproxMapping(ApproxBase): """ def __repr__(self): - return repr({ - k: self._approx_scalar(v) - for k,v in self.expected.items()}) + return repr(dict( + (k, self._approx_scalar(v)) + for k,v in self.expected.items())) def __eq__(self, actual): if actual.keys() != self.expected.keys(): From d6000e5ab1bc6fd5d8b4d90f230453704d5a9282 Mon Sep 17 00:00:00 2001 From: Kale Kundert Date: Thu, 15 Jun 2017 20:34:36 -0700 Subject: [PATCH 08/12] Remove py36 from .travis.yml I thought the file was just out of date, but adding py36 made Travis complain "InterpreterNotFound: python3.6", so I guess it was correct as it was. --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 29647f9bcb9..d3dce9141dc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,7 +16,6 @@ env: - TOXENV=py33 - TOXENV=py34 - TOXENV=py35 - - TOXENV=py36 - TOXENV=pypy - TOXENV=py27-pexpect - TOXENV=py27-xdist From 9597e674d924f2bc026b9f2743b6716a2a5434c9 Mon Sep 17 00:00:00 2001 From: Kale Kundert Date: Fri, 16 Jun 2017 08:25:13 -0700 Subject: [PATCH 09/12] Use sets to compare dictionary keys. --- _pytest/python_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_pytest/python_api.py b/_pytest/python_api.py index 91605aa550c..acc3ea28619 100644 --- a/_pytest/python_api.py +++ b/_pytest/python_api.py @@ -133,7 +133,7 @@ def __repr__(self): for k,v in self.expected.items())) def __eq__(self, actual): - if actual.keys() != self.expected.keys(): + if set(actual.keys()) != set(self.expected.keys()): return False return ApproxBase.__eq__(self, actual) From 8524a5707549225f60587ef7d965c5e3eb288681 Mon Sep 17 00:00:00 2001 From: Kale Kundert Date: Mon, 3 Jul 2017 22:44:37 -0700 Subject: [PATCH 10/12] Add "approx" to all the repr-strings. --- _pytest/python_api.py | 6 +++--- testing/python/approx.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/_pytest/python_api.py b/_pytest/python_api.py index acc3ea28619..ab7a0bc5ddd 100644 --- a/_pytest/python_api.py +++ b/_pytest/python_api.py @@ -95,7 +95,7 @@ def __new__(cls, expected, rel=None, abs=None, nan_ok=False): def __repr__(self): # It might be nice to rewrite this function to account for the # shape of the array... - return repr(list( + return "approx({0!r})".format(list( self._approx_scalar(x) for x in self.expected)) def __eq__(self, actual): @@ -128,7 +128,7 @@ class ApproxMapping(ApproxBase): """ def __repr__(self): - return repr(dict( + return "approx({0!r})".format(dict( (k, self._approx_scalar(v)) for k,v in self.expected.items())) @@ -152,7 +152,7 @@ def __repr__(self): seq_type = type(self.expected) if seq_type not in (tuple, list, set): seq_type = list - return repr(seq_type( + return "approx({0!r})".format(seq_type( self._approx_scalar(x) for x in self.expected)) def __eq__(self, actual): diff --git a/testing/python/approx.py b/testing/python/approx.py index 3005a7bbe76..a21f644f51c 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -28,8 +28,8 @@ def test_repr_string(self): if sys.version_info[:2] == (2, 6): tol1, tol2, infr = '???', '???', '???' assert repr(approx(1.0)) == '1.0 {pm} {tol1}'.format(pm=plus_minus, tol1=tol1) - assert repr(approx([1.0, 2.0])) == '[1.0 {pm} {tol1}, 2.0 {pm} {tol2}]'.format(pm=plus_minus, tol1=tol1, tol2=tol2) - assert repr(approx((1.0, 2.0))) == '(1.0 {pm} {tol1}, 2.0 {pm} {tol2})'.format(pm=plus_minus, tol1=tol1, tol2=tol2) + assert repr(approx([1.0, 2.0])) == 'approx([1.0 {pm} {tol1}, 2.0 {pm} {tol2}])'.format(pm=plus_minus, tol1=tol1, tol2=tol2) + assert repr(approx((1.0, 2.0))) == 'approx((1.0 {pm} {tol1}, 2.0 {pm} {tol2}))'.format(pm=plus_minus, tol1=tol1, tol2=tol2) assert repr(approx(inf)) == 'inf' assert repr(approx(1.0, rel=nan)) == '1.0 {pm} ???'.format(pm=plus_minus) assert repr(approx(1.0, rel=inf)) == '1.0 {pm} {infr}'.format(pm=plus_minus, infr=infr) @@ -37,8 +37,8 @@ def test_repr_string(self): # Dictionaries aren't ordered, so we need to check both orders. assert repr(approx({'a': 1.0, 'b': 2.0})) in ( - "{{'a': 1.0 {pm} {tol1}, 'b': 2.0 {pm} {tol2}}}".format(pm=plus_minus, tol1=tol1, tol2=tol2), - "{{'b': 2.0 {pm} {tol2}, 'a': 1.0 {pm} {tol1}}}".format(pm=plus_minus, tol1=tol1, tol2=tol2), + "approx({{'a': 1.0 {pm} {tol1}, 'b': 2.0 {pm} {tol2}}})".format(pm=plus_minus, tol1=tol1, tol2=tol2), + "approx({{'b': 2.0 {pm} {tol2}, 'a': 1.0 {pm} {tol1}}})".format(pm=plus_minus, tol1=tol1, tol2=tol2), ) def test_operator_overloading(self): From c111e9dac31664fd8398b9231bd3dbf87ad6646f Mon Sep 17 00:00:00 2001 From: Kale Kundert Date: Mon, 3 Jul 2017 22:45:24 -0700 Subject: [PATCH 11/12] Avoid making multiple ApproxNumpy types. --- _pytest/python_api.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/_pytest/python_api.py b/_pytest/python_api.py index ab7a0bc5ddd..264d925c308 100644 --- a/_pytest/python_api.py +++ b/_pytest/python_api.py @@ -49,13 +49,9 @@ class ApproxNumpyBase(ApproxBase): """ Perform approximate comparisons for numpy arrays. - This class should not be used directly. Instead, it should be used to make - a subclass that also inherits from `np.ndarray`, e.g.:: - - import numpy as np - ApproxNumpy = type('ApproxNumpy', (ApproxNumpyBase, np.ndarray), {}) - - This bizarre invocation is necessary because the object doing the + This class should not be used directly. Instead, the `inherit_ndarray()` + class method should be used to make a subclass that also inherits from + `np.ndarray`. This indirection 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 @@ -81,6 +77,18 @@ class ApproxNumpyBase(ApproxBase): it appears on. """ + subclass = None + + @classmethod + def inherit_ndarray(cls): + import numpy as np + assert not isinstance(cls, np.ndarray) + + if cls.subclass is None: + cls.subclass = type('ApproxNumpy', (ApproxNumpyBase, np.ndarray), {}) + + return cls.subclass + def __new__(cls, expected, rel=None, abs=None, nan_ok=False): """ Numpy uses __new__ (rather than __init__) to initialize objects. @@ -416,8 +424,7 @@ def approx(expected, rel=None, abs=None, nan_ok=False): 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), {}) + cls = ApproxNumpyBase.inherit_ndarray() elif isinstance(expected, Mapping): cls = ApproxMapping elif isinstance(expected, Sequence) and not isinstance(expected, String): From 7a1a439049c01630665aed04bbe18486f3eeda83 Mon Sep 17 00:00:00 2001 From: Kale Kundert Date: Tue, 4 Jul 2017 09:20:52 -0700 Subject: [PATCH 12/12] Use `cls` instead of `ApproxNumpyBase`. Slightly more general, probably doesn't make a difference. --- _pytest/python_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_pytest/python_api.py b/_pytest/python_api.py index 264d925c308..cb7d5e4590e 100644 --- a/_pytest/python_api.py +++ b/_pytest/python_api.py @@ -85,7 +85,7 @@ def inherit_ndarray(cls): assert not isinstance(cls, np.ndarray) if cls.subclass is None: - cls.subclass = type('ApproxNumpy', (ApproxNumpyBase, np.ndarray), {}) + cls.subclass = type('ApproxNumpy', (cls, np.ndarray), {}) return cls.subclass