ipystream 0.1.10__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.
- ipystream/__init__.py +0 -0
- ipystream/async_debounce.py +29 -0
- ipystream/renderer.py +30 -0
- ipystream/stream.py +297 -0
- ipystream/utils.py +38 -0
- ipystream/voila/__init__.py +0 -0
- ipystream/voila/auth_wall_limit.py +184 -0
- ipystream/voila/cookie.py +45 -0
- ipystream/voila/documentation.py +30 -0
- ipystream/voila/kernel.py +101 -0
- ipystream/voila/kernel_heartbeat.py +93 -0
- ipystream/voila/login.py +36 -0
- ipystream/voila/patch_voila.py +197 -0
- ipystream/voila/patched_generator.py +259 -0
- ipystream/voila/run_raw.py +52 -0
- ipystream/voila/spinned.py +50 -0
- ipystream/voila/utils.py +70 -0
- ipystream/widget_currents_children.py +84 -0
- ipystream-0.1.10.dist-info/LICENSE +21 -0
- ipystream-0.1.10.dist-info/METADATA +38 -0
- ipystream-0.1.10.dist-info/RECORD +22 -0
- ipystream-0.1.10.dist-info/WHEEL +4 -0
ipystream/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
from threading import Timer
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AsyncDebouncer:
|
|
6
|
+
def __init__(self, wait: float):
|
|
7
|
+
self.wait = wait
|
|
8
|
+
self.timer = None
|
|
9
|
+
|
|
10
|
+
def __call__(self, func):
|
|
11
|
+
@wraps(func)
|
|
12
|
+
def wrapped(*args, **kwargs):
|
|
13
|
+
# 1. Cancel the existing timer if it exists
|
|
14
|
+
if self.timer is not None:
|
|
15
|
+
self.timer.cancel()
|
|
16
|
+
|
|
17
|
+
# 2. Define the work to be done
|
|
18
|
+
def call_it():
|
|
19
|
+
try:
|
|
20
|
+
func(*args, **kwargs)
|
|
21
|
+
except Exception as e:
|
|
22
|
+
# Catch errors so they don't silently kill the thread
|
|
23
|
+
print(f"Error in debounced function: {e}")
|
|
24
|
+
|
|
25
|
+
# 3. Start a new timer (in a separate thread)
|
|
26
|
+
self.timer = Timer(self.wait, call_it)
|
|
27
|
+
self.timer.start()
|
|
28
|
+
|
|
29
|
+
return wrapped
|
ipystream/renderer.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
|
|
3
|
+
from ipywidgets import widgets
|
|
4
|
+
from plotly.io._utils import validate_coerce_fig_to_dict
|
|
5
|
+
import plotly.graph_objects as go
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def plotly_fig_to_html(fig: go.Figure):
|
|
9
|
+
# compute dimensions
|
|
10
|
+
fig_dict = validate_coerce_fig_to_dict(fig, True)
|
|
11
|
+
iframe_buffer = 20
|
|
12
|
+
layout = fig_dict.get("layout", {})
|
|
13
|
+
|
|
14
|
+
if layout.get("width", False):
|
|
15
|
+
width = str(layout["width"] + iframe_buffer) + "px"
|
|
16
|
+
else:
|
|
17
|
+
width = "100%"
|
|
18
|
+
|
|
19
|
+
if layout.get("height", False):
|
|
20
|
+
height = layout["height"] + iframe_buffer
|
|
21
|
+
else:
|
|
22
|
+
height = str(525 + iframe_buffer) + "px"
|
|
23
|
+
|
|
24
|
+
# build html
|
|
25
|
+
html_content = fig.to_html(full_html=True, include_plotlyjs="cdn")
|
|
26
|
+
encoded_html = base64.b64encode(html_content.encode("utf-8")).decode("ascii")
|
|
27
|
+
src_url = f"data:text/html;base64,{encoded_html}"
|
|
28
|
+
html_string = f'<iframe src="{src_url}" width="{width}" height="{height}" frameborder="0"></iframe>'
|
|
29
|
+
|
|
30
|
+
return widgets.HTML(value=html_string)
|
ipystream/stream.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from typing import Any, Callable
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import solara
|
|
5
|
+
from ipydatagrid import DataGrid
|
|
6
|
+
from IPython.core.display import Javascript
|
|
7
|
+
from IPython.core.display_functions import clear_output, display
|
|
8
|
+
from ipywidgets import HTML, HBox, IntText
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from ipystream.async_debounce import AsyncDebouncer
|
|
11
|
+
from ipystream.utils import (
|
|
12
|
+
proxy_display,
|
|
13
|
+
is_internal_counter,
|
|
14
|
+
proxy_update_display,
|
|
15
|
+
remove_internal_counter,
|
|
16
|
+
internal_counter_desc,
|
|
17
|
+
)
|
|
18
|
+
from ipystream.widget_currents_children import WidgetCurrentsChildren
|
|
19
|
+
|
|
20
|
+
display_sep = "---------------------------------------------------------"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WidgetUpdater(BaseModel):
|
|
24
|
+
widgets: list[Any]
|
|
25
|
+
updater: Callable[[WidgetCurrentsChildren], None] | None
|
|
26
|
+
vertical: bool
|
|
27
|
+
title: str | None
|
|
28
|
+
split_hbox_after: int | None
|
|
29
|
+
|
|
30
|
+
def stream_down(
|
|
31
|
+
self,
|
|
32
|
+
parents,
|
|
33
|
+
currents,
|
|
34
|
+
currents_level,
|
|
35
|
+
level_obj,
|
|
36
|
+
first_display: bool,
|
|
37
|
+
last_level: bool,
|
|
38
|
+
):
|
|
39
|
+
# disable all observed
|
|
40
|
+
self.disable_loading(level_obj, first_display, last_level)
|
|
41
|
+
|
|
42
|
+
with level_obj.lock:
|
|
43
|
+
wca = WidgetCurrentsChildren(
|
|
44
|
+
parents=parents,
|
|
45
|
+
currents=currents,
|
|
46
|
+
cache=level_obj.cache,
|
|
47
|
+
currents_level=currents_level,
|
|
48
|
+
vertical=self.vertical,
|
|
49
|
+
)
|
|
50
|
+
wca_cleaned = wca.remove_counter()
|
|
51
|
+
self.updater(wca_cleaned)
|
|
52
|
+
|
|
53
|
+
if first_display:
|
|
54
|
+
missing = len(wca_cleaned.currents) - len(currents) + 1
|
|
55
|
+
for _ in range(missing):
|
|
56
|
+
currents.insert(len(currents) - 1, None)
|
|
57
|
+
|
|
58
|
+
for i, widg in enumerate(wca_cleaned.currents):
|
|
59
|
+
currents[i] = widg
|
|
60
|
+
|
|
61
|
+
if self.title:
|
|
62
|
+
proxy_display(title_html(self.title), None, wca.cache)
|
|
63
|
+
|
|
64
|
+
cache = wca_cleaned.cache
|
|
65
|
+
level_obj.cache = cache
|
|
66
|
+
|
|
67
|
+
# update counter
|
|
68
|
+
if is_internal_counter(currents[-1]):
|
|
69
|
+
c = currents[-1]
|
|
70
|
+
c.value = c.value + 1
|
|
71
|
+
|
|
72
|
+
if self.vertical:
|
|
73
|
+
for i, w in enumerate(wca_cleaned.currents):
|
|
74
|
+
id = wca_cleaned.display_id(i)
|
|
75
|
+
if first_display:
|
|
76
|
+
proxy_display(w, id, cache)
|
|
77
|
+
# else:
|
|
78
|
+
# proxy_update_display(w, id, cache)
|
|
79
|
+
else:
|
|
80
|
+
self.display_horizontal(currents_level, wca_cleaned, first_display)
|
|
81
|
+
|
|
82
|
+
if last_level:
|
|
83
|
+
level_obj.stream_update_done_count = (
|
|
84
|
+
level_obj.stream_update_done_count + 1
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def display_horizontal(self, currents_level, wca_cleaned, first_display):
|
|
88
|
+
cache = wca_cleaned.cache
|
|
89
|
+
id = str(currents_level)
|
|
90
|
+
if self.split_hbox_after and len(wca_cleaned.currents) > self.split_hbox_after:
|
|
91
|
+
box1 = HBox(wca_cleaned.currents[0 : self.split_hbox_after]) # noqa: E203
|
|
92
|
+
box2 = HBox(wca_cleaned.currents[self.split_hbox_after :]) # noqa: E203
|
|
93
|
+
id1 = f"{id}_1"
|
|
94
|
+
id2 = f"{id}_2"
|
|
95
|
+
|
|
96
|
+
if first_display:
|
|
97
|
+
proxy_display(box1, id1, cache)
|
|
98
|
+
proxy_display(box2, id2, cache)
|
|
99
|
+
else:
|
|
100
|
+
proxy_update_display(box1, id1, cache)
|
|
101
|
+
proxy_update_display(box2, id2, cache)
|
|
102
|
+
|
|
103
|
+
else:
|
|
104
|
+
box = HBox(wca_cleaned.currents)
|
|
105
|
+
if first_display:
|
|
106
|
+
proxy_display(box, id, cache)
|
|
107
|
+
else:
|
|
108
|
+
proxy_update_display(box, id, cache)
|
|
109
|
+
|
|
110
|
+
def stream_down_obs(
|
|
111
|
+
self, parents, currents, debouncer, currents_level, level_obj, last_level
|
|
112
|
+
):
|
|
113
|
+
@debouncer
|
|
114
|
+
def widget_on_change(_):
|
|
115
|
+
self.stream_down(
|
|
116
|
+
parents, currents, currents_level, level_obj, False, last_level
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
for widget in parents:
|
|
120
|
+
widget.observe(widget_on_change, names="value")
|
|
121
|
+
|
|
122
|
+
def disable_loading(self, level_obj, first_display: bool, last_level: bool):
|
|
123
|
+
if not first_display:
|
|
124
|
+
level_to_widget = level_obj.level_to_widget
|
|
125
|
+
levels = list(level_to_widget.keys())
|
|
126
|
+
levels.sort()
|
|
127
|
+
levels.pop()
|
|
128
|
+
|
|
129
|
+
for lvl in levels:
|
|
130
|
+
widgets = level_to_widget[lvl].widgets
|
|
131
|
+
|
|
132
|
+
for w in widgets:
|
|
133
|
+
if hasattr(w, "disabled"):
|
|
134
|
+
if not last_level:
|
|
135
|
+
w.disabled = True
|
|
136
|
+
else:
|
|
137
|
+
w.disabled = False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def title_html(x):
|
|
141
|
+
x = f"<font size='4' style='font-weight:bold;line-height: 50px'>{x}</font>"
|
|
142
|
+
return HTML(x)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Stream(BaseModel):
|
|
146
|
+
debounce_sec: float = 1.0
|
|
147
|
+
level_to_widget: dict[int, WidgetUpdater] = {}
|
|
148
|
+
cache: dict = {}
|
|
149
|
+
lock: Any = None
|
|
150
|
+
stream_update_done_count: int = -1
|
|
151
|
+
debouncer: Any = None
|
|
152
|
+
|
|
153
|
+
def register(
|
|
154
|
+
self,
|
|
155
|
+
level,
|
|
156
|
+
widgets=None,
|
|
157
|
+
updater=None,
|
|
158
|
+
vertical=False,
|
|
159
|
+
title=None,
|
|
160
|
+
split_hbox_after=None,
|
|
161
|
+
):
|
|
162
|
+
if not self.level_to_widget:
|
|
163
|
+
self.lock = threading.RLock()
|
|
164
|
+
# check_javascript()
|
|
165
|
+
|
|
166
|
+
if not widgets:
|
|
167
|
+
widgets = []
|
|
168
|
+
|
|
169
|
+
self.level_to_widget[level] = WidgetUpdater(
|
|
170
|
+
widgets=[f(self) for f in widgets],
|
|
171
|
+
updater=updater,
|
|
172
|
+
vertical=vertical,
|
|
173
|
+
title=title,
|
|
174
|
+
split_hbox_after=split_hbox_after,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def display_registered(self):
|
|
178
|
+
if not self.debouncer:
|
|
179
|
+
self.debouncer = AsyncDebouncer(self.debounce_sec)
|
|
180
|
+
|
|
181
|
+
css = "<style>.widget-radio-box {margin-right: 40px;}</style>"
|
|
182
|
+
display(HTML(css))
|
|
183
|
+
|
|
184
|
+
levels = list(self.level_to_widget.keys())
|
|
185
|
+
levels.sort()
|
|
186
|
+
for level_i, level in enumerate(levels):
|
|
187
|
+
wu = self.level_to_widget[level]
|
|
188
|
+
currents = wu.widgets
|
|
189
|
+
|
|
190
|
+
# otherwise display happens in wu.stream_down()
|
|
191
|
+
if level_i == 0:
|
|
192
|
+
if wu.title:
|
|
193
|
+
proxy_display(title_html(wu.title), None, self.cache)
|
|
194
|
+
proxy_display(HBox(remove_internal_counter(currents)), None, self.cache)
|
|
195
|
+
print(display_sep)
|
|
196
|
+
|
|
197
|
+
level_below = level + 1
|
|
198
|
+
if level_below not in self.level_to_widget:
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
self.level_to_widget[level].updater = self.level_to_widget[
|
|
202
|
+
level_below
|
|
203
|
+
].updater
|
|
204
|
+
self.level_to_widget[level].vertical = self.level_to_widget[
|
|
205
|
+
level_below
|
|
206
|
+
].vertical
|
|
207
|
+
self.level_to_widget[level].title = self.level_to_widget[level_below].title
|
|
208
|
+
self.level_to_widget[level].split_hbox_after = self.level_to_widget[
|
|
209
|
+
level_below
|
|
210
|
+
].split_hbox_after
|
|
211
|
+
|
|
212
|
+
children = self.level_to_widget[level_below].widgets
|
|
213
|
+
int_txt = IntText(value=0, disabled=True, description=internal_counter_desc)
|
|
214
|
+
children.append(int_txt)
|
|
215
|
+
|
|
216
|
+
# init
|
|
217
|
+
last_level = level_i == len(levels) - 2
|
|
218
|
+
wu.stream_down(currents, children, level_below, self, True, last_level)
|
|
219
|
+
|
|
220
|
+
# update on change
|
|
221
|
+
wu.stream_down_obs(
|
|
222
|
+
currents, children, self.debouncer, level_below, self, last_level
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def manually_update_stream(self, start_level=None, level_to_default_value=None):
|
|
226
|
+
levels = list(self.level_to_widget.keys())
|
|
227
|
+
levels.sort()
|
|
228
|
+
for level_i, level in enumerate(levels):
|
|
229
|
+
wu = self.level_to_widget[level]
|
|
230
|
+
currents = wu.widgets
|
|
231
|
+
|
|
232
|
+
level_below = level + 1
|
|
233
|
+
if level_below not in self.level_to_widget or (
|
|
234
|
+
start_level and level_below < start_level
|
|
235
|
+
):
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
children = self.level_to_widget[level_below].widgets
|
|
239
|
+
last_level = level_i == len(levels) - 2
|
|
240
|
+
manually_stream_down(
|
|
241
|
+
wu,
|
|
242
|
+
currents,
|
|
243
|
+
children,
|
|
244
|
+
level_below,
|
|
245
|
+
self,
|
|
246
|
+
level_to_default_value,
|
|
247
|
+
last_level,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def manually_stream_down(
|
|
252
|
+
wu, parents, currents, currents_level, level_obj, level_to_default_value, last_level
|
|
253
|
+
):
|
|
254
|
+
level = currents_level - 1
|
|
255
|
+
if level_to_default_value and level in level_to_default_value:
|
|
256
|
+
default_value = level_to_default_value[level]
|
|
257
|
+
wu.widgets[0].value = default_value
|
|
258
|
+
|
|
259
|
+
wca = WidgetCurrentsChildren(
|
|
260
|
+
parents=parents,
|
|
261
|
+
currents=currents,
|
|
262
|
+
cache=level_obj.cache,
|
|
263
|
+
currents_level=currents_level,
|
|
264
|
+
vertical=wu.vertical,
|
|
265
|
+
)
|
|
266
|
+
wca_cleaned = wca.remove_counter()
|
|
267
|
+
wu.updater(wca_cleaned)
|
|
268
|
+
|
|
269
|
+
cache = wca_cleaned.cache
|
|
270
|
+
level_obj.cache = cache
|
|
271
|
+
|
|
272
|
+
if not wu.vertical:
|
|
273
|
+
wu.display_horizontal(currents_level, wca_cleaned, False)
|
|
274
|
+
|
|
275
|
+
if last_level:
|
|
276
|
+
level_obj.stream_update_done_count = level_obj.stream_update_done_count + 1
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def check_javascript():
|
|
280
|
+
grid = DataGrid(pd.DataFrame({"0": [0]}), layout={"height": "10px"})
|
|
281
|
+
display(grid)
|
|
282
|
+
|
|
283
|
+
dl = solara.FileDownload("a", filename="a", label="a")
|
|
284
|
+
display(dl)
|
|
285
|
+
|
|
286
|
+
js = """
|
|
287
|
+
var err = 'Click to show javascript ' + 'error';
|
|
288
|
+
var isJsError = document.body.innerHTML.includes(err);
|
|
289
|
+
|
|
290
|
+
if (isJsError){
|
|
291
|
+
alert('Browser will be refreshed to fully load Jupyter widgets');
|
|
292
|
+
window.location.reload();
|
|
293
|
+
}
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
display(Javascript(js))
|
|
297
|
+
clear_output()
|
ipystream/utils.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from IPython.core.display_functions import display, update_display
|
|
2
|
+
from ipywidgets import IntText
|
|
3
|
+
|
|
4
|
+
internal_counter_desc = "#{[34_9azerfcd"
|
|
5
|
+
quiet_display_key = "quiet_display"
|
|
6
|
+
logs_key = "logs"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_internal_counter(widget):
|
|
10
|
+
if not isinstance(widget, IntText):
|
|
11
|
+
return False
|
|
12
|
+
|
|
13
|
+
return widget.description == internal_counter_desc
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def remove_internal_counter(l):
|
|
17
|
+
return [x for x in l if not is_internal_counter(x)]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def proxy_display(widg, display_id, cache):
|
|
21
|
+
if quiet_display_key in cache:
|
|
22
|
+
log(widg, display_id, cache)
|
|
23
|
+
else:
|
|
24
|
+
display(widg, display_id=display_id)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def proxy_update_display(widg, display_id, cache):
|
|
28
|
+
if quiet_display_key in cache:
|
|
29
|
+
log(widg, display_id, cache)
|
|
30
|
+
else:
|
|
31
|
+
update_display(widg, display_id=display_id)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def log(widg, display_id, cache):
|
|
35
|
+
if logs_key not in cache:
|
|
36
|
+
cache[logs_key] = {}
|
|
37
|
+
|
|
38
|
+
cache[logs_key][display_id] = widg
|
|
File without changes
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from filelock import FileLock
|
|
4
|
+
from jupyter_server.services.kernels.kernelmanager import MappingKernelManager
|
|
5
|
+
from tornado.web import HTTPError
|
|
6
|
+
from voila.handler import VoilaHandler
|
|
7
|
+
from voila import voila_kernel_manager
|
|
8
|
+
|
|
9
|
+
from ipystream.voila.kernel import (
|
|
10
|
+
get_kernel_manager,
|
|
11
|
+
get_original_shutdown_kernel,
|
|
12
|
+
_load_kernel_to_user,
|
|
13
|
+
_save_kernel_to_user,
|
|
14
|
+
KERNEL_TO_TOKEN_FILE,
|
|
15
|
+
)
|
|
16
|
+
from ipystream.voila.utils import get_token_from_headers
|
|
17
|
+
|
|
18
|
+
MAX_KERNELS = 8
|
|
19
|
+
KERNEL_CLEANUP_TIMEOUT_SEC = 20
|
|
20
|
+
FILE_LOCK = "kernel.lock"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def patch(log_user_fun, token_to_user_fun):
|
|
24
|
+
# --- Monkey-patch shutdown_kernel to block external calls ---
|
|
25
|
+
def controlled_shutdown_kernel(self, kernel_id, **kwargs):
|
|
26
|
+
return asyncio.ensure_future(asyncio.sleep(0)) # Dummy completed awaitable
|
|
27
|
+
|
|
28
|
+
MappingKernelManager.shutdown_kernel = controlled_shutdown_kernel
|
|
29
|
+
|
|
30
|
+
# --- Patch kernel manager factory to assign user from handler ---
|
|
31
|
+
_original_factory = voila_kernel_manager.voila_kernel_manager_factory
|
|
32
|
+
|
|
33
|
+
def patched_voila_kernel_manager_factory(*args, **kwargs):
|
|
34
|
+
VoilaKernelManagerCls = _original_factory(*args, **kwargs)
|
|
35
|
+
|
|
36
|
+
_original_get_rendered_notebook = VoilaKernelManagerCls.get_rendered_notebook
|
|
37
|
+
|
|
38
|
+
async def _patched_get_rendered_notebook(
|
|
39
|
+
self, notebook_name: str, extra_kernel_env_variables: dict = {}, **kwargs
|
|
40
|
+
):
|
|
41
|
+
running = self.list_kernel_ids()
|
|
42
|
+
if len(running) >= MAX_KERNELS:
|
|
43
|
+
raise HTTPError(503)
|
|
44
|
+
|
|
45
|
+
token = None
|
|
46
|
+
user = None
|
|
47
|
+
headers = extra_kernel_env_variables.get("headers")
|
|
48
|
+
if headers:
|
|
49
|
+
headers_dict = json.loads(headers)
|
|
50
|
+
token = get_token_from_headers(headers_dict)
|
|
51
|
+
|
|
52
|
+
if token and token_to_user_fun:
|
|
53
|
+
user = token_to_user_fun(token)
|
|
54
|
+
|
|
55
|
+
with FileLock(FILE_LOCK):
|
|
56
|
+
if user:
|
|
57
|
+
data = _load_kernel_to_user()
|
|
58
|
+
await check_user_kernel_conflict(user, data)
|
|
59
|
+
|
|
60
|
+
# Call original method to get preheated kernel
|
|
61
|
+
(
|
|
62
|
+
render_task,
|
|
63
|
+
rendered_cache,
|
|
64
|
+
kernel_id,
|
|
65
|
+
) = await _original_get_rendered_notebook(
|
|
66
|
+
self, notebook_name, extra_kernel_env_variables, **kwargs
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Map kernel to user if available
|
|
70
|
+
if user:
|
|
71
|
+
data = _load_kernel_to_user()
|
|
72
|
+
data_token = _load_kernel_to_user(KERNEL_TO_TOKEN_FILE)
|
|
73
|
+
|
|
74
|
+
data[kernel_id] = user
|
|
75
|
+
data_token[kernel_id] = token
|
|
76
|
+
|
|
77
|
+
if log_user_fun:
|
|
78
|
+
with FileLock(FILE_LOCK):
|
|
79
|
+
log_user_fun(token)
|
|
80
|
+
|
|
81
|
+
_save_kernel_to_user(data)
|
|
82
|
+
_save_kernel_to_user(data_token, KERNEL_TO_TOKEN_FILE)
|
|
83
|
+
|
|
84
|
+
return render_task, rendered_cache, kernel_id
|
|
85
|
+
|
|
86
|
+
VoilaKernelManagerCls.get_rendered_notebook = _patched_get_rendered_notebook
|
|
87
|
+
return VoilaKernelManagerCls
|
|
88
|
+
|
|
89
|
+
voila_kernel_manager.voila_kernel_manager_factory = (
|
|
90
|
+
patched_voila_kernel_manager_factory
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def check_user_kernel_conflict(user: str, data: dict):
|
|
94
|
+
global_kernel_manager = get_kernel_manager()
|
|
95
|
+
|
|
96
|
+
count = 0
|
|
97
|
+
for existing_kid, existing_user in data.items():
|
|
98
|
+
if existing_user == user:
|
|
99
|
+
count += 1
|
|
100
|
+
km_info = global_kernel_manager.kernel_model(existing_kid)
|
|
101
|
+
connections = km_info["connections"]
|
|
102
|
+
if connections == 0:
|
|
103
|
+
# kill existing
|
|
104
|
+
_original_shutdown_kernel = get_original_shutdown_kernel()
|
|
105
|
+
await _original_shutdown_kernel(
|
|
106
|
+
global_kernel_manager, existing_kid, now=True
|
|
107
|
+
)
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
raise HTTPError(
|
|
111
|
+
503, f"User '{user}' already has a running kernel ({existing_kid})"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if count > 2:
|
|
115
|
+
raise HTTPError(503, f"User '{user}' already has 2 running kernels")
|
|
116
|
+
|
|
117
|
+
# --- VoilaHandler 503 page ---
|
|
118
|
+
def custom_voila_write_error(self, status_code, **kwargs):
|
|
119
|
+
if status_code == 503:
|
|
120
|
+
html = f"""
|
|
121
|
+
<html>
|
|
122
|
+
<head>
|
|
123
|
+
<title>App Limit Reached</title>
|
|
124
|
+
<style>
|
|
125
|
+
body {{
|
|
126
|
+
font-family: Arial, sans-serif;
|
|
127
|
+
background: #fafafa;
|
|
128
|
+
color: #333;
|
|
129
|
+
text-align: center;
|
|
130
|
+
padding-top: 10%;
|
|
131
|
+
}}
|
|
132
|
+
.box {{
|
|
133
|
+
display: inline-block;
|
|
134
|
+
background: white;
|
|
135
|
+
border: 1px solid #ccc;
|
|
136
|
+
border-radius: 12px;
|
|
137
|
+
padding: 30px 50px;
|
|
138
|
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
139
|
+
}}
|
|
140
|
+
h1 {{
|
|
141
|
+
color: #c0392b;
|
|
142
|
+
}}
|
|
143
|
+
</style>
|
|
144
|
+
</head>
|
|
145
|
+
<body>
|
|
146
|
+
<div class="box">
|
|
147
|
+
<h1>App Already Open</h1>
|
|
148
|
+
<p>You have duplicated pages of the app opened.<br>
|
|
149
|
+
Please re-use an existing tab or close extra ones.</p>
|
|
150
|
+
<p>Closed pages kernels are cleaned up after {KERNEL_CLEANUP_TIMEOUT_SEC} seconds</p>
|
|
151
|
+
</div>
|
|
152
|
+
</body>
|
|
153
|
+
</html>
|
|
154
|
+
"""
|
|
155
|
+
self.set_status(status_code)
|
|
156
|
+
self.set_header("Content-Type", "text/html")
|
|
157
|
+
self.finish(html)
|
|
158
|
+
elif status_code == 404:
|
|
159
|
+
html = f"""
|
|
160
|
+
<html>
|
|
161
|
+
<head><title>Not Found</title></head>
|
|
162
|
+
<body style="font-family:sans-serif;text-align:center;margin-top:10%;">
|
|
163
|
+
<h1>404: Page Not Found</h1>
|
|
164
|
+
<p>The page you're looking for doesn't exist or the session expired.</p>
|
|
165
|
+
<button onclick="goHome()">Go Home</button>
|
|
166
|
+
|
|
167
|
+
<script>
|
|
168
|
+
function goHome() {{
|
|
169
|
+
const query = window.location.search;
|
|
170
|
+
const target = '/' + (query ? query : '');
|
|
171
|
+
window.location.href = target;
|
|
172
|
+
}}
|
|
173
|
+
</script>
|
|
174
|
+
</body>
|
|
175
|
+
</html>
|
|
176
|
+
"""
|
|
177
|
+
self.set_status(status_code)
|
|
178
|
+
self.set_header("Content-Type", "text/html")
|
|
179
|
+
self.finish(html)
|
|
180
|
+
|
|
181
|
+
else:
|
|
182
|
+
super(VoilaHandler, self).write_error(status_code, **kwargs)
|
|
183
|
+
|
|
184
|
+
VoilaHandler.write_error = custom_voila_write_error
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from ipystream.voila.utils import PARAM_KEY_TOKEN, is_sagemaker
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def add_v_cookie(Voila):
|
|
5
|
+
def v_cookie_wrapper(handler_class):
|
|
6
|
+
class VCookieHandler(handler_class):
|
|
7
|
+
async def prepare(self):
|
|
8
|
+
# Get the token from URL query parameters
|
|
9
|
+
v = self.get_argument(PARAM_KEY_TOKEN, None)
|
|
10
|
+
if v:
|
|
11
|
+
# Set the cookie
|
|
12
|
+
self.set_cookie(PARAM_KEY_TOKEN, v, path="/", httponly=True)
|
|
13
|
+
host_info = self.request.host # e.g., "localhost:8867"
|
|
14
|
+
|
|
15
|
+
port = "8866"
|
|
16
|
+
if ":" in host_info:
|
|
17
|
+
port = host_info.split(":")[-1]
|
|
18
|
+
|
|
19
|
+
# Redirect to clean URL
|
|
20
|
+
self.redirect(clean_url(port))
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
# Call parent prepare (sync or async)
|
|
24
|
+
parent_prepare = super().prepare()
|
|
25
|
+
if parent_prepare is not None:
|
|
26
|
+
await parent_prepare
|
|
27
|
+
|
|
28
|
+
return VCookieHandler
|
|
29
|
+
|
|
30
|
+
_original_init_handlers = Voila.init_handlers
|
|
31
|
+
|
|
32
|
+
def _patched_init_handlers(self):
|
|
33
|
+
handlers = _original_init_handlers(self)
|
|
34
|
+
|
|
35
|
+
wrapped = []
|
|
36
|
+
for h in handlers:
|
|
37
|
+
pattern, handler_class, *rest = h
|
|
38
|
+
wrapped.append((pattern, v_cookie_wrapper(handler_class), *rest))
|
|
39
|
+
return wrapped
|
|
40
|
+
|
|
41
|
+
Voila.init_handlers = _patched_init_handlers
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def clean_url(port):
|
|
45
|
+
return f"/jupyterlab/default/proxy/{port}/" if is_sagemaker() else "/"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from ipywidgets import widgets
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def documentation_btn(html_content, button_text="Documentation"):
|
|
6
|
+
# Base64 encode the HTML content for safe embedding
|
|
7
|
+
b64_html = base64.b64encode(html_content.encode("utf-8")).decode("utf-8")
|
|
8
|
+
|
|
9
|
+
# Wrap button in a span to control alignment
|
|
10
|
+
js_code = f"""
|
|
11
|
+
<span style="display: inline-block !important; vertical-align: top !important; margin-top: -2px !important;">
|
|
12
|
+
<button onclick="
|
|
13
|
+
var b64 = '{b64_html}';
|
|
14
|
+
var html = atob(b64);
|
|
15
|
+
var newWin = window.open('about:blank', '_blank');
|
|
16
|
+
if (newWin) {{
|
|
17
|
+
newWin.document.open();
|
|
18
|
+
newWin.document.write(html);
|
|
19
|
+
newWin.document.close();
|
|
20
|
+
}} else {{
|
|
21
|
+
alert('Pop-up blocked!');
|
|
22
|
+
}}
|
|
23
|
+
" class="jupyter-button button-blue" style="
|
|
24
|
+
background-color: #673AB7 !important;
|
|
25
|
+
color: white !important;">
|
|
26
|
+
{button_text}
|
|
27
|
+
</button>
|
|
28
|
+
</span>
|
|
29
|
+
"""
|
|
30
|
+
return widgets.HTML(js_code)
|