Repository: LucaCappelletti94/dict_hash (language: python)

Issue:
Introduce hashable class for complex objects
The package currently is able to handle hashing of objects such as dictionaries, lists and pandas dataframes. It fails though when it comes to hashing more complex objects.


Discussion / hints:

Fix patch:
diff --git a/dict_hash/__init__.py b/dict_hash/__init__.py
index ec10fa4..49954b0 100644
--- a/dict_hash/__init__.py
+++ b/dict_hash/__init__.py
@@ -1,3 +1,5 @@
 from .dict_hash import sha256, dict_hash
+from .hashable import Hashable
+from .validate_consistent_hash import validate_consistent_hash
 
-__all__ = ["sha256", "dict_hash"]
\ No newline at end of file
+__all__ = ["sha256", "dict_hash", "Hashable", "validate_consistent_hash"]
diff --git a/dict_hash/dict_hash.py b/dict_hash/dict_hash.py
index b1d1763..258e948 100644
--- a/dict_hash/dict_hash.py
+++ b/dict_hash/dict_hash.py
@@ -1,37 +1,88 @@
 import hashlib
-from json import dumps
+import json
 from typing import Dict
 import pandas as pd
 import numpy as np
+from .hashable import Hashable
 
 
 def _convert(data):
+    """Returns given data as an hashable object or dictionary."""
+    # If given object is of type Hashable
+    if isinstance(data, Hashable):
+        # we call its method to convert it to an hash
+        # that can be further hashed as required.
+        return data.consistent_hash()
+    # If the given data is a simple object such as a string, an integer
+    # or a float we can leave it to be hashed.
     if isinstance(data, (str, int, float)):
         return data
+    # If it is a dictionary we need to hash every element of it.
     if isinstance(data, dict):
         return dict(map(_convert, data.items()))
+    # A similar behaviour is required for DataFrames.
     if isinstance(data, pd.DataFrame):
         return data.to_dict()
+    # And numpy arrays.
     if isinstance(data, np.ndarray):
         return _convert(pd.DataFrame(data))
+    # And iterables such as lists and tuples.
     if isinstance(data, (list, tuple)):
         return type(data)(map(_convert, data))
-    raise ValueError("Type {} not currently supported.".format(type(data)))
 
+    # Otherwise we need to raise an exception to warn the user.
+    raise ValueError("Object of class {} not currently supported.".format(
+        data.__class__.__name__
+    ))
 
-def _sanitize(d: Dict) -> str:
-    return dumps(_convert(d))
 
+def _sanitize(dictionary: Dict) -> str:
+    """Return given dictionary as JSON string.
 
-def dict_hash(d: Dict) -> str:
+    Parameters
+    -------------------
+    dictionary: Dict,
+        Dictionary to be converted to JSON.
+
+    Raises
+    -------------------
+    ValueError,
+        When the given object is not a dictionary.
+
+    Returns
+    -------------------
+    JSON string representation of given dictionary.
+    """
+    if not isinstance(dictionary, Dict):
+        raise ValueError("Given object to hash is not a dictionary.")
+    return json.dumps(_convert(dictionary))
+
+
+def dict_hash(dictionary: Dict) -> str:
     """Return hash of given dict (may not be equal for every session).
-        d:Dict, dictionary of which determine an unique hash.
+
+    Parameters
+    ------------------
+    dictionary: Dict,
+        Dictionary of which determine an unique hash.
+
+    Returns
+    ------------------
+    Session hash for the given dictionary.
     """
-    return hash(_sanitize(d))
+    return hash(_sanitize(dictionary))
 
 
-def sha256(d: Dict) -> str:
+def sha256(dictionary: Dict) -> str:
     """Return sha256 of given dict.
-        d:Dict, dictionary of which determine an unique hash.
+
+    Parameters
+    ------------------
+    dictionary: Dict,
+        Dictionary of which determine an unique hash.
+
+    Returns
+    ------------------
+    Deterministic hash for the given dictionary.
     """
