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
@@ -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`: Whether the input should be disabled. Useful for disabling sending further messages while a chatbot is replying,
60
- among other things.
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
- { "margin-left: 10px !important;" if notch else ""}
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
- { "margin-right: 10px !important;" if notch else ""}
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)
@@ -1 +1,2 @@
1
1
  from ..utils.dataframe import df_columns as use_df_column_names # noqa: F401
2
+ from ..utils.dataframe import df_row_names as df_row_names
@@ -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.path.is_dir():
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.name.endswith(".py"):
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.name.endswith(".ipynb"):
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
- load_app_widget(None, app, path)
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
- context = kernel_context.get_current_context()
478
- context.reload = reload
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
- self.ws.send(data)
68
+ try:
69
+ self.ws.send(data)
70
+ except simple_websocket.ws.ConnectionClosed:
71
+ raise websocket.WebSocketDisconnect()
69
72
 
70
73
  def send_bytes(self, data: bytes) -> None:
71
74
  with self.lock:
72
- self.ws.send(data)
75
+ try:
76
+ self.ws.send(data)
77
+ except simple_websocket.ws.ConnectionClosed:
78
+ raise websocket.WebSocketDisconnect()
73
79
 
74
80
  async def receive(self):
75
81
  from anyio import to_thread
@@ -285,3 +291,7 @@ if has_solara_enterprise:
285
291
 
286
292
  if __name__ == "__main__":
287
293
  app.run(debug=False, port=8765)
294
+
295
+ # we can only call this at the module level, which means that the solara script cannot import this
296
+ # module. This is a difference with the asgi standard, which provides a lifecycle hook (see starlette.py)
297
+ appmod.ensure_apps_initialized()
@@ -14,6 +14,7 @@ def _load_jupyter_server_extension(server_app):
14
14
  import solara.server.app
15
15
 
16
16
  solara.server.app.apps["__default__"] = solara.server.app.AppScript("solara.server.jupyter.solara:Page")
17
+ solara.server.app.apps["__default__"].init()
17
18
 
18
19
  web_app = server_app.web_app
19
20
 
solara/server/kernel.py CHANGED
@@ -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 repr(obj.item())
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: # noqa
219
- # in case of any issue, we simply remove it from the list
218
+ except websocket.WebSocketDisconnect:
219
+ # ignore the exception, we tried to send while websocket closed
220
+ # just remove it from the websocket set
221
+ try:
222
+ # websocket can be modified by another thread
223
+ websockets.remove(ws)
224
+ except KeyError:
225
+ pass # already removed
226
+ except Exception as e: # noqa
227
+ logger.exception("Error sending message: %s, closing websocket", e)
228
+ try:
229
+ ws.close()
230
+ except Exception as e: # noqa
231
+ logger.exception("Error closing websocket: %s", e)
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