11

For dictionaries without floating point numbers we are using the simple a == b where a and b are python dictionaries. This works well until we end up with a and b containing floating point numbers somewhere within. They are nested dictionaries so I think that is giving pytest.approx trouble.

What we want is something that will tell us that these two dictionaries are equal (or approximately equal, but something that won't fail only on floating point approximations):

{"foo": {"bar": 0.30000001}} == {"foo": {"bar": 0.30000002}}

pytest.approx() is almost what I want, but it doesn't support nested dictionaries. Is there something out there that can do what I want?

maccam912
  • 792
  • 1
  • 7
  • 22

6 Answers6

7

You can define your own approximation helper with support for nested dictionaries. Unfortunately, pytest doesn't support enhancement of approx with custom comparators, so you have to write your own function; however, it doesn't need to be too complicated:

import pytest
from collections.abc import Mapping
from _pytest.python_api import ApproxMapping


def my_approx(expected, rel=None, abs=None, nan_ok=False):
    if isinstance(expected, Mapping):
        return ApproxNestedMapping(expected, rel, abs, nan_ok)
    return pytest.approx(expected, rel, abs, nan_ok)


class ApproxNestedMapping(ApproxMapping):
    def _yield_comparisons(self, actual):
        for k in self.expected.keys():
            if isinstance(actual[k], type(self.expected)):
                gen = ApproxNestedMapping(
                    self.expected[k], rel=self.rel, abs=self.abs, nan_ok=self.nan_ok
                )._yield_comparisons(actual[k])
                for el in gen:
                    yield el
            else:
                yield actual[k], self.expected[k]

    def _check_type(self):
        for key, value in self.expected.items():
            if not isinstance(value, type(self.expected)):
                super()._check_type()

Now use my_approx instead of pytest.approx:

def test_nested():
    assert {'foo': {'bar': 0.30000001}} == my_approx({'foo': {'bar': 0.30000002}})
hoefling
  • 59,418
  • 12
  • 147
  • 194
  • Getting error `_pytest.compat import Mapping ImportError: cannot import name 'Mapping'` – Viragos Nov 15 '19 at 18:26
  • 1
    @Viragos simply replace the import with `from collections.abc import Mapping`. The compatibility stuff was removed when Python 2 support was dropped. – hoefling Nov 15 '19 at 19:48
  • not working for arrays inside the dictionary. – amitava mozumder Sep 28 '21 at 19:21
  • @amitavamozumder yes, the impl is for dictionaries only, as requested in the question. If you want to extend it to support sequences (and sequences of nested dictionaries), you'll need an `ApproxNestedSequencelike` similar to `ApproxNestedMapping` and invoke each other in `_yield_comparisons` impls where necessary. I'm not at my laptop ATM, but if you're stuck, ping me and I will extend the answer. – hoefling Sep 28 '21 at 20:22
  • @hoefling I'm trying to get this work for sequences, can you update your answer for sequences? – Vivek Joshy Aug 01 '22 at 16:08
2

For tests with only a few inaccurate values in the nested dictionary, wrapping just the values in pytest.approx() works great:

assert {"foo": {"bar": 0.30000001}} == \
       {"foo": {"bar": pytest.approx(0.30000002)}} 

Likewise, one can wrap the nested dict, as long as the wrapped dict has no nesting itself:

assert {"foo": {"bar": 0.30000001}} == \
       {"foo": pytest.approx({"bar": 0.30000002})}

assert {"foo": {"bar": 0.30000001, "foo": 0.40000001}} == \
       {"foo": pytest.approx({"bar": 0.30000002, "foo": 0.4000002})}
Arjan
  • 22,808
  • 11
  • 61
  • 71
1

Have you thought about copying the dictionaries (so as to not affect original values), iterating through every val, and rounding every float with round()?

math.isclose() also compares floats but I don't know of any that compare all floats within nested dictionaries.

Marcin Orlowski
  • 72,056
  • 11
  • 123
  • 141
Matt M
  • 691
  • 2
  • 6
  • 17
1

What you can do is separate out the values inside of the dictionaries and check if the absolute value of the difference between the values is less than what ever value makes it "Close Enough." I found the function from here which is my go-to function for unpacking nested dictionaries.

epislon = 5 

def extract_nested_values(it):
    if isinstance(it, list):
        for sub_it in it:
            yield from extract_nested_values(sub_it)
    elif isinstance(it, dict):
        for value in it.values():
            yield from extract_nested_values(value)
    else:
        yield it


d = {"foo": {"bar": 0.30000001}}
#[0.30000001]
e = {"foo": {"bar": 0.30000002}}
#[0.30000002]

d_value = list(extract_nested_values(d))
e_value = list(extract_nested_values(e))

if set(d.keys()) == set(e.keys()) and abs(e_value[0] - d_value[0]) < epislon:
    print('Close Enough')
else:
    print("not the same")

Output:

Close Enough
Edeki Okoh
  • 1,786
  • 15
  • 27
1

I wrote a similar function that works with nested data structures of the following types: dict, list, tuple, set. It might also work with their subtypes (e.g. OrderedDict, namedtuple, ...), but I have not tested those

# use an alias so I don't have to remember to avoid using "approx" as a variable name
from pytest import approx as pytest_approx


def is_primitive(x):
    return x is None or type(x) in (int, float, str, bool)


def approx_equal(A, B, absolute=1e-6, relative=1e-6, enforce_same_type=False):
    if enforce_same_type and type(A) != type(B) and not is_primitive(A):
        # I use `not is_primitive(A)` to enforce the same type only for data structures
        return False

    try:
        is_approx_equal = (A == pytest_approx(B, rel=relative, abs=absolute))
    except TypeError:
        is_approx_equal = False

    if is_approx_equal:
        # pytest_approx() can only compare primitives and non-nested data structures correctly
        # If the data structures are nested, then approx_equal() will try one of the other branches
        return True
    elif is_primitive(A) or is_primitive(B):
        return False
    elif isinstance(A, set) or isinstance(B, set):
        # if any of the data structures is a set, convert both of them to a sorted list, but return False if the length has changed
        len_A, len_B = len(A), len(B)
        A, B = sorted(A), sorted(B)
        if len_A != len(A) or len_B != len(B):
            return False

        for i in range(len(A)):
            if not approx_equal(A[i], B[i], absolute, relative):
                return False

        return True
    elif isinstance(A, dict) and isinstance(B, dict):
        for k in A.keys():
            if not approx_equal(A[k], B[k], absolute, relative):
                return False

        return True
    elif (isinstance(A, list) or isinstance(A, tuple)) and (isinstance(B, list) or isinstance(B, tuple)):
        for i in range(len(A)):
            if not approx_equal(A[i], B[i], absolute, relative):
                return False

        return True
    else:
        return False


print(approx_equal([1], {1.000001}, enforce_same_type=True)) # False
print(approx_equal([1], {1.000001}, enforce_same_type=False)) # True

print(approx_equal([123.001, (1,2)], [123, (1,2)])) # False
print(approx_equal([123.000001, (1,2)], [123, (1,2)])) # True

print(approx_equal({'a': {'b': 1}, 'c': 3.141592}, {'a': {'b': 1.0000005}, 'c': 3.1415})) # False
print(approx_equal({'a': {'b': 1}, 'c': 3.141592}, {'a': {'b': 1.0000005}, 'c': 3.141592})) # True
Vali Neagu
  • 15
  • 1
  • 6
0

It's possible to conver both dictionaries to Pandas series first and then use pandas.testing.assert_series_equal with atol and rtol if needed:

df = pd.Series(dic)
df_expected = pd.Series(dic_expected)
assert_series_equal(df, df_expected, rtol=1e-05)
Komov
  • 75
  • 2
  • 6