-    return hashlib.sha256(_sanitize(d).encode('utf-8')).hexdigest()
+    return hashlib.sha256(_sanitize(dictionary).encode('utf-8')).hexdigest()
diff --git a/dict_hash/hashable.py b/dict_hash/hashable.py
new file mode 100644
index 0000000..1fbeb5a
--- /dev/null
+++ b/dict_hash/hashable.py
@@ -0,0 +1,41 @@
+class Hashable:
+    """The class Hashable has to be implemented by objects you want to hash.
+
+    This abstract class requires the implementation of the method
+    consistent_hash, that returns a consistent hash of the function.
+
+    We do NOT want to use the native method __hash__ since that is willfully
+    not consistent between python instances, while we want a consistent hash.
+
+    A tipical implementation of the consistent_hash method could be the
+    following one:
+
+    ..code::python
+
+        from dict_hash import Hashable, sha256
+
+        class MyObject(Hashable):
+
+            def __init__(self, a:int):
+                self._a = a
+                self_time = time()
+
+            def consistent_hash(self)->str:
+                # The self._time variable here is ignored because we want this
+                # object to match also with objects created at other times.
+                return sha256({
+                    "a":self._a
+                })
+    """
+
+    def consistent_hash(self) -> str:
+        """Return consistent hash of the current object.
+
+        Returns
+        ------------------
+        A consistent hash of the object.
+        """
+        raise NotImplementedError(
+            "The method consistent_hash must be implemented"
+            " by the child classes of Hashable."
+        )
diff --git a/dict_hash/validate_consistent_hash.py b/dict_hash/validate_consistent_hash.py
new file mode 100644
index 0000000..c782c58
--- /dev/null
+++ b/dict_hash/validate_consistent_hash.py
@@ -0,0 +1,18 @@
+from .hashable import Hashable
+
+
+def validate_consistent_hash(first: Hashable, second: Hashable) -> bool:
+    """Return boolean validating if given object have consistent hash.
+
+    Parameters
+    ----------------------
+    first: Hashable,
+        First term to be hashed and validated.
+    second: Hashable
+        Second term to be hashed and validated.
+
+    Returns
+    ----------------------
+    Boolean representing if the two objects hash consistently.
+    """
+    return first.consistent_hash() == second.consistent_hash()
diff --git a/setup.py b/setup.py
index dd7137d..85f8447 100644
--- a/setup.py
+++ b/setup.py
@@ -30,7 +30,15 @@ def find_version(*file_paths):
 
 __version__ = find_version("dict_hash", "__version__.py")
 
-test_deps = ['pytest', 'pytest-cov', 'coveralls', 'validate_version_code', 'random_dict', 'codacy-coverage']
+test_deps = [
+    'pytest',
+    'pytest-cov',
+    'coveralls',
+    'validate_version_code',
+    'random_dict',
+    'codacy-coverage',
+    'tqdm'
+]
 
 extras = {
     'test': test_deps,
@@ -59,4 +67,4 @@ def find_version(*file_paths):
         "numpy"
     ],
     extras_require=extras,
-)
\ No newline at end of file
+)


Test patch:
diff --git a/tests/test_dict_hash_failure.py b/tests/test_dict_hash_failure.py
index a388111..349c709 100644
--- a/tests/test_dict_hash_failure.py
+++ b/tests/test_dict_hash_failure.py
@@ -7,3 +7,9 @@ def test_dict_hash_failure():
         dict_hash({
             "Test": lambda x: 0
         })
+
+    with pytest.raises(ValueError):
+        dict_hash(0)
+
+    with pytest.raises(ValueError):
+        sha256(0)
diff --git a/tests/test_hashable.py b/tests/test_hashable.py
new file mode 100644
index 0000000..8f3c706
--- /dev/null
+++ b/tests/test_hashable.py
@@ -0,0 +1,34 @@
+import pytest
+from time import time
+from dict_hash import Hashable, validate_consistent_hash, sha256
+
+
+class MyHashable(Hashable):
+
+    def __init__(self, a: int):
+        self._a = a
+        self._time = time()
+
+    def consistent_hash(self) -> str:
+        return sha256({
+            "a": self._a
+        })
+
+
+def test_hashable():
+    with pytest.raises(NotImplementedError):
+        Hashable().consistent_hash()
+
+    a = MyHashable(2)
+    b = MyHashable(2)
+    c = MyHashable(3)
+    assert validate_consistent_hash(a, b)
+    assert validate_consistent_hash(b, a)
+    assert not validate_consistent_hash(a, c)
+    assert not validate_consistent_hash(b, c)
+
+    assert sha256({
+        "my_hashable": a
+    }) == sha256({
+        "my_hashable": b
+    })
\ No newline at end of file
diff --git a/tests/test_with_fuzzying.py b/tests/test_with_fuzzying.py
new file mode 100644
index 0000000..1dd0f55
--- /dev/null
+++ b/tests/test_with_fuzzying.py
@@ -0,0 +1,10 @@
+from .utils import create_dict
+from tqdm.auto import trange
+from dict_hash import sha256, dict_hash
+
+
+def test_with_fuzzying():
+    for i in trange(1000, desc="Running some fuzzyng tests"):
+        d = create_dict(i)
+        assert dict_hash(d) == dict_hash(d)
+        assert sha256(d) == sha256(d)
\ No newline at end of file
diff --git a/tests/utils/create_dict.py b/tests/utils/create_dict.py
index 1a7a791..5dce563 100644
--- a/tests/utils/create_dict.py
+++ b/tests/utils/create_dict.py
@@ -1,12 +1,14 @@
-from random import seed, randint
 import pandas as pd
 import numpy as np
+import random
 from random_dict import random_dict
 
 
-def create_dict():
-    seed(0)
-    d = random_dict(randint(0, 10), randint(0, 10))
-    d["pandas"] = pd.DataFrame(d)
+def create_dict(seed=0):
+    random.seed(seed)
+    d = random_dict(random.randint(0, 10), random.randint(0, 10))
+    d["pandas"] = pd.DataFrame({
+        "a": [1, 2, 3]
+    })
     d["numpy"] = np.array([1, 2, 3])
     return d