solara-ui 1.31.0__py2.py3-none-any.whl → 1.32.1__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. solara/__init__.py +1 -1
  2. solara/components/applayout.py +6 -2
  3. solara/components/datatable.py +5 -12
  4. solara/components/markdown.py +38 -27
  5. solara/lab/hooks/dataframe.py +1 -12
  6. solara/lab/utils/dataframe.py +40 -0
  7. solara/minisettings.py +13 -5
  8. solara/server/flask.py +3 -5
  9. solara/server/kernel_context.py +110 -60
  10. solara/server/server.py +8 -4
  11. solara/server/settings.py +23 -1
  12. solara/server/starlette.py +4 -5
  13. solara/server/static/main-vuetify.js +3 -1
  14. solara/server/static/solara_bootstrap.py +1 -1
  15. solara/server/templates/solara.html.j2 +4 -4
  16. solara/tasks.py +19 -10
  17. solara/toestand.py +22 -13
  18. solara/website/assets/custom.css +13 -0
  19. solara/website/components/algolia.py +6 -0
  20. solara/website/components/algolia_api.vue +2 -1
  21. solara/website/components/header.py +9 -17
  22. solara/website/components/notebook.py +1 -1
  23. solara/website/components/sidebar.py +91 -0
  24. solara/website/pages/__init__.py +25 -67
  25. solara/website/pages/changelog/__init__.py +2 -0
  26. solara/website/pages/changelog/changelog.md +25 -0
  27. solara/website/pages/contact/__init__.py +2 -0
  28. solara/website/pages/documentation/__init__.py +2 -88
  29. solara/website/pages/documentation/advanced/content/10-howto/30-testing.md +267 -16
  30. solara/website/pages/documentation/advanced/content/15-reference/41-asset-files.md +36 -0
  31. solara/website/pages/documentation/advanced/content/20-understanding/40-routing.md +4 -4
  32. solara/website/pages/documentation/advanced/content/30-enterprise/10-oauth.md +11 -2
  33. solara/website/pages/documentation/advanced/content/40-development/10-setup.md +1 -1
  34. solara/website/pages/documentation/faq/content/99-faq.md +27 -0
  35. solara/website/pages/documentation/getting_started/content/02-installing.md +2 -2
  36. solara/website/pages/documentation/getting_started/content/04-tutorials/60-jupyter-dashboard-part1.py +50 -49
  37. solara/website/pages/documentation/getting_started/content/07-deploying/10-self-hosted.md +20 -4
  38. {solara_ui-1.31.0.dist-info → solara_ui-1.32.1.dist-info}/METADATA +2 -2
  39. {solara_ui-1.31.0.dist-info → solara_ui-1.32.1.dist-info}/RECORD +43 -41
  40. {solara_ui-1.31.0.data → solara_ui-1.32.1.data}/data/etc/jupyter/jupyter_notebook_config.d/solara.json +0 -0
  41. {solara_ui-1.31.0.data → solara_ui-1.32.1.data}/data/etc/jupyter/jupyter_server_config.d/solara.json +0 -0
  42. {solara_ui-1.31.0.dist-info → solara_ui-1.32.1.dist-info}/WHEEL +0 -0
  43. {solara_ui-1.31.0.dist-info → solara_ui-1.32.1.dist-info}/licenses/LICENSE +0 -0
