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.
- solara/__init__.py +1 -1
- solara/components/applayout.py +6 -2
- solara/components/datatable.py +5 -12
- solara/components/markdown.py +38 -27
- solara/lab/hooks/dataframe.py +1 -12
- solara/lab/utils/dataframe.py +40 -0
- solara/minisettings.py +13 -5
- solara/server/flask.py +3 -5
- solara/server/kernel_context.py +110 -60
- solara/server/server.py +8 -4
- solara/server/settings.py +23 -1
- solara/server/starlette.py +4 -5
- solara/server/static/main-vuetify.js +3 -1
- solara/server/static/solara_bootstrap.py +1 -1
- solara/server/templates/solara.html.j2 +4 -4
- solara/tasks.py +19 -10
- solara/toestand.py +22 -13
- solara/website/assets/custom.css +13 -0
- solara/website/components/algolia.py +6 -0
- solara/website/components/algolia_api.vue +2 -1
- solara/website/components/header.py +9 -17
- solara/website/components/notebook.py +1 -1
- solara/website/components/sidebar.py +91 -0
- solara/website/pages/__init__.py +25 -67
- solara/website/pages/changelog/__init__.py +2 -0
- solara/website/pages/changelog/changelog.md +25 -0
- solara/website/pages/contact/__init__.py +2 -0
- solara/website/pages/documentation/__init__.py +2 -88
- solara/website/pages/documentation/advanced/content/10-howto/30-testing.md +267 -16
- solara/website/pages/documentation/advanced/content/15-reference/41-asset-files.md +36 -0
- solara/website/pages/documentation/advanced/content/20-understanding/40-routing.md +4 -4
- solara/website/pages/documentation/advanced/content/30-enterprise/10-oauth.md +11 -2
- solara/website/pages/documentation/advanced/content/40-development/10-setup.md +1 -1
- solara/website/pages/documentation/faq/content/99-faq.md +27 -0
- solara/website/pages/documentation/getting_started/content/02-installing.md +2 -2
- solara/website/pages/documentation/getting_started/content/04-tutorials/60-jupyter-dashboard-part1.py +50 -49
- solara/website/pages/documentation/getting_started/content/07-deploying/10-self-hosted.md +20 -4
- {solara_ui-1.31.0.dist-info → solara_ui-1.32.1.dist-info}/METADATA +2 -2
- {solara_ui-1.31.0.dist-info → solara_ui-1.32.1.dist-info}/RECORD +43 -41
- {solara_ui-1.31.0.data → solara_ui-1.32.1.data}/data/etc/jupyter/jupyter_notebook_config.d/solara.json +0 -0
- {solara_ui-1.31.0.data → solara_ui-1.32.1.data}/data/etc/jupyter/jupyter_server_config.d/solara.json +0 -0
- {solara_ui-1.31.0.dist-info → solara_ui-1.32.1.dist-info}/WHEEL +0 -0
- {solara_ui-1.31.0.dist-info → solara_ui-1.32.1.dist-info}/licenses/LICENSE +0 -0
solara/__init__.py
CHANGED
solara/components/applayout.py
CHANGED
|
@@ -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
|
-
|
|
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)
|
solara/components/datatable.py
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
106
|
-
|
|
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,
|
|
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]
|
solara/components/markdown.py
CHANGED
|
@@ -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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
"
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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)
|
solara/lab/hooks/dataframe.py
CHANGED
|
@@ -1,12 +1 @@
|
|
|
1
|
-
from ..utils.dataframe import
|
|
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
|
solara/lab/utils/dataframe.py
CHANGED
|
@@ -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
|
-
|
|
65
|
-
for
|
|
66
|
-
if annotation == Optional[
|
|
67
|
-
annotation =
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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():
|
solara/server/kernel_context.py
CHANGED
|
@@ -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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
self.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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,
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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()
|
solara/server/starlette.py
CHANGED
|
@@ -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
|
-
|
|
315
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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>
|