solara-ui 1.40.0__py2.py3-none-any.whl → 1.42.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 +30 -11
- solara/_stores.py +185 -0
- solara/components/component_vue.py +26 -2
- solara/components/echarts.py +5 -2
- solara/components/echarts.vue +22 -5
- solara/components/file_drop.py +20 -0
- solara/components/input.py +16 -0
- solara/components/markdown.py +22 -13
- solara/components/spinner-solara.vue +2 -2
- solara/components/spinner.py +17 -2
- solara/hooks/use_reactive.py +8 -1
- solara/reactive.py +9 -3
- solara/server/assets/style.css +2 -0
- solara/server/kernel.py +2 -1
- solara/server/qt.py +113 -0
- solara/server/settings.py +1 -0
- solara/server/starlette.py +2 -2
- solara/server/static/main-vuetify.js +10 -0
- solara/server/static/solara_bootstrap.py +1 -1
- solara/server/templates/loader-solara.html +1 -1
- solara/server/templates/solara.html.j2 +6 -1
- solara/settings.py +14 -0
- solara/template/portal/pyproject.toml +1 -1
- solara/test/pytest_plugin.py +3 -0
- solara/toestand.py +139 -16
- solara/util.py +22 -0
- solara/website/components/markdown.py +45 -1
- solara/website/components/sidebar.py +3 -1
- solara/website/pages/__init__.py +13 -7
- solara/website/pages/changelog/changelog.md +9 -0
- solara/website/pages/documentation/advanced/content/20-understanding/40-routing.md +17 -1
- 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/utilities/on_kernel_start.py +17 -0
- solara/website/pages/documentation/components/input/input.py +22 -0
- solara/website/pages/documentation/components/viz/echarts.py +3 -1
- solara/website/pages/documentation/examples/__init__.py +13 -21
- solara/website/pages/documentation/examples/ai/chatbot.py +1 -1
- solara/website/pages/documentation/examples/general/pokemon_search.py +3 -3
- solara/website/pages/documentation/examples/general/vue_component.py +1 -1
- solara/website/pages/documentation/examples/libraries/altair.py +1 -0
- solara/website/pages/documentation/examples/libraries/bqplot.py +1 -1
- solara/website/pages/documentation/examples/libraries/ipyleaflet.py +1 -1
- solara/website/pages/documentation/examples/libraries/ipyleaflet_advanced.py +1 -1
- solara/website/pages/documentation/examples/utilities/countdown_timer.py +18 -20
- solara/website/pages/documentation/examples/visualization/annotator.py +1 -3
- solara/website/pages/documentation/examples/visualization/linked_views.py +3 -6
- solara/website/pages/documentation/getting_started/content/00-quickstart.md +19 -1
- solara/website/pages/documentation/getting_started/content/01-introduction.md +1 -1
- solara/website/pages/roadmap/roadmap.md +3 -0
- solara/widgets/vue/navigator.vue +46 -16
- solara/widgets/vue/vegalite.vue +18 -0
- {solara_ui-1.40.0.dist-info → solara_ui-1.42.0.dist-info}/METADATA +8 -5
- {solara_ui-1.40.0.dist-info → solara_ui-1.42.0.dist-info}/RECORD +66 -64
- {solara_ui-1.40.0.dist-info → solara_ui-1.42.0.dist-info}/WHEEL +1 -1
- {solara_ui-1.40.0.data → solara_ui-1.42.0.data}/data/etc/jupyter/jupyter_notebook_config.d/solara.json +0 -0
- {solara_ui-1.40.0.data → solara_ui-1.42.0.data}/data/etc/jupyter/jupyter_server_config.d/solara.json +0 -0
- {solara_ui-1.40.0.dist-info → solara_ui-1.42.0.dist-info}/licenses/LICENSE +0 -0
solara/components/spinner.py
CHANGED
|
@@ -8,12 +8,15 @@ class SpinnerSolaraWidget(ipyvue.VueTemplate):
|
|
|
8
8
|
template_file = (__file__, "spinner-solara.vue")
|
|
9
9
|
|
|
10
10
|
size = traitlets.Unicode("64px").tag(sync=True)
|
|
11
|
+
color_back = traitlets.Unicode("#FFCF64").tag(sync=True)
|
|
12
|
+
color_front = traitlets.Unicode("#FF8C3E").tag(sync=True)
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
@solara.component
|
|
14
|
-
def SpinnerSolara(size="64px"):
|
|
16
|
+
def SpinnerSolara(size="64px", color_back="#FFCF64", color_front="#FF8C3E"):
|
|
15
17
|
"""Spinner component with the Solara logo to indicate the app is busy.
|
|
16
18
|
|
|
19
|
+
## Examples
|
|
17
20
|
### Basic example
|
|
18
21
|
|
|
19
22
|
```solara
|
|
@@ -24,7 +27,19 @@ def SpinnerSolara(size="64px"):
|
|
|
24
27
|
solara.SpinnerSolara(size="100px")
|
|
25
28
|
```
|
|
26
29
|
|
|
30
|
+
## Changing the colors
|
|
31
|
+
```solara
|
|
32
|
+
import solara
|
|
33
|
+
|
|
34
|
+
@solara.component
|
|
35
|
+
def Page():
|
|
36
|
+
solara.SpinnerSolara(size="100px", color_back="Grey", color_front="Lime")
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
|
|
27
40
|
## Arguments
|
|
28
41
|
* `size`: Size of the spinner.
|
|
42
|
+
* `color_back`: Color of the spinner in the background.
|
|
43
|
+
* `color_front`: Color of the spinner in the foreground.
|
|
29
44
|
"""
|
|
30
|
-
return SpinnerSolaraWidget.element(size=size)
|
|
45
|
+
return SpinnerSolaraWidget.element(size=size, color_back=color_back, color_front=color_front)
|
solara/hooks/use_reactive.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Callable, Optional, TypeVar, Union
|
|
1
|
+
from typing import Any, Callable, Optional, TypeVar, Union
|
|
2
2
|
|
|
3
3
|
import solara
|
|
4
4
|
|
|
@@ -8,6 +8,7 @@ T = TypeVar("T")
|
|
|
8
8
|
def use_reactive(
|
|
9
9
|
value: Union[T, solara.Reactive[T]],
|
|
10
10
|
on_change: Optional[Callable[[T], None]] = None,
|
|
11
|
+
equals: Callable[[Any, Any], bool] = solara.util.equals_extra,
|
|
11
12
|
) -> solara.Reactive[T]:
|
|
12
13
|
"""Creates a reactive variable with the a local component scope.
|
|
13
14
|
|
|
@@ -44,6 +45,12 @@ def use_reactive(
|
|
|
44
45
|
* on_change (Optional[Callable[[T], None]]): An optional callback function
|
|
45
46
|
that will be called when the reactive variable's value changes.
|
|
46
47
|
|
|
48
|
+
* equals: A function that returns True if two values are considered equal, and False otherwise.
|
|
49
|
+
The default function is `solara.util.equals`, which performs a deep comparison of the two values
|
|
50
|
+
and is more forgiving than the default `==` operator.
|
|
51
|
+
You can provide a custom function if you need to define a different notion of equality.
|
|
52
|
+
|
|
53
|
+
|
|
47
54
|
Returns:
|
|
48
55
|
solara.Reactive[T]: A reactive variable with the specified initial value
|
|
49
56
|
or the provided reactive variable.
|
solara/reactive.py
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
from typing import TypeVar
|
|
1
|
+
from typing import Any, Callable, TypeVar
|
|
2
2
|
|
|
3
3
|
from solara.toestand import Reactive
|
|
4
|
+
import solara.util
|
|
4
5
|
|
|
5
6
|
__all__ = ["reactive", "Reactive"]
|
|
6
7
|
|
|
7
8
|
T = TypeVar("T")
|
|
8
9
|
|
|
9
10
|
|
|
10
|
-
def reactive(value: T) -> Reactive[T]:
|
|
11
|
+
def reactive(value: T, equals: Callable[[Any, Any], bool] = solara.util.equals_extra) -> Reactive[T]:
|
|
11
12
|
"""Creates a new Reactive object with the given initial value.
|
|
12
13
|
|
|
13
14
|
Reactive objects are mostly used to manage global or application-wide state in
|
|
@@ -35,6 +36,11 @@ def reactive(value: T) -> Reactive[T]:
|
|
|
35
36
|
|
|
36
37
|
Args:
|
|
37
38
|
value (T): The initial value of the reactive variable.
|
|
39
|
+
equals: A function that returns 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
|
+
|
|
38
44
|
|
|
39
45
|
Returns:
|
|
40
46
|
Reactive[T]: A new Reactive object with the specified initial value.
|
|
@@ -90,4 +96,4 @@ def reactive(value: T) -> Reactive[T]:
|
|
|
90
96
|
Whenever the counter value changes, `CounterDisplay` automatically updates to display the new value.
|
|
91
97
|
|
|
92
98
|
"""
|
|
93
|
-
return Reactive(value)
|
|
99
|
+
return Reactive(value, equals=equals)
|
solara/server/assets/style.css
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
html {
|
|
2
2
|
/* override vuetify's css reset ress.css */
|
|
3
3
|
overflow-y: auto;
|
|
4
|
+
scroll-behavior: smooth;
|
|
4
5
|
}
|
|
5
6
|
|
|
6
7
|
.jupyter-widgets code {
|
|
@@ -124,6 +125,7 @@ div.highlight {
|
|
|
124
125
|
|
|
125
126
|
.solara-autorouter-content {
|
|
126
127
|
height: 100%;
|
|
128
|
+
max-width: 100%;
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
/* originally from index.css */
|
solara/server/kernel.py
CHANGED
|
@@ -2,6 +2,7 @@ import json
|
|
|
2
2
|
import logging
|
|
3
3
|
import pdb
|
|
4
4
|
import queue
|
|
5
|
+
import re
|
|
5
6
|
import struct
|
|
6
7
|
import warnings
|
|
7
8
|
from binascii import b2a_base64
|
|
@@ -73,7 +74,7 @@ def json_dumps(data):
|
|
|
73
74
|
)
|
|
74
75
|
|
|
75
76
|
|
|
76
|
-
ipykernel_version = tuple(map(int,
|
|
77
|
+
ipykernel_version = tuple(map(int, re.split(r"\D+", ipykernel.__version__)[:3]))
|
|
77
78
|
if ipykernel_version >= (6, 18, 0):
|
|
78
79
|
import comm.base_comm
|
|
79
80
|
|
solara/server/qt.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import List
|
|
3
|
+
import webbrowser
|
|
4
|
+
from qtpy.QtWidgets import QApplication
|
|
5
|
+
from qtpy.QtWebEngineWidgets import QWebEngineView
|
|
6
|
+
from qtpy.QtWebChannel import QWebChannel
|
|
7
|
+
from qtpy import QtCore, QtGui
|
|
8
|
+
import signal
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
HERE = Path(__file__).parent
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# setUrlRequestInterceptor, navigationRequested and acceptNavigationRequest
|
|
15
|
+
# all trigger the websocket to disconnect, so we need to block cross origin
|
|
16
|
+
# requests on the frontend/browser side by intercepting clicks on links
|
|
17
|
+
|
|
18
|
+
cross_origin_block_js = """
|
|
19
|
+
var script = document.createElement('script');
|
|
20
|
+
script.src = 'qrc:///qtwebchannel/qwebchannel.js';
|
|
21
|
+
document.head.appendChild(script);
|
|
22
|
+
script.onload = function() {
|
|
23
|
+
new QWebChannel(qt.webChannelTransport, function(channel) {
|
|
24
|
+
let py_callback = channel.objects.py_callback;
|
|
25
|
+
|
|
26
|
+
document.addEventListener('click', function(event) {
|
|
27
|
+
let target = event.target;
|
|
28
|
+
while (target && target.tagName !== 'A') {
|
|
29
|
+
target = target.parentNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (target && target.tagName === 'A') {
|
|
33
|
+
const linkOrigin = new URL(target.href).origin;
|
|
34
|
+
const currentOrigin = window.location.origin;
|
|
35
|
+
|
|
36
|
+
if (linkOrigin !== currentOrigin) {
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
console.log("Blocked cross-origin navigation to:", target.href);
|
|
39
|
+
py_callback.open_link(target.href); // Call Python method
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}, true);
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PyCallback(QtCore.QObject):
|
|
49
|
+
@QtCore.Slot(str)
|
|
50
|
+
def open_link(self, url):
|
|
51
|
+
webbrowser.open(url)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class QWebEngineViewWithPopup(QWebEngineView):
|
|
55
|
+
# keep a strong reference to all windows
|
|
56
|
+
windows: List = []
|
|
57
|
+
|
|
58
|
+
def __init__(self):
|
|
59
|
+
super().__init__()
|
|
60
|
+
self.page().newWindowRequested.connect(self.handle_new_window_request)
|
|
61
|
+
|
|
62
|
+
# Set up WebChannel and py_callback object
|
|
63
|
+
self.py_callback = PyCallback()
|
|
64
|
+
self.channel = QWebChannel()
|
|
65
|
+
self.channel.registerObject("py_callback", self.py_callback)
|
|
66
|
+
self.page().setWebChannel(self.channel)
|
|
67
|
+
|
|
68
|
+
self.loadFinished.connect(self._inject_javascript)
|
|
69
|
+
|
|
70
|
+
def _inject_javascript(self, ok):
|
|
71
|
+
self.page().runJavaScript(cross_origin_block_js)
|
|
72
|
+
|
|
73
|
+
def handle_new_window_request(self, info):
|
|
74
|
+
webview = QWebEngineViewWithPopup()
|
|
75
|
+
geometry = info.requestedGeometry()
|
|
76
|
+
webview.resize(geometry.width(), geometry.height())
|
|
77
|
+
webview.setUrl(info.requestedUrl())
|
|
78
|
+
webview.show()
|
|
79
|
+
QWebEngineViewWithPopup.windows.append(webview)
|
|
80
|
+
return webview
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run_qt(url):
|
|
84
|
+
app = QApplication(["Solara App"])
|
|
85
|
+
web = QWebEngineViewWithPopup()
|
|
86
|
+
web.setUrl(QtCore.QUrl(url))
|
|
87
|
+
web.resize(1024, 1024)
|
|
88
|
+
web.show()
|
|
89
|
+
|
|
90
|
+
app_name = "Solara"
|
|
91
|
+
app.setApplicationDisplayName(app_name)
|
|
92
|
+
app.setApplicationName(app_name)
|
|
93
|
+
web.setWindowTitle(app_name)
|
|
94
|
+
app.setWindowIcon(QtGui.QIcon(str(HERE.parent / "website/public/logo.svg")))
|
|
95
|
+
if sys.platform.startswith("darwin"):
|
|
96
|
+
# Set app name, if PyObjC is installed
|
|
97
|
+
# Python 2 has PyObjC preinstalled
|
|
98
|
+
# Python 3: pip3 install pyobjc-framework-Cocoa
|
|
99
|
+
try:
|
|
100
|
+
from Foundation import NSBundle
|
|
101
|
+
|
|
102
|
+
bundle = NSBundle.mainBundle()
|
|
103
|
+
if bundle:
|
|
104
|
+
app_info = bundle.localizedInfoDictionary() or bundle.infoDictionary()
|
|
105
|
+
if app_info is not None:
|
|
106
|
+
app_info["CFBundleName"] = app_name
|
|
107
|
+
app_info["CFBundleDisplayName"] = app_name
|
|
108
|
+
except ModuleNotFoundError:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
# without this, ctrl-c does not work in the terminal
|
|
112
|
+
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
113
|
+
app.exec_()
|
solara/server/settings.py
CHANGED
solara/server/starlette.py
CHANGED
|
@@ -86,8 +86,8 @@ prefix = ""
|
|
|
86
86
|
# Since starlette seems to accept really large values for http, lets do the same for websockets
|
|
87
87
|
# An arbitrarily large value we settled on for now is 32kb
|
|
88
88
|
# If we don't do this, users with many cookies will fail to get a websocket connection.
|
|
89
|
-
|
|
90
|
-
if
|
|
89
|
+
ws_major_version = int(websockets.__version__.split(".")[0])
|
|
90
|
+
if ws_major_version >= 13:
|
|
91
91
|
websockets.legacy.http.MAX_LINE_LENGTH = int(os.environ.get("WEBSOCKETS_MAX_LINE_LENGTH", str(1024 * 32))) # type: ignore
|
|
92
92
|
else:
|
|
93
93
|
websockets.legacy.http.MAX_LINE = 1024 * 32 # type: ignore
|
|
@@ -138,15 +138,25 @@ async function solaraInit(mountId, appName) {
|
|
|
138
138
|
});
|
|
139
139
|
|
|
140
140
|
window.addEventListener('solara.router', function (event) {
|
|
141
|
+
app.$data.urlHasChanged = true;
|
|
141
142
|
if(kernel.status == 'busy') {
|
|
142
143
|
app.$data.loadingPage = true;
|
|
143
144
|
}
|
|
144
145
|
});
|
|
145
146
|
kernel.statusChanged.connect(() => {
|
|
147
|
+
// When navigation is triggered from the front-end, kernel.status becoming busy and
|
|
148
|
+
// solara.router event happen in a different order than when navigating through Python, so
|
|
149
|
+
// if the URL has changed when the kernel becomes busy, we set loadingPage to true
|
|
150
|
+
if (kernel.status == 'busy' && app.$data.urlHasChanged) {
|
|
151
|
+
app.$data.loadingPage = true;
|
|
152
|
+
}
|
|
146
153
|
// the first idle after a loadingPage == true (a router event)
|
|
147
154
|
// will be used as indicator that the page is loaded
|
|
148
155
|
if (app.$data.loadingPage && kernel.status == 'idle') {
|
|
149
156
|
app.$data.loadingPage = false;
|
|
157
|
+
app.$data.urlHasChanged = false;
|
|
158
|
+
const event = new Event('solara.pageReady');
|
|
159
|
+
window.dispatchEvent(event);
|
|
150
160
|
}
|
|
151
161
|
});
|
|
152
162
|
|
|
@@ -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.42.0-py2.py3-none-any.whl", keep_going=True)
|
|
123
123
|
import solara
|
|
124
124
|
|
|
125
125
|
el = solara.Warning("lala")
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
<jupyter-widget-mount-point mount-id="solara-main">
|
|
33
33
|
A widget with mount-id="solara-main" should go here
|
|
34
34
|
</jupyter-widget-mount-point>
|
|
35
|
-
<div style="position: absolute; right: 0px; bottom: 0px; padding: 10px;">
|
|
35
|
+
<div v-if="showBanner" style="position: absolute; right: 0px; bottom: 0px; padding: 10px;">
|
|
36
36
|
<b>This website runs on <a href="https://solara.dev">Solara</a></b>
|
|
37
37
|
</div>
|
|
38
38
|
</div>
|
|
@@ -172,7 +172,7 @@
|
|
|
172
172
|
</script>
|
|
173
173
|
{% endraw -%}
|
|
174
174
|
|
|
175
|
-
<body data-base-url="{{root_path}}/
|
|
175
|
+
<body data-base-url="{{root_path}}/jupyter/">
|
|
176
176
|
{% if perform_check %}
|
|
177
177
|
<iframe src="https://solara.dev/static/public/success.html?system=solara&check=html&version={{solara_version}}" style="display: none"></iframe>
|
|
178
178
|
{% endif %}
|
|
@@ -296,6 +296,10 @@
|
|
|
296
296
|
this.forceUpdateTrigger += 1;
|
|
297
297
|
original_$forceUpdate();
|
|
298
298
|
});
|
|
299
|
+
// in case we are showing a popop (e.g. using ipypopout), hide the banner
|
|
300
|
+
if(searchParams.has('modelid')) {
|
|
301
|
+
this.showBanner = false;
|
|
302
|
+
}
|
|
299
303
|
},
|
|
300
304
|
mounted() {
|
|
301
305
|
document.querySelector('#app').removeAttribute("style");
|
|
@@ -413,6 +417,7 @@
|
|
|
413
417
|
loadingPage: false,
|
|
414
418
|
_lastBusyTimer: null,
|
|
415
419
|
kernelBusyLong: false,
|
|
420
|
+
showBanner: theme.show_banner,
|
|
416
421
|
// outputMessages: [{ name: 'stderr', text: 'lala' }],
|
|
417
422
|
}
|
|
418
423
|
}
|
solara/settings.py
CHANGED
|
@@ -61,9 +61,23 @@ class MainSettings(BaseSettings):
|
|
|
61
61
|
env_file = ".env"
|
|
62
62
|
|
|
63
63
|
|
|
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
|
+
|
|
64
77
|
assets: Assets = Assets()
|
|
65
78
|
cache: Cache = Cache()
|
|
66
79
|
main = MainSettings()
|
|
80
|
+
storage = Storage()
|
|
67
81
|
|
|
68
82
|
if main.check_hooks not in ["off", "warn", "raise"]:
|
|
69
83
|
raise ValueError(f"Invalid value for check_hooks: {main.check_hooks}, expected one of ['off', 'warn', 'raise']")
|
solara/test/pytest_plugin.py
CHANGED
|
@@ -515,6 +515,8 @@ def create_runner_solara(solara_server, solara_app, page_session: "playwright.sy
|
|
|
515
515
|
|
|
516
516
|
def run(f: Callable, locals={}):
|
|
517
517
|
nonlocal count
|
|
518
|
+
from IPython.display import clear_output
|
|
519
|
+
|
|
518
520
|
path = Path(f.__code__.co_filename)
|
|
519
521
|
cwd = str(path.parent)
|
|
520
522
|
current_dir = os.getcwd()
|
|
@@ -523,6 +525,7 @@ def create_runner_solara(solara_server, solara_app, page_session: "playwright.sy
|
|
|
523
525
|
|
|
524
526
|
sys.path.append(cwd)
|
|
525
527
|
try:
|
|
528
|
+
clear_output()
|
|
526
529
|
f()
|
|
527
530
|
finally:
|
|
528
531
|
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,8 +92,9 @@ 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
|
|
|
@@ -199,8 +205,8 @@ class KernelStore(ValueBase[S], ABC):
|
|
|
199
205
|
_type_counter: Dict[Any, int] = defaultdict(int)
|
|
200
206
|
scope_lock = threading.RLock()
|
|
201
207
|
|
|
202
|
-
def __init__(self, key=None):
|
|
203
|
-
super().__init__()
|
|
208
|
+
def __init__(self, key=None, equals: Callable[[Any, Any], bool] = equals_extra):
|
|
209
|
+
super().__init__(equals=equals)
|
|
204
210
|
self.storage_key = key
|
|
205
211
|
self._global_dict = {}
|
|
206
212
|
# since a set can trigger events, which can trigger new updates, we need a recursive lock
|
|
@@ -250,7 +256,7 @@ class KernelStore(ValueBase[S], ABC):
|
|
|
250
256
|
def set(self, value: S):
|
|
251
257
|
scope_dict, scope_id = self._get_dict()
|
|
252
258
|
old = self.get()
|
|
253
|
-
if equals(old, value):
|
|
259
|
+
if self.equals(old, value):
|
|
254
260
|
return
|
|
255
261
|
scope_dict[self.storage_key] = value
|
|
256
262
|
|
|
@@ -268,23 +274,114 @@ class KernelStore(ValueBase[S], ABC):
|
|
|
268
274
|
def initial_value(self) -> S:
|
|
269
275
|
pass
|
|
270
276
|
|
|
277
|
+
def _check_mutation(self):
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _is_internal_module(file_name: str):
|
|
282
|
+
file_name_parts = file_name.split(os.sep)
|
|
283
|
+
if len(file_name_parts) < 2:
|
|
284
|
+
return False
|
|
285
|
+
return (
|
|
286
|
+
file_name_parts[-2:] == ["solara", "toestand.py"]
|
|
287
|
+
or file_name_parts[-2:] == ["solara", "reactive.py"]
|
|
288
|
+
or file_name_parts[-2:] == ["solara", "use_reactive.py"]
|
|
289
|
+
or file_name_parts[-2:] == ["reacton", "core.py"]
|
|
290
|
+
# If we use SomeClass[K](...) we go via the typing module, so we need to skip that as well
|
|
291
|
+
or (file_name_parts[-2].startswith("python") and file_name_parts[-1] == "typing.py")
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _find_outside_solara_frame() -> Optional[FrameType]:
|
|
296
|
+
# the module where the call stack origined from
|
|
297
|
+
current_frame: Optional[FrameType] = None
|
|
298
|
+
module_frame = None
|
|
299
|
+
|
|
300
|
+
# _getframe is not guaranteed to exist in all Python implementations,
|
|
301
|
+
# but is much faster than the inspect module
|
|
302
|
+
if hasattr(sys, "_getframe"):
|
|
303
|
+
current_frame = sys._getframe(1)
|
|
304
|
+
else:
|
|
305
|
+
current_frame = inspect.currentframe()
|
|
306
|
+
|
|
307
|
+
while current_frame is not None:
|
|
308
|
+
file_name = current_frame.f_code.co_filename
|
|
309
|
+
# Skip most common cases, i.e. toestand.py, reactive.py, use_reactive.py, Reacton's core.py, and the typing module
|
|
310
|
+
if not _is_internal_module(file_name):
|
|
311
|
+
module_frame = current_frame
|
|
312
|
+
break
|
|
313
|
+
current_frame = current_frame.f_back
|
|
314
|
+
|
|
315
|
+
return module_frame
|
|
316
|
+
|
|
271
317
|
|
|
272
318
|
class KernelStoreValue(KernelStore[S]):
|
|
273
319
|
default_value: S
|
|
320
|
+
_traceback: Optional[inspect.Traceback]
|
|
321
|
+
_default_value_copy: Optional[S]
|
|
274
322
|
|
|
275
|
-
def __init__(self, default_value: S, key=None):
|
|
323
|
+
def __init__(self, default_value: S, key=None, equals: Callable[[Any, Any], bool] = equals_extra, unwrap=lambda x: x):
|
|
276
324
|
self.default_value = default_value
|
|
325
|
+
self._unwrap = unwrap
|
|
326
|
+
self.equals = equals
|
|
327
|
+
self._mutation_detection = solara.settings.storage.mutation_detection
|
|
328
|
+
if self._mutation_detection:
|
|
329
|
+
frame = _find_outside_solara_frame()
|
|
330
|
+
if frame is not None:
|
|
331
|
+
self._traceback = inspect.getframeinfo(frame)
|
|
332
|
+
else:
|
|
333
|
+
self._traceback = None
|
|
334
|
+
self._default_value_copy = copy.deepcopy(default_value)
|
|
335
|
+
if not self.equals(self._unwrap(self.default_value), self._unwrap(self._default_value_copy)):
|
|
336
|
+
msg = """The equals function for this reactive value returned False when comparing a deepcopy to itself.
|
|
337
|
+
|
|
338
|
+
This reactive variable will not be able to detect mutations correctly, and is therefore disabled.
|
|
339
|
+
|
|
340
|
+
To avoid this warning, and to ensure that mutation detection works correctly, please provide a better equals function to the reactive variable.
|
|
341
|
+
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.
|
|
342
|
+
|
|
343
|
+
Example:
|
|
344
|
+
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
|
|
345
|
+
reactive_df = solara.reactive(df, equals=solara.util.equals_pickle)
|
|
346
|
+
"""
|
|
347
|
+
tb = self._traceback
|
|
348
|
+
if tb:
|
|
349
|
+
if tb.code_context:
|
|
350
|
+
code = tb.code_context[0]
|
|
351
|
+
else:
|
|
352
|
+
code = "<No code context available>"
|
|
353
|
+
msg += "This warning was triggered from:\n" f"{tb.filename}:{tb.lineno}\n" f"{code}"
|
|
354
|
+
warnings.warn(msg)
|
|
355
|
+
self._mutation_detection = False
|
|
277
356
|
cls = type(default_value)
|
|
278
357
|
if key is None:
|
|
279
358
|
with KernelStoreValue.scope_lock:
|
|
280
359
|
index = self._type_counter[cls]
|
|
281
360
|
self._type_counter[cls] += 1
|
|
282
361
|
key = cls.__module__ + ":" + cls.__name__ + ":" + str(index)
|
|
283
|
-
super().__init__(key=key)
|
|
362
|
+
super().__init__(key=key, equals=equals)
|
|
284
363
|
|
|
285
364
|
def initial_value(self) -> S:
|
|
365
|
+
self._check_mutation()
|
|
286
366
|
return self.default_value
|
|
287
367
|
|
|
368
|
+
def _check_mutation(self):
|
|
369
|
+
if not self._mutation_detection:
|
|
370
|
+
return
|
|
371
|
+
initial = self._unwrap(self._default_value_copy)
|
|
372
|
+
current = self._unwrap(self.default_value)
|
|
373
|
+
if not self.equals(initial, current):
|
|
374
|
+
tb = self._traceback
|
|
375
|
+
if tb:
|
|
376
|
+
if tb.code_context:
|
|
377
|
+
code = tb.code_context[0].strip()
|
|
378
|
+
else:
|
|
379
|
+
code = "No code context available"
|
|
380
|
+
msg = f"Reactive variable was initialized at {tb.filename}:{tb.lineno} with {initial!r}, but was mutated to {current!r}.\n" f"{code}"
|
|
381
|
+
else:
|
|
382
|
+
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)."
|
|
383
|
+
raise ValueError(msg)
|
|
384
|
+
|
|
288
385
|
|
|
289
386
|
def _create_key_callable(f: Callable[[], S]):
|
|
290
387
|
try:
|
|
@@ -302,22 +399,48 @@ def _create_key_callable(f: Callable[[], S]):
|
|
|
302
399
|
|
|
303
400
|
|
|
304
401
|
class KernelStoreFactory(KernelStore[S]):
|
|
305
|
-
def __init__(self, factory: Callable[[], S], key=None):
|
|
402
|
+
def __init__(self, factory: Callable[[], S], key=None, equals: Callable[[Any, Any], bool] = equals_extra):
|
|
306
403
|
self.factory = factory
|
|
307
404
|
key = key or _create_key_callable(factory)
|
|
308
|
-
super().__init__(key=key)
|
|
405
|
+
super().__init__(key=key, equals=equals)
|
|
309
406
|
|
|
310
407
|
def initial_value(self) -> S:
|
|
311
408
|
return self.factory()
|
|
312
409
|
|
|
313
410
|
|
|
411
|
+
def mutation_detection_storage(default_value: S, key=None, equals=None) -> ValueBase[S]:
|
|
412
|
+
from solara.util import equals_pickle as default_equals
|
|
413
|
+
from ._stores import MutateDetectorStore, StoreValue, _PublicValueNotSet
|
|
414
|
+
|
|
415
|
+
kernel_store = KernelStoreValue[StoreValue[S]](
|
|
416
|
+
StoreValue[S](private=default_value, public=_PublicValueNotSet(), get_traceback=None, set_value=None, set_traceback=None),
|
|
417
|
+
key=key,
|
|
418
|
+
unwrap=lambda x: x.private,
|
|
419
|
+
)
|
|
420
|
+
return MutateDetectorStore[S](kernel_store, equals=equals or default_equals)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def default_storage(default_value: S, key=None, equals=None) -> ValueBase[S]:
|
|
424
|
+
# in solara v2 we will also do this when mutation_detection is None
|
|
425
|
+
# and we do not run on production mode
|
|
426
|
+
if solara.settings.storage.mutation_detection is True:
|
|
427
|
+
return mutation_detection_storage(default_value, key=key, equals=equals)
|
|
428
|
+
else:
|
|
429
|
+
return KernelStoreValue[S](default_value, key=key, equals=equals or equals_extra)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _call_storage_factory(default_value: S, key=None, equals=None) -> ValueBase[S]:
|
|
433
|
+
factory = solara.settings.storage.get_factory()
|
|
434
|
+
return factory(default_value, key=key, equals=equals)
|
|
435
|
+
|
|
436
|
+
|
|
314
437
|
class Reactive(ValueBase[S]):
|
|
315
438
|
_storage: ValueBase[S]
|
|
316
439
|
|
|
317
|
-
def __init__(self, default_value: Union[S, ValueBase[S]], key=None):
|
|
440
|
+
def __init__(self, default_value: Union[S, ValueBase[S]], key=None, equals=None):
|
|
318
441
|
super().__init__()
|
|
319
442
|
if not isinstance(default_value, ValueBase):
|
|
320
|
-
self._storage =
|
|
443
|
+
self._storage = _call_storage_factory(default_value, key=key, equals=equals)
|
|
321
444
|
else:
|
|
322
445
|
self._storage = default_value
|
|
323
446
|
self.__post__init__()
|
|
@@ -505,11 +628,11 @@ def computed(
|
|
|
505
628
|
|
|
506
629
|
|
|
507
630
|
class ReactiveField(Reactive[T]):
|
|
508
|
-
def __init__(self, field: "FieldBase"):
|
|
631
|
+
def __init__(self, field: "FieldBase", equals: Callable[[Any, Any], bool] = equals_extra):
|
|
509
632
|
# super().__init__() # type: ignore
|
|
510
633
|
# We skip the Reactive constructor, because we do not need it, but we do
|
|
511
634
|
# want to be an instanceof for use in use_reactive
|
|
512
|
-
ValueBase.__init__(self)
|
|
635
|
+
ValueBase.__init__(self, equals=equals)
|
|
513
636
|
self._field = field
|
|
514
637
|
field = field
|
|
515
638
|
while not isinstance(field, ValueBase):
|
|
@@ -536,7 +659,7 @@ class ReactiveField(Reactive[T]):
|
|
|
536
659
|
except KeyError:
|
|
537
660
|
return # same
|
|
538
661
|
old_value = self._field.get(old)
|
|
539
|
-
if not equals(new_value, old_value):
|
|
662
|
+
if not self.equals(new_value, old_value):
|
|
540
663
|
listener(new_value)
|
|
541
664
|
|
|
542
665
|
return self._root.subscribe_change(on_change, scope=scope)
|
|
@@ -550,7 +673,7 @@ class ReactiveField(Reactive[T]):
|
|
|
550
673
|
except KeyError:
|
|
551
674
|
return # see subscribe
|
|
552
675
|
old_value = self._field.get(old)
|
|
553
|
-
if not equals(new_value, old_value):
|
|
676
|
+
if not self.equals(new_value, old_value):
|
|
554
677
|
listener(new_value, old_value)
|
|
555
678
|
|
|
556
679
|
return self._root.subscribe_change(on_change, scope=scope)
|