solara/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Build webapps using IPywidgets"""
2
2
 
3
- __version__ = "1.31.0"
3
+ __version__ = "1.32.1"
4
4
  github_url = "https://github.com/widgetti/solara"
5
5
  git_branch = "master"
6
6
 
@@ -310,8 +310,12 @@ def AppLayout(
310
310
  if title or children_appbartitle:
311
311
  v.ToolbarTitle(children=children_appbartitle or [title])
312
312
  v.Spacer()
313
- for child in children_appbar:
314
- solara.display(child)
313
+ for i, child in enumerate(children_appbar):
314
+ # if the user already provided a key, don't override it
315
+ if child._key is None:
316
+ solara.display(child.key(f"app-layout-appbar-user-child-{i}"))
317
+ else:
318
+ solara.display(child)
315
319
  solara.Button(icon_name="mdi-fullscreen", on_click=lambda: set_fullscreen(True), icon=True, dark=False)
316
320
  with v.Row(no_gutters=False, class_="solara-content-main"):
317
321
  v.Col(cols=12, children=children_content)
@@ -11,7 +11,7 @@ import solara.hooks.dataframe
11
11
  import solara.lab
12
12
  import traitlets
13
13
  from solara.lab.hooks.dataframe import use_df_column_names
14
- from solara.lab.utils.dataframe import df_type
14
+ from solara.lab.utils.dataframe import df_len, df_records, df_slice
15
15
 
16
16
  from .. import CellAction, ColumnAction
17
17
 
@@ -89,7 +89,7 @@ def DataTable(
89
89
  on_column_header_hover: Optional[Callable[[Optional[str]], None]] = None,
90
90
  column_header_info: Optional[solara.Element] = None,
91
91
  ):
92
- total_length = len(df)
92
+ total_length = df_len(df)
93
93
  options = {"descending": False, "page": page + 1, "itemsPerPage": items_per_page, "sortBy": [], "totalItems": total_length}
94
94
  options, set_options = solara.use_state(options, key="options")
95
95
  format = format or format_default
@@ -102,19 +102,12 @@ def DataTable(
102
102
  columns = use_df_column_names(df)
103
103
 
104
104
  items = []
105
- column_data = {}
106
- dfs = df[i1:i2]
107
-
108
- if df_type(df) == "pandas":
109
- column_data = dfs[columns].to_dict("records")
110
- elif df_type(df) == "polars":
111
- column_data = dfs[columns].to_dicts()
112
- else:
113
- column_data = dfs[columns].to_records()
105
+ dfs = df_slice(df, i1, i2)
106
+ records = df_records(dfs)
114
107
  for i in range(i2 - i1):
115
108
  item = {"__row__": i + i1} # special key for the row number
116
109
  for column in columns:
117
- item[column] = format(dfs, column, i + i1, column_data[i][column])
110
+ item[column] = format(dfs, column, i + i1, records[i][column])
118
111
  items.append(item)
119
112
 
120
113
  headers = [{"text": name, "value": name, "sortable": False} for name in columns]
@@ -341,34 +341,45 @@ def Markdown(md_text: str, unsafe_solara_execute=False, style: Union[str, Dict,
341
341
  logger.exception("Error highlighting code: %s", src)
342
342
  return repr(e)
343
343
 
344
- return markdown.Markdown( # type: ignore
345
- extensions=[
346
- "pymdownx.highlight",
347
- "pymdownx.superfences",
348
- "pymdownx.emoji",
349
- "toc", # so we get anchors for h1 h2 etc
350
- "tables",
351
- ],
352
- extension_configs={
353
- "pymdownx.emoji": {
354
- "emoji_index": _no_deep_copy_emojione,
344
+ if has_pymdownx:
345
+ return markdown.Markdown( # type: ignore
346
+ extensions=[
347
+ "pymdownx.highlight",
348
+ "pymdownx.superfences",
349
+ "pymdownx.emoji",
350
+ "toc", # so we get anchors for h1 h2 etc
351
+ "tables",
352
+ ],
353
+ extension_configs={
354
+ "pymdownx.emoji": {
355
+ "emoji_index": _no_deep_copy_emojione,
356
+ },
357
+ "pymdownx.superfences": {
358
+ "custom_fences": [
359
+ {
360
+ "name": "mermaid",
361
+ "class": "mermaid",
362
+ "format": pymdownx.superfences.fence_div_format,
363
+ },
364
+ {
365
+ "name": "solara",
366
+ "class": "",
367
+ "format": highlight,
368
+ },
369
+ ],
370
+ },
355
371
  },
356
- "pymdownx.superfences": {
357
- "custom_fences": [
358
- {
359
- "name": "mermaid",
360
- "class": "mermaid",
361
- "format": pymdownx.superfences.fence_div_format,
362
- },
363
- {
364
- "name": "solara",
365
- "class": "",
366
- "format": highlight,
367
- },
368
- ],
369
- },
370
- },
371
- )
372
+ )
373
+ else:
374
+ logger.warning("Pymdownx not installed, using default markdown. For a better experience, install pymdownx.")
375
+ return markdown.Markdown( # type: ignore
376
+ extensions=[
377
+ "fenced_code",
378
+ "codehilite",
379
+ "toc",
380
+ "tables",
381
+ ],
382
+ )
372
383
 
373
384
  md = solara.use_memo(make_markdown_object, dependencies=[unsafe_solara_execute])
374
385
  html = md.convert(md_text)
@@ -1,12 +1 @@
1
- from ..utils.dataframe import df_type
2
-
3
-
4
- def use_df_column_names(df):
5
- if df_type(df) == "vaex":
6
- return df.get_column_names()
7
- elif df_type(df) == "pandas":
8
- return df.columns.tolist()
9
- elif df_type(df) == "polars":
10
- return df.columns
11
- else:
12
- raise TypeError(f"{type(df)} not supported")
1
+ from ..utils.dataframe import df_columns as use_df_column_names # noqa: F401
@@ -1,3 +1,6 @@
1
+ from typing import List
2
+
3
+
1
4
  def get_pandas_major():
2
5
  import pandas as pd
3
6
 
@@ -8,6 +11,43 @@ def df_type(df):
8
11
  return df.__class__.__module__.split(".")[0]
9
12
 
10
13
 
14
+ def df_len(df) -> int:
15
+ """Return the number of rows in a dataframe."""
16
+ return len(df)
17
+
18
+
19
+ def df_columns(df) -> List[str]:
20
+ """Return a list of column names from a dataframe."""
21
+ if df_type(df) == "vaex":
22
+ return df.get_column_names()
23
+ elif df_type(df) == "pandas":
24
+ return df.columns.tolist()
25
+ elif df_type(df) == "polars":
26
+ return df.columns
27
+ else:
28
+ raise TypeError(f"{type(df)} not supported")
29
+
30
+
31
+ def df_slice(df, start: int, stop: int):
32
+ """Return a subset of rows from a dataframe."""
33
+ if df_type(df) == "pandas":
34
+ return df.iloc[start:stop]
35
+ else:
36
+ return df[start:stop]
37
+
38
+
39
+ def df_records(df) -> List[dict]:
40
+ """A list of records from a dataframe."""
41
+ if df_type(df) == "pandas":
42
+ return df.to_dict("records")
43
+ elif df_type(df) == "polars":
44
+ return df.to_dicts()
45
+ elif df_type(df) == "vaex":
46
+ return df.to_records()
47
+ else:
48
+ raise TypeError(f"{type(df)} not supported")
49
+
50
+
11
51
  def df_unique(df, column, limit=None):
12
52
  if df_type(df) == "vaex":
13
53
  return df.unique(column, limit=limit + 1 if limit else None, limit_raise=False)
solara/minisettings.py CHANGED
@@ -1,6 +1,7 @@
1
+ import inspect
1
2
  import os
2
3
  from pathlib import Path
3
- from typing import Any, Optional
4
+ from typing import Any, Optional, List, Type
4
5
 
5
6
  # similar API to pydantic/pydantic-settings but we prefer not to have a dependency on pydantic
6
7
  # since we cannot be compatible with pydantic1 and 2
@@ -61,11 +62,16 @@ class _Field:
61
62
 
62
63
 
63
64
  def convert(annotation, value: str) -> Any:
64
- check_optional_types = [str, int, float, bool, Path]
65
- for check_type in check_optional_types:
66
- if annotation == Optional[check_type]:
67
- annotation = check_type
65
+ check_sub_types: List[Type] = [str, int, float, bool, Path]
66
+ for sub_type in check_sub_types:
67
+ if annotation == Optional[sub_type]:
68
+ annotation = sub_type
68
69
  return convert(annotation, value)
70
+ for sub_type in check_sub_types:
71
+ if annotation == List[sub_type]: # type: ignore
72
+ annotation = sub_type
73
+ values = value.split(",")
74
+ return [convert(sub_type, k) for k in values]
69
75
  if annotation == str:
70
76
  return value
71
77
  elif annotation == int:
@@ -120,6 +126,8 @@ class BaseSettings:
120
126
  if key == "Config":
121
127
  continue
122
128
  if not isinstance(field, _Field):
129
+ if inspect.isfunction(field):
130
+ continue
123
131
  field = Field(field)
124
132
  setattr(cls, key, field)
125
133
  field.__set_name__(cls, key)
solara/server/flask.py CHANGED
@@ -162,8 +162,8 @@ def kernels_connection(ws: simple_websocket.Server, kernel_id: str, name: str):
162
162
  @blueprint.route("/_solara/api/close/<kernel_id>", methods=["GET", "POST"])
163
163
  def close(kernel_id: str):
164
164
  page_id = request.args["session_id"]
165
- if kernel_id in kernel_context.contexts:
166
- context = kernel_context.contexts[kernel_id]
165
+ context = kernel_context.contexts.get(kernel_id, None)
166
+ if context is not None:
167
167
  context.page_close(page_id)
168
168
  return ""
169
169
 
@@ -184,9 +184,7 @@ def public(path):
184
184
  def assets(path):
185
185
  if not allowed():
186
186
  abort(401)
187
- overrides = [app.directory.parent / "assets" for app in appmod.apps.values()]
188
- default = server.solara_static.parent / "assets"
189
- directories = [*overrides, default]
187
+ directories = server.asset_directories()
190
188
  for directory in directories:
191
189
  file = directory / path
192
190
  if file.exists():
@@ -73,6 +73,7 @@ class VirtualKernelContext:
73
73
  _last_kernel_cull_task: "Optional[asyncio.Future[None]]" = None
74
74
  closed_event: threading.Event = dataclasses.field(default_factory=threading.Event)
75
75
  _on_close_callbacks: List[Callable[[], None]] = dataclasses.field(default_factory=list)
76
+ lock: threading.RLock = dataclasses.field(default_factory=threading.RLock)
76
77
 
77
78
  def __post_init__(self):
78
79
  with self:
@@ -108,14 +109,15 @@ class VirtualKernelContext:
108
109
  current_context[key] = local.kernel_context_stack.pop()
109
110
 
110
111
  def close(self):
111
- if self.closed_event.is_set():
112
- logger.error("Tried to close a kernel context that is already closed: %s", self.id)
113
- return
114
- logger.info("Shut down virtual kernel: %s", self.id)
115
- with self:
112
+ with self, self.lock:
113
+ for key in self.page_status:
114
+ self.page_status[key] = PageStatus.CLOSED
115
+ if self.closed_event.is_set():
116
+ logger.error("Tried to close a kernel context that is already closed: %s", self.id)
117
+ return
118
+ logger.info("Shut down virtual kernel: %s", self.id)
116
119
  for f in reversed(self._on_close_callbacks):
117
120
  f()
118
- with self:
119
121
  if self.app_object is not None:
120
122
  if isinstance(self.app_object, reacton.core._RenderContext):
121
123
  try:
@@ -128,9 +130,9 @@ class VirtualKernelContext:
128
130
  # import gc
129
131
  # gc.collect()
130
132
  self.kernel.session.close()
131
- if self.id in contexts:
132
- del contexts[self.id]
133
- self.closed_event.set()
133
+ if self.id in contexts:
134
+ del contexts[self.id]
135
+ self.closed_event.set()
134
136
 
135
137
  def _state_reset(self):
136
138
  state_directory = Path(".") / "states"
@@ -157,77 +159,125 @@ class VirtualKernelContext:
157
159
 
158
160
  def page_connect(self, page_id: str):
159
161
  logger.info("Connect page %s for kernel %s", page_id, self.id)
160
- assert self.page_status.get(page_id) != PageStatus.CLOSED, "cannot connect with the same page_id after a close"
161
- self.page_status[page_id] = PageStatus.CONNECTED
162
- if self._last_kernel_cull_task:
163
- self._last_kernel_cull_task.cancel()
164
-
165
- def page_disconnect(self, page_id: str) -> "asyncio.Future[None]":
166
- """Signal that a page has disconnected, and schedule a kernel cull if needed.
167
-
168
- During the kernel reconnect window, we will keep the kernel alive, even if all pages have disconnected.
169
-
170
- Returns a future that is set when the kernel cull is done.
171
- The scheduled kernel cull can be cancelled when a new page connects, a new disconnect is scheduled,
172
- or a page if explicitly closed.
173
- """
174
- logger.info("Disconnect page %s for kernel %s", page_id, self.id)
175
- future: "asyncio.Future[None]" = asyncio.Future()
176
- self.page_status[page_id] = PageStatus.DISCONNECTED
177
- current_event_loop = asyncio.get_event_loop()
162
+ with self.lock:
163
+ if self.closed_event.is_set():
164
+ raise RuntimeError("Cannot connect a page to a closed kernel")
165
+ if page_id in self.page_status and self.page_status.get(page_id) == PageStatus.CLOSED:
166
+ raise RuntimeError("Cannot connect a page that is already closed")
167
+ self.page_status[page_id] = PageStatus.CONNECTED
168
+ if self._last_kernel_cull_task:
169
+ logger.info("Cancelling previous kernel cull task for virtual kernel %s", self.id)
170
+ self._last_kernel_cull_task.cancel()
178
171
 
172
+ def _bump_kernel_cull(self):
179
173
  async def kernel_cull():
180
174
  try:
181
175
  cull_timeout_sleep_seconds = solara.util.parse_timedelta(solara.server.settings.kernel.cull_timeout)
182
176
  logger.info("Scheduling kernel cull, will wait for max %s before shutting down the virtual kernel %s", cull_timeout_sleep_seconds, self.id)
183
177
  await asyncio.sleep(cull_timeout_sleep_seconds)
184
- has_connected_pages = PageStatus.CONNECTED in self.page_status.values()
185
- if has_connected_pages:
186
- logger.info("We have (re)connected pages, keeping the virtual kernel %s alive", self.id)
187
- else:
188
- logger.info("No connected pages, and timeout reached, shutting down virtual kernel %s", self.id)
189
- self.close()
190
- current_event_loop.call_soon_threadsafe(future.set_result, None)
178
+ logger.info("Timeout reached, checking if we should be shutting down virtual kernel %s", self.id)
179
+ with self.lock:
180
+ has_connected_pages = PageStatus.CONNECTED in self.page_status.values()
181
+ if has_connected_pages:
182
+ logger.info("We have (re)connected pages, keeping the virtual kernel %s alive", self.id)
183
+ else:
184
+ logger.info("No connected pages, and timeout reached, shutting down virtual kernel %s", self.id)
185
+ self.close()
186
+ if current_event_loop is not None and future is not None:
187
+ current_event_loop.call_soon_threadsafe(future.set_result, None)
191
188
  except asyncio.CancelledError:
192
- if sys.version_info >= (3, 9):
193
- current_event_loop.call_soon_threadsafe(future.cancel, "cancelled because a new cull task was scheduled")
194
- else:
195
- current_event_loop.call_soon_threadsafe(future.cancel)
189
+ 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)
196
194
  raise
197
195
 
198
- has_connected_pages = PageStatus.CONNECTED in self.page_status.values()
199
- if not has_connected_pages:
200
- # when we have no connected pages, we will schedule a kernel cull
196
+ async def create_task():
197
+ task = asyncio.create_task(kernel_cull())
198
+ # create a reference to the task so we can cancel it later
199
+ self._last_kernel_cull_task = task
200
+ await task
201
+
202
+ with self.lock:
203
+ future: "Optional[asyncio.Future[None]]" = None
204
+ current_event_loop: Optional[asyncio.AbstractEventLoop] = None
205
+ try:
206
+ future = asyncio.Future()
207
+ current_event_loop = asyncio.get_event_loop()
208
+ except RuntimeError:
209
+ pass
201
210
  if self._last_kernel_cull_task:
211
+ logger.info("Cancelling previous kernel cull tas for virtual kernel %s", self.id)
202
212
  self._last_kernel_cull_task.cancel()
203
213
 
204
- async def create_task():
205
- task = asyncio.create_task(kernel_cull())
206
- # create a reference to the task so we can cancel it later
207
- self._last_kernel_cull_task = task
208
- await task
209
-
214
+ logger.info("Scheduling kernel cull for virtual kernel %s", self.id)
210
215
  asyncio.run_coroutine_threadsafe(create_task(), keep_alive_event_loop)
211
- else:
212
- future.set_result(None)
213
- return future
216
+ return future
217
+
218
+ def page_disconnect(self, page_id: str) -> "Optional[asyncio.Future[None]]":
219
+ """Signal that a page has disconnected, and schedule a kernel cull if needed.
220
+
221
+ During the kernel reconnect window, we will keep the kernel alive, even if all pages have disconnected.
222
+
223
+ Will return a future that is set when the kernel cull is done, when an event loop is available.
224
+ The scheduled kernel cull can be cancelled when a new page connects, a new disconnect is scheduled,
225
+ or a page if explicitly closed.
226
+ """
227
+
228
+ logger.info("Disconnect page %s for kernel %s", page_id, self.id)
229
+ future: "asyncio.Future[None]" = asyncio.Future()
230
+ with self.lock:
231
+ if self.page_status[page_id] == PageStatus.CLOSED:
232
+ # this happens when the close beackon call happens before the websocket disconnect
233
+ logger.info("Page %s already closed for kernel %s", page_id, self.id)
234
+ future.set_result(None)
235
+ return future
236
+ assert self.page_status[page_id] == PageStatus.CONNECTED, "cannot disconnect a page that is in state: %r" % self.page_status[page_id]
237
+ self.page_status[page_id] = PageStatus.DISCONNECTED
238
+ has_connected_pages = PageStatus.CONNECTED in self.page_status.values()
239
+ if not has_connected_pages:
240
+ # when we have no connected pages, we will schedule a kernel cull
241
+ future = self._bump_kernel_cull()
242
+ else:
243
+ logger.info("Still have connected pages, do nothing for kernel %s", self.id)
244
+ future.set_result(None)
245
+ return future
214
246
 
215
247
  def page_close(self, page_id: str):
216
- """Signal that a page has closed, and close the context if needed.
248
+ """Signal that a page has closed, close the context if needed and schedule a kernel cull if needed.
217
249
 
218
250
  Closing the browser tab or a page navigation means an explicit close, which is
219
251
  different from a websocket/page disconnect, which we might want to recover from.
220
252
 
221
253
  """
