From d190ed34ddd92c9451b207ca9987ba8c62501037 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Piotr=20Ma=C5=9Blanka?= <piotr.maslanka@henrietta.com.pl>
Date: Sat, 3 Jul 2021 17:33:53 +0200
Subject: [PATCH] added deep compare

---
 CHANGELOG.md                           |  1 +
 docs/coding/functions.rst              | 13 ++++++
 satella/__init__.py                    |  2 +-
 satella/coding/__init__.py             |  2 +
 satella/coding/deep_compare.py         | 63 ++++++++++++++++++++++++++
 tests/test_coding/test_deep_compare.py | 28 ++++++++++++
 6 files changed, 108 insertions(+), 1 deletion(-)
 create mode 100644 satella/coding/deep_compare.py
 create mode 100644 tests/test_coding/test_deep_compare.py

diff --git a/CHANGELOG.md b/CHANGELOG.md
index de391252..3656f0ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,2 +1,3 @@
 # v2.17.12
 
+* added deep comparison
diff --git a/docs/coding/functions.rst b/docs/coding/functions.rst
index 828ab5fd..a9299340 100644
--- a/docs/coding/functions.rst
+++ b/docs/coding/functions.rst
@@ -110,3 +110,16 @@ Without running into `TypeError: metaclass conflict: the metaclass of a derived
 Following function will help with that:
 
 .. autofunction:: satella.coding.metaclass_maker
+
+Deep comparison
+---------------
+
+To analyze why two objects don't compare the same, you can use the following functions:
+
+.. autofunction:: satella.coding.assert_equal
+
+.. autoclass:: satella.coding.Inequal
+    :members:
+
+.. autoclass:: satella.coding.InequalityReason
+    :members:
diff --git a/satella/__init__.py b/satella/__init__.py
index 18ad53ff..154feec1 100644
--- a/satella/__init__.py
+++ b/satella/__init__.py
@@ -1 +1 @@
-__version__ = '2.17.12a1'
+__version__ = '2.17.12a2'
diff --git a/satella/coding/__init__.py b/satella/coding/__init__.py
index d30cbf43..1d58a54b 100644
--- a/satella/coding/__init__.py
+++ b/satella/coding/__init__.py
@@ -20,8 +20,10 @@ from .overloading import overload, class_or_instancemethod
 from .recast_exceptions import rethrow_as, silence_excs, catch_exception, log_exceptions, \
     raises_exception, reraise_as
 from .expect_exception import expect_exception
+from .deep_compare import assert_equal, InequalityReason, Inequal
 
 __all__ = [
+    'assert_equal', 'InequalityReason', 'Inequal',
     'Closeable', 'contains', 'enum_value', 'reraise_as',
     'expect_exception',
     'overload', 'class_or_instancemethod',
diff --git a/satella/coding/deep_compare.py b/satella/coding/deep_compare.py
new file mode 100644
index 00000000..530b70d8
--- /dev/null
+++ b/satella/coding/deep_compare.py
@@ -0,0 +1,63 @@
+import enum
+
+
+class InequalityReason(enum.IntEnum):
+    NOT_EQUAL = 0           #: direct eq yielded not equal
+    LENGTH_MISMATCH = 1     #: length didn't match
+    KEY_NOT_FOUND = 2       #: key given as obj1 was not found
+
+
+class Inequal(Exception):
+    """
+    An exception raised by :meth:`~satella.coding.deep_compare` if two objects don't match
+
+    :ivar obj1: first object that was not equal, or key name
+    :ivar obj2: second object that was not equal, or None
+    :ivar reason: (:class:`~satella.coding.InequalityReason`) reason for inequality
+    """
+    def __init__(self, obj1, obj2, reason: InequalityReason):
+        self.obj1 = obj1
+        self.obj2 = obj2
+        self.reason = reason
+
+
+def assert_equal(a, b):
+    """
+    Assert that two values are equal. If not, an :class:`satella.coding.Inequality` exception
+    will be thrown.
+
+    Objects are tried to compare using it's :code:`__eq__`.
+
+    :param a: first value to compare
+    :param b: second value to compare
+    :raises Inequal: objects were not equal
+    """
+    if isinstance(a, (int, float, str, bytes, bytearray, type(None))):
+        if a != b:
+            raise Inequal(a, b, InequalityReason.NOT_EQUAL)
+        return
+    elif isinstance(a, (set, frozenset)):
+        if len(a) != len(b):
+            raise Inequal(a, b, InequalityReason.LENGTH_MISMATCH)
+        if a != b:
+            raise Inequal(a, b, InequalityReason.LENGTH_MISMATCH)
+    elif isinstance(a, (list, tuple)):
+        if len(a) != len(b):
+            raise Inequal(a, b, InequalityReason.LENGTH_MISMATCH)
+        for c, d in zip(a, b):
+            assert_equal(c, d)
+    elif isinstance(a, dict):
+        if len(a) != len(b):
+            raise Inequal(a, b, InequalityReason.LENGTH_MISMATCH)
+        for key in a.keys():
+            if key not in b:
+                raise Inequal(key, None, InequalityReason.KEY_NOT_FOUND)
+            assert_equal(a[key], b[key])
+    else:
+        try:
+            if a == b:
+                return
+            raise Inequal(a, b, InequalityReason.NOT_EQUAL)
+        except TypeError:
+            pass
+
diff --git a/tests/test_coding/test_deep_compare.py b/tests/test_coding/test_deep_compare.py
new file mode 100644
index 00000000..5d046c34
--- /dev/null
+++ b/tests/test_coding/test_deep_compare.py
@@ -0,0 +1,28 @@
+import unittest
+
+from satella.coding import assert_equal, Inequal
+
+
+class TestDeepCompare(unittest.TestCase):
+    def assertInequal(self, a, b):
+        self.assertRaises(Inequal, lambda: assert_equal(a, b))
+
+    def test_compare(self):
+        assert_equal(2, 2)
+        self.assertInequal(3, 2)
+        self.assertInequal([], [6])
+        self.assertInequal({1: 2}, {3: 4})
+        self.assertInequal({1: 2}, {1: 4})
+        self.assertInequal({1: 2}, {3: 4, 5: 6})
+        assert_equal([1], [1])
+        assert_equal(set([1, 2]), set([2, 1]))
+
+        class Object:
+            def __init__(self, a):
+                self.a = a
+
+            def __eq__(self, other):
+                return self.a == other.a
+
+        assert_equal(Object(3), Object(3))
+        self.assertInequal(Object(3), Object(4))
-- 
GitLab