Repository: ethereum/web3.py

Issue:
web3.datastructures.AttributeDict.__hash__ fails if event logs contain list objects
* Version: 5.31.1
* Python: 3.8
* OS: linux
* pip freeze output omitted since not a package specific issue

### What was wrong?

Note: Not exactly an issue but something I had to circumvent myself when using the library and thinking it may be helpful if `web3.py` handled it instead directly.

I was trying to use functions requiring `__hash__` on event logs and the code failed saying lists are not hashable [here](https://github.com/ethereum/web3.py/blob/402d001b4f0a19720d859efe3c9e0ece39168224/web3/datastructures.py#L117). This happens if the event logs contain `list` objects nested inside them (which some smart contracts seem to return), as lists by default are mutable and thus not hashable in Python.

### How can it be fixed?

Would it make sense to (possibly recursively) cast any present lists to tuples inside the hash function to avoid crashes? Would some detection mechanism inside these pseudo-immutable classes be helpful to make sure they are indeed hashable (e.g., raising an error if a list is added)?

Any thoughts?


Discussion:
I think that since it's already casting it to a tuple, recursively tupelizing  the whole thing would be fine. We'd also need to preserve the sorting.

If you have the time to put in a PR, that would be great! Otherwise I'll take further look later.
Thank you @pacrob, I agree.

Was thinking of adding an additional class simulating immutable lists similar to the already present `AttributeDict` logic, but I believe that would bring unneeded complexity and little practical benefit. I think the simplest approach would be to leave the lists as is, but only convert them recursively to tuples inside the hash checks, with preserved sorting, so that those will not crash anymore.
If you have any thoughts feel free to let me know, otherwise I would proceed with implementation.

Give me some time (~2 weeks), I will put in a small PR.

Resolving patch:
diff --git a/web3/datastructures.py b/web3/datastructures.py
--- a/web3/datastructures.py
+++ b/web3/datastructures.py
@@ -114,7 +114,7 @@ def __delattr__(self, key: str) -> None:
         raise TypeError("This data is immutable -- create a copy instead of modifying")
 
     def __hash__(self) -> int:
-        return hash(tuple(sorted(self.items())))
+        return hash(tuple(sorted(tupleize_lists_nested(self).items())))
 
     def __eq__(self, other: Any) -> bool:
         if isinstance(other, Mapping):
@@ -123,6 +123,29 @@ def __eq__(self, other: Any) -> bool:
             return False
 
 
+def tupleize_lists_nested(d: Mapping[TKey, TValue]) -> AttributeDict[TKey, TValue]:
+    """
+    Unhashable types inside dicts will throw an error if attempted to be hashed.
+    This method converts lists to tuples, rendering them hashable.
+    Other unhashable types found will raise a TypeError
+    """
+
+    def _to_tuple(lst: List[Any]) -> Any:
+        return tuple(_to_tuple(i) if isinstance(i, list) else i for i in lst)
+
+    ret = dict()
+    for k, v in d.items():
+        if isinstance(v, List):
+            ret[k] = _to_tuple(v)
+        elif isinstance(v, Mapping):
+            ret[k] = tupleize_lists_nested(v)
+        elif not isinstance(v, Hashable):
+            raise TypeError(f"Found unhashable type '{type(v).__name__}': {v}")
+        else:
+            ret[k] = v
+    return AttributeDict(ret)
+
+
 class NamedElementOnion(Mapping[TKey, TValue]):
     """
     Add layers to an onion-shaped structure. Optionally, inject to a specific layer.


Test patch:
diff --git a/tests/core/datastructures/test_tuplelize_nested_lists.py b/tests/core/datastructures/test_tuplelize_nested_lists.py
new file mode 100644
--- /dev/null
+++ b/tests/core/datastructures/test_tuplelize_nested_lists.py
@@ -0,0 +1,122 @@
+import pytest
+import re
+
+from web3.datastructures import (
+    AttributeDict,
+    tupleize_lists_nested,
+)
+
+
+@pytest.mark.parametrize(
+    "input,expected",
+    (
+        (
+            {
+                "mylst": [1, 2, 3, [4, 5, [6, 7], 8], 9, 10],
+                "nested": {"mylst": [1, 2, 3, [1], [2, 3]]},
+            },
+            AttributeDict(
+                {
+                    "mylst": (1, 2, 3, (4, 5, (6, 7), 8), 9, 10),
+                    "nested": AttributeDict({"mylst": (1, 2, 3, (1,), (2, 3))}),
+                }
+            ),
+        ),
+        (
+            {
+                "mylst": [1, 2, 3, [5, 4, [6, 7], 8], 9, 10],
+                "nested": {"mylst": [1, 2, 3, [1], [2, 3]]},
+            },
+            AttributeDict(
+                {
+                    "nested": AttributeDict({"mylst": (1, 2, 3, (1,), (2, 3))}),
+                    "mylst": (1, 2, 3, (5, 4, (6, 7), 8), 9, 10),
+                }
+            ),
+        ),
+        (
+            AttributeDict(
+                {
+                    "mylst": [1, 2, 3, [4, 5, [6, 7], 8], 9, 10],
+                    "nested": AttributeDict({"mylst": [1, 2, 3, [1], [2, 3]]}),
+                }
+            ),
+            AttributeDict(
+                {
+                    "mylst": (1, 2, 3, (4, 5, (6, 7), 8), 9, 10),
+                    "nested": AttributeDict({"mylst": (1, 2, 3, (1,), (2, 3))}),
+                }
+            ),
+        ),
+    ),
+)
+def test_tupleization_and_hashing(input, expected):
+    assert tupleize_lists_nested(input) == expected
+    assert hash(AttributeDict(input)) == hash(expected)
+
+
+@pytest.mark.parametrize(
+    "input, error",
+    (
+        (
+            AttributeDict(
+                {
+                    "myset": set({1, 2, 3}),
+                    "nested": AttributeDict({"mylst": (1, 2, 3, (1,), (2, 3))}),
+                }
+            ),
+            {
+                "expected_exception": TypeError,
+                "match": "Found unhashable type 'set': {(1, 2, 3)}",
+            },
+        ),
+        (
+            AttributeDict(
+                {
+                    "mybytearray": bytearray((1, 2, 3)),
+                    "nested": AttributeDict({"mylst": [1, 2, 3, [1], [2, 3]]}),
+                }
+            ),
+            {
+                "expected_exception": TypeError,
+                "match": re.escape(
+                    "Found unhashable type 'bytearray': bytearray(b'\\x01\\x02\\x03')"
+                ),
+            },
+        ),
+    ),
+)
+def test_tupleization_and_hashing_error(input, error):
+    with pytest.raises(**error):
+        assert hash(input)
+
+
+@pytest.mark.parametrize(
+    "input, error",
+    (
+        (
+            AttributeDict(
+                {
+                    "mylst": (1, 2, 3, (4, 5, (6, 7), 8), 9, 10),
+                    "nested": AttributeDict({"mylst": (1, 2, 3, (1,), (2, 3))}),
+                }
+            ),
+            None,
+        ),
+        (
+            AttributeDict(
+                {
+                    "mylst": [1, 2, 3, [4, 5, [6, 7], 8], 9, 10],
+                    "nested": AttributeDict({"mylst": [1, 2, 3, [1], [2, 3]]}),
+                }
+            ),
+            {"expected_exception": TypeError, "match": "unhashable type: 'list'"},
+        ),
+    ),
+)
+def test_AttributeDict_hashing_backwards_compatibility(input, error):
+    if error:
+        with pytest.raises(**error):
+            assert hash(tuple(sorted(input.items()))) == hash(input)
+    else:
+        assert hash(tuple(sorted(input.items()))) == hash(input)