Repository: pygfx/pygfx
Pull request #1066 (2025-04-03 11:18:03)

Make the Store track hashable objects by value
* [x] ~Can only store hashable objects on the `Store`.~
* [x] Objects that are not primitive types, but are hashable, are now tracked by their hash. 
* [x] Implement simple `ReadOnlyDict` that is hashable. Not used yet, but I plan to use it in #1002

Korijn: Why not frozendict? It's built in?
almarklein: > Why not frozendict? It's built in?

There is `frozenset`, but the PEP for `frozendict` was rejected, apparently.
almarklein: Ah, mesh morphing stores a list on the store 🤔 
almarklein: I restored being able to strore non-hashable types. So this simply adds better support for hashable types.

Patch:
diff --git a/pygfx/utils/__init__.py b/pygfx/utils/__init__.py
index 9aa1cbc..6f08f19 100644
--- a/pygfx/utils/__init__.py
+++ b/pygfx/utils/__init__.py
@@ -219,3 +219,43 @@ def assert_type(name, value, *classes):
 
         # Raise message with alt traceback
         raise TypeError(msg).with_traceback(tb) from None
+
+
+class ReadOnlyDict(dict):
+    """A read-only dict, for storing structured data that can be hashed."""
+
+    __slots__ = ["_hash"]
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        # Calculate hash in a way that requires any value to also be hashable
+        parts = []
+        for k in sorted(self.keys()):
+            v = self[k]
+            parts.append(str(hash(k)))
+            parts.append(str(hash(v)))
+        self._hash = hash(" ".join(parts))
+
+    def __setitem__(self, *args, **kwargs):
+        raise TypeError("Cannot modify ReadOnlyDict")
+
+    def __delitem__(self, *args, **kwargs):
+        raise TypeError("Cannot modify ReadOnlyDict")
+
+    def clear(self, *args, **kwargs):
+        raise TypeError("Cannot modify ReadOnlyDict")
+
+    def pop(self, *args, **kwargs):
+        raise TypeError("Cannot modify ReadOnlyDict")
+
+    def popitem(self, *args, **kwargs):
+        raise TypeError("Cannot modify ReadOnlyDict")
+
+    def setdefault(self, *args, **kwargs):
+        raise TypeError("Cannot modify ReadOnlyDict")
+
+    def update(self, *args, **kwargs):
+        raise TypeError("Cannot modify ReadOnlyDict")
+
+    def __hash__(self):
+        return self._hash
diff --git a/pygfx/utils/trackable.py b/pygfx/utils/trackable.py
index 08c5c7f..52b80a8 100644
--- a/pygfx/utils/trackable.py
+++ b/pygfx/utils/trackable.py
@@ -70,7 +70,10 @@ def get_comp_value(value):
     elif isinstance(value, tuple):
         return tuple(get_comp_value(v) for v in value)
     else:
-        return f"id:{id(value)}"
+        try:
+            return "hash:" + str(hash(value))
+        except TypeError:
+            return "id:" + str(id(value))
 
 
 class Undefined:


