solara-ui 1.42.0__py2.py3-none-any.whl → 1.44.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 +12 -7
- solara/_stores.py +128 -16
- solara/cache.py +6 -4
- solara/checks.py +1 -1
- solara/components/__init__.py +18 -1
- solara/components/datatable.py +4 -4
- solara/components/input.py +5 -1
- solara/components/markdown.py +46 -10
- solara/components/misc.py +2 -2
- solara/components/select.py +1 -1
- solara/components/style.py +1 -1
- solara/hooks/use_reactive.py +16 -1
- solara/lab/components/__init__.py +1 -0
- solara/lab/components/chat.py +15 -9
- solara/lab/components/input_time.py +133 -0
- solara/lab/hooks/dataframe.py +1 -0
- solara/lab/utils/dataframe.py +11 -1
- solara/server/app.py +66 -30
- solara/server/flask.py +12 -2
- solara/server/jupyter/server_extension.py +1 -0
- solara/server/kernel.py +50 -3
- solara/server/kernel_context.py +68 -9
- solara/server/patch.py +28 -30
- solara/server/server.py +16 -6
- solara/server/settings.py +11 -0
- solara/server/shell.py +19 -1
- solara/server/starlette.py +72 -14
- solara/server/static/solara_bootstrap.py +1 -1
- solara/settings.py +3 -0
- solara/tasks.py +30 -9
- solara/test/pytest_plugin.py +4 -2
- solara/toestand.py +119 -28
- solara/util.py +18 -0
- solara/website/components/docs.py +24 -1
- solara/website/components/markdown.py +17 -3
- solara/website/pages/changelog/changelog.md +26 -1
- solara/website/pages/documentation/advanced/content/10-howto/20-layout.md +1 -1
- solara/website/pages/documentation/advanced/content/20-understanding/50-solara-server.md +10 -0
- solara/website/pages/documentation/advanced/content/30-enterprise/10-oauth.md +4 -2
- 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/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/lab/input_time.py +15 -0
- solara/website/pages/documentation/components/lab/theming.py +6 -4
- 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/output/sql_code.py +23 -25
- solara/website/pages/documentation/components/page/head.py +4 -7
- 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 +10 -0
- solara/website/pages/documentation/examples/ai/chatbot.py +62 -44
- solara/website/pages/documentation/examples/general/live_update.py +22 -28
- solara/website/pages/documentation/examples/general/pokemon_search.py +1 -1
- solara/website/pages/documentation/faq/content/99-faq.md +9 -0
- solara/website/pages/documentation/getting_started/content/00-quickstart.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 +3 -0
- {solara_ui-1.42.0.dist-info → solara_ui-1.44.0.dist-info}/METADATA +2 -2
- {solara_ui-1.42.0.dist-info → solara_ui-1.44.0.dist-info}/RECORD +71 -69
- {solara_ui-1.42.0.data → solara_ui-1.44.0.data}/data/etc/jupyter/jupyter_notebook_config.d/solara.json +0 -0
- {solara_ui-1.42.0.data → solara_ui-1.44.0.data}/data/etc/jupyter/jupyter_server_config.d/solara.json +0 -0
- {solara_ui-1.42.0.dist-info → solara_ui-1.44.0.dist-info}/WHEEL +0 -0
- {solara_ui-1.42.0.dist-info → solara_ui-1.44.0.dist-info}/licenses/LICENSE +0 -0
solara/server/kernel_context.py
CHANGED
|
@@ -6,6 +6,8 @@ try:
|
|
|
6
6
|
except ModuleNotFoundError:
|
|
7
7
|
contextvars = None # type: ignore
|
|
8
8
|
|
|
9
|
+
import concurrent.futures
|
|
10
|
+
import contextlib
|
|
9
11
|
import dataclasses
|
|
10
12
|
import enum
|
|
11
13
|
import logging
|
|
@@ -24,7 +26,7 @@ from ipywidgets import DOMWidget, Widget
|
|
|
24
26
|
import solara.server.settings
|
|
25
27
|
import solara.util
|
|
26
28
|
|
|
27
|
-
from . import kernel,
|
|
29
|
+
from . import kernel, websocket
|
|
28
30
|
from .. import lifecycle
|
|
29
31
|
from .kernel import Kernel, WebsocketStreamWrapper
|
|
30
32
|
|
|
@@ -33,7 +35,7 @@ logger = logging.getLogger("solara.server.app")
|
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
class Local(threading.local):
|
|
36
|
-
kernel_context_stack: Optional[List[Optional["
|
|
38
|
+
kernel_context_stack: Optional[List[Optional["VirtualKernelContext"]]] = None
|
|
37
39
|
|
|
38
40
|
|
|
39
41
|
local = Local()
|
|
@@ -71,6 +73,7 @@ class VirtualKernelContext:
|
|
|
71
73
|
page_status: Dict[str, PageStatus] = dataclasses.field(default_factory=dict)
|
|
72
74
|
# only used for testing
|
|
73
75
|
_last_kernel_cull_task: "Optional[asyncio.Future[None]]" = None
|
|
76
|
+
_last_kernel_cull_future: "Optional[concurrent.futures.Future[None]]" = None
|
|
74
77
|
closed_event: threading.Event = dataclasses.field(default_factory=threading.Event)
|
|
75
78
|
_on_close_callbacks: List[Callable[[], None]] = dataclasses.field(default_factory=list)
|
|
76
79
|
lock: threading.RLock = dataclasses.field(default_factory=threading.RLock)
|
|
@@ -112,6 +115,10 @@ class VirtualKernelContext:
|
|
|
112
115
|
with self, self.lock:
|
|
113
116
|
for key in self.page_status:
|
|
114
117
|
self.page_status[key] = PageStatus.CLOSED
|
|
118
|
+
if self._last_kernel_cull_task:
|
|
119
|
+
self._last_kernel_cull_task.cancel()
|
|
120
|
+
if self._last_kernel_cull_future:
|
|
121
|
+
self._last_kernel_cull_future.cancel()
|
|
115
122
|
if self.closed_event.is_set():
|
|
116
123
|
logger.error("Tried to close a kernel context that is already closed: %s", self.id)
|
|
117
124
|
return
|
|
@@ -129,9 +136,18 @@ class VirtualKernelContext:
|
|
|
129
136
|
# what if we reference each other
|
|
130
137
|
# import gc
|
|
131
138
|
# gc.collect()
|
|
132
|
-
self.kernel.
|
|
139
|
+
self.kernel.close()
|
|
140
|
+
self.kernel = None # type: ignore
|
|
133
141
|
if self.id in contexts:
|
|
134
142
|
del contexts[self.id]
|
|
143
|
+
del current_context[get_current_thread_key()]
|
|
144
|
+
# We saw in memleak_test that there are sometimes other entries in current_context
|
|
145
|
+
# In which _DummyThread's reference this context, so we remove those references too
|
|
146
|
+
# TODO: Think about what to do with those Threads
|
|
147
|
+
_contexts = current_context.copy()
|
|
148
|
+
for key, _ctx in _contexts.items():
|
|
149
|
+
if _ctx is self:
|
|
150
|
+
del current_context[key]
|
|
135
151
|
self.closed_event.set()
|
|
136
152
|
|
|
137
153
|
def _state_reset(self):
|
|
@@ -158,6 +174,8 @@ class VirtualKernelContext:
|
|
|
158
174
|
pickle.dump(state, f)
|
|
159
175
|
|
|
160
176
|
def page_connect(self, page_id: str):
|
|
177
|
+
if self.closed_event.is_set():
|
|
178
|
+
raise RuntimeError("Cannot connect a page to a closed kernel")
|
|
161
179
|
logger.info("Connect page %s for kernel %s", page_id, self.id)
|
|
162
180
|
with self.lock:
|
|
163
181
|
if self.closed_event.is_set():
|
|
@@ -184,13 +202,19 @@ class VirtualKernelContext:
|
|
|
184
202
|
logger.info("No connected pages, and timeout reached, shutting down virtual kernel %s", self.id)
|
|
185
203
|
self.close()
|
|
186
204
|
if current_event_loop is not None and future is not None:
|
|
187
|
-
|
|
205
|
+
try:
|
|
206
|
+
current_event_loop.call_soon_threadsafe(future.set_result, None)
|
|
207
|
+
except RuntimeError:
|
|
208
|
+
pass # event loop already closed, happens during testing
|
|
188
209
|
except asyncio.CancelledError:
|
|
189
210
|
if current_event_loop is not None and future is not None:
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
211
|
+
try:
|
|
212
|
+
if sys.version_info >= (3, 9):
|
|
213
|
+
current_event_loop.call_soon_threadsafe(future.cancel, "cancelled because a new cull task was scheduled")
|
|
214
|
+
else:
|
|
215
|
+
current_event_loop.call_soon_threadsafe(future.cancel)
|
|
216
|
+
except RuntimeError:
|
|
217
|
+
pass # event loop already closed, happens during testing
|
|
194
218
|
raise
|
|
195
219
|
|
|
196
220
|
async def create_task():
|
|
@@ -212,7 +236,17 @@ class VirtualKernelContext:
|
|
|
212
236
|
self._last_kernel_cull_task.cancel()
|
|
213
237
|
|
|
214
238
|
logger.info("Scheduling kernel cull for virtual kernel %s", self.id)
|
|
215
|
-
|
|
239
|
+
|
|
240
|
+
async def create_task():
|
|
241
|
+
task = asyncio.create_task(kernel_cull())
|
|
242
|
+
# create a reference to the task so we can cancel it later
|
|
243
|
+
self._last_kernel_cull_task = task
|
|
244
|
+
try:
|
|
245
|
+
await task
|
|
246
|
+
except RuntimeError:
|
|
247
|
+
pass # event loop already closed, happens during testing
|
|
248
|
+
|
|
249
|
+
self._last_kernel_cull_future = asyncio.run_coroutine_threadsafe(create_task(), keep_alive_event_loop)
|
|
216
250
|
return future
|
|
217
251
|
|
|
218
252
|
def page_disconnect(self, page_id: str) -> "Optional[asyncio.Future[None]]":
|
|
@@ -259,7 +293,12 @@ class VirtualKernelContext:
|
|
|
259
293
|
pass
|
|
260
294
|
else:
|
|
261
295
|
future.set_result(None)
|
|
296
|
+
|
|
297
|
+
logger.info("page status: %s", self.page_status)
|
|
262
298
|
with self.lock:
|
|
299
|
+
if self.closed_event.is_set():
|
|
300
|
+
logger.info("Kernel %s was already closed when page %s attempted to close", self.id, page_id)
|
|
301
|
+
return
|
|
263
302
|
if self.page_status[page_id] == PageStatus.CLOSED:
|
|
264
303
|
logger.info("Page %s already closed for kernel %s", page_id, self.id)
|
|
265
304
|
return
|
|
@@ -351,6 +390,11 @@ def set_context_for_thread(context: VirtualKernelContext, thread: threading.Thre
|
|
|
351
390
|
current_context[key] = context
|
|
352
391
|
|
|
353
392
|
|
|
393
|
+
def clear_context_for_thread(thread: threading.Thread):
|
|
394
|
+
key = get_thread_key(thread)
|
|
395
|
+
current_context.pop(key, None)
|
|
396
|
+
|
|
397
|
+
|
|
354
398
|
def has_current_context() -> bool:
|
|
355
399
|
thread_key = get_current_thread_key()
|
|
356
400
|
return (thread_key in current_context) and (current_context[thread_key] is not None)
|
|
@@ -377,6 +421,21 @@ def set_current_context(context: Optional[VirtualKernelContext]):
|
|
|
377
421
|
current_context[thread_key] = context
|
|
378
422
|
|
|
379
423
|
|
|
424
|
+
@contextlib.contextmanager
|
|
425
|
+
def without_context():
|
|
426
|
+
context = None
|
|
427
|
+
try:
|
|
428
|
+
context = get_current_context()
|
|
429
|
+
except RuntimeError:
|
|
430
|
+
pass
|
|
431
|
+
thread_key = get_current_thread_key()
|
|
432
|
+
current_context[thread_key] = None
|
|
433
|
+
try:
|
|
434
|
+
yield
|
|
435
|
+
finally:
|
|
436
|
+
current_context[thread_key] = context
|
|
437
|
+
|
|
438
|
+
|
|
380
439
|
def initialize_virtual_kernel(session_id: str, kernel_id: str, websocket: websocket.WebsocketWrapper):
|
|
381
440
|
from solara.server import app as appmodule
|
|
382
441
|
|
solara/server/patch.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import functools
|
|
2
1
|
import logging
|
|
3
2
|
import os
|
|
4
3
|
import pdb
|
|
@@ -15,6 +14,9 @@ import ipywidgets
|
|
|
15
14
|
import ipywidgets.widgets.widget_output
|
|
16
15
|
from IPython.core.interactiveshell import InteractiveShell
|
|
17
16
|
|
|
17
|
+
import solara
|
|
18
|
+
import solara.util
|
|
19
|
+
|
|
18
20
|
from . import app, kernel_context, reload, settings
|
|
19
21
|
from .utils import pdb_guard
|
|
20
22
|
|
|
@@ -39,6 +41,7 @@ class FakeIPython:
|
|
|
39
41
|
# (although we don't really support it)
|
|
40
42
|
self.events = mock.MagicMock()
|
|
41
43
|
self.user_ns: Dict[Any, Any] = {}
|
|
44
|
+
self.custom_exceptions = ()
|
|
42
45
|
|
|
43
46
|
def enable_gui(self, gui):
|
|
44
47
|
logger.error("ignoring call to enable_gui(%s)", gui)
|
|
@@ -171,6 +174,8 @@ def display_solara(
|
|
|
171
174
|
# if display_id:
|
|
172
175
|
# return DisplayHandle(display_id)
|
|
173
176
|
|
|
177
|
+
get_ipython_original = IPython.get_ipython
|
|
178
|
+
|
|
174
179
|
|
|
175
180
|
def get_ipython():
|
|
176
181
|
if kernel_context.has_current_context():
|
|
@@ -178,7 +183,7 @@ def get_ipython():
|
|
|
178
183
|
our_fake_ipython = FakeIPython(context)
|
|
179
184
|
return our_fake_ipython
|
|
180
185
|
else:
|
|
181
|
-
return
|
|
186
|
+
return get_ipython_original()
|
|
182
187
|
|
|
183
188
|
|
|
184
189
|
class context_dict(MutableMapping):
|
|
@@ -249,7 +254,8 @@ def auto_watch_get_template(get_template):
|
|
|
249
254
|
|
|
250
255
|
def wrapper(abs_path):
|
|
251
256
|
template = get_template(abs_path)
|
|
252
|
-
|
|
257
|
+
with kernel_context.without_context():
|
|
258
|
+
reload.reloader.watcher.add_file(abs_path)
|
|
253
259
|
return template
|
|
254
260
|
|
|
255
261
|
return wrapper
|
|
@@ -272,10 +278,13 @@ def WidgetContextAwareThread__init__(self, *args, **kwargs):
|
|
|
272
278
|
ThreadDebugInfo.created += 1
|
|
273
279
|
|
|
274
280
|
self.current_context = None
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
281
|
+
# if we do this for the dummy threads, we got into a recursion
|
|
282
|
+
# since threading.current_thread will call the _DummyThread constructor
|
|
283
|
+
if not ("name" in kwargs and "Dummy-" in kwargs["name"]):
|
|
284
|
+
try:
|
|
285
|
+
self.current_context = kernel_context.get_current_context()
|
|
286
|
+
except RuntimeError:
|
|
287
|
+
logger.debug(f"No context for thread {self._name}")
|
|
279
288
|
|
|
280
289
|
|
|
281
290
|
def WidgetContextAwareThread__bootstrap(self):
|
|
@@ -291,6 +300,7 @@ def WidgetContextAwareThread__bootstrap(self):
|
|
|
291
300
|
|
|
292
301
|
def _WidgetContextAwareThread__bootstrap(self):
|
|
293
302
|
if not hasattr(self, "current_context"):
|
|
303
|
+
# this happens when a thread was running before we patched
|
|
294
304
|
return Thread__bootstrap(self)
|
|
295
305
|
if self.current_context:
|
|
296
306
|
# we need to call this manually, because set_context_for_thread
|
|
@@ -299,15 +309,20 @@ def _WidgetContextAwareThread__bootstrap(self):
|
|
|
299
309
|
if kernel_context.async_context_id is not None:
|
|
300
310
|
kernel_context.async_context_id.set(self.current_context.id)
|
|
301
311
|
kernel_context.set_context_for_thread(self.current_context, self)
|
|
302
|
-
|
|
303
312
|
shell = self.current_context.kernel.shell
|
|
304
|
-
shell.display_pub
|
|
313
|
+
display_pub = shell.display_pub
|
|
314
|
+
display_in_reacton_hook = shell.display_in_reacton_hook
|
|
315
|
+
display_pub.register_hook(display_in_reacton_hook)
|
|
305
316
|
try:
|
|
306
|
-
|
|
317
|
+
context = self.current_context or solara.util.nullcontext()
|
|
318
|
+
with pdb_guard(), context:
|
|
307
319
|
Thread__bootstrap(self)
|
|
308
320
|
finally:
|
|
309
|
-
|
|
310
|
-
|
|
321
|
+
current_context = self.current_context
|
|
322
|
+
self.current_context = None
|
|
323
|
+
kernel_context.clear_context_for_thread(self)
|
|
324
|
+
if current_context:
|
|
325
|
+
display_pub.unregister_hook(display_in_reacton_hook)
|
|
311
326
|
|
|
312
327
|
|
|
313
328
|
_patched = False
|
|
@@ -354,24 +369,7 @@ def patch_ipyreact():
|
|
|
354
369
|
ipyreact.importmap._update_import_map = lambda: None
|
|
355
370
|
|
|
356
371
|
|
|
357
|
-
|
|
358
|
-
called = False
|
|
359
|
-
return_value = None
|
|
360
|
-
|
|
361
|
-
@functools.wraps(f)
|
|
362
|
-
def wrapper():
|
|
363
|
-
nonlocal called
|
|
364
|
-
nonlocal return_value
|
|
365
|
-
if called:
|
|
366
|
-
return return_value
|
|
367
|
-
called = True
|
|
368
|
-
return_value = f()
|
|
369
|
-
return return_value
|
|
370
|
-
|
|
371
|
-
return wrapper
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
@once
|
|
372
|
+
@solara.util.once
|
|
375
373
|
def patch_matplotlib():
|
|
376
374
|
import matplotlib
|
|
377
375
|
import matplotlib._pylab_helpers
|
solara/server/server.py
CHANGED
|
@@ -16,6 +16,7 @@ import requests
|
|
|
16
16
|
import solara
|
|
17
17
|
import solara.routing
|
|
18
18
|
import solara.settings
|
|
19
|
+
import solara.server.settings
|
|
19
20
|
from solara.lab import cookies as solara_cookies
|
|
20
21
|
from solara.lab import headers as solara_headers
|
|
21
22
|
|
|
@@ -66,6 +67,8 @@ nbextensions_ignorelist = [
|
|
|
66
67
|
"jupyter-js/extension",
|
|
67
68
|
"jupyter-js-widgets/extension",
|
|
68
69
|
"jupyter_dash/main",
|
|
70
|
+
"dash/main",
|
|
71
|
+
*solara.server.settings.server.ignore_nbextensions,
|
|
69
72
|
]
|
|
70
73
|
|
|
71
74
|
|
|
@@ -157,7 +160,8 @@ async def app_loop(
|
|
|
157
160
|
message = await ws.receive()
|
|
158
161
|
except websocket.WebSocketDisconnect:
|
|
159
162
|
try:
|
|
160
|
-
context.kernel.session
|
|
163
|
+
if context.kernel is not None and context.kernel.session is not None:
|
|
164
|
+
context.kernel.session.websockets.remove(ws)
|
|
161
165
|
except KeyError:
|
|
162
166
|
pass
|
|
163
167
|
logger.debug("Disconnected")
|
|
@@ -168,17 +172,22 @@ async def app_loop(
|
|
|
168
172
|
else:
|
|
169
173
|
msg = deserialize_binary_message(message)
|
|
170
174
|
t1 = time.time()
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
+
# we don't want to have the kernel closed while we are processing a message
|
|
176
|
+
# therefore we use this mutex that is also used in the context.close method
|
|
177
|
+
with context.lock:
|
|
178
|
+
if context.closed_event.is_set():
|
|
179
|
+
return
|
|
180
|
+
if not process_kernel_messages(kernel, msg):
|
|
181
|
+
# if we shut down the kernel, we do not keep the page session alive
|
|
182
|
+
context.close()
|
|
183
|
+
return
|
|
175
184
|
t2 = time.time()
|
|
176
185
|
if settings.main.timing:
|
|
177
186
|
widgets_ids_after = set(patch.widgets)
|
|
178
187
|
created_widgets_count = len(widgets_ids_after - widgets_ids)
|
|
179
188
|
close_widgets_count = len(widgets_ids - widgets_ids_after)
|
|
180
189
|
print( # noqa: T201
|
|
181
|
-
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"
|
|
182
191
|
f" widget: created: {created_widgets_count} closed: {close_widgets_count}"
|
|
183
192
|
)
|
|
184
193
|
finally:
|
|
@@ -282,6 +291,7 @@ def read_root(
|
|
|
282
291
|
return content
|
|
283
292
|
|
|
284
293
|
default_app = app.apps["__default__"]
|
|
294
|
+
default_app.check()
|
|
285
295
|
routes = default_app.routes
|
|
286
296
|
router = solara.routing.Router(path, routes)
|
|
287
297
|
if not router.possible_match:
|
solara/server/settings.py
CHANGED
|
@@ -132,6 +132,7 @@ OAUTH_TEST_CLIENT_IDs = [AUTH0_TEST_CLIENT_ID, FIEF_TEST_CLIENT_ID]
|
|
|
132
132
|
|
|
133
133
|
class Session(BaseSettings):
|
|
134
134
|
secret_key: str = SESSION_SECRET_KEY_DEFAULT
|
|
135
|
+
http_only: bool = False
|
|
135
136
|
https_only: Optional[bool] = None
|
|
136
137
|
same_site: str = "lax"
|
|
137
138
|
|
|
@@ -163,6 +164,15 @@ if is_mac_os_conda or is_wsl_windows:
|
|
|
163
164
|
HOST_DEFAULT = "localhost"
|
|
164
165
|
|
|
165
166
|
|
|
167
|
+
class Server(BaseSettings):
|
|
168
|
+
ignore_nbextensions: List[str] = []
|
|
169
|
+
|
|
170
|
+
class Config:
|
|
171
|
+
env_prefix = "solara_server_"
|
|
172
|
+
case_sensitive = False
|
|
173
|
+
env_file = ".env"
|
|
174
|
+
|
|
175
|
+
|
|
166
176
|
class MainSettings(BaseSettings):
|
|
167
177
|
use_pdb: bool = False
|
|
168
178
|
mode: str = "production"
|
|
@@ -181,6 +191,7 @@ class MainSettings(BaseSettings):
|
|
|
181
191
|
|
|
182
192
|
|
|
183
193
|
main = MainSettings()
|
|
194
|
+
server = Server()
|
|
184
195
|
theme = ThemeSettings()
|
|
185
196
|
telemetry = Telemetry()
|
|
186
197
|
ssg = SSG()
|
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
|
|
|
@@ -112,6 +113,7 @@ class WebsocketWrapper(websocket.WebsocketWrapper):
|
|
|
112
113
|
# we store a strong reference
|
|
113
114
|
self.tasks: Set[asyncio.Task] = set()
|
|
114
115
|
self.event_loop = asyncio.get_event_loop()
|
|
116
|
+
self._thread_id = threading.get_ident()
|
|
115
117
|
if settings.main.experimental_performance:
|
|
116
118
|
self.task = asyncio.ensure_future(self.process_messages_task())
|
|
117
119
|
|
|
@@ -121,37 +123,84 @@ class WebsocketWrapper(websocket.WebsocketWrapper):
|
|
|
121
123
|
while len(self.to_send) > 0:
|
|
122
124
|
first = self.to_send.pop(0)
|
|
123
125
|
if isinstance(first, bytes):
|
|
124
|
-
await self.
|
|
126
|
+
await self._send_bytes_exc(first)
|
|
125
127
|
else:
|
|
126
|
-
await self.
|
|
128
|
+
await self._send_text_exc(first)
|
|
129
|
+
|
|
130
|
+
async def _send_bytes_exc(self, data: bytes):
|
|
131
|
+
# make sures we catch the starlette/websockets specific exception
|
|
132
|
+
# and re-raise it as a websocket.WebSocketDisconnect
|
|
133
|
+
try:
|
|
134
|
+
await self.ws.send_bytes(data)
|
|
135
|
+
except (websockets.exceptions.ConnectionClosed, starlette.websockets.WebSocketDisconnect, RuntimeError) as e:
|
|
136
|
+
# starlette throws a RuntimeError once you call send after the connection is closed
|
|
137
|
+
if isinstance(e, RuntimeError) and "close message" in repr(e):
|
|
138
|
+
raise websocket.WebSocketDisconnect() from e
|
|
139
|
+
else:
|
|
140
|
+
raise
|
|
141
|
+
|
|
142
|
+
async def _send_text_exc(self, data: str):
|
|
143
|
+
# make sures we catch the starlette/websockets specific exception
|
|
144
|
+
# and re-raise it as a websocket.WebSocketDisconnect
|
|
145
|
+
try:
|
|
146
|
+
await self.ws.send_text(data)
|
|
147
|
+
except (websockets.exceptions.ConnectionClosed, starlette.websockets.WebSocketDisconnect, RuntimeError) as e:
|
|
148
|
+
if isinstance(e, RuntimeError) and "close message" in repr(e):
|
|
149
|
+
raise websocket.WebSocketDisconnect() from e
|
|
150
|
+
else:
|
|
151
|
+
raise
|
|
127
152
|
|
|
128
153
|
def close(self):
|
|
129
154
|
if self.portal is None:
|
|
130
155
|
asyncio.ensure_future(self.ws.close())
|
|
131
156
|
else:
|
|
132
|
-
self.portal.call(self.ws.close)
|
|
157
|
+
self.portal.call(self.ws.close)
|
|
133
158
|
|
|
134
159
|
def send_text(self, data: str) -> None:
|
|
135
160
|
if self.portal is None:
|
|
136
|
-
task = self.event_loop.create_task(self.
|
|
161
|
+
task = self.event_loop.create_task(self._send_text_exc(data))
|
|
137
162
|
self.tasks.add(task)
|
|
138
163
|
task.add_done_callback(self.tasks.discard)
|
|
139
164
|
else:
|
|
140
165
|
if settings.main.experimental_performance:
|
|
141
166
|
self.to_send.append(data)
|
|
142
167
|
else:
|
|
143
|
-
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)
|
|
144
181
|
|
|
145
182
|
def send_bytes(self, data: bytes) -> None:
|
|
146
183
|
if self.portal is None:
|
|
147
|
-
task = self.event_loop.create_task(self.
|
|
184
|
+
task = self.event_loop.create_task(self._send_bytes_exc(data))
|
|
148
185
|
self.tasks.add(task)
|
|
149
186
|
task.add_done_callback(self.tasks.discard)
|
|
150
187
|
else:
|
|
151
188
|
if settings.main.experimental_performance:
|
|
152
189
|
self.to_send.append(data)
|
|
153
190
|
else:
|
|
154
|
-
self.
|
|
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
|
+
|
|
203
|
+
self.portal.call(self._send_bytes_exc, data)
|
|
155
204
|
|
|
156
205
|
async def receive(self):
|
|
157
206
|
if self.portal is None:
|
|
@@ -159,9 +208,9 @@ class WebsocketWrapper(websocket.WebsocketWrapper):
|
|
|
159
208
|
else:
|
|
160
209
|
if hasattr(self.portal, "start_task_soon"):
|
|
161
210
|
# version 3+
|
|
162
|
-
fut = self.portal.start_task_soon(self.ws.receive)
|
|
211
|
+
fut = self.portal.start_task_soon(self.ws.receive)
|
|
163
212
|
else:
|
|
164
|
-
fut = self.portal.spawn_task(self.ws.receive)
|
|
213
|
+
fut = self.portal.spawn_task(self.ws.receive)
|
|
165
214
|
|
|
166
215
|
message = await asyncio.wrap_future(fut)
|
|
167
216
|
if "text" in message:
|
|
@@ -331,7 +380,7 @@ async def root(request: Request, fullpath: str = ""):
|
|
|
331
380
|
forwarded_proto = request.headers.get("x-forwarded-proto")
|
|
332
381
|
host = request.headers.get("host")
|
|
333
382
|
if forwarded_proto and forwarded_proto != request.scope["scheme"]:
|
|
334
|
-
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)
|
|
335
384
|
|
|
336
385
|
This might be a configuration mismatch behind a reverse proxy and can cause issues with redirect urls, and auth.
|
|
337
386
|
|
|
@@ -390,10 +439,10 @@ This could be a configuration mismatch behind a reverse proxy and can cause issu
|
|
|
390
439
|
See also https://solara.dev/documentation/getting_started/deploying/self-hosted
|
|
391
440
|
"""
|
|
392
441
|
if "script-name" in request.headers:
|
|
393
|
-
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}
|
|
394
443
|
"""
|
|
395
444
|
if "x-script-name" in request.headers:
|
|
396
|
-
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}
|
|
397
446
|
"""
|
|
398
447
|
if configured_root_path:
|
|
399
448
|
msg += f"""It looks like the root path was configured to {configured_root_path!r} in the settings
|
|
@@ -444,6 +493,7 @@ See also https://solara.dev/documentation/getting_started/deploying/self-hosted
|
|
|
444
493
|
session_id = request.cookies.get(server.COOKIE_KEY_SESSION_ID) or str(uuid4())
|
|
445
494
|
samesite = "lax"
|
|
446
495
|
secure = False
|
|
496
|
+
httponly = settings.session.http_only
|
|
447
497
|
# we want samesite, so we can set a cookie when embedded in an iframe, such as on huggingface
|
|
448
498
|
# however, samesite=none requires Secure https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
|
|
449
499
|
# when hosted on the localhost domain we can always set the Secure flag
|
|
@@ -452,8 +502,8 @@ See also https://solara.dev/documentation/getting_started/deploying/self-hosted
|
|
|
452
502
|
samesite = "none"
|
|
453
503
|
secure = True
|
|
454
504
|
elif request.base_url.hostname != "localhost":
|
|
455
|
-
warnings.warn(f"""Cookies with samesite=none require https, but according to the asgi framework, the scheme is {request.scope[
|
|
456
|
-
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.
|
|
457
507
|
|
|
458
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.
|
|
459
509
|
|
|
@@ -469,6 +519,7 @@ Also check out the following Solara documentation:
|
|
|
469
519
|
expires="Fri, 01 Jan 2038 00:00:00 GMT",
|
|
470
520
|
samesite=samesite, # type: ignore
|
|
471
521
|
secure=secure, # type: ignore
|
|
522
|
+
httponly=httponly, # type: ignore
|
|
472
523
|
) # type: ignore
|
|
473
524
|
return response
|
|
474
525
|
|
|
@@ -549,12 +600,19 @@ class StaticCdn(StaticFilesOptionalAuth):
|
|
|
549
600
|
|
|
550
601
|
|
|
551
602
|
def on_startup():
|
|
603
|
+
appmod.ensure_apps_initialized()
|
|
552
604
|
# TODO: configure and set max number of threads
|
|
553
605
|
# see https://github.com/encode/starlette/issues/1724
|
|
554
606
|
telemetry.server_start()
|
|
555
607
|
|
|
556
608
|
|
|
557
609
|
def on_shutdown():
|
|
610
|
+
# shutdown all kernels
|
|
611
|
+
for context in list(kernel_context.contexts.values()):
|
|
612
|
+
try:
|
|
613
|
+
context.close()
|
|
614
|
+
except: # noqa
|
|
615
|
+
logger.exception("error closing kernel on shutdown")
|
|
558
616
|
telemetry.server_stop()
|
|
559
617
|
|
|
560
618
|
|
|
@@ -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.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_"
|