Repository: zauberzeug/nicegui
Pull request #977 (2023-06-02 09:33:48)

Observables
This PR introduces observable collections that execute a callback whenever there is a change to the stored data.

Example:
```py
d = ObservableDict({'a': 0}, lambda: ui.notify(d))
ui.button('Change', on_click=lambda: d.update(a=d['a']+1))
```


Patch:
diff --git a/nicegui/observables.py b/nicegui/observables.py
new file mode 100644
index 00000000..5c7fec15
--- /dev/null
+++ b/nicegui/observables.py
@@ -0,0 +1,212 @@
+from typing import Any, Callable, Dict, Iterable, List, Set, Union, overload
+
+from typing_extensions import SupportsIndex
+
+
+class ObservableDict(dict):
+
+    def __init__(self, data: Dict, on_change: Callable) -> None:
+        super().__init__(data)
+        for key, value in self.items():
+            super().__setitem__(key, make_observable(value, on_change))
+        self.on_change = on_change
+
+    def pop(self, k: Any, d: Any = None) -> Any:
+        item = super().pop(k, d)
+        self.on_change()
+        return item
+
+    def popitem(self) -> Any:
+        item = super().popitem()
+        self.on_change()
+        return item
+
+    def update(self, *args: Any, **kwargs: Any) -> None:
+        super().update(make_observable(dict(*args, **kwargs), self.on_change))
+        self.on_change()
+
+    def clear(self) -> None:
+        super().clear()
+        self.on_change()
+
+    def setdefault(self, __key: Any, __default: Any = None) -> Any:
+        item = super().setdefault(__key, make_observable(__default, self.on_change))
+        self.on_change()
+        return item
+
+    def __setitem__(self, __key: Any, __value: Any) -> None:
+        super().__setitem__(__key, make_observable(__value, self.on_change))
+        self.on_change()
+
+    def __delitem__(self, __key: Any) -> None:
+        super().__delitem__(__key)
+        self.on_change()
+
+    def __or__(self, other: Any) -> Any:
+        return super().__or__(other)
+
+    def __ior__(self, other: Any) -> Any:
+        super().__ior__(make_observable(dict(other), self.on_change))
+        self.on_change()
+        return self
+
+
+class ObservableList(list):
+
+    def __init__(self, data: List, on_change: Callable) -> None:
+        super().__init__(data)
+        for i, item in enumerate(self):
+            super().__setitem__(i, make_observable(item, on_change))
+        self.on_change = on_change
+
+    def append(self, item: Any) -> None:
+        super().append(make_observable(item, self.on_change))
+        self.on_change()
+
+    def extend(self, iterable: Iterable) -> None:
+        super().extend(make_observable(list(iterable), self.on_change))
+        self.on_change()
+
+    def insert(self, index: SupportsIndex, object: Any) -> None:
+        super().insert(index, make_observable(object, self.on_change))
+        self.on_change()
+
+    def remove(self, value: Any) -> None:
+        super().remove(value)
+        self.on_change()
+
+    def pop(self, index: SupportsIndex = -1) -> Any:
+        item = super().pop(index)
+        self.on_change()
+        return item
+
+    def clear(self) -> None:
+        super().clear()
+        self.on_change()
+
+    def sort(self, **kwargs: Any) -> None:
+        super().sort(**kwargs)
+        self.on_change()
+
+    def reverse(self) -> None:
+        super().reverse()
+        self.on_change()
+
+    def __delitem__(self, key: Union[SupportsIndex, slice]) -> None:
+        super().__delitem__(key)
+        self.on_change()
+
+    def __setitem__(self, key: Union[SupportsIndex, slice], value: Any) -> None:
+        super().__setitem__(key, make_observable(value, self.on_change))
+        self.on_change()
+
+    def __add__(self, other: Any) -> Any:
+        return super().__add__(other)
+
+    def __iadd__(self, other: Any) -> Any:
+        super().__iadd__(make_observable(other, self.on_change))
+        self.on_change()
+        return self
+
+
+class ObservableSet(set):
+
+    def __init__(self, data: set, on_change: Callable) -> None:
+        super().__init__(data)
+        for item in self:
+            super().add(make_observable(item, on_change))
+        self.on_change = on_change
+
+    def add(self, item: Any) -> None:
+        super().add(make_observable(item, self.on_change))
+        self.on_change()
+
+    def remove(self, item: Any) -> None:
+        super().remove(item)
+        self.on_change()
+
+    def discard(self, item: Any) -> None:
+        super().discard(item)
+        self.on_change()
+
+    def pop(self) -> Any:
+        item = super().pop()
+        self.on_change()
+        return item
+
+    def clear(self) -> None:
+        super().clear()
+        self.on_change()
+
+    def update(self, *s: Iterable[Any]) -> None:
+        super().update(make_observable(set(*s), self.on_change))
+        self.on_change()
+
+    def intersection_update(self, *s: Iterable[Any]) -> None:
+        super().intersection_update(*s)
+        self.on_change()
+
+    def difference_update(self, *s: Iterable[Any]) -> None:
+        super().difference_update(*s)
+        self.on_change()
+
+    def symmetric_difference_update(self, *s: Iterable[Any]) -> None:
+        super().symmetric_difference_update(*s)
+        self.on_change()
+
+    def __or__(self, other: Any) -> Any:
+        return super().__or__(other)
+
+    def __ior__(self, other: Any) -> Any:
+        super().__ior__(make_observable(other, self.on_change))
+        self.on_change()
+        return self
+
+    def __and__(self, other: Any) -> set:
+        return super().__and__(other)
+
+    def __iand__(self, other: Any) -> Any:
+        super().__iand__(make_observable(other, self.on_change))
+        self.on_change()
+        return self
+
+    def __sub__(self, other: Any) -> set:
+        return super().__sub__(other)
+
+    def __isub__(self, other: Any) -> Any:
+        super().__isub__(make_observable(other, self.on_change))
+        self.on_change()
+        return self
+
+    def __xor__(self, other: Any) -> set:
+        return super().__xor__(other)
+
+    def __ixor__(self, other: Any) -> Any:
+        super().__ixor__(make_observable(other, self.on_change))
+        self.on_change()
+        return self
+
+
+@overload
+def make_observable(data: Dict, on_change: Callable) -> ObservableDict:
+    ...
+
+
+@overload
+def make_observable(data: List, on_change: Callable) -> ObservableList:
+    ...
+
+
+@overload
+def make_observable(data: Set, on_change: Callable) -> ObservableSet:
+    ...
+
+
+def make_observable(data: Any, on_change: Callable) -> Any:
+    if isinstance(data, dict):
+        return ObservableDict(data, on_change)
+    if isinstance(data, list):
+        return ObservableList(data, on_change)
+    if isinstance(data, set):
+        return ObservableSet(data, on_change)
+    return data


