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.
Files changed (71) hide show
  1. solara/__init__.py +1 -1
  2. solara/__main__.py +12 -7
  3. solara/_stores.py +128 -16
  4. solara/cache.py +6 -4
  5. solara/checks.py +1 -1
  6. solara/components/__init__.py +18 -1
  7. solara/components/datatable.py +4 -4
  8. solara/components/input.py +5 -1
  9. solara/components/markdown.py +46 -10
  10. solara/components/misc.py +2 -2
  11. solara/components/select.py +1 -1
  12. solara/components/style.py +1 -1
  13. solara/hooks/use_reactive.py +16 -1
  14. solara/lab/components/__init__.py +1 -0
  15. solara/lab/components/chat.py +15 -9
  16. solara/lab/components/input_time.py +133 -0
  17. solara/lab/hooks/dataframe.py +1 -0
  18. solara/lab/utils/dataframe.py +11 -1
  19. solara/server/app.py +66 -30
  20. solara/server/flask.py +12 -2
  21. solara/server/jupyter/server_extension.py +1 -0
  22. solara/server/kernel.py +50 -3
  23. solara/server/kernel_context.py +68 -9
  24. solara/server/patch.py +28 -30
  25. solara/server/server.py +16 -6
  26. solara/server/settings.py +11 -0
  27. solara/server/shell.py +19 -1
  28. solara/server/starlette.py +72 -14
  29. solara/server/static/solara_bootstrap.py +1 -1
  30. solara/settings.py +3 -0
  31. solara/tasks.py +30 -9
  32. solara/test/pytest_plugin.py +4 -2
  33. solara/toestand.py +119 -28
  34. solara/util.py +18 -0
  35. solara/website/components/docs.py +24 -1
  36. solara/website/components/markdown.py +17 -3
  37. solara/website/pages/changelog/changelog.md +26 -1
  38. solara/website/pages/documentation/advanced/content/10-howto/20-layout.md +1 -1
  39. solara/website/pages/documentation/advanced/content/20-understanding/50-solara-server.md +10 -0
  40. solara/website/pages/documentation/advanced/content/30-enterprise/10-oauth.md +4 -2
  41. solara/website/pages/documentation/api/routing/route.py +10 -12
  42. solara/website/pages/documentation/api/routing/use_route.py +26 -30
  43. solara/website/pages/documentation/components/advanced/link.py +6 -8
  44. solara/website/pages/documentation/components/advanced/meta.py +6 -9
  45. solara/website/pages/documentation/components/advanced/style.py +7 -9
  46. solara/website/pages/documentation/components/input/file_browser.py +12 -14
  47. solara/website/pages/documentation/components/lab/input_time.py +15 -0
  48. solara/website/pages/documentation/components/lab/theming.py +6 -4
  49. solara/website/pages/documentation/components/layout/columns_responsive.py +37 -39
  50. solara/website/pages/documentation/components/layout/gridfixed.py +4 -6
  51. solara/website/pages/documentation/components/output/html.py +1 -3
  52. solara/website/pages/documentation/components/output/sql_code.py +23 -25
  53. solara/website/pages/documentation/components/page/head.py +4 -7
  54. solara/website/pages/documentation/components/page/title.py +12 -14
  55. solara/website/pages/documentation/components/status/error.py +17 -18
  56. solara/website/pages/documentation/components/status/info.py +17 -18
  57. solara/website/pages/documentation/examples/__init__.py +10 -0
  58. solara/website/pages/documentation/examples/ai/chatbot.py +62 -44
  59. solara/website/pages/documentation/examples/general/live_update.py +22 -28
  60. solara/website/pages/documentation/examples/general/pokemon_search.py +1 -1
  61. solara/website/pages/documentation/faq/content/99-faq.md +9 -0
  62. solara/website/pages/documentation/getting_started/content/00-quickstart.md +1 -1
  63. solara/website/pages/documentation/getting_started/content/04-tutorials/_jupyter_dashboard_1.ipynb +23 -19
  64. solara/website/pages/documentation/getting_started/content/07-deploying/10-self-hosted.md +2 -2
  65. solara/website/pages/roadmap/roadmap.md +3 -0
  66. {solara_ui-1.42.0.dist-info → solara_ui-1.44.0.dist-info}/METADATA +2 -2
  67. {solara_ui-1.42.0.dist-info → solara_ui-1.44.0.dist-info}/RECORD +71 -69
  68. {solara_ui-1.42.0.data → solara_ui-1.44.0.data}/data/etc/jupyter/jupyter_notebook_config.d/solara.json +0 -0
  69. {solara_ui-1.42.0.data → solara_ui-1.44.0.data}/data/etc/jupyter/jupyter_server_config.d/solara.json +0 -0
  70. {solara_ui-1.42.0.dist-info → solara_ui-1.44.0.dist-info}/WHEEL +0 -0
  71. {solara_ui-1.42.0.dist-info → solara_ui-1.44.0.dist-info}/licenses/LICENSE +0 -0
@@ -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, kernel_context, websocket
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["kernel_context.VirtualKernelContext"]]] = None
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.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)
@@ -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 None
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
- reload.reloader.watcher.add_file(abs_path)
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
- try:
276
- self.current_context = kernel_context.get_current_context()
277
- except RuntimeError:
278
- logger.debug(f"No context for thread {self}")
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.register_hook(shell.display_in_reacton_hook)
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
- with pdb_guard():
317
+ context = self.current_context or solara.util.nullcontext()
318
+ with pdb_guard(), context:
307
319
  Thread__bootstrap(self)
308
320
  finally:
309
- if self.current_context:
310
- shell.display_pub.unregister_hook(shell.display_in_reacton_hook)
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
- 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
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.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,17 +172,22 @@ 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)
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
 
@@ -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.ws.send_bytes(first)
126
+ await self._send_bytes_exc(first)
125
127
  else:
126
- await self.ws.send_text(first)
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) # type: ignore
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.ws.send_text(data))
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.portal.call(self.ws.send_bytes, data) # type: ignore
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.ws.send_bytes(data))
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.portal.call(self.ws.send_bytes, data) # type: ignore
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) # type: ignore
211
+ fut = self.portal.start_task_soon(self.ws.receive)
163
212
  else:
164
- fut = self.portal.spawn_task(self.ws.receive) # type: ignore
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['scheme']!r} as given by the asgi framework (probably uvicorn)
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['script-name']!r}
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['x-script-name']!r}
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['scheme']!r}
456
- and the x-forwarded-proto header is {request.headers.get('x-forwarded-proto', 'http')!r}. We will fallback to samesite=lax.
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.42.0-py2.py3-none-any.whl", keep_going=True)
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_"