222
- self.page_status[page_id] = PageStatus.CLOSED
223
- logger.info("Close page %s for kernel %s", page_id, self.id)
224
- has_connected_pages = PageStatus.CONNECTED in self.page_status.values()
225
- has_disconnected_pages = PageStatus.DISCONNECTED in self.page_status.values()
226
- if not (has_connected_pages or has_disconnected_pages):
227
- logger.info("No connected or disconnected pages, shutting down virtual kernel %s", self.id)
228
- if self._last_kernel_cull_task:
229
- self._last_kernel_cull_task.cancel()
230
- self.close()
254
+ future: "Optional[asyncio.Future[None]]" = None
255
+
256
+ try:
257
+ future = asyncio.Future()
258
+ except RuntimeError:
259
+ pass
260
+ else:
261
+ future.set_result(None)
262
+ with self.lock:
263
+ if self.page_status[page_id] == PageStatus.CLOSED:
264
+ logger.info("Page %s already closed for kernel %s", page_id, self.id)
265
+ return
266
+ self.page_status[page_id] = PageStatus.CLOSED
267
+ logger.info("Close page %s for kernel %s", page_id, self.id)
268
+ has_connected_pages = PageStatus.CONNECTED in self.page_status.values()
269
+ has_disconnected_pages = PageStatus.DISCONNECTED in self.page_status.values()
270
+ # if we have disconnected pages, we may have cancelled the kernel cull task
271
+ # if we still have connected pages, it will go to a disconnected state again
272
+ # which will also trigger a new kernel cull
273
+ if has_disconnected_pages:
274
+ future = self._bump_kernel_cull()
275
+ if not (has_connected_pages or has_disconnected_pages):
276
+ logger.info("No connected or disconnected pages, shutting down virtual kernel %s", self.id)
277
+ self.close()
278
+ else:
279
+ logger.info("Still have connected or disconnected pages, keeping virtual kernel %s alive", self.id)
280
+ return future
231
281
 