Test patch:
diff --git a/tests/test_observables.py b/tests/test_observables.py
new file mode 100644
index 00000000..1b44f7d3
--- /dev/null
+++ b/tests/test_observables.py
@@ -0,0 +1,121 @@
+import sys
+
+from nicegui.observables import make_observable
+
+count = 0
+
+
+def reset_counter():
+    global count
+    count = 0
+
+
+def increment_counter():
+    global count
+    count += 1
+
+
+def test_observable_dict():
+    reset_counter()
+    data = make_observable({}, increment_counter)
+    data['a'] = 1
+    assert count == 1
+    del data['a']
+    assert count == 2
+    data.update({'b': 2, 'c': 3})
+    assert count == 3
+    data.pop('b')
+    assert count == 4
+    data.popitem()
+    assert count == 5
+    data.clear()
+    assert count == 6
+    data.setdefault('a', 1)
+    assert count == 7
+    if sys.version_info >= (3, 9):
+        data |= {'b': 2}
+        assert count == 8
+
+
+def test_observable_list():
+    reset_counter()
+    data = make_observable([], increment_counter)
+    data.append(1)
+    assert count == 1
+    data.extend([2, 3, 4])
+    assert count == 2
+    data.insert(0, 0)
+    assert count == 3
+    data.remove(1)
+    assert count == 4
+    data.pop()
+    assert count == 5
+    data.sort()
+    assert count == 6
+    data.reverse()
+    assert count == 7
+    data[0] = 1
+    assert count == 8
+    data[0:2] = [1, 2, 3]
+    assert count == 9
+    del data[0]
+    assert count == 10
+    del data[0:1]
+    assert count == 11
+    data.clear()
+    assert count == 12
+    data += [1, 2, 3]
+    assert count == 13
+
+
+def test_observable_set():
+    reset_counter()
+    data = make_observable({1, 2, 3, 4, 5}, increment_counter)
+    data.add(1)
+    assert count == 1
+    data.remove(1)
+    assert count == 2
+    data.discard(2)
+    assert count == 3
+    data.pop()
+    assert count == 4
+    data.clear()
+    assert count == 5
+    data.update({1, 2, 3})
+    assert count == 6
+    data.intersection_update({1, 2})
+    assert count == 7
+    data.difference_update({1})
+    assert count == 8
+    data.symmetric_difference_update({1, 2})
+    assert count == 9
+    data |= {1, 2, 3}
+    assert count == 10
+    data &= {1, 2}
+    assert count == 11
+    data -= {1}
+    assert count == 12
+    data ^= {1, 2}
+    assert count == 13
+
+
+def test_nested_observables():
+    reset_counter()
+    data = make_observable({
+        'a': 1,
+        'b': [1, 2, 3, {'x': 1, 'y': 2, 'z': 3}],
+        'c': {'x': 1, 'y': 2, 'z': 3, 't': [1, 2, 3]},
+        'd': {1, 2, 3},
+    }, increment_counter)
+    data['a'] = 42
+    assert count == 1
+    data['b'].append(4)
+    assert count == 2
+    data['b'][3].update(t=4)
+    assert count == 3
+    data['c']['x'] = 2
+    assert count == 4
+    data['c']['t'].append(4)
+    assert count == 5
+    data['d'].add(4)
+    assert count == 6