Skip to content

Commit e8f4191

Browse files
refactor: mutation detection is now a decorator class
1 parent 74ad63e commit e8f4191

File tree

8 files changed

+337
-185
lines changed

8 files changed

+337
-185
lines changed

solara/_stores.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import copy
2+
import dataclasses
3+
import inspect
4+
from typing import Callable, ContextManager, Generic, Optional
5+
import warnings
6+
from .toestand import ValueBase, S, _find_outside_solara_frame
7+
import solara.util
8+
9+
10+
@dataclasses.dataclass
11+
class StoreValue(Generic[S]):
12+
private: S # the internal private value, should never be mutated
13+
public: Optional[S] # this is the value that is exposed in .get(), it is a deep copy of private
14+
get_traceback: Optional[inspect.Traceback]
15+
set_value: Optional[S] # the value that was set using .set(..), we deepcopy this to set private
16+
set_traceback: Optional[inspect.Traceback]
17+
18+
19+
class MutateDetectorStore(ValueBase[S]):
20+
def __init__(self, store: ValueBase[StoreValue[S]], equals=solara.util.equals):
21+
self._storage = store
22+
self._enabled = True
23+
super().__init__(equals=equals)
24+
25+
@property
26+
def lock(self):
27+
return self._storage.lock
28+
29+
def get(self) -> S:
30+
print("check!")
31+
self.check_mutations()
32+
self._ensure_public_exists()
33+
value = self._storage.get()
34+
assert value.public is not None
35+
return value.public
36+
37+
def peek(self) -> S:
38+
"""Return the value without automatically subscribing to listeners."""
39+
self.check_mutations()
40+
store_value = self._storage.peek()
41+
self._ensure_public_exists()
42+
assert store_value.public is not None
43+
return store_value.public
44+
45+
def set(self, value: S):
46+
self._ensure_public_exists()
47+
private = copy.deepcopy(value)
48+
self._check_equals(private, value)
49+
frame = _find_outside_solara_frame()
50+
if frame is not None:
51+
frame_info = inspect.getframeinfo(frame)
52+
else:
53+
frame_info = None
54+
store_value = StoreValue(private=private, public=None, get_traceback=None, set_value=value, set_traceback=frame_info)
55+
self._storage.set(store_value)
56+
57+
def check_mutations(self):
58+
if not self._enabled:
59+
return
60+
store_value = self._storage.peek()
61+
if store_value.public is not None and not self.equals(store_value.public, store_value.private):
62+
tb = store_value.get_traceback
63+
# TODO: make the error message as elaborate as below
64+
msg = (
65+
f"Reactive variable was read when it had the value of {store_value.private!r}, but was later mutated to {store_value.public!r}.\n"
66+
"Mutation should not be done on the value of a reactive variable, as in production mode we would be unable to track changes.\n"
67+
)
68+
if tb:
69+
if tb.code_context:
70+
code = tb.code_context[0]
71+
else:
72+
code = "<No code context available>"
73+
msg += f"The last value was read in the following code:\n" f"{tb.filename}:{tb.lineno}\n" f"{code}"
74+
raise ValueError(msg)
75+
elif store_value.set_value is not None and not self.equals(store_value.set_value, store_value.private):
76+
tb = store_value.set_traceback
77+
msg = f"""Reactive variable was set with a value of {store_value.private!r}, but was later mutated mutated to {store_value.set_value!r}.
78+
79+
Mutation should not be done on the value of a reactive variable, as in production mode we would be unable to track changes.
80+
81+
Bad:
82+
mylist = reactive([]]
83+
some_values = [1, 2, 3]
84+
mylist.value = some_values # you give solara a reference to your list
85+
some_values.append(4) # but later mutate it (solara cannot detect this change, so a render will not be triggered)
86+
# if later on a re-render happens for a different reason, you will read of the mutated list.
87+
88+
Good (if you want the reactive variable to be updated):
89+
mylist = reactive([]]
90+
some_values = [1, 2, 3]
91+
mylist.value = some_values
92+
mylist.value = some_values + [4]
93+
94+
Good (if you want to keep mutating your own list):
95+
mylist = reactive([]]
96+
some_values = [1, 2, 3]
97+
mylist.value = some_values.copy() # this gives solara a copy of the list
98+
some_values.append(4) # you are free to mutate your own list, solara will not see this
99+
100+
"""
101+
if tb:
102+
if tb.code_context:
103+
code = tb.code_context[0]
104+
else:
105+
code = "<No code context available>"
106+
msg += "The last time the value was set was at:\n" f"{tb.filename}:{tb.lineno}\n" f"{code}"
107+
raise ValueError(msg)
108+
109+
def _ensure_public_exists(self):
110+
store_value = self._storage.peek()
111+
if store_value.public is None:
112+
with self.lock:
113+
if store_value.public is None:
114+
frame = _find_outside_solara_frame()
115+
if frame is not None:
116+
frame_info = inspect.getframeinfo(frame)
117+
else:
118+
frame_info = None
119+
store_value.public = copy.deepcopy(store_value.private)
120+
self._check_equals(store_value.public, store_value.private)
121+
store_value.get_traceback = frame_info
122+
123+
def _check_equals(self, a: S, b: S):
124+
if not self._enabled:
125+
return
126+
if not self.equals(a, b):
127+
frame = _find_outside_solara_frame()
128+
if frame is not None:
129+
frame_info = inspect.getframeinfo(frame)
130+
else:
131+
frame_info = None
132+
133+
warn = """The equals function for this reactive value returned False when comparing a deepcopy to itself.
134+
135+
This reactive variable will not be able to detect mutations correctly, and is therefore disabled.
136+
137+
To avoid this warning, and to ensure that mutation detection works correctly, please provide a better equals function to the reactive variable.
138+
A good choice for dataframes and numpy arrays might be solara.util.equals_pickle, which will also attempt to compare the pickled values of the objects.
139+
140+
Example:
141+
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
142+
reactive_df = solara.reactive(df, equals=solara.util.equals_pickle)
143+
"""
144+
tb = frame_info
145+
if tb:
146+
if tb.code_context:
147+
code = tb.code_context[0]
148+
else:
149+
code = "<No code context available>"
150+
warn += "This warning was triggered from:\n" f"{tb.filename}:{tb.lineno}\n" f"{code}"
151+
warnings.warn(warn)
152+
self._enabled = False
153+
154+
def subscribe(self, listener: Callable[[S], None], scope: Optional[ContextManager] = None):
155+
def listener_wrapper(new: StoreValue[S], previous: StoreValue[S]):
156+
self._ensure_public_exists()
157+
assert new.public is not None
158+
assert previous.public is not None
159+
previous_value = previous.set_value if previous.set_value is not None else previous.private
160+
new_value = new.set_value
161+
assert new_value is not None
162+
if not self.equals(new_value, previous_value):
163+
listener(new_value)
164+
165+
return self._storage.subscribe_change(listener_wrapper, scope=scope)
166+
167+
def subscribe_change(self, listener: Callable[[S, S], None], scope: Optional[ContextManager] = None):
168+
def listener_wrapper(new: StoreValue[S], previous: StoreValue[S]):
169+
self._ensure_public_exists()
170+
assert new.public is not None
171+
assert previous.public is not None
172+
previous_value = previous.set_value if previous.set_value is not None else previous.private
173+
new_value = new.set_value
174+
assert new_value is not None
175+
if not self.equals(new_value, previous_value):
176+
listener(new_value, previous_value)
177+
178+
return self._storage.subscribe_change(listener_wrapper, scope=scope)