232
282
 
233
283
  try:
solara/server/server.py CHANGED
@@ -258,6 +258,13 @@ def process_kernel_messages(kernel: Kernel, msg: Dict) -> bool:
258
258
  return False
259
259
 
260
260
 
261
+ def asset_directories():
262
+ application = [app.directory.parent / "assets" for app in app.apps.values()]
263
+ extra_paths = settings.assets.extra_paths()
264
+ solara_assets = solara_static.parent / "assets"
265
+ return [*application, *extra_paths, solara_assets]
266
+
267
+
261
268
  def read_root(
262
269
  path: str,
263
270
  root_path: str = "",
@@ -292,10 +299,7 @@ def read_root(
292
299
  directories = [default_app.directory.parent / "public"]
293
300
  filename = path[len("/static/public/") :]
294
301
  elif path.startswith("/static/assets/"):
295
- directories = [
296
- default_app.directory.parent / "assets",
297
- solara_static.parent / "assets",
298
- ]
302
+ directories = asset_directories()
299
303
  filename = path[len("/static/assets/") :]
300
304
  elif path.startswith("/static/"):
301
305
  directories = [solara_static.parent / "static"]
solara/server/settings.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import importlib
2
3
  import re
3
4
  import site
4
5
  import sys
@@ -6,7 +7,7 @@ import uuid
6
7
  import warnings
7
8
  from enum import Enum
8
9
  from pathlib import Path
9
- from typing import Optional
10
+ from typing import Optional, List
10
11
 
11
12
  try:
12
13
  from filelock import FileLock
@@ -79,6 +80,23 @@ class Assets(BaseSettings):
79
80
  proxy_cache_dir: Path = Path(prefix + "/share/solara/cdn/")
80
81
  fontawesome_enabled: bool = True
81
82
  fontawesome_path: str = "/font-awesome@4.5.0/css/font-awesome.min.css"
83
+ extra_locations: List[str] = []
84
+
85
+ def extra_paths(self) -> List[Path]:
86
+ # translate locations (packages, directories) into list of paths
87
+ paths = []
88
+ for location in self.extra_locations:
89
+ if Path(location).exists():
90
+ paths.append(Path(location))
91
+ else:
92
+ try:
93
+ package = importlib.import_module(location)
94
+ except ModuleNotFoundError:
95
+ raise RuntimeError(f"Could not find {location} as a file or package (SOLARA_ASSETS_EXTRA_LOCATION={self.extra_locations!r}) ")
96
+ if not hasattr(package, "__path__"):
97
+ raise RuntimeError(f"{location} is not a package (SOLARA_ASSETS_EXTRA_LOCATION={self.extra_locations!r}) ")
98
+ paths.append(Path(package.__path__[0]))
99
+ return paths
82
100
 
83
101
  class Config:
84
102
  env_prefix = "solara_assets_"
@@ -213,3 +231,7 @@ if oauth.client_id:
213
231
  # for the test accounts, this is fine
214
232
  if session.https_only is None:
215
233
  session.https_only = False
234
+
235
+
236
+ # call early so a misconfiguration fails early
237
+ assets.extra_paths()
@@ -311,8 +311,8 @@ async def _kernel_connection(ws: starlette.websockets.WebSocket):
311
311
  def close(request: Request):
312
312
  kernel_id = request.path_params["kernel_id"]
313
313
  page_id = request.query_params["session_id"]
314
- if kernel_id in kernel_context.contexts:
315
- context = kernel_context.contexts[kernel_id]
314
+ context = kernel_context.contexts.get(kernel_id, None)
315
+ if context is not None:
316
316
  context.page_close(page_id)
317
317
  response = HTMLResponse(content="", status_code=200)
318
318
  return response
@@ -439,9 +439,8 @@ class StaticAssets(StaticFilesOptionalAuth):
439
439
  ) -> List[Union[str, "os.PathLike[str]"]]:
440
440
  # we only know the .directory at runtime (after startup)
441
441
  # which means we cannot pass the directory to the StaticFiles constructor
442
- overrides = [app.directory.parent / "assets" for app in appmod.apps.values()]
443
- default = server.solara_static.parent / "assets"
444
- return cast(List[Union[str, "os.PathLike[str]"]], [*overrides, default])
442
+ directories = server.asset_directories()
443
+ return cast(List[Union[str, "os.PathLike[str]"]], directories)
445
444
 
446
445
 
447
446
  class StaticCdn(StaticFilesOptionalAuth):
@@ -138,7 +138,9 @@ async function solaraInit(mountId, appName) {
138
138
  });
