solara-ui 1.43.0__py2.py3-none-any.whl → 1.44.1__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 +2 -2
- solara/_stores.py +114 -6
- solara/cache.py +6 -4
- solara/checks.py +1 -1
- solara/components/select.py +1 -1
- solara/components/style.py +1 -1
- solara/hooks/use_reactive.py +16 -1
- solara/lab/components/chat.py +13 -7
- solara/server/app.py +4 -1
- solara/server/kernel_context.py +2 -2
- solara/server/patch.py +10 -1
- solara/server/server.py +1 -1
- solara/server/starlette.py +39 -9
- solara/server/static/solara_bootstrap.py +1 -1
- solara/tasks.py +12 -1
- solara/test/pytest_plugin.py +4 -3
- solara/toestand.py +86 -26
- solara/website/components/docs.py +20 -1
- solara/website/pages/changelog/changelog.md +32 -0
- solara/website/pages/documentation/advanced/content/10-howto/20-layout.md +1 -1
- solara/website/pages/documentation/advanced/content/30-enterprise/10-oauth.md +4 -2
- solara/website/pages/documentation/components/lab/theming.py +6 -4
- solara/website/pages/documentation/components/output/sql_code.py +23 -25
- solara/website/pages/documentation/components/page/title.py +12 -14
- solara/website/pages/documentation/components/status/error.py +17 -18
- solara/website/pages/documentation/components/status/info.py +17 -18
- solara/website/pages/documentation/examples/__init__.py +1 -0
- solara/website/pages/documentation/examples/ai/chatbot.py +3 -1
- solara/website/pages/documentation/examples/general/live_update.py +22 -29
- solara/website/pages/documentation/examples/general/pokemon_search.py +1 -1
- {solara_ui-1.43.0.dist-info → solara_ui-1.44.1.dist-info}/METADATA +1 -1
- {solara_ui-1.43.0.dist-info → solara_ui-1.44.1.dist-info}/RECORD +37 -37
- {solara_ui-1.43.0.data → solara_ui-1.44.1.data}/data/etc/jupyter/jupyter_notebook_config.d/solara.json +0 -0
- {solara_ui-1.43.0.data → solara_ui-1.44.1.data}/data/etc/jupyter/jupyter_server_config.d/solara.json +0 -0
- {solara_ui-1.43.0.dist-info → solara_ui-1.44.1.dist-info}/WHEEL +0 -0
- {solara_ui-1.43.0.dist-info → solara_ui-1.44.1.dist-info}/licenses/LICENSE +0 -0
solara/__init__.py
CHANGED
solara/__main__.py
CHANGED
|
@@ -156,7 +156,7 @@ if "SOLARA_MODE" in os.environ:
|
|
|
156
156
|
"--restart-dir",
|
|
157
157
|
"restart_dirs",
|
|
158
158
|
multiple=True,
|
|
159
|
-
help="Set restart directories explicitly, instead of using the current working
|
|
159
|
+
help="Set restart directories explicitly, instead of using the current working directory.",
|
|
160
160
|
type=click.Path(exists=True),
|
|
161
161
|
)
|
|
162
162
|
@click.option(
|
|
@@ -172,7 +172,7 @@ if "SOLARA_MODE" in os.environ:
|
|
|
172
172
|
"--workers",
|
|
173
173
|
default=None,
|
|
174
174
|
type=int,
|
|
175
|
-
help="Number of worker processes. Defaults to the $WEB_CONCURRENCY environment
|
|
175
|
+
help="Number of worker processes. Defaults to the $WEB_CONCURRENCY environment variable if available, or 1. Not valid with --auto-restart/-a.",
|
|
176
176
|
)
|
|
177
177
|
@click.option(
|
|
178
178
|
"--env-file",
|
solara/_stores.py
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import copy
|
|
2
2
|
import dataclasses
|
|
3
3
|
import inspect
|
|
4
|
-
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
from typing import Callable, ContextManager, Generic, Optional, Union, cast, Any
|
|
5
7
|
import warnings
|
|
6
|
-
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
from .toestand import ValueBase, S, _find_outside_solara_frame, _DEBUG
|
|
11
|
+
|
|
7
12
|
import solara.util
|
|
13
|
+
import solara.settings
|
|
8
14
|
|
|
9
15
|
|
|
10
16
|
class _PublicValueNotSet:
|
|
@@ -25,7 +31,7 @@ class StoreValue(Generic[S]):
|
|
|
25
31
|
|
|
26
32
|
|
|
27
33
|
class MutateDetectorStore(ValueBase[S]):
|
|
28
|
-
def __init__(self, store:
|
|
34
|
+
def __init__(self, store: ValueBase[StoreValue[S]], equals=solara.util.equals_extra):
|
|
29
35
|
self._storage = store
|
|
30
36
|
self._enabled = True
|
|
31
37
|
super().__init__(equals=equals)
|
|
@@ -81,7 +87,7 @@ class MutateDetectorStore(ValueBase[S]):
|
|
|
81
87
|
code = tb.code_context[0]
|
|
82
88
|
else:
|
|
83
89
|
code = "<No code context available>"
|
|
84
|
-
msg += f"The last value was read in the following code:\n
|
|
90
|
+
msg += f"The last value was read in the following code:\n{tb.filename}:{tb.lineno}\n{code}"
|
|
85
91
|
raise ValueError(msg)
|
|
86
92
|
elif not isinstance(store_value.set_value, _SetValueNotSet) and not self.equals(store_value.set_value, store_value.private):
|
|
87
93
|
tb = store_value.set_traceback
|
|
@@ -114,7 +120,7 @@ Good (if you want to keep mutating your own list):
|
|
|
114
120
|
code = tb.code_context[0]
|
|
115
121
|
else:
|
|
116
122
|
code = "<No code context available>"
|
|
117
|
-
msg += "The last time the value was set was at:\n
|
|
123
|
+
msg += f"The last time the value was set was at:\n{tb.filename}:{tb.lineno}\n{code}"
|
|
118
124
|
raise ValueError(msg)
|
|
119
125
|
|
|
120
126
|
def _ensure_public_exists(self):
|
|
@@ -158,7 +164,7 @@ reactive_df = solara.reactive(df, equals=solara.util.equals_pickle)
|
|
|
158
164
|
code = tb.code_context[0]
|
|
159
165
|
else:
|
|
160
166
|
code = "<No code context available>"
|
|
161
|
-
warn += "This warning was triggered from:\n
|
|
167
|
+
warn += f"This warning was triggered from:\n{tb.filename}:{tb.lineno}\n{code}"
|
|
162
168
|
warnings.warn(warn)
|
|
163
169
|
self._enabled = False
|
|
164
170
|
|
|
@@ -187,3 +193,105 @@ reactive_df = solara.reactive(df, equals=solara.util.equals_pickle)
|
|
|
187
193
|
listener(new_value, previous_value)
|
|
188
194
|
|
|
189
195
|
return self._storage.subscribe_change(listener_wrapper, scope=scope)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class SharedStore(ValueBase[S]):
|
|
199
|
+
"""Stores a single value, not kernel scoped."""
|
|
200
|
+
|
|
201
|
+
_traceback: Optional[inspect.Traceback]
|
|
202
|
+
_original_ref: Optional[S]
|
|
203
|
+
_original_ref_copy: Optional[S]
|
|
204
|
+
|
|
205
|
+
def __init__(self, value: S, equals: Callable[[Any, Any], bool] = solara.util.equals_extra, unwrap=lambda x: x):
|
|
206
|
+
# since a set can trigger events, which can trigger new updates, we need a recursive lock
|
|
207
|
+
self._lock = threading.RLock()
|
|
208
|
+
self.local = threading.local()
|
|
209
|
+
self.equals = equals
|
|
210
|
+
|
|
211
|
+
self._value = value
|
|
212
|
+
self._original_ref = None
|
|
213
|
+
self._original_ref_copy = None
|
|
214
|
+
self._unwrap = unwrap
|
|
215
|
+
self._mutation_detection = solara.settings.storage.mutation_detection
|
|
216
|
+
if self._mutation_detection:
|
|
217
|
+
frame = _find_outside_solara_frame()
|
|
218
|
+
if frame is not None:
|
|
219
|
+
self._traceback = inspect.getframeinfo(frame)
|
|
220
|
+
else:
|
|
221
|
+
self._traceback = None
|
|
222
|
+
self._original_ref = value
|
|
223
|
+
self._original_ref_copy = copy.deepcopy(self._original_ref)
|
|
224
|
+
if not self.equals(self._unwrap(self._original_ref), self._unwrap(self._original_ref_copy)):
|
|
225
|
+
msg = """The equals function for this reactive value returned False when comparing a deepcopy to itself.
|
|
226
|
+
|
|
227
|
+
This reactive variable will not be able to detect mutations correctly, and is therefore disabled.
|
|
228
|
+
|
|
229
|
+
To avoid this warning, and to ensure that mutation detection works correctly, please provide a better equals function to the reactive variable.
|
|
230
|
+
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.
|
|
231
|
+
|
|
232
|
+
Example:
|
|
233
|
+
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
|
|
234
|
+
reactive_df = solara.reactive(df, equals=solara.util.equals_pickle)
|
|
235
|
+
"""
|
|
236
|
+
tb = self._traceback
|
|
237
|
+
if tb:
|
|
238
|
+
if tb.code_context:
|
|
239
|
+
code = tb.code_context[0]
|
|
240
|
+
else:
|
|
241
|
+
code = "<No code context available>"
|
|
242
|
+
msg += f"This warning was triggered from:\n{tb.filename}:{tb.lineno}\n{code}"
|
|
243
|
+
warnings.warn(msg)
|
|
244
|
+
self._mutation_detection = False
|
|
245
|
+
super().__init__(equals=equals)
|
|
246
|
+
|
|
247
|
+
def _check_mutation(self):
|
|
248
|
+
if not self._mutation_detection:
|
|
249
|
+
return
|
|
250
|
+
current = self._unwrap(self._original_ref)
|
|
251
|
+
initial = self._unwrap(self._original_ref_copy)
|
|
252
|
+
if not self.equals(initial, current):
|
|
253
|
+
tb = self._traceback
|
|
254
|
+
if tb:
|
|
255
|
+
if tb.code_context:
|
|
256
|
+
code = tb.code_context[0].strip()
|
|
257
|
+
else:
|
|
258
|
+
code = "No code context available"
|
|
259
|
+
msg = f"Reactive variable was initialized at {tb.filename}:{tb.lineno} with {initial!r}, but was mutated to {current!r}.\n{code}"
|
|
260
|
+
else:
|
|
261
|
+
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)."
|
|
262
|
+
raise ValueError(msg)
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def lock(self):
|
|
266
|
+
return self._lock
|
|
267
|
+
|
|
268
|
+
def peek(self):
|
|
269
|
+
self._check_mutation()
|
|
270
|
+
return self._value
|
|
271
|
+
|
|
272
|
+
def get(self):
|
|
273
|
+
self._check_mutation()
|
|
274
|
+
return self._value
|
|
275
|
+
|
|
276
|
+
def clear(self):
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
def _get_scope_key(self):
|
|
280
|
+
return "GLOBAL"
|
|
281
|
+
|
|
282
|
+
def set(self, value: S):
|
|
283
|
+
self._check_mutation()
|
|
284
|
+
old = self.get()
|
|
285
|
+
if self.equals(old, value):
|
|
286
|
+
return
|
|
287
|
+
self._value = value
|
|
288
|
+
|
|
289
|
+
if _DEBUG:
|
|
290
|
+
import traceback
|
|
291
|
+
|
|
292
|
+
traceback.print_stack(limit=17, file=sys.stdout)
|
|
293
|
+
|
|
294
|
+
print("change old", old) # noqa
|
|
295
|
+
print("change new", value) # noqa
|
|
296
|
+
|
|
297
|
+
self.fire(value, old)
|
solara/cache.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from collections.abc import Hashable
|
|
1
2
|
import hashlib
|
|
2
3
|
import inspect
|
|
3
4
|
import logging
|
|
@@ -50,6 +51,7 @@ if has_cachetools:
|
|
|
50
51
|
class Memory(cachetools.LRUCache):
|
|
51
52
|
def __init__(self, max_items=solara.settings.cache.memory_max_items):
|
|
52
53
|
super().__init__(maxsize=max_items)
|
|
54
|
+
|
|
53
55
|
else:
|
|
54
56
|
|
|
55
57
|
class Memory(dict): # type: ignore
|
|
@@ -64,7 +66,7 @@ def _default_key(*args, **kwargs):
|
|
|
64
66
|
|
|
65
67
|
|
|
66
68
|
class MemoizedFunction(Generic[P, R]):
|
|
67
|
-
def __init__(self, function: Callable[P, R], key: Callable[P,
|
|
69
|
+
def __init__(self, function: Callable[P, R], key: Callable[P, Hashable], storage: Optional[Storage], allow_nonlocals=False):
|
|
68
70
|
self.function = function
|
|
69
71
|
f: Callable = self.function
|
|
70
72
|
if not allow_nonlocals:
|
|
@@ -170,7 +172,7 @@ def memoize(
|
|
|
170
172
|
@overload
|
|
171
173
|
def memoize(
|
|
172
174
|
function: None = None,
|
|
173
|
-
key: Callable[P,
|
|
175
|
+
key: Callable[P, Hashable] = ...,
|
|
174
176
|
storage: Optional[Storage] = None,
|
|
175
177
|
allow_nonlocals=False,
|
|
176
178
|
) -> Callable[[Callable[P, R]], MemoizedFunction[P, R]]: ...
|
|
@@ -187,7 +189,7 @@ def memoize(
|
|
|
187
189
|
|
|
188
190
|
def memoize(
|
|
189
191
|
function: Union[None, Callable[P, R]] = None,
|
|
190
|
-
key: Union[None, Callable[P,
|
|
192
|
+
key: Union[None, Callable[P, Hashable]] = None,
|
|
191
193
|
storage: Optional[Storage] = None,
|
|
192
194
|
allow_nonlocals: bool = False,
|
|
193
195
|
) -> Union[Callable[[Callable[P, R]], MemoizedFunction[P, R]], MemoizedFunction[P, R]]:
|
|
@@ -249,7 +251,7 @@ def memoize(
|
|
|
249
251
|
def wrapper(func: Callable[P, R]) -> MemoizedFunction[P, R]:
|
|
250
252
|
return MemoizedFunction[P, R](
|
|
251
253
|
func,
|
|
252
|
-
cast(Callable[P,
|
|
254
|
+
cast(Callable[P, Hashable], key or _default_key),
|
|
253
255
|
storage,
|
|
254
256
|
allow_nonlocals,
|
|
255
257
|
)
|
solara/checks.py
CHANGED
|
@@ -164,7 +164,7 @@ def get_server_python_executable(silent: bool = False):
|
|
|
164
164
|
else:
|
|
165
165
|
python = pythons[0]
|
|
166
166
|
if not silent:
|
|
167
|
-
warnings.warn("Found multiple find servers:\n
|
|
167
|
+
warnings.warn(f"Found multiple find servers:\n{info}\nWe are assuming the server is running under Python executable: {python}")
|
|
168
168
|
else:
|
|
169
169
|
python = pythons[0]
|
|
170
170
|
return python
|
solara/components/select.py
CHANGED
solara/components/style.py
CHANGED
|
@@ -101,5 +101,5 @@ module.exports = {
|
|
|
101
101
|
{css_content}
|
|
102
102
|
</style>
|
|
103
103
|
"""
|
|
104
|
-
# using .key avoids
|
|
104
|
+
# using .key avoids reusing the template, which causes a flicker (due to ipyvue)
|
|
105
105
|
return v.VuetifyTemplate.element(template=template).key(key)
|
solara/hooks/use_reactive.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from typing import Any, Callable, Optional, TypeVar, Union
|
|
2
2
|
|
|
3
3
|
import solara
|
|
4
|
+
import solara.settings
|
|
4
5
|
|
|
5
6
|
T = TypeVar("T")
|
|
6
7
|
|
|
@@ -105,7 +106,21 @@ def use_reactive(
|
|
|
105
106
|
|
|
106
107
|
def create():
|
|
107
108
|
if not isinstance(value, solara.Reactive):
|
|
108
|
-
|
|
109
|
+
from solara._stores import SharedStore, MutateDetectorStore, StoreValue, _PublicValueNotSet, _SetValueNotSet
|
|
110
|
+
from solara.toestand import ValueBase
|
|
111
|
+
|
|
112
|
+
store: ValueBase[T]
|
|
113
|
+
|
|
114
|
+
if solara.settings.storage.mutation_detection is True:
|
|
115
|
+
shared_store = SharedStore[StoreValue[T]](
|
|
116
|
+
StoreValue[T](private=value, public=_PublicValueNotSet(), get_traceback=None, set_value=_SetValueNotSet(), set_traceback=None),
|
|
117
|
+
unwrap=lambda x: x.private,
|
|
118
|
+
)
|
|
119
|
+
store = MutateDetectorStore[T](shared_store, equals=equals)
|
|
120
|
+
else:
|
|
121
|
+
store = SharedStore(value, equals=equals)
|
|
122
|
+
|
|
123
|
+
return solara.Reactive(store)
|
|
109
124
|
|
|
110
125
|
reactive_value = solara.use_memo(create, dependencies=[])
|
|
111
126
|
if isinstance(value, solara.Reactive):
|
solara/lab/components/chat.py
CHANGED
|
@@ -32,7 +32,7 @@ def ChatBox(
|
|
|
32
32
|
if "overflow-y" not in style_flat:
|
|
33
33
|
style_flat += " overflow-y: auto;"
|
|
34
34
|
|
|
35
|
-
classes
|
|
35
|
+
classes = [*classes, "chat-box"]
|
|
36
36
|
with solara.Column(
|
|
37
37
|
style=style_flat,
|
|
38
38
|
classes=classes,
|
|
@@ -45,7 +45,10 @@ def ChatBox(
|
|
|
45
45
|
def ChatInput(
|
|
46
46
|
send_callback: Optional[Callable[[str], None]] = None,
|
|
47
47
|
disabled: bool = False,
|
|
48
|
+
disabled_input: bool = False,
|
|
49
|
+
disabled_send: bool = False,
|
|
48
50
|
style: Optional[Union[str, Dict[str, str]]] = None,
|
|
51
|
+
autofocus: bool = False,
|
|
49
52
|
input_text_style: Optional[Union[str, Dict[str, str]]] = None,
|
|
50
53
|
classes: List[str] = [],
|
|
51
54
|
input_text_classes: List[str] = [],
|
|
@@ -56,9 +59,11 @@ def ChatInput(
|
|
|
56
59
|
# Arguments
|
|
57
60
|
|
|
58
61
|
* `send_callback`: A callback function for when the user presses enter or clicks the send button taking the message as an argument.
|
|
59
|
-
* `disabled`:
|
|
60
|
-
|
|
62
|
+
* `disabled`: disable both input and send.
|
|
63
|
+
* `disabled_input`: Whether the input should be disabled. Useful for disabling messages while a chatbot is replying.
|
|
64
|
+
* `disabled_send`: Whether the send button should be disabled. Useful for disabling sending further messages while a chatbot is replying.
|
|
61
65
|
* `style`: CSS styles to apply to the `solara.Row` containing the input field and submit button. Either a string or a dictionary.
|
|
66
|
+
* `autofocus`: Determines if a component is to be autofocused or not (Default is False). Autofocus will occur during page load and only one component per page can have autofocus active.
|
|
62
67
|
* `input_text_style`: CSS styles to apply to the `InputText` part of the component. Either a string or a dictionary.
|
|
63
68
|
* `classes`: A list of CSS classes to apply to the component. Also applied to the container.
|
|
64
69
|
* `input_text_classes`: A list of CSS classes to apply to the `InputText` part of the component.
|
|
@@ -84,14 +89,15 @@ def ChatInput(
|
|
|
84
89
|
rounded=True,
|
|
85
90
|
filled=True,
|
|
86
91
|
hide_details=True,
|
|
92
|
+
autofocus=autofocus,
|
|
87
93
|
style_="flex-grow: 1;" + input_text_style_flat,
|
|
88
|
-
disabled=disabled,
|
|
94
|
+
disabled=disabled or disabled_input,
|
|
89
95
|
class_=" ".join(input_text_classes),
|
|
90
96
|
)
|
|
91
97
|
|
|
92
98
|
use_change(message_input, send, update_events=["keyup.enter"])
|
|
93
99
|
|
|
94
|
-
button = solara.v.Btn(color="primary", icon=True, children=[solara.v.Icon(children=["mdi-send"])], disabled=message == "")
|
|
100
|
+
button = solara.v.Btn(color="primary", icon=True, children=[solara.v.Icon(children=["mdi-send"])], disabled=message == "" or disabled or disabled_send)
|
|
95
101
|
|
|
96
102
|
use_change(button, send, update_events=["click"])
|
|
97
103
|
|
|
@@ -197,12 +203,12 @@ def ChatMessage(
|
|
|
197
203
|
.chat-message-{msg_uuid}.left{{
|
|
198
204
|
border-top-left-radius: 0;
|
|
199
205
|
background-color:var(--color);
|
|
200
|
-
{
|
|
206
|
+
{"margin-left: 10px !important;" if notch else ""}
|
|
201
207
|
}}
|
|
202
208
|
.chat-message-{msg_uuid}.right{{
|
|
203
209
|
border-top-right-radius: 0;
|
|
204
210
|
background-color:var(--color);
|
|
205
|
-
{
|
|
211
|
+
{"margin-right: 10px !important;" if notch else ""}
|
|
206
212
|
}}
|
|
207
213
|
{extra_styles}
|
|
208
214
|
"""
|
solara/server/app.py
CHANGED
|
@@ -90,6 +90,7 @@ class AppScript:
|
|
|
90
90
|
self.path = Path(spec.origin)
|
|
91
91
|
self.directory = self.path.parent
|
|
92
92
|
self._initialized = False
|
|
93
|
+
self._lock = threading.Lock()
|
|
93
94
|
|
|
94
95
|
def init(self):
|
|
95
96
|
try:
|
|
@@ -225,7 +226,9 @@ class AppScript:
|
|
|
225
226
|
|
|
226
227
|
def check(self):
|
|
227
228
|
if not self._initialized:
|
|
228
|
-
|
|
229
|
+
with self._lock:
|
|
230
|
+
if not self._initialized:
|
|
231
|
+
self.init()
|
|
229
232
|
|
|
230
233
|
def run(self):
|
|
231
234
|
self.check()
|
solara/server/kernel_context.py
CHANGED
|
@@ -26,7 +26,7 @@ from ipywidgets import DOMWidget, Widget
|
|
|
26
26
|
import solara.server.settings
|
|
27
27
|
import solara.util
|
|
28
28
|
|
|
29
|
-
from . import kernel,
|
|
29
|
+
from . import kernel, websocket
|
|
30
30
|
from .. import lifecycle
|
|
31
31
|
from .kernel import Kernel, WebsocketStreamWrapper
|
|
32
32
|
|
|
@@ -35,7 +35,7 @@ logger = logging.getLogger("solara.server.app")
|
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
class Local(threading.local):
|
|
38
|
-
kernel_context_stack: Optional[List[Optional["
|
|
38
|
+
kernel_context_stack: Optional[List[Optional["VirtualKernelContext"]]] = None
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
local = Local()
|
solara/server/patch.py
CHANGED
|
@@ -174,6 +174,8 @@ def display_solara(
|
|
|
174
174
|
# if display_id:
|
|
175
175
|
# return DisplayHandle(display_id)
|
|
176
176
|
|
|
177
|
+
get_ipython_original = IPython.get_ipython
|
|
178
|
+
|
|
177
179
|
|
|
178
180
|
def get_ipython():
|
|
179
181
|
if kernel_context.has_current_context():
|
|
@@ -181,7 +183,7 @@ def get_ipython():
|
|
|
181
183
|
our_fake_ipython = FakeIPython(context)
|
|
182
184
|
return our_fake_ipython
|
|
183
185
|
else:
|
|
184
|
-
return
|
|
186
|
+
return get_ipython_original()
|
|
185
187
|
|
|
186
188
|
|
|
187
189
|
class context_dict(MutableMapping):
|
|
@@ -405,6 +407,13 @@ def patch_matplotlib():
|
|
|
405
407
|
# same as _get
|
|
406
408
|
return self[key]
|
|
407
409
|
|
|
410
|
+
def clear(self):
|
|
411
|
+
# in matplotlib .clear is effectively a no-op
|
|
412
|
+
# see https://github.com/matplotlib/matplotlib/issues/25855
|
|
413
|
+
pass
|
|
414
|
+
# in the future, we may want to clear the context dict if this is fixed
|
|
415
|
+
# self._get_context_dict().clear()
|
|
416
|
+
|
|
408
417
|
def _get_context_dict(self) -> dict:
|
|
409
418
|
if not self._was_initialized:
|
|
410
419
|
# since we monkey patch the class after __init__ was called
|
solara/server/server.py
CHANGED
|
@@ -187,7 +187,7 @@ async def app_loop(
|
|
|
187
187
|
created_widgets_count = len(widgets_ids_after - widgets_ids)
|
|
188
188
|
close_widgets_count = len(widgets_ids - widgets_ids_after)
|
|
189
189
|
print( # noqa: T201
|
|
190
|
-
f"timing: total={t2-t0:.3f}s, deserialize={t1-t0:.3f}s, kernel={t2-t1:.3f}s"
|
|
190
|
+
f"timing: total={t2 - t0:.3f}s, deserialize={t1 - t0:.3f}s, kernel={t2 - t1:.3f}s"
|
|
191
191
|
f" widget: created: {created_widgets_count} closed: {close_widgets_count}"
|
|
192
192
|
)
|
|
193
193
|
finally:
|
solara/server/starlette.py
CHANGED
|
@@ -113,6 +113,7 @@ class WebsocketWrapper(websocket.WebsocketWrapper):
|
|
|
113
113
|
# we store a strong reference
|
|
114
114
|
self.tasks: Set[asyncio.Task] = set()
|
|
115
115
|
self.event_loop = asyncio.get_event_loop()
|
|
116
|
+
self._thread_id = threading.get_ident()
|
|
116
117
|
if settings.main.experimental_performance:
|
|
117
118
|
self.task = asyncio.ensure_future(self.process_messages_task())
|
|
118
119
|
|
|
@@ -133,7 +134,10 @@ class WebsocketWrapper(websocket.WebsocketWrapper):
|
|
|
133
134
|
await self.ws.send_bytes(data)
|
|
134
135
|
except (websockets.exceptions.ConnectionClosed, starlette.websockets.WebSocketDisconnect, RuntimeError) as e:
|
|
135
136
|
# starlette throws a RuntimeError once you call send after the connection is closed
|
|
136
|
-
|
|
137
|
+
if isinstance(e, RuntimeError) and "close message" in repr(e):
|
|
138
|
+
raise websocket.WebSocketDisconnect() from e
|
|
139
|
+
else:
|
|
140
|
+
raise
|
|
137
141
|
|
|
138
142
|
async def _send_text_exc(self, data: str):
|
|
139
143
|
# make sures we catch the starlette/websockets specific exception
|
|
@@ -141,8 +145,10 @@ class WebsocketWrapper(websocket.WebsocketWrapper):
|
|
|
141
145
|
try:
|
|
142
146
|
await self.ws.send_text(data)
|
|
143
147
|
except (websockets.exceptions.ConnectionClosed, starlette.websockets.WebSocketDisconnect, RuntimeError) as e:
|
|
144
|
-
|
|
145
|
-
|
|
148
|
+
if isinstance(e, RuntimeError) and "close message" in repr(e):
|
|
149
|
+
raise websocket.WebSocketDisconnect() from e
|
|
150
|
+
else:
|
|
151
|
+
raise
|
|
146
152
|
|
|
147
153
|
def close(self):
|
|
148
154
|
if self.portal is None:
|
|
@@ -159,7 +165,19 @@ class WebsocketWrapper(websocket.WebsocketWrapper):
|
|
|
159
165
|
if settings.main.experimental_performance:
|
|
160
166
|
self.to_send.append(data)
|
|
161
167
|
else:
|
|
162
|
-
self.
|
|
168
|
+
if self._thread_id == threading.get_ident():
|
|
169
|
+
warnings.warn("""You are triggering a websocket send from the event loop thread.
|
|
170
|
+
Support for this is experimental, and to avoid this message, make sure you trigger updates
|
|
171
|
+
that trigger this from a different thread, e.g.:
|
|
172
|
+
|
|
173
|
+
from anyio import to_thread
|
|
174
|
+
await to_thread.run_sync(my_update)
|
|
175
|
+
""")
|
|
176
|
+
task = self.event_loop.create_task(self._send_text_exc(data))
|
|
177
|
+
self.tasks.add(task)
|
|
178
|
+
task.add_done_callback(self.tasks.discard)
|
|
179
|
+
else:
|
|
180
|
+
self.portal.call(self._send_text_exc, data)
|
|
163
181
|
|
|
164
182
|
def send_bytes(self, data: bytes) -> None:
|
|
165
183
|
if self.portal is None:
|
|
@@ -170,6 +188,18 @@ class WebsocketWrapper(websocket.WebsocketWrapper):
|
|
|
170
188
|
if settings.main.experimental_performance:
|
|
171
189
|
self.to_send.append(data)
|
|
172
190
|
else:
|
|
191
|
+
if self._thread_id == threading.get_ident():
|
|
192
|
+
warnings.warn("""You are triggering a websocket send from the event loop thread.
|
|
193
|
+
Support for this is experimental, and to avoid this message, make sure you trigger updates
|
|
194
|
+
that trigger this from a different thread, e.g.:
|
|
195
|
+
|
|
196
|
+
from anyio import to_thread
|
|
197
|
+
await to_thread.run_sync(my_update)
|
|
198
|
+
""")
|
|
199
|
+
task = self.event_loop.create_task(self._send_bytes_exc(data))
|
|
200
|
+
self.tasks.add(task)
|
|
201
|
+
task.add_done_callback(self.tasks.discard)
|
|
202
|
+
|
|
173
203
|
self.portal.call(self._send_bytes_exc, data)
|
|
174
204
|
|
|
175
205
|
async def receive(self):
|
|
@@ -350,7 +380,7 @@ async def root(request: Request, fullpath: str = ""):
|
|
|
350
380
|
forwarded_proto = request.headers.get("x-forwarded-proto")
|
|
351
381
|
host = request.headers.get("host")
|
|
352
382
|
if forwarded_proto and forwarded_proto != request.scope["scheme"]:
|
|
353
|
-
warnings.warn(f"""Header x-forwarded-proto={forwarded_proto!r} does not match scheme={request.scope[
|
|
383
|
+
warnings.warn(f"""Header x-forwarded-proto={forwarded_proto!r} does not match scheme={request.scope["scheme"]!r} as given by the asgi framework (probably uvicorn)
|
|
354
384
|
|
|
355
385
|
This might be a configuration mismatch behind a reverse proxy and can cause issues with redirect urls, and auth.
|
|
356
386
|
|
|
@@ -409,10 +439,10 @@ This could be a configuration mismatch behind a reverse proxy and can cause issu
|
|
|
409
439
|
See also https://solara.dev/documentation/getting_started/deploying/self-hosted
|
|
410
440
|
"""
|
|
411
441
|
if "script-name" in request.headers:
|
|
412
|
-
msg += f"""It looks like the reverse proxy sets the script-name header to {request.headers[
|
|
442
|
+
msg += f"""It looks like the reverse proxy sets the script-name header to {request.headers["script-name"]!r}
|
|
413
443
|
"""
|
|
414
444
|
if "x-script-name" in request.headers:
|
|
415
|
-
msg += f"""It looks like the reverse proxy sets the x-script-name header to {request.headers[
|
|
445
|
+
msg += f"""It looks like the reverse proxy sets the x-script-name header to {request.headers["x-script-name"]!r}
|
|
416
446
|
"""
|
|
417
447
|
if configured_root_path:
|
|
418
448
|
msg += f"""It looks like the root path was configured to {configured_root_path!r} in the settings
|
|
@@ -472,8 +502,8 @@ See also https://solara.dev/documentation/getting_started/deploying/self-hosted
|
|
|
472
502
|
samesite = "none"
|
|
473
503
|
secure = True
|
|
474
504
|
elif request.base_url.hostname != "localhost":
|
|
475
|
-
warnings.warn(f"""Cookies with samesite=none require https, but according to the asgi framework, the scheme is {request.scope[
|
|
476
|
-
and the x-forwarded-proto header is {request.headers.get(
|
|
505
|
+
warnings.warn(f"""Cookies with samesite=none require https, but according to the asgi framework, the scheme is {request.scope["scheme"]!r}
|
|
506
|
+
and the x-forwarded-proto header is {request.headers.get("x-forwarded-proto", "http")!r}. We will fallback to samesite=lax.
|
|
477
507
|
|
|
478
508
|
If you embed solara in an iframe, make sure you forward the x-forwarded-proto header correctly so that the session cookie can be set.
|
|
479
509
|
|
|
@@ -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.44.1-py2.py3-none-any.whl", keep_going=True)
|
|
123
123
|
import solara
|
|
124
124
|
|
|
125
125
|
el = solara.Warning("lala")
|
solara/tasks.py
CHANGED
|
@@ -104,6 +104,9 @@ class Task(Generic[P, R], abc.ABC):
|
|
|
104
104
|
self._progress = ref(self._result.fields.progress)
|
|
105
105
|
self._exception = ref(self._result.fields.exception)
|
|
106
106
|
self._state_ = ref(self._result.fields._state)
|
|
107
|
+
# used for tests only
|
|
108
|
+
self._start_event = threading.Event()
|
|
109
|
+
self._start_event.set()
|
|
107
110
|
|
|
108
111
|
@property
|
|
109
112
|
def result(self) -> TaskResult[R]:
|
|
@@ -253,6 +256,8 @@ class TaskAsyncio(Task[P, R]):
|
|
|
253
256
|
return (self.current_task == asyncio.current_task()) and not running_task.cancelled()
|
|
254
257
|
|
|
255
258
|
async def _async_run(self, call_event_loop: asyncio.AbstractEventLoop, future: asyncio.Future, args, kwargs) -> None:
|
|
259
|
+
self._start_event.wait()
|
|
260
|
+
|
|
256
261
|
task_for_this_call = asyncio.current_task()
|
|
257
262
|
assert task_for_this_call is not None
|
|
258
263
|
|
|
@@ -304,6 +309,7 @@ class TaskThreaded(Task[P, R]):
|
|
|
304
309
|
self.__qualname__ = function.__qualname__
|
|
305
310
|
self.function = function
|
|
306
311
|
self.lock = threading.Lock()
|
|
312
|
+
self._local = threading.local()
|
|
307
313
|
|
|
308
314
|
def cancel(self) -> None:
|
|
309
315
|
if self._cancel:
|
|
@@ -343,12 +349,17 @@ class TaskThreaded(Task[P, R]):
|
|
|
343
349
|
current_thread.start()
|
|
344
350
|
|
|
345
351
|
def is_current(self):
|
|
352
|
+
cancel_event = getattr(self._local, "cancel_event", None)
|
|
353
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
354
|
+
return False
|
|
346
355
|
return self._current_thread == threading.current_thread()
|
|
347
356
|
|
|
348
357
|
def _run(self, _last_finished_event, previous_thread: Optional[threading.Thread], cancel_event, args, kwargs) -> None:
|
|
349
358
|
# use_thread has this as default, which can make code run 10x slower
|
|
359
|
+
self._start_event.wait()
|
|
350
360
|
intrusive_cancel = False
|
|
351
361
|
wait_on_previous = False
|
|
362
|
+
self._local.cancel_event = cancel_event
|
|
352
363
|
|
|
353
364
|
def runner():
|
|
354
365
|
if wait_on_previous:
|
|
@@ -405,7 +416,7 @@ class TaskThreaded(Task[P, R]):
|
|
|
405
416
|
# this means this thread is cancelled not be request, but because
|
|
406
417
|
# a new thread is running, we can ignore this
|
|
407
418
|
finally:
|
|
408
|
-
if self.
|
|
419
|
+
if self._current_thread == threading.current_thread():
|
|
409
420
|
self.running_thread = None
|
|
410
421
|
logger.info("thread done!")
|
|
411
422
|
if cancel_event.is_set():
|
solara/test/pytest_plugin.py
CHANGED
|
@@ -101,7 +101,7 @@ def context_session(
|
|
|
101
101
|
if capture_screenshot:
|
|
102
102
|
for index, page in enumerate(pages):
|
|
103
103
|
human_readable_status = "failed" if failed else "finished"
|
|
104
|
-
screenshot_path = _build_artifact_test_folder(pytestconfig, request, f"test-{human_readable_status}-{index+1}.png")
|
|
104
|
+
screenshot_path = _build_artifact_test_folder(pytestconfig, request, f"test-{human_readable_status}-{index + 1}.png")
|
|
105
105
|
try:
|
|
106
106
|
page.screenshot(timeout=5000, path=screenshot_path)
|
|
107
107
|
except Error:
|
|
@@ -144,13 +144,14 @@ def solara_app(solara_server):
|
|
|
144
144
|
used_app = None
|
|
145
145
|
|
|
146
146
|
@contextlib.contextmanager
|
|
147
|
-
def run(app: Union[solara.server.app.AppScript, str]):
|
|
147
|
+
def run(app: Union[solara.server.app.AppScript, str], init=True):
|
|
148
148
|
nonlocal used_app
|
|
149
149
|
if "__default__" in solara.server.app.apps:
|
|
150
150
|
solara.server.app.apps["__default__"].close()
|
|
151
151
|
if isinstance(app, str):
|
|
152
152
|
app = solara.server.app.AppScript(app)
|
|
153
|
-
|
|
153
|
+
if init:
|
|
154
|
+
app.init()
|
|
154
155
|
used_app = app
|
|
155
156
|
solara.server.app.apps["__default__"] = app
|
|
156
157
|
try:
|