solara/hooks/use_reactive.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
from typing import Callable, Optional, TypeVar, Union
22

33
import solara
4+
from solara.toestand import Equals
45

56
T = TypeVar("T")
67

78

89
def use_reactive(
910
value: Union[T, solara.Reactive[T]],
1011
on_change: Optional[Callable[[T], None]] = None,
12+
equals: Equals = solara.util.equals,
1113
) -> solara.Reactive[T]:
1214
"""Creates a reactive variable with the a local component scope.
1315
@@ -44,6 +46,12 @@ def Page():
4446
* on_change (Optional[Callable[[T], None]]): An optional callback function
4547
that will be called when the reactive variable's value changes.
4648
49+
* equals: A function that return True if two values are considered equal, and False otherwise.
50+
The default function is `solara.util.equals`, which performs a deep comparison of the two values
51+
and is more forgiving than the default `==` operator.
52+
You can provide a custom function if you need to define a different notion of equality.
53+
54+
4755
Returns:
4856
solara.Reactive[T]: A reactive variable with the specified initial value
4957
or the provided reactive variable.

solara/reactive.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from typing import TypeVar
22

3-
from solara.toestand import Reactive
3+
from solara.toestand import Reactive, Equals
4+
import solara.util
45

56
__all__ = ["reactive", "Reactive"]
67

78
T = TypeVar("T")
89

910

10-
def reactive(value: T) -> Reactive[T]:
11+
def reactive(value: T, equals: Equals = solara.util.equals) -> Reactive[T]:
1112
"""Creates a new Reactive object with the given initial value.
1213
1314
Reactive objects are mostly used to manage global or application-wide state in
@@ -35,6 +36,11 @@ def reactive(value: T) -> Reactive[T]:
3536
3637
Args:
3738
value (T): The initial value of the reactive variable.
39+
equals: A function that return True if two values are considered equal, and False otherwise.
40+
The default function is `solara.util.equals`, which performs a deep comparison of the two values
41+
and is more forgiving than the default `==` operator.
42+
You can provide a custom function if you need to define a different notion of equality.
43+
3844
3945
Returns:
4046
Reactive[T]: A new Reactive object with the specified initial value.
@@ -90,4 +96,4 @@ def Page():
9096
Whenever the counter value changes, `CounterDisplay` automatically updates to display the new value.
9197
9298
"""
93-
return Reactive(value)
99+
return Reactive(value, equals=equals)

solara/settings.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,23 @@ class Config:
6161
env_file = ".env"
6262

6363

64+
class Storage(BaseSettings):
65+
mutation_detection: Optional[bool] = None # True/False, or None to auto determine
66+
factory: str = "solara.toestand.default_storage"
67+
68+
def get_factory(self):
69+
return solara.util.import_item(self.factory)
70+
71+
class Config:
72+
env_prefix = "solara_storage_"
73+
case_sensitive = False
74+
env_file = ".env"
75+
76+
6477
assets: Assets = Assets()
6578
cache: Cache = Cache()
6679
main = MainSettings()
80+
storage = Storage()
6781

6882
if main.check_hooks not in ["off", "warn", "raise"]:
6983
raise ValueError(f"Invalid value for check_hooks: {main.check_hooks}, expected one of ['off', 'warn', 'raise']")

0 commit comments

Comments
 (0)