solara-ui 1.41.0__py2.py3-none-any.whl → 1.43.0__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solara/__init__.py +1 -1
- solara/__main__.py +17 -6
- solara/_stores.py +189 -0
- solara/components/__init__.py +18 -1
- solara/components/component_vue.py +23 -0
- solara/components/datatable.py +4 -4
- solara/components/echarts.py +5 -2
- solara/components/echarts.vue +22 -5
- solara/components/file_drop.py +20 -0
- solara/components/input.py +21 -1
- solara/components/markdown.py +62 -17
- solara/components/misc.py +2 -2
- solara/components/spinner-solara.vue +2 -2
- solara/components/spinner.py +17 -2
- solara/hooks/use_reactive.py +8 -1
- solara/lab/components/__init__.py +1 -0
- solara/lab/components/chat.py +3 -3
- solara/lab/components/input_time.py +133 -0
- solara/lab/hooks/dataframe.py +1 -0
- solara/lab/utils/dataframe.py +11 -1
- solara/reactive.py +9 -3
- solara/server/app.py +63 -30
- solara/server/flask.py +12 -2
- solara/server/jupyter/server_extension.py +1 -0
- solara/server/kernel.py +52 -4
- solara/server/kernel_context.py +66 -7
- solara/server/patch.py +25 -29
- solara/server/qt.py +1 -1
- solara/server/server.py +15 -5
- solara/server/settings.py +11 -0
- solara/server/shell.py +19 -1
- solara/server/starlette.py +39 -11
- solara/server/static/solara_bootstrap.py +1 -1
- solara/settings.py +17 -0
- solara/tasks.py +18 -8
- solara/template/portal/pyproject.toml +1 -1
- solara/test/pytest_plugin.py +4 -0
- solara/toestand.py +170 -16
- solara/util.py +40 -0
- solara/website/components/docs.py +4 -0
- solara/website/components/markdown.py +60 -2
- solara/website/pages/changelog/changelog.md +17 -0
- solara/website/pages/documentation/advanced/content/20-understanding/50-solara-server.md +10 -0
- solara/website/pages/documentation/api/cross_filter/cross_filter_dataframe.py +4 -5
- solara/website/pages/documentation/api/cross_filter/cross_filter_report.py +3 -5
- solara/website/pages/documentation/api/cross_filter/cross_filter_select.py +3 -5
- solara/website/pages/documentation/api/cross_filter/cross_filter_slider.py +3 -5
- solara/website/pages/documentation/api/hooks/use_cross_filter.py +3 -5
- solara/website/pages/documentation/api/hooks/use_exception.py +9 -11
- solara/website/pages/documentation/api/hooks/use_previous.py +6 -9
- solara/website/pages/documentation/api/hooks/use_state_or_update.py +23 -26
- solara/website/pages/documentation/api/hooks/use_thread.py +11 -13
- solara/website/pages/documentation/api/routing/route.py +10 -12
- solara/website/pages/documentation/api/routing/use_route.py +26 -30
- solara/website/pages/documentation/api/utilities/on_kernel_start.py +17 -0
- solara/website/pages/documentation/components/advanced/link.py +6 -8
- solara/website/pages/documentation/components/advanced/meta.py +6 -9
- solara/website/pages/documentation/components/advanced/style.py +7 -9
- solara/website/pages/documentation/components/input/file_browser.py +12 -14
- solara/website/pages/documentation/components/input/input.py +22 -0
- solara/website/pages/documentation/components/lab/input_time.py +15 -0
- solara/website/pages/documentation/components/layout/columns_responsive.py +37 -39
- solara/website/pages/documentation/components/layout/gridfixed.py +4 -6
- solara/website/pages/documentation/components/output/html.py +1 -3
- solara/website/pages/documentation/components/page/head.py +4 -7
- solara/website/pages/documentation/components/viz/echarts.py +3 -1
- solara/website/pages/documentation/examples/__init__.py +9 -0
- solara/website/pages/documentation/examples/ai/chatbot.py +60 -44
- solara/website/pages/documentation/examples/general/live_update.py +1 -0
- solara/website/pages/documentation/examples/general/pokemon_search.py +3 -3
- solara/website/pages/documentation/examples/visualization/linked_views.py +0 -3
- solara/website/pages/documentation/faq/content/99-faq.md +9 -0
- solara/website/pages/documentation/getting_started/content/00-quickstart.md +2 -2
- solara/website/pages/documentation/getting_started/content/01-introduction.md +1 -1
- solara/website/pages/documentation/getting_started/content/04-tutorials/_jupyter_dashboard_1.ipynb +23 -19
- solara/website/pages/documentation/getting_started/content/07-deploying/10-self-hosted.md +2 -2
- solara/website/pages/roadmap/roadmap.md +6 -0
- {solara_ui-1.41.0.dist-info → solara_ui-1.43.0.dist-info}/METADATA +9 -6
- {solara_ui-1.41.0.dist-info → solara_ui-1.43.0.dist-info}/RECORD +83 -80
- {solara_ui-1.41.0.dist-info → solara_ui-1.43.0.dist-info}/WHEEL +1 -1
- {solara_ui-1.41.0.data → solara_ui-1.43.0.data}/data/etc/jupyter/jupyter_notebook_config.d/solara.json +0 -0
- {solara_ui-1.41.0.data → solara_ui-1.43.0.data}/data/etc/jupyter/jupyter_server_config.d/solara.json +0 -0
- {solara_ui-1.41.0.dist-info → solara_ui-1.43.0.dist-info}/licenses/LICENSE +0 -0
solara/server/shell.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import atexit
|
|
1
2
|
import io
|
|
2
3
|
import sys
|
|
3
4
|
from binascii import b2a_base64
|
|
@@ -58,9 +59,12 @@ class SolaraDisplayPublisher(DisplayPublisher):
|
|
|
58
59
|
self,
|
|
59
60
|
data,
|
|
60
61
|
metadata=None,
|
|
62
|
+
source=None,
|
|
63
|
+
*, # Enforce keyword-only arguments to match DisplayPublisher.publish
|
|
61
64
|
transient=None,
|
|
62
65
|
update=False,
|
|
63
|
-
|
|
66
|
+
**kwargs, # Make sure we're compatible with DisplayPublisher.publish
|
|
67
|
+
) -> None:
|
|
64
68
|
"""Publish a display-data message
|
|
65
69
|
|
|
66
70
|
Parameters
|
|
@@ -180,10 +184,24 @@ class SolaraInteractiveShell(InteractiveShell):
|
|
|
180
184
|
history_manager = Any() # type: ignore
|
|
181
185
|
display_pub: SolaraDisplayPublisher
|
|
182
186
|
|
|
187
|
+
def __init__(self, *args, **kwargs):
|
|
188
|
+
super().__init__(*args, **kwargs)
|
|
189
|
+
atexit.unregister(self.atexit_operations)
|
|
190
|
+
|
|
191
|
+
if self.magics_manager:
|
|
192
|
+
magic = self.magics_manager.registry["ScriptMagics"]
|
|
193
|
+
atexit.unregister(magic.kill_bg_processes)
|
|
194
|
+
|
|
183
195
|
def set_parent(self, parent):
|
|
184
196
|
"""Tell the children about the parent message."""
|
|
185
197
|
self.display_pub.set_parent(parent)
|
|
186
198
|
|
|
199
|
+
def init_sys_modules(self):
|
|
200
|
+
pass # don't create a __main__, it will cause a mem leak
|
|
201
|
+
|
|
202
|
+
def init_prefilter(self):
|
|
203
|
+
pass # avoid consuming memory
|
|
204
|
+
|
|
187
205
|
def init_history(self):
|
|
188
206
|
self.history_manager = Mock() # type: ignore
|
|
189
207
|
|
solara/server/starlette.py
CHANGED
|
@@ -15,6 +15,7 @@ import anyio
|
|
|
15
15
|
import starlette.websockets
|
|
16
16
|
import uvicorn.server
|
|
17
17
|
import websockets.legacy.http
|
|
18
|
+
import websockets.exceptions
|
|
18
19
|
|
|
19
20
|
from solara.server.utils import path_is_child_of
|
|
20
21
|
|
|
@@ -86,8 +87,8 @@ prefix = ""
|
|
|
86
87
|
# Since starlette seems to accept really large values for http, lets do the same for websockets
|
|
87
88
|
# An arbitrarily large value we settled on for now is 32kb
|
|
88
89
|
# If we don't do this, users with many cookies will fail to get a websocket connection.
|
|
89
|
-
|
|
90
|
-
if
|
|
90
|
+
ws_major_version = int(websockets.__version__.split(".")[0])
|
|
91
|
+
if ws_major_version >= 13:
|
|
91
92
|
websockets.legacy.http.MAX_LINE_LENGTH = int(os.environ.get("WEBSOCKETS_MAX_LINE_LENGTH", str(1024 * 32))) # type: ignore
|
|
92
93
|
else:
|
|
93
94
|
websockets.legacy.http.MAX_LINE = 1024 * 32 # type: ignore
|
|
@@ -121,37 +122,55 @@ class WebsocketWrapper(websocket.WebsocketWrapper):
|
|
|
121
122
|
while len(self.to_send) > 0:
|
|
122
123
|
first = self.to_send.pop(0)
|
|
123
124
|
if isinstance(first, bytes):
|
|
124
|
-
await self.
|
|
125
|
+
await self._send_bytes_exc(first)
|
|
125
126
|
else:
|
|
126
|
-
await self.
|
|
127
|
+
await self._send_text_exc(first)
|
|
128
|
+
|
|
129
|
+
async def _send_bytes_exc(self, data: bytes):
|
|
130
|
+
# make sures we catch the starlette/websockets specific exception
|
|
131
|
+
# and re-raise it as a websocket.WebSocketDisconnect
|
|
132
|
+
try:
|
|
133
|
+
await self.ws.send_bytes(data)
|
|
134
|
+
except (websockets.exceptions.ConnectionClosed, starlette.websockets.WebSocketDisconnect, RuntimeError) as e:
|
|
135
|
+
# starlette throws a RuntimeError once you call send after the connection is closed
|
|
136
|
+
raise websocket.WebSocketDisconnect() from e
|
|
137
|
+
|
|
138
|
+
async def _send_text_exc(self, data: str):
|
|
139
|
+
# make sures we catch the starlette/websockets specific exception
|
|
140
|
+
# and re-raise it as a websocket.WebSocketDisconnect
|
|
141
|
+
try:
|
|
142
|
+
await self.ws.send_text(data)
|
|
143
|
+
except (websockets.exceptions.ConnectionClosed, starlette.websockets.WebSocketDisconnect, RuntimeError) as e:
|
|
144
|
+
# starlette throws a RuntimeError once you call send after the connection is closed
|
|
145
|
+
raise websocket.WebSocketDisconnect() from e
|
|
127
146
|
|
|
128
147
|
def close(self):
|
|
129
148
|
if self.portal is None:
|
|
130
149
|
asyncio.ensure_future(self.ws.close())
|
|
131
150
|
else:
|
|
132
|
-
self.portal.call(self.ws.close)
|
|
151
|
+
self.portal.call(self.ws.close)
|
|
133
152
|
|
|
134
153
|
def send_text(self, data: str) -> None:
|
|
135
154
|
if self.portal is None:
|
|
136
|
-
task = self.event_loop.create_task(self.
|
|
155
|
+
task = self.event_loop.create_task(self._send_text_exc(data))
|
|
137
156
|
self.tasks.add(task)
|
|
138
157
|
task.add_done_callback(self.tasks.discard)
|
|
139
158
|
else:
|
|
140
159
|
if settings.main.experimental_performance:
|
|
141
160
|
self.to_send.append(data)
|
|
142
161
|
else:
|
|
143
|
-
self.portal.call(self.
|
|
162
|
+
self.portal.call(self._send_text_exc, data)
|
|
144
163
|
|
|
145
164
|
def send_bytes(self, data: bytes) -> None:
|
|
146
165
|
if self.portal is None:
|
|
147
|
-
task = self.event_loop.create_task(self.
|
|
166
|
+
task = self.event_loop.create_task(self._send_bytes_exc(data))
|
|
148
167
|
self.tasks.add(task)
|
|
149
168
|
task.add_done_callback(self.tasks.discard)
|
|
150
169
|
else:
|
|
151
170
|
if settings.main.experimental_performance:
|
|
152
171
|
self.to_send.append(data)
|
|
153
172
|
else:
|
|
154
|
-
self.portal.call(self.
|
|
173
|
+
self.portal.call(self._send_bytes_exc, data)
|
|
155
174
|
|
|
156
175
|
async def receive(self):
|
|
157
176
|
if self.portal is None:
|
|
@@ -159,9 +178,9 @@ class WebsocketWrapper(websocket.WebsocketWrapper):
|
|
|
159
178
|
else:
|
|
160
179
|
if hasattr(self.portal, "start_task_soon"):
|
|
161
180
|
# version 3+
|
|
162
|
-
fut = self.portal.start_task_soon(self.ws.receive)
|
|
181
|
+
fut = self.portal.start_task_soon(self.ws.receive)
|
|
163
182
|
else:
|
|
164
|
-
fut = self.portal.spawn_task(self.ws.receive)
|
|
183
|
+
fut = self.portal.spawn_task(self.ws.receive)
|
|
165
184
|
|
|
166
185
|
message = await asyncio.wrap_future(fut)
|
|
167
186
|
if "text" in message:
|
|
@@ -444,6 +463,7 @@ See also https://solara.dev/documentation/getting_started/deploying/self-hosted
|
|
|
444
463
|
session_id = request.cookies.get(server.COOKIE_KEY_SESSION_ID) or str(uuid4())
|
|
445
464
|
samesite = "lax"
|
|
446
465
|
secure = False
|
|
466
|
+
httponly = settings.session.http_only
|
|
447
467
|
# we want samesite, so we can set a cookie when embedded in an iframe, such as on huggingface
|
|
448
468
|
# however, samesite=none requires Secure https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
|
|
449
469
|
# when hosted on the localhost domain we can always set the Secure flag
|
|
@@ -469,6 +489,7 @@ Also check out the following Solara documentation:
|
|
|
469
489
|
expires="Fri, 01 Jan 2038 00:00:00 GMT",
|
|
470
490
|
samesite=samesite, # type: ignore
|
|
471
491
|
secure=secure, # type: ignore
|
|
492
|
+
httponly=httponly, # type: ignore
|
|
472
493
|
) # type: ignore
|
|
473
494
|
return response
|
|
474
495
|
|
|
@@ -549,12 +570,19 @@ class StaticCdn(StaticFilesOptionalAuth):
|
|
|
549
570
|
|
|
550
571
|
|
|
551
572
|
def on_startup():
|
|
573
|
+
appmod.ensure_apps_initialized()
|
|
552
574
|
# TODO: configure and set max number of threads
|
|
553
575
|
# see https://github.com/encode/starlette/issues/1724
|
|
554
576
|
telemetry.server_start()
|
|
555
577
|
|
|
556
578
|
|
|
557
579
|
def on_shutdown():
|
|
580
|
+
# shutdown all kernels
|
|
581
|
+
for context in list(kernel_context.contexts.values()):
|
|
582
|
+
try:
|
|
583
|
+
context.close()
|
|
584
|
+
except: # noqa
|
|
585
|
+
logger.exception("error closing kernel on shutdown")
|
|
558
586
|
telemetry.server_stop()
|
|
559
587
|
|
|
560
588
|
|
|
@@ -119,7 +119,7 @@ async def main():
|
|
|
119
119
|
]
|
|
120
120
|
for dep in requirements:
|
|
121
121
|
await micropip.install(dep, keep_going=True)
|
|
122
|
-
await micropip.install("/wheels/solara-1.
|
|
122
|
+
await micropip.install("/wheels/solara-1.43.0-py2.py3-none-any.whl", keep_going=True)
|
|
123
123
|
import solara
|
|
124
124
|
|
|
125
125
|
el = solara.Warning("lala")
|
solara/settings.py
CHANGED
|
@@ -54,6 +54,9 @@ class Assets(BaseSettings):
|
|
|
54
54
|
|
|
55
55
|
class MainSettings(BaseSettings):
|
|
56
56
|
check_hooks: str = "warn"
|
|
57
|
+
allow_reactive_boolean: bool = True
|
|
58
|
+
# TODO: also change default_container in solara/components/__init__.py
|
|
59
|
+
default_container: Optional[str] = "Column"
|
|
57
60
|
|
|
58
61
|
class Config:
|
|
59
62
|
env_prefix = "solara_"
|
|
@@ -61,9 +64,23 @@ class MainSettings(BaseSettings):
|
|
|
61
64
|
env_file = ".env"
|
|
62
65
|
|
|
63
66
|
|
|
67
|
+
class Storage(BaseSettings):
|
|
68
|
+
mutation_detection: Optional[bool] = None # True/False, or None to auto determine
|
|
69
|
+
factory: str = "solara.toestand.default_storage"
|
|
70
|
+
|
|
71
|
+
def get_factory(self):
|
|
72
|
+
return solara.util.import_item(self.factory)
|
|
73
|
+
|
|
74
|
+
class Config:
|
|
75
|
+
env_prefix = "solara_storage_"
|
|
76
|
+
case_sensitive = False
|
|
77
|
+
env_file = ".env"
|
|
78
|
+
|
|
79
|
+
|
|
64
80
|
assets: Assets = Assets()
|
|
65
81
|
cache: Cache = Cache()
|
|
66
82
|
main = MainSettings()
|
|
83
|
+
storage = Storage()
|
|
67
84
|
|
|
68
85
|
if main.check_hooks not in ["off", "warn", "raise"]:
|
|
69
86
|
raise ValueError(f"Invalid value for check_hooks: {main.check_hooks}, expected one of ['off', 'warn', 'raise']")
|
solara/tasks.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import sys
|
|
1
2
|
import abc
|
|
2
3
|
import asyncio
|
|
3
4
|
import dataclasses
|
|
@@ -27,6 +28,12 @@ from solara.toestand import Singleton
|
|
|
27
28
|
|
|
28
29
|
from .toestand import Ref as ref
|
|
29
30
|
|
|
31
|
+
if sys.version_info >= (3, 8):
|
|
32
|
+
from typing import Literal
|
|
33
|
+
else:
|
|
34
|
+
from typing_extensions import Literal
|
|
35
|
+
|
|
36
|
+
|
|
30
37
|
R = TypeVar("R")
|
|
31
38
|
T = TypeVar("T")
|
|
32
39
|
P = typing_extensions.ParamSpec("P")
|
|
@@ -686,24 +693,27 @@ def task(
|
|
|
686
693
|
return wrapper(f)
|
|
687
694
|
|
|
688
695
|
|
|
696
|
+
# Quotes around Task[...] are needed in Python <= 3.9, since ParamSpec doesn't properly support non-type arguments
|
|
697
|
+
# i.e. [] is taken as a value instead of a type
|
|
698
|
+
# See https://github.com/python/typing_extensions/issues/126 and related issues
|
|
689
699
|
@overload
|
|
690
700
|
def use_task(
|
|
691
701
|
f: None = None,
|
|
692
702
|
*,
|
|
693
|
-
dependencies: None = ...,
|
|
703
|
+
dependencies: Literal[None] = ...,
|
|
694
704
|
raise_error=...,
|
|
695
705
|
prefer_threaded=...,
|
|
696
|
-
) -> Callable[[Callable[
|
|
706
|
+
) -> Callable[[Callable[[], R]], "Task[[], R]"]: ...
|
|
697
707
|
|
|
698
708
|
|
|
699
709
|
@overload
|
|
700
710
|
def use_task(
|
|
701
|
-
f: Callable[
|
|
711
|
+
f: Callable[[], R],
|
|
702
712
|
*,
|
|
703
|
-
dependencies: None = ...,
|
|
713
|
+
dependencies: Literal[None] = ...,
|
|
704
714
|
raise_error=...,
|
|
705
715
|
prefer_threaded=...,
|
|
706
|
-
) -> Task[
|
|
716
|
+
) -> "Task[[], R]": ...
|
|
707
717
|
|
|
708
718
|
|
|
709
719
|
@overload
|
|
@@ -727,12 +737,12 @@ def use_task(
|
|
|
727
737
|
|
|
728
738
|
|
|
729
739
|
def use_task(
|
|
730
|
-
f: Union[None, Callable[
|
|
740
|
+
f: Union[None, Callable[[], R]] = None,
|
|
731
741
|
*,
|
|
732
742
|
dependencies: Union[None, List] = [],
|
|
733
743
|
raise_error=True,
|
|
734
744
|
prefer_threaded=True,
|
|
735
|
-
) -> Union[Callable[[Callable[
|
|
745
|
+
) -> Union[Callable[[Callable[[], R]], "Task[[], R]"], "Task[[], R]"]:
|
|
736
746
|
"""A hook that runs a function or coroutine function as a task and returns the result.
|
|
737
747
|
|
|
738
748
|
Allows you to run code in the background, with the UI available to the user. This is useful for long running tasks,
|
|
@@ -811,7 +821,7 @@ def use_task(
|
|
|
811
821
|
"""
|
|
812
822
|
|
|
813
823
|
def wrapper(f):
|
|
814
|
-
def create_task() -> Task[
|
|
824
|
+
def create_task() -> "Task[[], R]":
|
|
815
825
|
return task(f, prefer_threaded=prefer_threaded)
|
|
816
826
|
|
|
817
827
|
task_instance = solara.use_memo(create_task, dependencies=[])
|
solara/test/pytest_plugin.py
CHANGED
|
@@ -150,6 +150,7 @@ def solara_app(solara_server):
|
|
|
150
150
|
solara.server.app.apps["__default__"].close()
|
|
151
151
|
if isinstance(app, str):
|
|
152
152
|
app = solara.server.app.AppScript(app)
|
|
153
|
+
app.init()
|
|
153
154
|
used_app = app
|
|
154
155
|
solara.server.app.apps["__default__"] = app
|
|
155
156
|
try:
|
|
@@ -515,6 +516,8 @@ def create_runner_solara(solara_server, solara_app, page_session: "playwright.sy
|
|
|
515
516
|
|
|
516
517
|
def run(f: Callable, locals={}):
|
|
517
518
|
nonlocal count
|
|
519
|
+
from IPython.display import clear_output
|
|
520
|
+
|
|
518
521
|
path = Path(f.__code__.co_filename)
|
|
519
522
|
cwd = str(path.parent)
|
|
520
523
|
current_dir = os.getcwd()
|
|
@@ -523,6 +526,7 @@ def create_runner_solara(solara_server, solara_app, page_session: "playwright.sy
|
|
|
523
526
|
|
|
524
527
|
sys.path.append(cwd)
|
|
525
528
|
try:
|
|
529
|
+
clear_output()
|
|
526
530
|
f()
|
|
527
531
|
finally:
|
|
528
532
|
os.chdir(current_dir)
|
solara/toestand.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import contextlib
|
|
2
2
|
import dataclasses
|
|
3
|
+
import inspect
|
|
3
4
|
import logging
|
|
5
|
+
import os
|
|
4
6
|
import sys
|
|
5
7
|
import threading
|
|
8
|
+
from types import FrameType
|
|
6
9
|
import warnings
|
|
10
|
+
import copy
|
|
7
11
|
from abc import ABC, abstractmethod
|
|
8
12
|
from collections import defaultdict
|
|
9
13
|
from operator import getitem
|
|
@@ -24,9 +28,10 @@ from typing import (
|
|
|
24
28
|
|
|
25
29
|
import react_ipywidgets as react
|
|
26
30
|
import reacton.core
|
|
27
|
-
from
|
|
31
|
+
from solara.util import equals_extra
|
|
28
32
|
|
|
29
33
|
import solara
|
|
34
|
+
import solara.settings
|
|
30
35
|
from solara import _using_solara_server
|
|
31
36
|
|
|
32
37
|
T = TypeVar("T")
|
|
@@ -61,7 +66,7 @@ def use_sync_external_store(subscribe: Callable[[Callable[[], None]], Callable[[
|
|
|
61
66
|
|
|
62
67
|
def on_store_change(_ignore_new_state=None):
|
|
63
68
|
new_state = get_snapshot()
|
|
64
|
-
if not
|
|
69
|
+
if not equals_extra(new_state, prev_state.current):
|
|
65
70
|
prev_state.current = new_state
|
|
66
71
|
force_update()
|
|
67
72
|
|
|
@@ -87,11 +92,43 @@ def merge_state(d1: S, **kwargs) -> S:
|
|
|
87
92
|
|
|
88
93
|
|
|
89
94
|
class ValueBase(Generic[T]):
|
|
90
|
-
def __init__(self, merge: Callable = merge_state):
|
|
95
|
+
def __init__(self, merge: Callable = merge_state, equals=equals_extra):
|
|
91
96
|
self.merge = merge
|
|
97
|
+
self.equals = equals
|
|
92
98
|
self.listeners: Dict[str, Set[Tuple[Callable[[T], None], Optional[ContextManager]]]] = defaultdict(set)
|
|
93
99
|
self.listeners2: Dict[str, Set[Tuple[Callable[[T, T], None], Optional[ContextManager]]]] = defaultdict(set)
|
|
94
100
|
|
|
101
|
+
# make sure all boolean operations give type errors
|
|
102
|
+
if not solara.settings.main.allow_reactive_boolean:
|
|
103
|
+
|
|
104
|
+
def __bool__(self):
|
|
105
|
+
raise TypeError("Reactive vars are not allowed in boolean expressions, did you mean to use .value?")
|
|
106
|
+
|
|
107
|
+
def __eq__(self, other):
|
|
108
|
+
raise TypeError(f"'==' not supported between a Reactive and {other.__class__.__name__}, did you mean to use .value?")
|
|
109
|
+
|
|
110
|
+
def __ne__(self, other):
|
|
111
|
+
raise TypeError(f"'!=' not supported between a Reactive and {other.__class__.__name__}, did you mean to use .value?")
|
|
112
|
+
|
|
113
|
+
# If we explicitly define __eq__, we need to explicitly define __hash__ as well
|
|
114
|
+
# Otherwise our class is marked unhashable
|
|
115
|
+
__hash__ = object.__hash__
|
|
116
|
+
|
|
117
|
+
def __lt__(self, other):
|
|
118
|
+
raise TypeError(f"'<' not supported between a Reactive and {other.__class__.__name__}, did you mean to use .value?")
|
|
119
|
+
|
|
120
|
+
def __le__(self, other):
|
|
121
|
+
raise TypeError(f"'<=' not supported between a Reactive and {other.__class__.__name__}, did you mean to use .value?")
|
|
122
|
+
|
|
123
|
+
def __gt__(self, other):
|
|
124
|
+
raise TypeError(f"'>' not supported between a Reactive and {other.__class__.__name__}, did you mean to use .value?")
|
|
125
|
+
|
|
126
|
+
def __ge__(self, other):
|
|
127
|
+
raise TypeError(f"'>=' not supported between a Reactive and {other.__class__.__name__}, did you mean to use .value?")
|
|
128
|
+
|
|
129
|
+
def __len__(self):
|
|
130
|
+
raise TypeError("'len(...)' is not supported for a Reactive, did you mean to use .value?")
|
|
131
|
+
|
|
95
132
|
@property
|
|
96
133
|
def lock(self):
|
|
97
134
|
raise NotImplementedError
|
|
@@ -199,8 +236,8 @@ class KernelStore(ValueBase[S], ABC):
|
|
|
199
236
|
_type_counter: Dict[Any, int] = defaultdict(int)
|
|
200
237
|
scope_lock = threading.RLock()
|
|
201
238
|
|
|
202
|
-
def __init__(self, key=None):
|
|
203
|
-
super().__init__()
|
|
239
|
+
def __init__(self, key=None, equals: Callable[[Any, Any], bool] = equals_extra):
|
|
240
|
+
super().__init__(equals=equals)
|
|
204
241
|
self.storage_key = key
|
|
205
242
|
self._global_dict = {}
|
|
206
243
|
# since a set can trigger events, which can trigger new updates, we need a recursive lock
|
|
@@ -250,7 +287,7 @@ class KernelStore(ValueBase[S], ABC):
|
|
|
250
287
|
def set(self, value: S):
|
|
251
288
|
scope_dict, scope_id = self._get_dict()
|
|
252
289
|
old = self.get()
|
|
253
|
-
if equals(old, value):
|
|
290
|
+
if self.equals(old, value):
|
|
254
291
|
return
|
|
255
292
|
scope_dict[self.storage_key] = value
|
|
256
293
|
|
|
@@ -268,23 +305,114 @@ class KernelStore(ValueBase[S], ABC):
|
|
|
268
305
|
def initial_value(self) -> S:
|
|
269
306
|
pass
|
|
270
307
|
|
|
308
|
+
def _check_mutation(self):
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _is_internal_module(file_name: str):
|
|
313
|
+
file_name_parts = file_name.split(os.sep)
|
|
314
|
+
if len(file_name_parts) < 2:
|
|
315
|
+
return False
|
|
316
|
+
return (
|
|
317
|
+
file_name_parts[-2:] == ["solara", "toestand.py"]
|
|
318
|
+
or file_name_parts[-2:] == ["solara", "reactive.py"]
|
|
319
|
+
or file_name_parts[-2:] == ["solara", "use_reactive.py"]
|
|
320
|
+
or file_name_parts[-2:] == ["reacton", "core.py"]
|
|
321
|
+
# If we use SomeClass[K](...) we go via the typing module, so we need to skip that as well
|
|
322
|
+
or (file_name_parts[-2].startswith("python") and file_name_parts[-1] == "typing.py")
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _find_outside_solara_frame() -> Optional[FrameType]:
|
|
327
|
+
# the module where the call stack origined from
|
|
328
|
+
current_frame: Optional[FrameType] = None
|
|
329
|
+
module_frame = None
|
|
330
|
+
|
|
331
|
+
# _getframe is not guaranteed to exist in all Python implementations,
|
|
332
|
+
# but is much faster than the inspect module
|
|
333
|
+
if hasattr(sys, "_getframe"):
|
|
334
|
+
current_frame = sys._getframe(1)
|
|
335
|
+
else:
|
|
336
|
+
current_frame = inspect.currentframe()
|
|
337
|
+
|
|
338
|
+
while current_frame is not None:
|
|
339
|
+
file_name = current_frame.f_code.co_filename
|
|
340
|
+
# Skip most common cases, i.e. toestand.py, reactive.py, use_reactive.py, Reacton's core.py, and the typing module
|
|
341
|
+
if not _is_internal_module(file_name):
|
|
342
|
+
module_frame = current_frame
|
|
343
|
+
break
|
|
344
|
+
current_frame = current_frame.f_back
|
|
345
|
+
|
|
346
|
+
return module_frame
|
|
347
|
+
|
|
271
348
|
|
|
272
349
|
class KernelStoreValue(KernelStore[S]):
|
|
273
350
|
default_value: S
|
|
351
|
+
_traceback: Optional[inspect.Traceback]
|
|
352
|
+
_default_value_copy: Optional[S]
|
|
274
353
|
|
|
275
|
-
def __init__(self, default_value: S, key=None):
|
|
354
|
+
def __init__(self, default_value: S, key=None, equals: Callable[[Any, Any], bool] = equals_extra, unwrap=lambda x: x):
|
|
276
355
|
self.default_value = default_value
|
|
356
|
+
self._unwrap = unwrap
|
|
357
|
+
self.equals = equals
|
|
358
|
+
self._mutation_detection = solara.settings.storage.mutation_detection
|
|
359
|
+
if self._mutation_detection:
|
|
360
|
+
frame = _find_outside_solara_frame()
|
|
361
|
+
if frame is not None:
|
|
362
|
+
self._traceback = inspect.getframeinfo(frame)
|
|
363
|
+
else:
|
|
364
|
+
self._traceback = None
|
|
365
|
+
self._default_value_copy = copy.deepcopy(default_value)
|
|
366
|
+
if not self.equals(self._unwrap(self.default_value), self._unwrap(self._default_value_copy)):
|
|
367
|
+
msg = """The equals function for this reactive value returned False when comparing a deepcopy to itself.
|
|
368
|
+
|
|
369
|
+
This reactive variable will not be able to detect mutations correctly, and is therefore disabled.
|
|
370
|
+
|
|
371
|
+
To avoid this warning, and to ensure that mutation detection works correctly, please provide a better equals function to the reactive variable.
|
|
372
|
+
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.
|
|
373
|
+
|
|
374
|
+
Example:
|
|
375
|
+
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
|
|
376
|
+
reactive_df = solara.reactive(df, equals=solara.util.equals_pickle)
|
|
377
|
+
"""
|
|
378
|
+
tb = self._traceback
|
|
379
|
+
if tb:
|
|
380
|
+
if tb.code_context:
|
|
381
|
+
code = tb.code_context[0]
|
|
382
|
+
else:
|
|
383
|
+
code = "<No code context available>"
|
|
384
|
+
msg += "This warning was triggered from:\n" f"{tb.filename}:{tb.lineno}\n" f"{code}"
|
|
385
|
+
warnings.warn(msg)
|
|
386
|
+
self._mutation_detection = False
|
|
277
387
|
cls = type(default_value)
|
|
278
388
|
if key is None:
|
|
279
389
|
with KernelStoreValue.scope_lock:
|
|
280
390
|
index = self._type_counter[cls]
|
|
281
391
|
self._type_counter[cls] += 1
|
|
282
392
|
key = cls.__module__ + ":" + cls.__name__ + ":" + str(index)
|
|
283
|
-
super().__init__(key=key)
|
|
393
|
+
super().__init__(key=key, equals=equals)
|
|
284
394
|
|
|
285
395
|
def initial_value(self) -> S:
|
|
396
|
+
self._check_mutation()
|
|
286
397
|
return self.default_value
|
|
287
398
|
|
|
399
|
+
def _check_mutation(self):
|
|
400
|
+
if not self._mutation_detection:
|
|
401
|
+
return
|
|
402
|
+
initial = self._unwrap(self._default_value_copy)
|
|
403
|
+
current = self._unwrap(self.default_value)
|
|
404
|
+
if not self.equals(initial, current):
|
|
405
|
+
tb = self._traceback
|
|
406
|
+
if tb:
|
|
407
|
+
if tb.code_context:
|
|
408
|
+
code = tb.code_context[0].strip()
|
|
409
|
+
else:
|
|
410
|
+
code = "No code context available"
|
|
411
|
+
msg = f"Reactive variable was initialized at {tb.filename}:{tb.lineno} with {initial!r}, but was mutated to {current!r}.\n" f"{code}"
|
|
412
|
+
else:
|
|
413
|
+
msg = f"Reactive variable was initialized with a value of {initial!r}, but was mutated to {current!r} (unable to report the location in the source code)."
|
|
414
|
+
raise ValueError(msg)
|
|
415
|
+
|
|
288
416
|
|
|
289
417
|
def _create_key_callable(f: Callable[[], S]):
|
|
290
418
|
try:
|
|
@@ -302,22 +430,48 @@ def _create_key_callable(f: Callable[[], S]):
|
|
|
302
430
|
|
|
303
431
|
|
|
304
432
|
class KernelStoreFactory(KernelStore[S]):
|
|
305
|
-
def __init__(self, factory: Callable[[], S], key=None):
|
|
433
|
+
def __init__(self, factory: Callable[[], S], key=None, equals: Callable[[Any, Any], bool] = equals_extra):
|
|
306
434
|
self.factory = factory
|
|
307
435
|
key = key or _create_key_callable(factory)
|
|
308
|
-
super().__init__(key=key)
|
|
436
|
+
super().__init__(key=key, equals=equals)
|
|
309
437
|
|
|
310
438
|
def initial_value(self) -> S:
|
|
311
439
|
return self.factory()
|
|
312
440
|
|
|
313
441
|
|
|
442
|
+
def mutation_detection_storage(default_value: S, key=None, equals=None) -> ValueBase[S]:
|
|
443
|
+
from solara.util import equals_pickle as default_equals
|
|
444
|
+
from ._stores import MutateDetectorStore, StoreValue, _PublicValueNotSet, _SetValueNotSet
|
|
445
|
+
|
|
446
|
+
kernel_store = KernelStoreValue[StoreValue[S]](
|
|
447
|
+
StoreValue[S](private=default_value, public=_PublicValueNotSet(), get_traceback=None, set_value=_SetValueNotSet(), set_traceback=None),
|
|
448
|
+
key=key,
|
|
449
|
+
unwrap=lambda x: x.private,
|
|
450
|
+
)
|
|
451
|
+
return MutateDetectorStore[S](kernel_store, equals=equals or default_equals)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def default_storage(default_value: S, key=None, equals=None) -> ValueBase[S]:
|
|
455
|
+
# in solara v2 we will also do this when mutation_detection is None
|
|
456
|
+
# and we do not run on production mode
|
|
457
|
+
if solara.settings.storage.mutation_detection is True:
|
|
458
|
+
return mutation_detection_storage(default_value, key=key, equals=equals)
|
|
459
|
+
else:
|
|
460
|
+
return KernelStoreValue[S](default_value, key=key, equals=equals or equals_extra)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _call_storage_factory(default_value: S, key=None, equals=None) -> ValueBase[S]:
|
|
464
|
+
factory = solara.settings.storage.get_factory()
|
|
465
|
+
return factory(default_value, key=key, equals=equals)
|
|
466
|
+
|
|
467
|
+
|
|
314
468
|
class Reactive(ValueBase[S]):
|
|
315
469
|
_storage: ValueBase[S]
|
|
316
470
|
|
|
317
|
-
def __init__(self, default_value: Union[S, ValueBase[S]], key=None):
|
|
471
|
+
def __init__(self, default_value: Union[S, ValueBase[S]], key=None, equals=None):
|
|
318
472
|
super().__init__()
|
|
319
473
|
if not isinstance(default_value, ValueBase):
|
|
320
|
-
self._storage =
|
|
474
|
+
self._storage = _call_storage_factory(default_value, key=key, equals=equals)
|
|
321
475
|
else:
|
|
322
476
|
self._storage = default_value
|
|
323
477
|
self.__post__init__()
|
|
@@ -505,11 +659,11 @@ def computed(
|
|
|
505
659
|
|
|
506
660
|
|
|
507
661
|
class ReactiveField(Reactive[T]):
|
|
508
|
-
def __init__(self, field: "FieldBase"):
|
|
662
|
+
def __init__(self, field: "FieldBase", equals: Callable[[Any, Any], bool] = equals_extra):
|
|
509
663
|
# super().__init__() # type: ignore
|
|
510
664
|
# We skip the Reactive constructor, because we do not need it, but we do
|
|
511
665
|
# want to be an instanceof for use in use_reactive
|
|
512
|
-
ValueBase.__init__(self)
|
|
666
|
+
ValueBase.__init__(self, equals=equals)
|
|
513
667
|
self._field = field
|
|
514
668
|
field = field
|
|
515
669
|
while not isinstance(field, ValueBase):
|
|
@@ -536,7 +690,7 @@ class ReactiveField(Reactive[T]):
|
|
|
536
690
|
except KeyError:
|
|
537
691
|
return # same
|
|
538
692
|
old_value = self._field.get(old)
|
|
539
|
-
if not equals(new_value, old_value):
|
|
693
|
+
if not self.equals(new_value, old_value):
|
|
540
694
|
listener(new_value)
|
|
541
695
|
|
|
542
696
|
return self._root.subscribe_change(on_change, scope=scope)
|
|
@@ -550,7 +704,7 @@ class ReactiveField(Reactive[T]):
|
|
|
550
704
|
except KeyError:
|
|
551
705
|
return # see subscribe
|
|
552
706
|
old_value = self._field.get(old)
|
|
553
|
-
if not equals(new_value, old_value):
|
|
707
|
+
if not self.equals(new_value, old_value):
|
|
554
708
|
listener(new_value, old_value)
|
|
555
709
|
|
|
556
710
|
return self._root.subscribe_change(on_change, scope=scope)
|
solara/util.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import base64
|
|
2
2
|
import contextlib
|
|
3
|
+
import functools
|
|
3
4
|
import gzip
|
|
4
5
|
import hashlib
|
|
5
6
|
import json
|
|
@@ -31,6 +32,28 @@ except RuntimeError:
|
|
|
31
32
|
has_threads = False
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
from reacton.utils import equals as equals_extra
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def equals_pickle(a, b):
|
|
39
|
+
"""Compare two values for equality.
|
|
40
|
+
|
|
41
|
+
Avoid false negative, e.g. when comparing dataframes, we want to compare
|
|
42
|
+
the data, not the object identity.
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
if equals_extra(a, b):
|
|
46
|
+
return True
|
|
47
|
+
import pickle
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
if pickle.dumps(a) == pickle.dumps(b):
|
|
51
|
+
return True
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
34
57
|
def github_url(file):
|
|
35
58
|
rel_path = os.path.relpath(file, Path(solara.__file__).parent.parent)
|
|
36
59
|
github_url = solara.github_url + f"/blob/{solara.git_branch}/" + rel_path
|
|
@@ -306,3 +329,20 @@ def is_running_in_vscode():
|
|
|
306
329
|
|
|
307
330
|
def is_running_in_voila():
|
|
308
331
|
return os.environ.get("SERVER_SOFTWARE", "").startswith("voila")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def once(f):
|
|
335
|
+
called = False
|
|
336
|
+
return_value = None
|
|
337
|
+
|
|
338
|
+
@functools.wraps(f)
|
|
339
|
+
def wrapper():
|
|
340
|
+
nonlocal called
|
|
341
|
+
nonlocal return_value
|
|
342
|
+
if called:
|
|
343
|
+
return return_value
|
|
344
|
+
called = True
|
|
345
|
+
return_value = f()
|
|
346
|
+
return return_value
|
|
347
|
+
|
|
348
|
+
return wrapper
|