139
139
 
140
140
  window.addEventListener('solara.router', function (event) {
141
- app.$data.loadingPage = true;
141
+ if(kernel.status == 'busy') {
142
+ app.$data.loadingPage = true;
143
+ }
142
144
  });
143
145
  kernel.statusChanged.connect(() => {
144
146
  // the first idle after a loadingPage == true (a router event)
@@ -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.31.0-py2.py3-none-any.whl", keep_going=True)
122
+ await micropip.install("/wheels/solara-1.32.1-py2.py3-none-any.whl", keep_going=True)
123
123
  import solara
124
124
 
125
125
  el = solara.Warning("lala")
@@ -15,7 +15,7 @@
15
15
  {% if vue3 == True %}
16
16
  <link href="{{cdn}}/@widgetti/solara-vuetify3-app@5.0.2/dist/main{{ipywidget_major_version}}.css" rel="stylesheet" class="solara-template-css"></link>
17
17
  {% else %}
18
- <link href="{{cdn}}/@widgetti/solara-vuetify-app@10.0.2/dist/main{{ipywidget_major_version}}.css" rel="stylesheet" class="solara-template-css"></link>
18
+ <link href="{{cdn}}/@widgetti/solara-vuetify-app@10.0.3/dist/main{{ipywidget_major_version}}.css" rel="stylesheet" class="solara-template-css"></link>
19
19
  {% endif %}
20
20
 
21
21
 
@@ -228,11 +228,11 @@
228
228
  <script src="{{cdn}}/@widgetti/solara-vuetify3-app@5.0.2/dist/solara-vuetify-app{{ipywidget_major_version}}.js"></script>
229
229
  {% endif %}
230
230
  {% else %}
231
- <link href="{{cdn}}/@widgetti/solara-vuetify-app@10.0.2/dist/fonts.css" rel="stylesheet" fetchpriority="low"></link>
231
+ <link href="{{cdn}}/@widgetti/solara-vuetify-app@10.0.3/dist/fonts.css" rel="stylesheet" fetchpriority="low"></link>
232
232
  {% if production %}
233
- <script src="{{cdn}}/@widgetti/solara-vuetify-app@10.0.2/dist/solara-vuetify-app{{ipywidget_major_version}}.min.js"></script>
233
+ <script src="{{cdn}}/@widgetti/solara-vuetify-app@10.0.3/dist/solara-vuetify-app{{ipywidget_major_version}}.min.js"></script>
234
234
  {% else %}
235
- <script src="{{cdn}}/@widgetti/solara-vuetify-app@10.0.2/dist/solara-vuetify-app{{ipywidget_major_version}}.js"></script>
235
+ <script src="{{cdn}}/@widgetti/solara-vuetify-app@10.0.3/dist/solara-vuetify-app{{ipywidget_major_version}}.js"></script>
236
236
  {% endif %}
237
237
  {% endif %}
238
238
  <script>