Skip to content

Commit

Permalink
feat: Computed reactive variables and Singleton.
Browse files Browse the repository at this point in the history
Both are very similar, but Singleton will never re-compute,
both are not settable (read-only).

Example:
```python
from solara.toestand import Computed, Reactive

x = Reactive(1)
y = Reactive(2)

def conditional_add():
    if x.value == 0:
        return 42
    else:
        return x.value + y.value

z = Computed(conditional_add)
```

z.value will be lazily executed the first time, and will be updated
when one of the dependencies changes.
  • Loading branch information
maartenbreddels committed Jan 10, 2024
1 parent 58fe1a8 commit eca69f7
Show file tree
Hide file tree
Showing 2 changed files with 120 additions and 17 deletions.
75 changes: 58 additions & 17 deletions solara/toestand.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ class KernelStore(ValueBase[S], ABC):
_global_dict: Dict[str, S] = {} # outside of solara context, this is used
# we keep a counter per type, so the storage keys we generate are deterministic
_type_counter: Dict[Type, int] = defaultdict(int)
scope_lock = threading.Lock()
scope_lock = threading.RLock()

def __init__(self, key=None):
super().__init__()
Expand Down Expand Up @@ -270,6 +270,15 @@ def initial_value(self) -> S:
return self.default_value


class KernelStoreFactory(KernelStore[S]):
def __init__(self, factory: Callable[[], S], key=None):
self.factory = factory
super().__init__(key=key)

def initial_value(self) -> S:
return self.factory()


class Reactive(ValueBase[S]):
_storage: ValueBase[S]

Expand Down Expand Up @@ -327,27 +336,43 @@ def subscribe_change(self, listener: Callable[[S, S], None], scope: Optional[Con
return self._storage.subscribe_change(listener, scope=scope)

def computed(self, f: Callable[[S], T]) -> "Computed[T]":
return Computed(f, self)
def func():
return f(self.get())

return Computed(func, key=f.__qualname__)

class Computed(Generic[T]):
def __init__(self, compute: Callable[[S], T], state: Reactive[S]):
self.compute = compute
self.state = state

def get(self) -> T:
return self.compute(self.state.get())
class Singleton(Reactive[S]):
def __init__(self, factory: Callable[[], S]):
super().__init__(KernelStoreFactory(factory), key=factory.__qualname__)

def subscribe(self, listener: Callable[[T], None], scope: Optional[ContextManager] = None):
return self.state.subscribe(lambda _: listener(self.get()), scope=scope)
def __set__(self, obj, value):
raise AttributeError("Can't set a singleton")

def use(self, selector: Callable[[T], T]) -> T:
slice = use_sync_external_store_with_selector(
self.subscribe,
self.get,
selector,
)
return slice

class Computed(Reactive[S]):
def __init__(self, f: Callable[[], S], key=None):
self.f = f

def on_change(*ignore):
with self._auto_subscriber:
self.set(f())

self._auto_subscriber = AutoSubscribeContextManager(on_change)

def factory():
with self._auto_subscriber:
return f()

super().__init__(KernelStoreFactory(factory, key=key or factory.__qualname__))

def __repr__(self):
# value = self.get(add_watch=False)
value = "LALA"
if self._name:
return f"<Computed {self._owner.__name__}.{self._name} value={value!r} id={hex(id(self))}>"
else:
return f"<Computed value={value!r} id={hex(id(self))}>"


class ValueSubField(ValueBase[T]):
Expand Down Expand Up @@ -575,6 +600,22 @@ def cleanup():
solara.use_effect(on_close, [])


class AutoSubscribeContextManager(AutoSubscribeContextManagerBase):
on_change: Callable[[], None]

def __init__(self, on_change: Callable[[], None]):
super().__init__()
self.on_change = on_change

def __enter__(self):
super().__enter__()

def __exit__(self, exc_type, exc_val, exc_tb):
value = super().__exit__(exc_type, exc_val, exc_tb)
self.update_subscribers(self.on_change)
return value


# alias for compatibility
State = Reactive

Expand Down
62 changes: 62 additions & 0 deletions tests/unit/toestand_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -535,8 +535,13 @@ def test_store_computed():
count = list_store.computed(len)
last = list_store.computed(lambda x: x[-1] if x else None)

assert count._auto_subscriber.reactive_used is None
assert count.get() == 3
assert count._auto_subscriber.reactive_used == {list_store}

assert last._auto_subscriber.reactive_used is None
assert last.get() == 3
assert last._auto_subscriber.reactive_used == {list_store}
mock = unittest.mock.Mock()
mock_last = unittest.mock.Mock()
unsub = count.subscribe(mock)
Expand Down Expand Up @@ -1054,3 +1059,60 @@ def modify():

box, rc = solara.render(Test(), handle_error=False)
assert rc.find(v.Slider).widget.v_model == 2


def test_singleton():
from solara.toestand import Singleton

calls = 0

def factory():
nonlocal calls
calls += 1
return Bears(type="brown", count=1)

s = Singleton(factory)
assert calls == 0
assert s.get() == Bears(type="brown", count=1)
assert calls == 1
assert s.get() == Bears(type="brown", count=1)
assert calls == 1


def test_computed():
from solara.toestand import Computed

x = Reactive(1)
y = Reactive(2)
calls = 0

def conditional_add():
nonlocal calls
calls += 1
if x.value == 0:
return 42
else:
return x.value + y.value

z = Computed(conditional_add)
assert z._auto_subscriber.reactive_used is None
assert z.value == 3
assert z._auto_subscriber.reactive_used == {x, y}
assert calls == 1
x.value = 2
assert z.value == 4
assert z._auto_subscriber.reactive_used == {x, y}
assert calls == 2
y.value = 3
assert z.value == 5
assert z._auto_subscriber.reactive_used == {x, y}
assert calls == 3
# now we do not depend on y anymore
x.value = 0
assert z.value == 42
assert z._auto_subscriber.reactive_used == {x}
assert calls == 4
y.value = 4
assert z.value == 42
assert z._auto_subscriber.reactive_used == {x}
assert calls == 4

0 comments on commit eca69f7

Please sign in to comment.