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 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)