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.
- solara/__init__.py +1 -1
- solara/__main__.py +12 -7
- solara/_stores.py +128 -16
- solara/cache.py +6 -4
- solara/checks.py +1 -1
- solara/components/__init__.py +18 -1
- solara/components/datatable.py +4 -4
- solara/components/input.py +5 -1
- solara/components/markdown.py +46 -10
- solara/components/misc.py +2 -2
- solara/components/select.py +1 -1
- solara/components/style.py +1 -1
- solara/hooks/use_reactive.py +16 -1
- solara/lab/components/__init__.py +1 -0
- solara/lab/components/chat.py +15 -9
- solara/lab/components/input_time.py +133 -0
- solara/lab/hooks/dataframe.py +1 -0
- solara/lab/utils/dataframe.py +11 -1
- solara/server/app.py +66 -30
- solara/server/flask.py +12 -2
- solara/server/jupyter/server_extension.py +1 -0
- solara/server/kernel.py +50 -3
- solara/server/kernel_context.py +68 -9
- solara/server/patch.py +28 -30
- solara/server/server.py +16 -6
- solara/server/settings.py +11 -0
- solara/server/shell.py +19 -1
- solara/server/starlette.py +72 -14
- solara/server/static/solara_bootstrap.py +1 -1
- solara/settings.py +3 -0
- solara/tasks.py +30 -9
- solara/test/pytest_plugin.py +4 -2
- solara/toestand.py +119 -28
- solara/util.py +18 -0
- solara/website/components/docs.py +24 -1
- solara/website/components/markdown.py +17 -3
- solara/website/pages/changelog/changelog.md +26 -1
- solara/website/pages/documentation/advanced/content/10-howto/20-layout.md +1 -1
- solara/website/pages/documentation/advanced/content/20-understanding/50-solara-server.md +10 -0
- solara/website/pages/documentation/advanced/content/30-enterprise/10-oauth.md +4 -2
- solara/website/pages/documentation/api/routing/route.py +10 -12
- solara/website/pages/documentation/api/routing/use_route.py +26 -30
- solara/website/pages/documentation/components/advanced/link.py +6 -8
- solara/website/pages/documentation/components/advanced/meta.py +6 -9
- solara/website/pages/documentation/components/advanced/style.py +7 -9
- solara/website/pages/documentation/components/input/file_browser.py +12 -14
- solara/website/pages/documentation/components/lab/input_time.py +15 -0
- solara/website/pages/documentation/components/lab/theming.py +6 -4
- solara/website/pages/documentation/components/layout/columns_responsive.py +37 -39
- solara/website/pages/documentation/components/layout/gridfixed.py +4 -6
- solara/website/pages/documentation/components/output/html.py +1 -3
- solara/website/pages/documentation/components/output/sql_code.py +23 -25
- solara/website/pages/documentation/components/page/head.py +4 -7
- solara/website/pages/documentation/components/page/title.py +12 -14
- solara/website/pages/documentation/components/status/error.py +17 -18
- solara/website/pages/documentation/components/status/info.py +17 -18
- solara/website/pages/documentation/examples/__init__.py +10 -0
- solara/website/pages/documentation/examples/ai/chatbot.py +62 -44
- solara/website/pages/documentation/examples/general/live_update.py +22 -28
- solara/website/pages/documentation/examples/general/pokemon_search.py +1 -1
- solara/website/pages/documentation/faq/content/99-faq.md +9 -0
- solara/website/pages/documentation/getting_started/content/00-quickstart.md +1 -1
- solara/website/pages/documentation/getting_started/content/04-tutorials/_jupyter_dashboard_1.ipynb +23 -19
- solara/website/pages/documentation/getting_started/content/07-deploying/10-self-hosted.md +2 -2
- solara/website/pages/roadmap/roadmap.md +3 -0
- {solara_ui-1.42.0.dist-info → solara_ui-1.44.0.dist-info}/METADATA +2 -2
- {solara_ui-1.42.0.dist-info → solara_ui-1.44.0.dist-info}/RECORD +71 -69
- {solara_ui-1.42.0.data → solara_ui-1.44.0.data}/data/etc/jupyter/jupyter_notebook_config.d/solara.json +0 -0
- {solara_ui-1.42.0.data → solara_ui-1.44.0.data}/data/etc/jupyter/jupyter_server_config.d/solara.json +0 -0
- {solara_ui-1.42.0.dist-info → solara_ui-1.44.0.dist-info}/WHEEL +0 -0
- {solara_ui-1.42.0.dist-info → solara_ui-1.44.0.dist-info}/licenses/LICENSE +0 -0
solara/lab/components/chat.py
CHANGED
|
@@ -43,9 +43,12 @@ def ChatBox(
|
|
|
43
43
|
|
|
44
44
|
@solara.component
|
|
45
45
|
def ChatInput(
|
|
46
|
-
send_callback: Optional[Callable] = None,
|
|
46
|
+
send_callback: Optional[Callable[[str], None]] = None,
|
|
47
47
|
disabled: bool = False,
|
|
48
|
+
disabled_input: bool = False,
|
|
49
|
+
disabled_send: bool = False,
|
|
48
50
|
style: Optional[Union[str, Dict[str, str]]] = None,
|
|
51
|
+
autofocus: bool = False,
|
|
49
52
|
input_text_style: Optional[Union[str, Dict[str, str]]] = None,
|
|
50
53
|
classes: List[str] = [],
|
|
51
54
|
input_text_classes: List[str] = [],
|
|
@@ -55,10 +58,12 @@ def ChatInput(
|
|
|
55
58
|
|
|
56
59
|
# Arguments
|
|
57
60
|
|
|
58
|
-
* `send_callback`: A callback function for when the user presses enter or clicks the send button.
|
|
59
|
-
* `disabled`:
|
|
60
|
-
|
|
61
|
+
* `send_callback`: A callback function for when the user presses enter or clicks the send button taking the message as an argument.
|
|
62
|
+
* `disabled`: disable both input and send.
|
|
63
|
+
* `disabled_input`: Whether the input should be disabled. Useful for disabling messages while a chatbot is replying.
|
|
64
|
+
* `disabled_send`: Whether the send button should be disabled. Useful for disabling sending further messages while a chatbot is replying.
|
|
61
65
|
* `style`: CSS styles to apply to the `solara.Row` containing the input field and submit button. Either a string or a dictionary.
|
|
66
|
+
* `autofocus`: Determines if a component is to be autofocused or not (Default is False). Autofocus will occur during page load and only one component per page can have autofocus active.
|
|
62
67
|
* `input_text_style`: CSS styles to apply to the `InputText` part of the component. Either a string or a dictionary.
|
|
63
68
|
* `classes`: A list of CSS classes to apply to the component. Also applied to the container.
|
|
64
69
|
* `input_text_classes`: A list of CSS classes to apply to the `InputText` part of the component.
|
|
@@ -84,21 +89,22 @@ def ChatInput(
|
|
|
84
89
|
rounded=True,
|
|
85
90
|
filled=True,
|
|
86
91
|
hide_details=True,
|
|
92
|
+
autofocus=autofocus,
|
|
87
93
|
style_="flex-grow: 1;" + input_text_style_flat,
|
|
88
|
-
disabled=disabled,
|
|
94
|
+
disabled=disabled or disabled_input,
|
|
89
95
|
class_=" ".join(input_text_classes),
|
|
90
96
|
)
|
|
91
97
|
|
|
92
98
|
use_change(message_input, send, update_events=["keyup.enter"])
|
|
93
99
|
|
|
94
|
-
button = solara.v.Btn(color="primary", icon=True, children=[solara.v.Icon(children=["mdi-send"])], disabled=message == "")
|
|
100
|
+
button = solara.v.Btn(color="primary", icon=True, children=[solara.v.Icon(children=["mdi-send"])], disabled=message == "" or disabled or disabled_send)
|
|
95
101
|
|
|
96
102
|
use_change(button, send, update_events=["click"])
|
|
97
103
|
|
|
98
104
|
|
|
99
105
|
@solara.component
|
|
100
106
|
def ChatMessage(
|
|
101
|
-
children: Union[List[solara.Element], str],
|
|
107
|
+
children: Union[List[solara.Element], str] = [],
|
|
102
108
|
user: bool = False,
|
|
103
109
|
avatar: Union[solara.Element, str, Literal[False], None] = None,
|
|
104
110
|
name: Optional[str] = None,
|
|
@@ -197,12 +203,12 @@ def ChatMessage(
|
|
|
197
203
|
.chat-message-{msg_uuid}.left{{
|
|
198
204
|
border-top-left-radius: 0;
|
|
199
205
|
background-color:var(--color);
|
|
200
|
-
{
|
|
206
|
+
{"margin-left: 10px !important;" if notch else ""}
|
|
201
207
|
}}
|
|
202
208
|
.chat-message-{msg_uuid}.right{{
|
|
203
209
|
border-top-right-radius: 0;
|
|
204
210
|
background-color:var(--color);
|
|
205
|
-
{
|
|
211
|
+
{"margin-right: 10px !important;" if notch else ""}
|
|
206
212
|
}}
|
|
207
213
|
{extra_styles}
|
|
208
214
|
"""
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import datetime as dt
|
|
2
|
+
from typing import Callable, Dict, List, Optional, Union
|
|
3
|
+
|
|
4
|
+
import solara
|
|
5
|
+
import solara.lab
|
|
6
|
+
from solara.lab.components.input_date import use_close_menu
|
|
7
|
+
from solara.components.input import _use_input_type
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@solara.component
|
|
11
|
+
def InputTime(
|
|
12
|
+
value: Union[solara.Reactive[Optional[dt.time]], Optional[dt.time]],
|
|
13
|
+
on_value: Optional[Callable[[Optional[dt.time]], None]] = None,
|
|
14
|
+
label: str = "Pick a time",
|
|
15
|
+
children: List[solara.Element] = [],
|
|
16
|
+
open_value: Union[solara.Reactive[bool], bool] = False,
|
|
17
|
+
on_open_value: Optional[Callable[[bool], None]] = None,
|
|
18
|
+
optional: bool = False,
|
|
19
|
+
twelve_hour_clock: bool = False,
|
|
20
|
+
use_seconds: bool = False,
|
|
21
|
+
allowed_minutes: Optional[List[int]] = None,
|
|
22
|
+
style: Optional[Union[str, Dict[str, str]]] = None,
|
|
23
|
+
classes: Optional[List[str]] = None,
|
|
24
|
+
):
|
|
25
|
+
"""
|
|
26
|
+
Show a textfield, which when clicked, opens a timepicker. The input time should be of type `datetime.time`.
|
|
27
|
+
|
|
28
|
+
## Basic Example
|
|
29
|
+
|
|
30
|
+
```solara {pycafe-link}
|
|
31
|
+
import solara
|
|
32
|
+
import solara.lab
|
|
33
|
+
import datetime as dt
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@solara.component
|
|
37
|
+
def Page():
|
|
38
|
+
time = solara.use_reactive(dt.time(12, 0))
|
|
39
|
+
|
|
40
|
+
solara.lab.InputTime(time, allowed_minutes=[0, 15, 30, 45])
|
|
41
|
+
solara.Text(str(time.value))
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Arguments
|
|
45
|
+
|
|
46
|
+
* value: Reactive variable of type `datetime.time`, or `None`. This time is selected the first time the component is rendered.
|
|
47
|
+
* on_value: a callback function for when value changes. The callback function receives the new value as an argument.
|
|
48
|
+
* label: Text used to label the text field that triggers the timepicker.
|
|
49
|
+
* children: List of Elements to be rendered under the timepicker. If empty, a close button is rendered.
|
|
50
|
+
* open_value: Controls and communicates the state of the timepicker. If True, the timepicker is open. If False, the timepicker is closed.
|
|
51
|
+
Intended to be used in conjunction with a custom set of controls to close the timepicker.
|
|
52
|
+
* on_open_value: a callback function for when open_value changes. Also receives the new value as an argument.
|
|
53
|
+
* optional: Determines whether to show an error when value is `None`. If `True`, no error is shown.
|
|
54
|
+
* twelve_hour_clock: If `True`, the timepicker will display in 12-hour format. If `False`, the timepicker will display in 24-hour format.
|
|
55
|
+
* use_seconds: If `True`, the timepicker will allow input of seconds.
|
|
56
|
+
* allowed_minutes: List of allowed minutes for the timepicker. Restricts the input to specific minute intervals.
|
|
57
|
+
* style: CSS style to apply to the text field. Either a string or a dictionary of CSS properties (i.e. `{"property": "value"}`).
|
|
58
|
+
* classes: List of CSS classes to apply to the text field.
|
|
59
|
+
"""
|
|
60
|
+
time_format_internal = f"%H:%M{':%S' if use_seconds else ''}"
|
|
61
|
+
time_format_display = f"%H:%M{':%S' if use_seconds else ''}"
|
|
62
|
+
if twelve_hour_clock:
|
|
63
|
+
time_format_display = f"%I:%M{':%S' if use_seconds else ''} %p"
|
|
64
|
+
value_reactive = solara.use_reactive(value, on_value) # type: ignore
|
|
65
|
+
del value, on_value
|
|
66
|
+
timepicker_is_open = solara.use_reactive(open_value, on_open_value) # type: ignore
|
|
67
|
+
del open_value, on_open_value
|
|
68
|
+
|
|
69
|
+
def set_time_typed_cast(value: Optional[str]):
|
|
70
|
+
if value:
|
|
71
|
+
try:
|
|
72
|
+
time_value = dt.datetime.strptime(value, time_format_display).time()
|
|
73
|
+
return time_value
|
|
74
|
+
except ValueError:
|
|
75
|
+
raise ValueError(f"Time {value} does not match format {time_format_display.replace('%', '')}")
|
|
76
|
+
elif optional:
|
|
77
|
+
return None
|
|
78
|
+
else:
|
|
79
|
+
raise ValueError("Time cannot be empty")
|
|
80
|
+
|
|
81
|
+
def time_to_str(time: Optional[dt.time]) -> str:
|
|
82
|
+
if time is not None:
|
|
83
|
+
return time.strftime(time_format_display)
|
|
84
|
+
return ""
|
|
85
|
+
|
|
86
|
+
def set_time_cast(new_value: Optional[str]):
|
|
87
|
+
if new_value:
|
|
88
|
+
time_value = dt.datetime.strptime(new_value, time_format_internal).time()
|
|
89
|
+
value_reactive.value = time_value
|
|
90
|
+
|
|
91
|
+
def standard_strfy(time: Optional[dt.time]):
|
|
92
|
+
if time is None:
|
|
93
|
+
return None
|
|
94
|
+
else:
|
|
95
|
+
return time.strftime(time_format_internal)
|
|
96
|
+
|
|
97
|
+
time_standard_str = standard_strfy(value_reactive.value)
|
|
98
|
+
|
|
99
|
+
style_flat = solara.util._flatten_style(style)
|
|
100
|
+
|
|
101
|
+
internal_value, error_message, set_value_cast = _use_input_type(value_reactive, set_time_typed_cast, time_to_str)
|
|
102
|
+
|
|
103
|
+
if error_message:
|
|
104
|
+
label += f" ({error_message})"
|
|
105
|
+
input = solara.v.TextField(
|
|
106
|
+
label=label,
|
|
107
|
+
v_model=internal_value,
|
|
108
|
+
on_v_model=set_value_cast,
|
|
109
|
+
append_icon="mdi-clock",
|
|
110
|
+
error=bool(error_message),
|
|
111
|
+
style_="min-width: 290px;" + style_flat,
|
|
112
|
+
class_=", ".join(classes) if classes else "",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
use_close_menu(input, timepicker_is_open)
|
|
116
|
+
|
|
117
|
+
with solara.lab.Menu(
|
|
118
|
+
activator=input,
|
|
119
|
+
close_on_content_click=False,
|
|
120
|
+
open_value=timepicker_is_open,
|
|
121
|
+
use_activator_width=False,
|
|
122
|
+
):
|
|
123
|
+
with solara.v.TimePicker(
|
|
124
|
+
ampm_in_title=twelve_hour_clock,
|
|
125
|
+
v_model=time_standard_str,
|
|
126
|
+
on_v_model=set_time_cast,
|
|
127
|
+
format="24hr" if not twelve_hour_clock else "ampm",
|
|
128
|
+
allowed_minutes=allowed_minutes,
|
|
129
|
+
use_seconds=use_seconds,
|
|
130
|
+
style_="width: 100%;",
|
|
131
|
+
):
|
|
132
|
+
if len(children) > 0:
|
|
133
|
+
solara.display(*children)
|
solara/lab/hooks/dataframe.py
CHANGED
solara/lab/utils/dataframe.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import List
|
|
1
|
+
from typing import List, Union
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
def get_pandas_major():
|
|
@@ -28,6 +28,16 @@ def df_columns(df) -> List[str]:
|
|
|
28
28
|
raise TypeError(f"{type(df)} not supported")
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def df_row_names(df) -> List[Union[int, str]]:
|
|
32
|
+
"""Return a list of row names from a dataframe."""
|
|
33
|
+
if df_type(df) == "vaex" or df_type(df) == "polars":
|
|
34
|
+
return list(range(df_len(df)))
|
|
35
|
+
elif df_type(df) == "pandas":
|
|
36
|
+
return df.index.tolist()
|
|
37
|
+
else:
|
|
38
|
+
raise TypeError(f"{type(df)} not supported")
|
|
39
|
+
|
|
40
|
+
|
|
31
41
|
def df_slice(df, start: int, stop: int):
|
|
32
42
|
"""Return a subset of rows from a dataframe."""
|
|
33
43
|
if df_type(df) == "pandas":
|
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,36 @@ 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
|
+
self._lock = threading.Lock()
|
|
94
|
+
|
|
95
|
+
def init(self):
|
|
65
96
|
try:
|
|
66
97
|
context = kernel_context.get_current_context()
|
|
67
98
|
except RuntimeError:
|
|
@@ -84,6 +115,7 @@ class AppScript:
|
|
|
84
115
|
package_root_path = Path(mod.__file__).parent
|
|
85
116
|
reload.reloader.root_path = package_root_path
|
|
86
117
|
dummy_kernel_context.close()
|
|
118
|
+
self._initialized = True
|
|
87
119
|
|
|
88
120
|
def _execute(self):
|
|
89
121
|
logger.info("Executing %s", self.name)
|
|
@@ -97,10 +129,8 @@ class AppScript:
|
|
|
97
129
|
if working_directory not in sys.path:
|
|
98
130
|
sys.path.insert(0, working_directory)
|
|
99
131
|
|
|
100
|
-
if self.
|
|
101
|
-
self.type = AppType.DIRECTORY
|
|
132
|
+
if self.type == AppType.DIRECTORY:
|
|
102
133
|
# resolve the directory, because Path("file").parent.parent == "." != ".."
|
|
103
|
-
self.directory = self.path.resolve()
|
|
104
134
|
routes = solara.generate_routes_directory(self.path)
|
|
105
135
|
|
|
106
136
|
if any(name for name in sys.modules.keys() if name.startswith(self.name)):
|
|
@@ -110,45 +140,26 @@ class AppScript:
|
|
|
110
140
|
"can avoid this ambiguity."
|
|
111
141
|
)
|
|
112
142
|
|
|
113
|
-
elif self.
|
|
114
|
-
self.type = AppType.SCRIPT
|
|
143
|
+
elif self.type == AppType.SCRIPT:
|
|
115
144
|
add_path()
|
|
116
145
|
# manually add the script to the watcher
|
|
117
146
|
reload.reloader.watcher.add_file(self.path)
|
|
118
|
-
self.directory = self.path.parent.resolve()
|
|
119
147
|
initial_namespace = {
|
|
120
148
|
"__name__": "__main__",
|
|
121
149
|
}
|
|
122
150
|
with reload.reloader.watch():
|
|
123
151
|
routes = [solara.autorouting._generate_route_path(self.path, first=True, initial_namespace=initial_namespace)]
|
|
124
|
-
elif self.
|
|
125
|
-
self.type = AppType.NOTEBOOK
|
|
152
|
+
elif self.type == AppType.NOTEBOOK:
|
|
126
153
|
add_path()
|
|
127
154
|
# manually add the notebook to the watcher
|
|
128
155
|
reload.reloader.watcher.add_file(self.path)
|
|
129
|
-
self.directory = self.path.parent.resolve()
|
|
130
156
|
with reload.reloader.watch():
|
|
131
157
|
routes = [solara.autorouting._generate_route_path(self.path, first=True)]
|
|
132
158
|
else:
|
|
133
159
|
# the module itself will be added by reloader
|
|
134
160
|
# automatically
|
|
135
|
-
with reload.reloader.watch():
|
|
161
|
+
with kernel_context.without_context(), reload.reloader.watch():
|
|
136
162
|
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
163
|
mod = importlib.import_module(self.name)
|
|
153
164
|
routes = solara.generate_routes(mod)
|
|
154
165
|
|
|
@@ -213,7 +224,14 @@ class AppScript:
|
|
|
213
224
|
for context in context_values:
|
|
214
225
|
context.close()
|
|
215
226
|
|
|
227
|
+
def check(self):
|
|
228
|
+
if not self._initialized:
|
|
229
|
+
with self._lock:
|
|
230
|
+
if not self._initialized:
|
|
231
|
+
self.init()
|
|
232
|
+
|
|
216
233
|
def run(self):
|
|
234
|
+
self.check()
|
|
217
235
|
if reload.reloader.requires_reload or self._first_execute_app is None:
|
|
218
236
|
with thread_lock:
|
|
219
237
|
if reload.reloader.requires_reload or self._first_execute_app is None:
|
|
@@ -420,6 +438,9 @@ def solara_comm_target(comm, msg_first):
|
|
|
420
438
|
|
|
421
439
|
def on_msg(msg):
|
|
422
440
|
nonlocal app
|
|
441
|
+
comm = comm_ref()
|
|
442
|
+
assert comm is not None
|
|
443
|
+
context = kernel_context.get_current_context()
|
|
423
444
|
data = msg["content"]["data"]
|
|
424
445
|
method = data["method"]
|
|
425
446
|
if method == "run":
|
|
@@ -435,7 +456,12 @@ def solara_comm_target(comm, msg_first):
|
|
|
435
456
|
themes = args.get("themes")
|
|
436
457
|
dark = args.get("dark")
|
|
437
458
|
load_themes(themes, dark)
|
|
438
|
-
|
|
459
|
+
try:
|
|
460
|
+
load_app_widget(None, app, path)
|
|
461
|
+
except Exception as e:
|
|
462
|
+
msg = f"Error loading app: from path {path} and app {app_name}"
|
|
463
|
+
logger.exception(msg)
|
|
464
|
+
raise RuntimeError(msg) from e
|
|
439
465
|
comm.send({"method": "finished", "widget_id": context.container._model_id})
|
|
440
466
|
elif method == "app-status":
|
|
441
467
|
context = kernel_context.get_current_context()
|
|
@@ -464,9 +490,10 @@ def solara_comm_target(comm, msg_first):
|
|
|
464
490
|
else:
|
|
465
491
|
logger.error("Unknown comm method called on solara.control comm: %s", method)
|
|
466
492
|
|
|
467
|
-
comm.on_msg(on_msg)
|
|
468
|
-
|
|
469
493
|
def reload():
|
|
494
|
+
comm = comm_ref()
|
|
495
|
+
assert comm is not None
|
|
496
|
+
context = kernel_context.get_current_context()
|
|
470
497
|
# we don't reload the app ourself, we send a message to the client
|
|
471
498
|
# this ensures that we don't run code of any client that for some reason is connected
|
|
472
499
|
# but not working anymore. And it indirectly passes a message from the current thread
|
|
@@ -474,8 +501,11 @@ def solara_comm_target(comm, msg_first):
|
|
|
474
501
|
logger.debug(f"Send reload to client: {context.id}")
|
|
475
502
|
comm.send({"method": "reload"})
|
|
476
503
|
|
|
477
|
-
|
|
478
|
-
|
|
504
|
+
comm.on_msg(on_msg)
|
|
505
|
+
comm_ref = weakref.ref(comm)
|
|
506
|
+
del comm
|
|
507
|
+
|
|
508
|
+
kernel_context.get_current_context().reload = reload
|
|
479
509
|
|
|
480
510
|
|
|
481
511
|
def register_solara_comm_target(kernel: Kernel):
|
|
@@ -489,3 +519,9 @@ patch.patch()
|
|
|
489
519
|
if "SOLARA_APP" in os.environ:
|
|
490
520
|
with pdb_guard():
|
|
491
521
|
apps["__default__"] = AppScript(os.environ.get("SOLARA_APP", "solara.website.pages:Page"))
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@solara.util.once
|
|
525
|
+
def ensure_apps_initialized():
|
|
526
|
+
for app in apps.values():
|
|
527
|
+
app.init()
|
solara/server/flask.py
CHANGED
|
@@ -65,11 +65,17 @@ class WebsocketWrapper(websocket.WebsocketWrapper):
|
|
|
65
65
|
|
|
66
66
|
def send_text(self, data: str) -> None:
|
|
67
67
|
with self.lock:
|
|
68
|
-
|
|
68
|
+
try:
|
|
69
|
+
self.ws.send(data)
|
|
70
|
+
except simple_websocket.ws.ConnectionClosed:
|
|
71
|
+
raise websocket.WebSocketDisconnect()
|
|
69
72
|
|
|
70
73
|
def send_bytes(self, data: bytes) -> None:
|
|
71
74
|
with self.lock:
|
|
72
|
-
|
|
75
|
+
try:
|
|
76
|
+
self.ws.send(data)
|
|
77
|
+
except simple_websocket.ws.ConnectionClosed:
|
|
78
|
+
raise websocket.WebSocketDisconnect()
|
|
73
79
|
|
|
74
80
|
async def receive(self):
|
|
75
81
|
from anyio import to_thread
|
|
@@ -285,3 +291,7 @@ if has_solara_enterprise:
|
|
|
285
291
|
|
|
286
292
|
if __name__ == "__main__":
|
|
287
293
|
app.run(debug=False, port=8765)
|
|
294
|
+
|
|
295
|
+
# we can only call this at the module level, which means that the solara script cannot import this
|
|
296
|
+
# module. This is a difference with the asgi standard, which provides a lifecycle hook (see starlette.py)
|
|
297
|
+
appmod.ensure_apps_initialized()
|
|
@@ -14,6 +14,7 @@ def _load_jupyter_server_extension(server_app):
|
|
|
14
14
|
import solara.server.app
|
|
15
15
|
|
|
16
16
|
solara.server.app.apps["__default__"] = solara.server.app.AppScript("solara.server.jupyter.solara:Page")
|
|
17
|
+
solara.server.app.apps["__default__"].init()
|
|
17
18
|
|
|
18
19
|
web_app = server_app.web_app
|
|
19
20
|
|
solara/server/kernel.py
CHANGED
|
@@ -54,7 +54,7 @@ def json_default(obj):
|
|
|
54
54
|
import numpy as np
|
|
55
55
|
|
|
56
56
|
if isinstance(obj, np.number):
|
|
57
|
-
return
|
|
57
|
+
return obj.item()
|
|
58
58
|
else:
|
|
59
59
|
raise TypeError("%r is not JSON serializable" % obj)
|
|
60
60
|
else:
|
|
@@ -215,8 +215,20 @@ def send_websockets(websockets: Set[websocket.WebsocketWrapper], binary_msg):
|
|
|
215
215
|
for ws in list(websockets):
|
|
216
216
|
try:
|
|
217
217
|
ws.send(binary_msg)
|
|
218
|
-
except:
|
|
219
|
-
#
|
|
218
|
+
except websocket.WebSocketDisconnect:
|
|
219
|
+
# ignore the exception, we tried to send while websocket closed
|
|
220
|
+
# just remove it from the websocket set
|
|
221
|
+
try:
|
|
222
|
+
# websocket can be modified by another thread
|
|
223
|
+
websockets.remove(ws)
|
|
224
|
+
except KeyError:
|
|
225
|
+
pass # already removed
|
|
226
|
+
except Exception as e: # noqa
|
|
227
|
+
logger.exception("Error sending message: %s, closing websocket", e)
|
|
228
|
+
try:
|
|
229
|
+
ws.close()
|
|
230
|
+
except Exception as e: # noqa
|
|
231
|
+
logger.exception("Error closing websocket: %s", e)
|
|
220
232
|
try:
|
|
221
233
|
# websocket can be modified by another thread
|
|
222
234
|
websockets.remove(ws)
|
|
@@ -248,6 +260,8 @@ class SessionWebsocket(session.Session):
|
|
|
248
260
|
header=None,
|
|
249
261
|
metadata=None,
|
|
250
262
|
):
|
|
263
|
+
if stream is None:
|
|
264
|
+
return # can happen when the kernel is closed but someone was still trying to send a message
|
|
251
265
|
try:
|
|
252
266
|
if isinstance(msg_or_type, dict):
|
|
253
267
|
msg = msg_or_type
|
|
@@ -314,6 +328,39 @@ class Kernel(ipykernel.kernelbase.Kernel):
|
|
|
314
328
|
self.shell.display_pub.session = self.session
|
|
315
329
|
self.shell.display_pub.pub_socket = self.iopub_socket
|
|
316
330
|
|
|
331
|
+
def close(self):
|
|
332
|
+
if self.comm_manager is None:
|
|
333
|
+
raise RuntimeError("Kernel already closed")
|
|
334
|
+
self.session.close()
|
|
335
|
+
self._cleanup_references()
|
|
336
|
+
|
|
337
|
+
def _cleanup_references(self):
|
|
338
|
+
try:
|
|
339
|
+
# all of these reduce the circular references
|
|
340
|
+
# making it easier for the garbage collector to clean up
|
|
341
|
+
self.shell_handlers.clear()
|
|
342
|
+
self.control_handlers.clear()
|
|
343
|
+
for comm_object in list(self.comm_manager.comms.values()): # type: ignore
|
|
344
|
+
comm_object.close()
|
|
345
|
+
self.comm_manager.targets.clear() # type: ignore
|
|
346
|
+
# self.comm_manager.kernel points to us, but we cannot set it to None
|
|
347
|
+
# so we remove the circular reference by setting the comm_manager to None
|
|
348
|
+
self.comm_manager = None # type: ignore
|
|
349
|
+
self.session.parent = None # type: ignore
|
|
350
|
+
|
|
351
|
+
self.shell.display_pub.session = None # type: ignore
|
|
352
|
+
self.shell.display_pub.pub_socket = None # type: ignore
|
|
353
|
+
del self.shell.__dict__
|
|
354
|
+
self.shell = None # type: ignore
|
|
355
|
+
self.session.websockets.clear()
|
|
356
|
+
self.session.stream = None # type: ignore
|
|
357
|
+
self.session = None # type: ignore
|
|
358
|
+
self.stream.session = None # type: ignore
|
|
359
|
+
self.stream = None # type: ignore
|
|
360
|
+
self.iopub_socket = None # type: ignore
|
|
361
|
+
except Exception:
|
|
362
|
+
logger.exception("Error cleaning up references from kernel, not fatal")
|
|
363
|
+
|
|
317
364
|
async def _flush_control_queue(self):
|
|
318
365
|
pass
|
|
319
366
|
|