solara-ui 1.41.0__py2.py3-none-any.whl → 1.43.0__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. solara/__init__.py +1 -1
  2. solara/__main__.py +17 -6
  3. solara/_stores.py +189 -0
  4. solara/components/__init__.py +18 -1
  5. solara/components/component_vue.py +23 -0
  6. solara/components/datatable.py +4 -4
  7. solara/components/echarts.py +5 -2
  8. solara/components/echarts.vue +22 -5
  9. solara/components/file_drop.py +20 -0
  10. solara/components/input.py +21 -1
  11. solara/components/markdown.py +62 -17
  12. solara/components/misc.py +2 -2
  13. solara/components/spinner-solara.vue +2 -2
  14. solara/components/spinner.py +17 -2
  15. solara/hooks/use_reactive.py +8 -1
  16. solara/lab/components/__init__.py +1 -0
  17. solara/lab/components/chat.py +3 -3
  18. solara/lab/components/input_time.py +133 -0
  19. solara/lab/hooks/dataframe.py +1 -0
  20. solara/lab/utils/dataframe.py +11 -1
  21. solara/reactive.py +9 -3
  22. solara/server/app.py +63 -30
  23. solara/server/flask.py +12 -2
  24. solara/server/jupyter/server_extension.py +1 -0
  25. solara/server/kernel.py +52 -4
  26. solara/server/kernel_context.py +66 -7
  27. solara/server/patch.py +25 -29
  28. solara/server/qt.py +1 -1
  29. solara/server/server.py +15 -5
  30. solara/server/settings.py +11 -0
  31. solara/server/shell.py +19 -1
  32. solara/server/starlette.py +39 -11
  33. solara/server/static/solara_bootstrap.py +1 -1
  34. solara/settings.py +17 -0
  35. solara/tasks.py +18 -8
  36. solara/template/portal/pyproject.toml +1 -1
  37. solara/test/pytest_plugin.py +4 -0
  38. solara/toestand.py +170 -16
  39. solara/util.py +40 -0
  40. solara/website/components/docs.py +4 -0
  41. solara/website/components/markdown.py +60 -2
  42. solara/website/pages/changelog/changelog.md +17 -0
  43. solara/website/pages/documentation/advanced/content/20-understanding/50-solara-server.md +10 -0
  44. solara/website/pages/documentation/api/cross_filter/cross_filter_dataframe.py +4 -5
  45. solara/website/pages/documentation/api/cross_filter/cross_filter_report.py +3 -5
  46. solara/website/pages/documentation/api/cross_filter/cross_filter_select.py +3 -5
  47. solara/website/pages/documentation/api/cross_filter/cross_filter_slider.py +3 -5
  48. solara/website/pages/documentation/api/hooks/use_cross_filter.py +3 -5
  49. solara/website/pages/documentation/api/hooks/use_exception.py +9 -11
  50. solara/website/pages/documentation/api/hooks/use_previous.py +6 -9
  51. solara/website/pages/documentation/api/hooks/use_state_or_update.py +23 -26
  52. solara/website/pages/documentation/api/hooks/use_thread.py +11 -13
  53. solara/website/pages/documentation/api/routing/route.py +10 -12
  54. solara/website/pages/documentation/api/routing/use_route.py +26 -30
  55. solara/website/pages/documentation/api/utilities/on_kernel_start.py +17 -0
  56. solara/website/pages/documentation/components/advanced/link.py +6 -8
  57. solara/website/pages/documentation/components/advanced/meta.py +6 -9
  58. solara/website/pages/documentation/components/advanced/style.py +7 -9
  59. solara/website/pages/documentation/components/input/file_browser.py +12 -14
  60. solara/website/pages/documentation/components/input/input.py +22 -0
  61. solara/website/pages/documentation/components/lab/input_time.py +15 -0
  62. solara/website/pages/documentation/components/layout/columns_responsive.py +37 -39
  63. solara/website/pages/documentation/components/layout/gridfixed.py +4 -6
  64. solara/website/pages/documentation/components/output/html.py +1 -3
  65. solara/website/pages/documentation/components/page/head.py +4 -7
  66. solara/website/pages/documentation/components/viz/echarts.py +3 -1
  67. solara/website/pages/documentation/examples/__init__.py +9 -0
  68. solara/website/pages/documentation/examples/ai/chatbot.py +60 -44
  69. solara/website/pages/documentation/examples/general/live_update.py +1 -0
  70. solara/website/pages/documentation/examples/general/pokemon_search.py +3 -3
  71. solara/website/pages/documentation/examples/visualization/linked_views.py +0 -3
  72. solara/website/pages/documentation/faq/content/99-faq.md +9 -0
  73. solara/website/pages/documentation/getting_started/content/00-quickstart.md +2 -2
  74. solara/website/pages/documentation/getting_started/content/01-introduction.md +1 -1
  75. solara/website/pages/documentation/getting_started/content/04-tutorials/_jupyter_dashboard_1.ipynb +23 -19
  76. solara/website/pages/documentation/getting_started/content/07-deploying/10-self-hosted.md +2 -2
  77. solara/website/pages/roadmap/roadmap.md +6 -0
  78. {solara_ui-1.41.0.dist-info → solara_ui-1.43.0.dist-info}/METADATA +9 -6
  79. {solara_ui-1.41.0.dist-info → solara_ui-1.43.0.dist-info}/RECORD +83 -80
  80. {solara_ui-1.41.0.dist-info → solara_ui-1.43.0.dist-info}/WHEEL +1 -1
  81. {solara_ui-1.41.0.data → solara_ui-1.43.0.data}/data/etc/jupyter/jupyter_notebook_config.d/solara.json +0 -0
  82. {solara_ui-1.41.0.data → solara_ui-1.43.0.data}/data/etc/jupyter/jupyter_server_config.d/solara.json +0 -0
  83. {solara_ui-1.41.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.path.is_dir():
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.name.endswith(".py"):
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.name.endswith(".ipynb"):
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
- load_app_widget(None, app, path)
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
- context = kernel_context.get_current_context()
478
- context.reload = reload
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
- self.ws.send(data)
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
- self.ws.send(data)
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
@@ -2,6 +2,7 @@ import json
2
2
  import logging
3
3
  import pdb
4
4
  import queue
5
+ import re
5
6
  import struct
6
7
  import warnings
7
8
  from binascii import b2a_base64
@@ -53,7 +54,7 @@ def json_default(obj):
53
54
  import numpy as np
54
55
 
55
56
  if isinstance(obj, np.number):
56
- return repr(obj.item())
57
+ return obj.item()
57
58
  else:
58
59
  raise TypeError("%r is not JSON serializable" % obj)
59
60
  else:
@@ -73,7 +74,7 @@ def json_dumps(data):
73
74
  )