Test patch:
diff --git a/tests/utils/test_readonlydict.py b/tests/utils/test_readonlydict.py
new file mode 100644
index 0000000..a3cf675
--- /dev/null
+++ b/tests/utils/test_readonlydict.py
@@ -0,0 +1,95 @@
+from pygfx.utils import ReadOnlyDict
+import pytest
+
+
+def test_readonlydict_immutable():
+    d = ReadOnlyDict(foo=3, bar=4, spam=5)
+
+    with pytest.raises(TypeError):
+        d["foo"] = 1
+    with pytest.raises(TypeError):
+        d["xxxxxxx"] = 1
+    with pytest.raises(TypeError):
+        del d["foo"]
+    with pytest.raises(TypeError):
+        d.update({})
+    with pytest.raises(TypeError):
+        d.clear()
+    with pytest.raises(TypeError):
+        d.pop("foo", None)
+    with pytest.raises(TypeError):
+        d.popitem()
+    with pytest.raises(TypeError):
+        d.setdefault("foo", 42)
+    with pytest.raises(TypeError):
+        d.setdefault("foo", 42)
+
+    # Also immutable (using __slots__)
+    with pytest.raises(AttributeError):
+        d.foo = 3
+
+
+def test_readonlydict_hash():
+    d1 = ReadOnlyDict(foo=3, bar=4, spam=5)
+    d2 = ReadOnlyDict(foo=3, bar=4, spam=5)
+    d3 = ReadOnlyDict(d1)
+    d4 = ReadOnlyDict(bar=4, spam=5, foo=3)
+    d5 = ReadOnlyDict(spam=5, bar=4, foo=3)
+
+    for d in [d2, d3, d4, d5]:
+        assert d1 == d
+        assert hash(d1) == hash(d)
+
+    d11 = ReadOnlyDict(foo=2, bar=4, spam=5)
+    d12 = ReadOnlyDict(foo=3, bar=4, spam=5.01)
+    d13 = ReadOnlyDict(foo=3, bar=4)
+    d14 = ReadOnlyDict(foo=3, spam=5)
+    d15 = ReadOnlyDict(bar=4, spam=5)
+    d16 = ReadOnlyDict(foo=3, bar=4, spam=5, x=1)
+    d17 = ReadOnlyDict(foo=3, x=1, bar=4, spam=5)
+
+    for d in [d11, d12, d13, d14, d15, d16, d17]:
+        assert d1 != d
+        assert hash(d1) != hash(d)
+
+
+def test_readonlydict_init():
+    d1 = ReadOnlyDict(foo=3, bar=4, spam=5)
+    d2 = ReadOnlyDict(d1)
+    d3 = ReadOnlyDict({"foo": 3, "bar": 4, "spam": 5})
+    d4 = ReadOnlyDict({"foo": 3, "bar": 4}, spam=5)
+    d5 = ReadOnlyDict(dict(d1))
+
+    for d in [d2, d3, d4, d5]:
+        assert d1 == d
+        assert hash(d1) == hash(d)
+
+
+def test_readonlydict_values():
+    # Unhashable types
+    with pytest.raises(TypeError):
+        d1 = ReadOnlyDict(foo=[])
+    with pytest.raises(TypeError):
+        d1 = ReadOnlyDict(foo=dict(foo=3))
+
+    # This works
+    ReadOnlyDict(foo=ReadOnlyDict(foo=3))
+
+    class Foo:
+        def __init__(self, v):
+            self.v = v
+
+    # This works, but the Foo object is hashed using its id
+    d1 = ReadOnlyDict(foo=Foo(3))
+    d2 = ReadOnlyDict(foo=d1["foo"])
+    d3 = ReadOnlyDict(foo=Foo(3))
+
+    assert hash(d1) == hash(d2)
+    assert hash(d1) != hash(d3)
+
+
+if __name__ == "__main__":
+    test_readonlydict_immutable()
+    test_readonlydict_hash()
+    test_readonlydict_init()
+    test_readonlydict_values()
diff --git a/tests/utils/test_trackable.py b/tests/utils/test_trackable.py
index b6a636b..eb66a03 100644
--- a/tests/utils/test_trackable.py
+++ b/tests/utils/test_trackable.py
@@ -3,6 +3,7 @@ import time
 import weakref
 
 from pygfx.utils.trackable import Trackable, Store, PropTracker
+from pygfx.utils import ReadOnlyDict
 
 # ruff: noqa: B018 - in these tests we access values for their side-effect
 
@@ -294,6 +295,8 @@ def test_tuple_values():
 
 
 def test_list_values():
+    # Lists are not hashable, so are tracked by id
+
     rt = MyRootTrackable()
 
     v1 = [1, 2]
@@ -333,6 +336,35 @@ def test_list_values():
     # assert rt.pop_changed() == {"foo"}
 
 
+def test_dict_and_readonlydict():
+    rt = MyRootTrackable()
+
+    with rt.track_usage("foo"):
+        rt.foo
+    assert rt.pop_changed() == set()
+
+    # Can set a dict
+    d = {"foo": 42}
+    rt.foo = d
+    assert rt.pop_changed() == {"foo"}
+
+    # Which tracks by id ...
+    rt.foo = d
+    assert rt.pop_changed() == set()
+
+    # ... not by value
+    rt.foo = {"foo": 42}
+    assert rt.pop_changed() == {"foo"}
+
+    # But ReadOnlyDict is tracked by value, because it is hashable
+    rt.foo = ReadOnlyDict({"foo": 42})
+    assert rt.pop_changed() == {"foo"}
+
+    # Changing again with the same value
+    rt.foo = ReadOnlyDict({"foo": 42})
+    assert rt.pop_changed() == set()
+
+
 def test_whole_tree_get_removed():
     # When a branch is removed, check that the rest of that branch stops tracking too.