solara-ui 1.42.0__py2.py3-none-any.whl → 1.43.0__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solara/__init__.py +1 -1
- solara/__main__.py +10 -5
- solara/_stores.py +14 -10
- 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/lab/components/__init__.py +1 -0
- solara/lab/components/chat.py +3 -3
- solara/lab/components/input_time.py +133 -0
- solara/lab/hooks/dataframe.py +1 -0
- solara/lab/utils/dataframe.py +11 -1
- solara/server/app.py +63 -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 +66 -7
- solara/server/patch.py +25 -29
- solara/server/server.py +15 -5
- solara/server/settings.py +11 -0
- solara/server/shell.py +19 -1
- solara/server/starlette.py +37 -9
- solara/server/static/solara_bootstrap.py +1 -1
- solara/settings.py +3 -0
- solara/tasks.py +18 -8
- solara/test/pytest_plugin.py +1 -0
- solara/toestand.py +33 -2
- solara/util.py +18 -0
- solara/website/components/docs.py +4 -0
- solara/website/components/markdown.py +17 -3
- solara/website/pages/changelog/changelog.md +9 -1
- solara/website/pages/documentation/advanced/content/20-understanding/50-solara-server.md +10 -0
- 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/layout/columns_responsive.py +37 -39
- solara/website/pages/documentation/components/layout/gridfixed.py +4 -6
- solara/website/pages/documentation/components/output/html.py +1 -3
- solara/website/pages/documentation/components/page/head.py +4 -7
- solara/website/pages/documentation/examples/__init__.py +9 -0
- solara/website/pages/documentation/examples/ai/chatbot.py +60 -44
- solara/website/pages/documentation/examples/general/live_update.py +1 -0
- solara/website/pages/documentation/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.43.0.dist-info}/METADATA +2 -2
- {solara_ui-1.42.0.dist-info → solara_ui-1.43.0.dist-info}/RECORD +58 -56
- {solara_ui-1.42.0.data → solara_ui-1.43.0.data}/data/etc/jupyter/jupyter_notebook_config.d/solara.json +0 -0
- {solara_ui-1.42.0.data → solara_ui-1.43.0.data}/data/etc/jupyter/jupyter_server_config.d/solara.json +0 -0
- {solara_ui-1.42.0.dist-info → solara_ui-1.43.0.dist-info}/WHEEL +0 -0
- {solara_ui-1.42.0.dist-info → solara_ui-1.43.0.dist-info}/licenses/LICENSE +0 -0
solara/server/app.py
CHANGED
|
@@ -7,6 +7,7 @@ import sys
|
|
|
7
7
|
import threading
|
|
8
8
|
import traceback
|
|
9
9
|
import warnings
|
|
10
|
+
import weakref
|
|
10
11
|
from enum import Enum
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any, Dict, List, Optional, cast
|
|
@@ -62,6 +63,35 @@ class AppScript:
|
|
|
62
63
|
else:
|
|
63
64
|
self.name = name
|
|
64
65
|
self.path: Path = Path(self.name).resolve()
|
|
66
|
+
if self.path.is_dir():
|
|
67
|
+
self.type = AppType.DIRECTORY
|
|
68
|
+
# resolve the directory, because Path("file").parent.parent == "." != ".."
|
|
69
|
+
self.directory = self.path.resolve()
|
|
70
|
+
elif self.name.endswith(".py"):
|
|
71
|
+
self.type = AppType.SCRIPT
|
|
72
|
+
self.directory = self.path.parent.resolve()
|
|
73
|
+
elif self.name.endswith(".ipynb"):
|
|
74
|
+
self.type = AppType.NOTEBOOK
|
|
75
|
+
self.directory = self.path.parent.resolve()
|
|
76
|
+
else:
|
|
77
|
+
self.type = AppType.MODULE
|
|
78
|
+
try:
|
|
79
|
+
spec = importlib.util.find_spec(self.name)
|
|
80
|
+
except ValueError:
|
|
81
|
+
if self.name not in sys.modules:
|
|
82
|
+
raise ImportError(f"Module {self.name} not found")
|
|
83
|
+
spec = importlib.util.spec_from_file_location(self.name, sys.modules[self.name].__file__)
|
|
84
|
+
if spec is None:
|
|
85
|
+
raise ImportError(f"Module {self.name} cannot be found")
|
|
86
|
+
assert spec is not None
|
|
87
|
+
if spec.origin is None:
|
|
88
|
+
raise ImportError(f"Module {self.name} cannot be found, or is a namespace package")
|
|
89
|
+
assert spec.origin is not None
|
|
90
|
+
self.path = Path(spec.origin)
|
|
91
|
+
self.directory = self.path.parent
|
|
92
|
+
self._initialized = False
|
|
93
|
+
|
|
94
|
+
def init(self):
|
|
65
95
|
try:
|
|
66
96
|
context = kernel_context.get_current_context()
|
|
67
97
|
except RuntimeError:
|
|
@@ -84,6 +114,7 @@ class AppScript:
|
|
|
84
114
|
package_root_path = Path(mod.__file__).parent
|
|
85
115
|
reload.reloader.root_path = package_root_path
|
|
86
116
|
dummy_kernel_context.close()
|
|
117
|
+
self._initialized = True
|
|
87
118
|
|
|
88
119
|
def _execute(self):
|
|
89
120
|
logger.info("Executing %s", self.name)
|
|
@@ -97,10 +128,8 @@ class AppScript:
|
|
|
97
128
|
if working_directory not in sys.path:
|
|
98
129
|
sys.path.insert(0, working_directory)
|
|
99
130
|
|
|
100
|
-
if self.
|
|
101
|
-
self.type = AppType.DIRECTORY
|
|
131
|
+
if self.type == AppType.DIRECTORY:
|
|
102
132
|
# resolve the directory, because Path("file").parent.parent == "." != ".."
|
|
103
|
-
self.directory = self.path.resolve()
|
|
104
133
|
routes = solara.generate_routes_directory(self.path)
|
|
105
134
|
|
|
106
135
|
if any(name for name in sys.modules.keys() if name.startswith(self.name)):
|
|
@@ -110,45 +139,26 @@ class AppScript:
|
|
|
110
139
|
"can avoid this ambiguity."
|
|
111
140
|
)
|
|
112
141
|
|
|
113
|
-
elif self.
|
|
114
|
-
self.type = AppType.SCRIPT
|
|
142
|
+
elif self.type == AppType.SCRIPT:
|
|
115
143
|
add_path()
|
|
116
144
|
# manually add the script to the watcher
|
|
117
145
|
reload.reloader.watcher.add_file(self.path)
|
|
118
|
-
self.directory = self.path.parent.resolve()
|
|
119
146
|
initial_namespace = {
|
|
120
147
|
"__name__": "__main__",
|
|
121
148
|
}
|
|
122
149
|
with reload.reloader.watch():
|
|
123
150
|
routes = [solara.autorouting._generate_route_path(self.path, first=True, initial_namespace=initial_namespace)]
|
|
124
|
-
elif self.
|
|
125
|
-
self.type = AppType.NOTEBOOK
|
|
151
|
+
elif self.type == AppType.NOTEBOOK:
|
|
126
152
|
add_path()
|
|
127
153
|
# manually add the notebook to the watcher
|
|
128
154
|
reload.reloader.watcher.add_file(self.path)
|
|
129
|
-
self.directory = self.path.parent.resolve()
|
|
130
155
|
with reload.reloader.watch():
|
|
131
156
|
routes = [solara.autorouting._generate_route_path(self.path, first=True)]
|
|
132
157
|
else:
|
|
133
158
|
# the module itself will be added by reloader
|
|
134
159
|
# automatically
|
|
135
|
-
with reload.reloader.watch():
|
|
160
|
+
with kernel_context.without_context(), reload.reloader.watch():
|
|
136
161
|
self.type = AppType.MODULE
|
|
137
|
-
try:
|
|
138
|
-
spec = importlib.util.find_spec(self.name)
|
|
139
|
-
except ValueError:
|
|
140
|
-
if self.name not in sys.modules:
|
|
141
|
-
raise ImportError(f"Module {self.name} not found")
|
|
142
|
-
spec = importlib.util.spec_from_file_location(self.name, sys.modules[self.name].__file__)
|
|
143
|
-
if spec is None:
|
|
144
|
-
raise ImportError(f"Module {self.name} cannot be found")
|
|
145
|
-
assert spec is not None
|
|
146
|
-
if spec.origin is None:
|
|
147
|
-
raise ImportError(f"Module {self.name} cannot be found, or is a namespace package")
|
|
148
|
-
assert spec.origin is not None
|
|
149
|
-
self.path = Path(spec.origin)
|
|
150
|
-
self.directory = self.path.parent
|
|
151
|
-
|
|
152
162
|
mod = importlib.import_module(self.name)
|
|
153
163
|
routes = solara.generate_routes(mod)
|
|
154
164
|
|
|
@@ -213,7 +223,12 @@ class AppScript:
|
|
|
213
223
|
for context in context_values:
|
|
214
224
|
context.close()
|
|
215
225
|
|
|
226
|
+
def check(self):
|
|
227
|
+
if not self._initialized:
|
|
228
|
+
raise RuntimeError("Call solara.server.app.ensure_apps_initialized() first")
|
|
229
|
+
|
|
216
230
|
def run(self):
|
|
231
|
+
self.check()
|
|
217
232
|
if reload.reloader.requires_reload or self._first_execute_app is None:
|
|
218
233
|
with thread_lock:
|
|
219
234
|
if reload.reloader.requires_reload or self._first_execute_app is None:
|
|
@@ -420,6 +435,9 @@ def solara_comm_target(comm, msg_first):
|
|
|
420
435
|
|
|
421
436
|
def on_msg(msg):
|
|
422
437
|
nonlocal app
|
|
438
|
+
comm = comm_ref()
|
|
439
|
+
assert comm is not None
|
|
440
|
+
context = kernel_context.get_current_context()
|
|
423
441
|
data = msg["content"]["data"]
|
|
424
442
|
method = data["method"]
|
|
425
443
|
if method == "run":
|
|
@@ -435,7 +453,12 @@ def solara_comm_target(comm, msg_first):
|
|
|
435
453
|
themes = args.get("themes")
|
|
436
454
|
dark = args.get("dark")
|
|
437
455
|
load_themes(themes, dark)
|
|
438
|
-
|
|
456
|
+
try:
|
|
457
|
+
load_app_widget(None, app, path)
|
|
458
|
+
except Exception as e:
|
|
459
|
+
msg = f"Error loading app: from path {path} and app {app_name}"
|
|
460
|
+
logger.exception(msg)
|
|
461
|
+
raise RuntimeError(msg) from e
|
|
439
462
|
comm.send({"method": "finished", "widget_id": context.container._model_id})
|
|
440
463
|
elif method == "app-status":
|
|
441
464
|
context = kernel_context.get_current_context()
|
|
@@ -464,9 +487,10 @@ def solara_comm_target(comm, msg_first):
|
|
|
464
487
|
else:
|
|
465
488
|
logger.error("Unknown comm method called on solara.control comm: %s", method)
|
|
466
489
|
|
|
467
|
-
comm.on_msg(on_msg)
|
|
468
|
-
|
|
469
490
|
def reload():
|
|
491
|
+
comm = comm_ref()
|
|
492
|
+
assert comm is not None
|
|
493
|
+
context = kernel_context.get_current_context()
|
|
470
494
|
# we don't reload the app ourself, we send a message to the client
|
|
471
495
|
# this ensures that we don't run code of any client that for some reason is connected
|
|
472
496
|
# but not working anymore. And it indirectly passes a message from the current thread
|
|
@@ -474,8 +498,11 @@ def solara_comm_target(comm, msg_first):
|
|
|
474
498
|
logger.debug(f"Send reload to client: {context.id}")
|
|
475
499
|
comm.send({"method": "reload"})
|
|
476
500
|
|
|
477
|
-
|
|
478
|
-
|
|
501
|
+
comm.on_msg(on_msg)
|
|
502
|
+
comm_ref = weakref.ref(comm)
|
|
503
|
+
del comm
|
|
504
|
+
|
|
505
|
+
kernel_context.get_current_context().reload = reload
|
|
479
506
|
|
|
480
507
|
|
|
481
508
|
def register_solara_comm_target(kernel: Kernel):
|
|
@@ -489,3 +516,9 @@ patch.patch()
|
|
|
489
516
|
if "SOLARA_APP" in os.environ:
|
|
490
517
|
with pdb_guard():
|
|
491
518
|
apps["__default__"] = AppScript(os.environ.get("SOLARA_APP", "solara.website.pages:Page"))
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
@solara.util.once
|
|
522
|
+
def ensure_apps_initialized():
|
|
523
|
+
for app in apps.values():
|
|
524
|
+
app.init()
|
solara/server/flask.py
CHANGED
|
@@ -65,11 +65,17 @@ class WebsocketWrapper(websocket.WebsocketWrapper):
|
|
|
65
65
|
|
|
66
66
|
def send_text(self, data: str) -> None:
|
|
67
67
|
with self.lock:
|
|
68
|
-
|
|
68
|
+
try:
|
|
69
|
+
self.ws.send(data)
|
|
70
|
+
except simple_websocket.ws.ConnectionClosed:
|
|
71
|
+
raise websocket.WebSocketDisconnect()
|
|
69
72
|
|
|
70
73
|
def send_bytes(self, data: bytes) -> None:
|
|
71
74
|
with self.lock:
|
|
72
|
-
|
|
75
|
+
try:
|
|
76
|
+
self.ws.send(data)
|
|
77
|
+
except simple_websocket.ws.ConnectionClosed:
|
|
78
|
+
raise websocket.WebSocketDisconnect()
|
|
73
79
|
|
|
74
80
|
async def receive(self):
|
|
75
81
|
from anyio import to_thread
|
|
@@ -285,3 +291,7 @@ if has_solara_enterprise:
|
|
|
285
291
|
|
|
286
292
|
if __name__ == "__main__":
|
|
287
293
|
app.run(debug=False, port=8765)
|
|
294
|
+
|
|
295
|
+
# we can only call this at the module level, which means that the solara script cannot import this
|
|
296
|
+
# module. This is a difference with the asgi standard, which provides a lifecycle hook (see starlette.py)
|
|
297
|
+
appmod.ensure_apps_initialized()
|
|
@@ -14,6 +14,7 @@ def _load_jupyter_server_extension(server_app):
|
|
|
14
14
|
import solara.server.app
|
|
15
15
|
|
|
16
16
|
solara.server.app.apps["__default__"] = solara.server.app.AppScript("solara.server.jupyter.solara:Page")
|
|
17
|
+
solara.server.app.apps["__default__"].init()
|
|
17
18
|
|
|
18
19
|
web_app = server_app.web_app
|
|
19
20
|
|
solara/server/kernel.py
CHANGED
|
@@ -54,7 +54,7 @@ def json_default(obj):
|
|
|
54
54
|
import numpy as np
|
|
55
55
|
|
|
56
56
|
if isinstance(obj, np.number):
|
|
57
|
-
return
|
|
57
|
+
return obj.item()
|
|
58
58
|
else:
|
|
59
59
|
raise TypeError("%r is not JSON serializable" % obj)
|
|
60
60
|
else:
|
|
@@ -215,8 +215,20 @@ def send_websockets(websockets: Set[websocket.WebsocketWrapper], binary_msg):
|
|
|
215
215
|
for ws in list(websockets):
|
|
216
216
|
try:
|
|
217
217
|
ws.send(binary_msg)
|
|
218
|
-
except:
|
|
219
|
-
#
|
|
218
|
+
except websocket.WebSocketDisconnect:
|
|
219
|
+
# ignore the exception, we tried to send while websocket closed
|
|
220
|
+
# just remove it from the websocket set
|
|
221
|
+
try:
|
|
222
|
+
# websocket can be modified by another thread
|
|
223
|
+
websockets.remove(ws)
|
|
224
|
+
except KeyError:
|
|
225
|
+
pass # already removed
|
|
226
|
+
except Exception as e: # noqa
|
|
227
|
+
logger.exception("Error sending message: %s, closing websocket", e)
|
|
228
|
+
try:
|
|
229
|
+
ws.close()
|
|
230
|
+
except Exception as e: # noqa
|
|
231
|
+
logger.exception("Error closing websocket: %s", e)
|
|
220
232
|
try:
|
|
221
233
|
# websocket can be modified by another thread
|
|
222
234
|
websockets.remove(ws)
|
|
@@ -248,6 +260,8 @@ class SessionWebsocket(session.Session):
|
|
|
248
260
|
header=None,
|
|
249
261
|
metadata=None,
|
|
250
262
|
):
|
|
263
|
+
if stream is None:
|
|
264
|
+
return # can happen when the kernel is closed but someone was still trying to send a message
|
|
251
265
|
try:
|
|
252
266
|
if isinstance(msg_or_type, dict):
|
|
253
267
|
msg = msg_or_type
|
|
@@ -314,6 +328,39 @@ class Kernel(ipykernel.kernelbase.Kernel):
|
|
|
314
328
|
self.shell.display_pub.session = self.session
|
|
315
329
|
self.shell.display_pub.pub_socket = self.iopub_socket
|
|
316
330
|
|
|
331
|
+
def close(self):
|
|
332
|
+
if self.comm_manager is None:
|
|
333
|
+
raise RuntimeError("Kernel already closed")
|
|
334
|
+
self.session.close()
|
|
335
|
+
self._cleanup_references()
|
|
336
|
+
|
|
337
|
+
def _cleanup_references(self):
|
|
338
|
+
try:
|
|
339
|
+
# all of these reduce the circular references
|
|
340
|
+
# making it easier for the garbage collector to clean up
|
|
341
|
+
self.shell_handlers.clear()
|
|
342
|
+
self.control_handlers.clear()
|
|
343
|
+
for comm_object in list(self.comm_manager.comms.values()): # type: ignore
|
|
344
|
+
comm_object.close()
|
|
345
|
+
self.comm_manager.targets.clear() # type: ignore
|
|
346
|
+
# self.comm_manager.kernel points to us, but we cannot set it to None
|
|
347
|
+
# so we remove the circular reference by setting the comm_manager to None
|
|
348
|
+
self.comm_manager = None # type: ignore
|
|
349
|
+
self.session.parent = None # type: ignore
|
|
350
|
+
|
|
351
|
+
self.shell.display_pub.session = None # type: ignore
|
|
352
|
+
self.shell.display_pub.pub_socket = None # type: ignore
|
|
353
|
+
del self.shell.__dict__
|
|
354
|
+
self.shell = None # type: ignore
|
|
355
|
+
self.session.websockets.clear()
|
|
356
|
+
self.session.stream = None # type: ignore
|
|
357
|
+
self.session = None # type: ignore
|
|
358
|
+
self.stream.session = None # type: ignore
|
|
359
|
+
self.stream = None # type: ignore
|
|
360
|
+
self.iopub_socket = None # type: ignore
|
|
361
|
+
except Exception:
|
|
362
|
+
logger.exception("Error cleaning up references from kernel, not fatal")
|
|
363
|
+
|
|
317
364
|
async def _flush_control_queue(self):
|
|
318
365
|
pass
|
|
319
366
|
|
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
|
|
@@ -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)
|
|
@@ -249,7 +252,8 @@ def auto_watch_get_template(get_template):
|
|
|
249
252
|
|
|
250
253
|
def wrapper(abs_path):
|
|
251
254
|
template = get_template(abs_path)
|
|
252
|
-
|
|
255
|
+
with kernel_context.without_context():
|
|
256
|
+
reload.reloader.watcher.add_file(abs_path)
|
|
253
257
|
return template
|
|
254
258
|
|
|
255
259
|
return wrapper
|
|
@@ -272,10 +276,13 @@ def WidgetContextAwareThread__init__(self, *args, **kwargs):
|
|
|
272
276
|
ThreadDebugInfo.created += 1
|
|
273
277
|
|
|
274
278
|
self.current_context = None
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
+
# if we do this for the dummy threads, we got into a recursion
|
|
280
|
+
# since threading.current_thread will call the _DummyThread constructor
|
|
281
|
+
if not ("name" in kwargs and "Dummy-" in kwargs["name"]):
|
|
282
|
+
try:
|
|
283
|
+
self.current_context = kernel_context.get_current_context()
|
|
284
|
+
except RuntimeError:
|
|
285
|
+
logger.debug(f"No context for thread {self._name}")
|
|
279
286
|
|
|
280
287
|
|
|
281
288
|
def WidgetContextAwareThread__bootstrap(self):
|
|
@@ -291,6 +298,7 @@ def WidgetContextAwareThread__bootstrap(self):
|
|
|
291
298
|
|
|
292
299
|
def _WidgetContextAwareThread__bootstrap(self):
|
|
293
300
|
if not hasattr(self, "current_context"):
|
|
301
|
+
# this happens when a thread was running before we patched
|
|
294
302
|
return Thread__bootstrap(self)
|
|
295
303
|
if self.current_context:
|
|
296
304
|
# we need to call this manually, because set_context_for_thread
|
|
@@ -299,15 +307,20 @@ def _WidgetContextAwareThread__bootstrap(self):
|
|
|
299
307
|
if kernel_context.async_context_id is not None:
|
|
300
308
|
kernel_context.async_context_id.set(self.current_context.id)
|
|
301
309
|
kernel_context.set_context_for_thread(self.current_context, self)
|
|
302
|
-
|
|
303
310
|
shell = self.current_context.kernel.shell
|
|
304
|
-
shell.display_pub
|
|
311
|
+
display_pub = shell.display_pub
|
|
312
|
+
display_in_reacton_hook = shell.display_in_reacton_hook
|
|
313
|
+
display_pub.register_hook(display_in_reacton_hook)
|
|
305
314
|
try:
|
|
306
|
-
|
|
315
|
+
context = self.current_context or solara.util.nullcontext()
|
|
316
|
+
with pdb_guard(), context:
|
|
307
317
|
Thread__bootstrap(self)
|
|
308
318
|
finally:
|
|
309
|
-
|
|
310
|
-
|
|
319
|
+
current_context = self.current_context
|
|
320
|
+
self.current_context = None
|
|
321
|
+
kernel_context.clear_context_for_thread(self)
|
|
322
|
+
if current_context:
|
|
323
|
+
display_pub.unregister_hook(display_in_reacton_hook)
|
|
311
324
|
|
|
312
325
|
|
|
313
326
|
_patched = False
|
|
@@ -354,24 +367,7 @@ def patch_ipyreact():
|
|
|
354
367
|
ipyreact.importmap._update_import_map = lambda: None
|
|
355
368
|
|
|
356
369
|
|
|
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
|
|
370
|
+
@solara.util.once
|
|
375
371
|
def patch_matplotlib():
|
|
376
372
|
import matplotlib
|
|
377
373
|
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,10 +172,15 @@ 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)
|
|
@@ -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
|
|