74
75
 
75
76
 
76
- ipykernel_version = tuple(map(int, ipykernel.__version__.split(".")))
77
+ ipykernel_version = tuple(map(int, re.split(r"\D+", ipykernel.__version__)[:3]))
77
78
  if ipykernel_version >= (6, 18, 0):
78
79
  import comm.base_comm
79
80
 
@@ -214,8 +215,20 @@ def send_websockets(websockets: Set[websocket.WebsocketWrapper], binary_msg):
214
215
  for ws in list(websockets):
215
216
  try:
216
217
  ws.send(binary_msg)
217
- except: # noqa
218
- # in case of any issue, we simply remove it from the list
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)
219
232
  try:
220
233
  # websocket can be modified by another thread
221
234
  websockets.remove(ws)
@@ -247,6 +260,8 @@ class SessionWebsocket(session.Session):
247
260
  header=None,
248
261
  metadata=None,
249
262
  ):
263
+ if stream is None:
264
+ return # can happen when the kernel is closed but someone was still trying to send a message
250
265
  try:
251
266
  if isinstance(msg_or_type, dict):
252
267
  msg = msg_or_type
@@ -313,6 +328,39 @@ class Kernel(ipykernel.kernelbase.Kernel):
313
328
  self.shell.display_pub.session = self.session
314
329
  self.shell.display_pub.pub_socket = self.iopub_socket
315
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
+
316
364
  async def _flush_control_queue(self):
317
365
  pass
318
366
 
@@ -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.session.close()
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
- current_event_loop.call_soon_threadsafe(future.set_result, None)
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
- if sys.version_info >= (3, 9):
191
- current_event_loop.call_soon_threadsafe(future.cancel, "cancelled because a new cull task was scheduled")
192
- else:
193
- current_event_loop.call_soon_threadsafe(future.cancel)
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
- asyncio.run_coroutine_threadsafe(create_task(), keep_alive_event_loop)
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
- reload.reloader.watcher.add_file(abs_path)
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
- try:
276
- self.current_context = kernel_context.get_current_context()
277
- except RuntimeError:
278
- logger.debug(f"No context for thread {self}")
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.register_hook(shell.display_in_reacton_hook)
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
- with pdb_guard():
315
+ context = self.current_context or solara.util.nullcontext()
316
+ with pdb_guard(), context:
307
317
  Thread__bootstrap(self)
308
318
  finally:
309
- if self.current_context:
310
- shell.display_pub.unregister_hook(shell.display_in_reacton_hook)
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
- def once(f):
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/qt.py CHANGED
@@ -81,7 +81,7 @@ class QWebEngineViewWithPopup(QWebEngineView):
81
81
 
82
82
 
83
83
  def run_qt(url):
84
- app = QApplication([])
84
+ app = QApplication(["Solara App"])
85
85
  web = QWebEngineViewWithPopup()
86
86
  web.setUrl(QtCore.QUrl(url))
87
87
  web.resize(1024, 1024)
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.websockets.remove(ws)
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
- if not process_kernel_messages(kernel, msg):
172
- # if we shut down the kernel, we do not keep the page session alive
173
- context.close()
174
- return
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()