euporie 2.8.5__py3-none-any.whl → 2.8.7__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.
- euporie/console/app.py +2 -0
- euporie/console/tabs/console.py +27 -17
- euporie/core/__init__.py +2 -2
- euporie/core/__main__.py +2 -2
- euporie/core/_settings.py +7 -2
- euporie/core/app/_commands.py +20 -12
- euporie/core/app/_settings.py +34 -4
- euporie/core/app/app.py +31 -18
- euporie/core/bars/command.py +53 -27
- euporie/core/bars/search.py +43 -2
- euporie/core/border.py +7 -2
- euporie/core/comm/base.py +2 -2
- euporie/core/comm/ipywidgets.py +3 -3
- euporie/core/commands.py +44 -24
- euporie/core/completion.py +14 -6
- euporie/core/convert/datum.py +7 -7
- euporie/core/data_structures.py +20 -1
- euporie/core/filters.py +40 -9
- euporie/core/format.py +2 -3
- euporie/core/ft/html.py +47 -40
- euporie/core/graphics.py +199 -31
- euporie/core/history.py +15 -5
- euporie/core/inspection.py +16 -9
- euporie/core/kernel/__init__.py +53 -1
- euporie/core/kernel/base.py +571 -0
- euporie/core/kernel/{client.py → jupyter.py} +173 -430
- euporie/core/kernel/{manager.py → jupyter_manager.py} +4 -3
- euporie/core/kernel/local.py +694 -0
- euporie/core/key_binding/bindings/basic.py +6 -3
- euporie/core/keys.py +26 -25
- euporie/core/layout/cache.py +31 -7
- euporie/core/layout/containers.py +88 -13
- euporie/core/layout/scroll.py +69 -170
- euporie/core/log.py +2 -5
- euporie/core/path.py +61 -13
- euporie/core/style.py +2 -1
- euporie/core/suggest.py +155 -74
- euporie/core/tabs/__init__.py +12 -4
- euporie/core/tabs/_commands.py +76 -0
- euporie/core/tabs/_settings.py +16 -0
- euporie/core/tabs/base.py +89 -9
- euporie/core/tabs/kernel.py +83 -38
- euporie/core/tabs/notebook.py +28 -76
- euporie/core/utils.py +2 -19
- euporie/core/validation.py +8 -8
- euporie/core/widgets/_settings.py +19 -2
- euporie/core/widgets/cell.py +32 -32
- euporie/core/widgets/cell_outputs.py +10 -1
- euporie/core/widgets/dialog.py +60 -76
- euporie/core/widgets/display.py +2 -2
- euporie/core/widgets/forms.py +71 -59
- euporie/core/widgets/inputs.py +7 -4
- euporie/core/widgets/layout.py +281 -93
- euporie/core/widgets/menu.py +56 -16
- euporie/core/widgets/palette.py +3 -1
- euporie/core/widgets/tree.py +86 -76
- euporie/notebook/app.py +35 -16
- euporie/notebook/tabs/display.py +2 -2
- euporie/notebook/tabs/edit.py +11 -46
- euporie/notebook/tabs/json.py +8 -4
- euporie/notebook/tabs/notebook.py +26 -8
- euporie/preview/tabs/notebook.py +17 -13
- euporie/web/__init__.py +1 -0
- euporie/web/tabs/__init__.py +14 -0
- euporie/web/tabs/web.py +30 -5
- euporie/web/widgets/__init__.py +1 -0
- euporie/web/widgets/webview.py +5 -4
- {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/METADATA +4 -2
- {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/RECORD +74 -68
- {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/entry_points.txt +1 -1
- {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/licenses/LICENSE +1 -1
- {euporie-2.8.5.data → euporie-2.8.7.data}/data/share/applications/euporie-console.desktop +0 -0
- {euporie-2.8.5.data → euporie-2.8.7.data}/data/share/applications/euporie-notebook.desktop +0 -0
- {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/WHEEL +0 -0
@@ -32,9 +32,10 @@ class LoggingLocalProvisioner(LocalProvisioner): # type:ignore[misc]
|
|
32
32
|
|
33
33
|
def log_kernel_output(pipe: TextIO, log_func: Callable) -> None:
|
34
34
|
try:
|
35
|
-
|
36
|
-
|
37
|
-
|
35
|
+
if pipe:
|
36
|
+
with pipe:
|
37
|
+
for line in iter(pipe.readline, ""):
|
38
|
+
log_func(line.rstrip())
|
38
39
|
except StopIteration:
|
39
40
|
pass
|
40
41
|
|
@@ -0,0 +1,694 @@
|
|
1
|
+
"""Local Python interpreter kernel implementation."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import ast
|
6
|
+
import code
|
7
|
+
import getpass
|
8
|
+
import logging
|
9
|
+
import sys
|
10
|
+
import threading
|
11
|
+
import traceback
|
12
|
+
from asyncio import to_thread
|
13
|
+
from functools import update_wrapper
|
14
|
+
from linecache import cache as line_cache
|
15
|
+
from typing import TYPE_CHECKING, cast
|
16
|
+
|
17
|
+
from pygments import highlight
|
18
|
+
from pygments.formatters import Terminal256Formatter
|
19
|
+
from pygments.lexers import Python3TracebackLexer
|
20
|
+
|
21
|
+
from euporie.core.app.current import get_app
|
22
|
+
from euporie.core.kernel.base import BaseKernel, KernelInfo, MsgCallbacks
|
23
|
+
|
24
|
+
if TYPE_CHECKING:
|
25
|
+
from typing import Any, Callable, Unpack
|
26
|
+
|
27
|
+
from euporie.core.tabs.kernel import KernelTab
|
28
|
+
|
29
|
+
log = logging.getLogger(__name__)
|
30
|
+
|
31
|
+
|
32
|
+
class LocalPythonKernel(BaseKernel):
|
33
|
+
"""Run code in a local Python interpreter."""
|
34
|
+
|
35
|
+
@classmethod
|
36
|
+
def variants(cls) -> list[KernelInfo]:
|
37
|
+
"""Return available kernel specifications."""
|
38
|
+
return [
|
39
|
+
KernelInfo(
|
40
|
+
name="local-python",
|
41
|
+
display_name="Local Python",
|
42
|
+
factory=cls,
|
43
|
+
kind="new",
|
44
|
+
type=cls,
|
45
|
+
)
|
46
|
+
]
|
47
|
+
|
48
|
+
# Thread-local storage for callbacks during execution
|
49
|
+
_thread_local = threading.local()
|
50
|
+
|
51
|
+
def __init__(
|
52
|
+
self,
|
53
|
+
kernel_tab: KernelTab,
|
54
|
+
default_callbacks: MsgCallbacks | None = None,
|
55
|
+
allow_stdin: bool = False,
|
56
|
+
**kwargs: Any,
|
57
|
+
) -> None:
|
58
|
+
"""Initialize the local Python interpreter kernel.
|
59
|
+
|
60
|
+
Args:
|
61
|
+
kernel_tab: The notebook this kernel belongs to
|
62
|
+
allow_stdin: Whether the kernel is allowed to request input
|
63
|
+
default_callbacks: The default callbacks to use on receipt of a message
|
64
|
+
connection_file: Not used for local kernel
|
65
|
+
**kwargs: Additional keyword arguments
|
66
|
+
"""
|
67
|
+
super().__init__(
|
68
|
+
kernel_tab=kernel_tab,
|
69
|
+
allow_stdin=allow_stdin,
|
70
|
+
default_callbacks=default_callbacks,
|
71
|
+
)
|
72
|
+
# Create interpreter with callback for error handling
|
73
|
+
self.locals: dict[str, object] = {}
|
74
|
+
self.execution_count = 0
|
75
|
+
self.kc = None
|
76
|
+
self._input_event = threading.Event()
|
77
|
+
self._input_buffer: str | None = None
|
78
|
+
self.hook_manager = HookManager(self)
|
79
|
+
|
80
|
+
@property
|
81
|
+
def missing(self) -> bool:
|
82
|
+
"""Return whether the kernel is missing."""
|
83
|
+
return False
|
84
|
+
|
85
|
+
@property
|
86
|
+
def id(self) -> str | None:
|
87
|
+
"""Return the kernel ID."""
|
88
|
+
return "local-python"
|
89
|
+
|
90
|
+
def info(
|
91
|
+
self,
|
92
|
+
set_kernel_info: Callable[[dict[str, Any]], None] | None = None,
|
93
|
+
set_status: Callable[[str], None] | None = None,
|
94
|
+
) -> None:
|
95
|
+
"""Request information about the kernel.
|
96
|
+
|
97
|
+
Args:
|
98
|
+
set_kernel_info: Callback to set kernel info
|
99
|
+
set_status: Callback to set kernel status
|
100
|
+
"""
|
101
|
+
kernel_info = {
|
102
|
+
"language_info": {
|
103
|
+
"name": "python",
|
104
|
+
"version": sys.version.split()[0],
|
105
|
+
"mimetype": "text/x-python",
|
106
|
+
"file_extension": ".py",
|
107
|
+
"pygments_lexer": "python3",
|
108
|
+
"codemirror_mode": {
|
109
|
+
"name": "python",
|
110
|
+
"version": sys.version_info.major,
|
111
|
+
},
|
112
|
+
"nbconvert_exporter": "python",
|
113
|
+
},
|
114
|
+
"implementation": sys.implementation.name,
|
115
|
+
"implementation_version": sys.version.split()[0],
|
116
|
+
"banner": f"Python {sys.version}",
|
117
|
+
"help_links": ["https://euporie.readthedocs.io/"],
|
118
|
+
"status": "ok",
|
119
|
+
}
|
120
|
+
|
121
|
+
if callable(set_kernel_info):
|
122
|
+
set_kernel_info(kernel_info)
|
123
|
+
|
124
|
+
if callable(set_status):
|
125
|
+
set_status(self.status)
|
126
|
+
|
127
|
+
async def start_async(self) -> None:
|
128
|
+
"""Start the local interpreter."""
|
129
|
+
self.error = None
|
130
|
+
self.status = "idle"
|
131
|
+
self.locals.clear()
|
132
|
+
self.execution_count = 0
|
133
|
+
if callable(
|
134
|
+
set_execution_count := self.default_callbacks.get("set_execution_count")
|
135
|
+
):
|
136
|
+
set_execution_count(self.execution_count)
|
137
|
+
|
138
|
+
@property
|
139
|
+
def spec(self) -> dict[str, str]:
|
140
|
+
"""The kernelspec metadata for the current kernel instance."""
|
141
|
+
return {
|
142
|
+
"name": "local-python",
|
143
|
+
"display_name": "Local Python",
|
144
|
+
"language": "python",
|
145
|
+
}
|
146
|
+
|
147
|
+
def stop(self, cb: Callable | None = None, wait: bool = False) -> None:
|
148
|
+
"""Stop the local interpreter."""
|
149
|
+
self.status = "stopped"
|
150
|
+
if callable(cb):
|
151
|
+
cb()
|
152
|
+
|
153
|
+
def showtraceback(
|
154
|
+
self, filename: str, cb: Callable[[dict[str, Any], bool], None] | None
|
155
|
+
) -> None:
|
156
|
+
"""Format and display tracebacks for exceptions."""
|
157
|
+
typ, value, tb = sys.exc_info()
|
158
|
+
stack_summary = list(traceback.extract_tb(tb))
|
159
|
+
|
160
|
+
# Filter items from stack prior to executed code
|
161
|
+
for i, frame in enumerate(stack_summary):
|
162
|
+
if frame.filename == filename:
|
163
|
+
stack_summary = stack_summary[i:]
|
164
|
+
break
|
165
|
+
else:
|
166
|
+
stack_summary = []
|
167
|
+
|
168
|
+
# Format the traceback text
|
169
|
+
traceback_text = "".join(
|
170
|
+
[
|
171
|
+
*traceback.format_list(stack_summary),
|
172
|
+
*traceback.format_exception_only(typ, value),
|
173
|
+
]
|
174
|
+
)
|
175
|
+
|
176
|
+
# Color the traceback using Pygments
|
177
|
+
colored_traceback = highlight(
|
178
|
+
traceback_text,
|
179
|
+
Python3TracebackLexer(),
|
180
|
+
Terminal256Formatter(style=get_app().config.syntax_theme),
|
181
|
+
).rstrip()
|
182
|
+
|
183
|
+
# Send the error through the callback
|
184
|
+
if callable(cb):
|
185
|
+
cb(
|
186
|
+
{
|
187
|
+
"output_type": "error",
|
188
|
+
"ename": "Exception" if typ is None else typ.__name__,
|
189
|
+
"evalue": str(value),
|
190
|
+
"traceback": colored_traceback.splitlines(),
|
191
|
+
},
|
192
|
+
True,
|
193
|
+
)
|
194
|
+
|
195
|
+
def _execute_code(
|
196
|
+
self,
|
197
|
+
body: list[ast.stmt],
|
198
|
+
last: ast.stmt | None,
|
199
|
+
filename: str,
|
200
|
+
callbacks: MsgCallbacks,
|
201
|
+
) -> None:
|
202
|
+
"""Execute code in the interpreter.
|
203
|
+
|
204
|
+
Args:
|
205
|
+
body: List of statements to execute
|
206
|
+
last: Optional final expression to evaluate
|
207
|
+
filename: Source filename for tracebacks
|
208
|
+
callbacks: Message callbacks
|
209
|
+
"""
|
210
|
+
# Store callbacks in thread local storage
|
211
|
+
self._thread_local.callbacks = callbacks
|
212
|
+
add_output = callbacks["add_output"]
|
213
|
+
|
214
|
+
with self.hook_manager:
|
215
|
+
# Execute body
|
216
|
+
try:
|
217
|
+
if body:
|
218
|
+
exec( # noqa: S102
|
219
|
+
compile(
|
220
|
+
ast.Module(body=body, type_ignores=[]),
|
221
|
+
filename=filename,
|
222
|
+
mode="exec",
|
223
|
+
),
|
224
|
+
self.locals,
|
225
|
+
)
|
226
|
+
# Last statement is an expression - eval it
|
227
|
+
if last is not None:
|
228
|
+
exec( # noqa: S102
|
229
|
+
compile(
|
230
|
+
ast.Interactive([last]),
|
231
|
+
filename=filename,
|
232
|
+
mode="single",
|
233
|
+
),
|
234
|
+
self.locals,
|
235
|
+
)
|
236
|
+
except SystemExit:
|
237
|
+
get_app().exit()
|
238
|
+
except Exception:
|
239
|
+
self.showtraceback(filename, cb=add_output)
|
240
|
+
if callable(done := callbacks.get("done")):
|
241
|
+
done({"status": "error"})
|
242
|
+
|
243
|
+
async def run_async(
|
244
|
+
self, source: str, **local_callbacks: Unpack[MsgCallbacks]
|
245
|
+
) -> None:
|
246
|
+
"""Execute code in the local interpreter."""
|
247
|
+
callbacks = MsgCallbacks(
|
248
|
+
{
|
249
|
+
**self.default_callbacks,
|
250
|
+
**cast(
|
251
|
+
"MsgCallbacks",
|
252
|
+
{k: v for k, v in local_callbacks.items() if v is not None},
|
253
|
+
),
|
254
|
+
}
|
255
|
+
)
|
256
|
+
|
257
|
+
self.status = "busy"
|
258
|
+
|
259
|
+
# Set execution count
|
260
|
+
self.execution_count += 1
|
261
|
+
if callable(set_execution_count := callbacks.get("set_execution_count")):
|
262
|
+
set_execution_count(self.execution_count)
|
263
|
+
|
264
|
+
# Add source to line cache (for display in tracebacks)
|
265
|
+
filename = f"<input_{self.execution_count}>"
|
266
|
+
line_cache[filename] = (
|
267
|
+
len(source),
|
268
|
+
None,
|
269
|
+
source.splitlines(keepends=True),
|
270
|
+
filename,
|
271
|
+
)
|
272
|
+
try:
|
273
|
+
# Parse the source into an AST
|
274
|
+
tree = ast.parse(source, filename=filename)
|
275
|
+
except Exception:
|
276
|
+
# Check for syntax errors
|
277
|
+
self.showtraceback(filename, cb=callbacks["add_output"])
|
278
|
+
return
|
279
|
+
else:
|
280
|
+
if not tree.body:
|
281
|
+
return
|
282
|
+
|
283
|
+
# Split into statements and final expression
|
284
|
+
body = tree.body
|
285
|
+
last = None
|
286
|
+
if isinstance(tree.body[-1], ast.Expr):
|
287
|
+
last = tree.body.pop()
|
288
|
+
|
289
|
+
# Execute the code in a thread
|
290
|
+
await to_thread(self._execute_code, body, last, filename, callbacks)
|
291
|
+
|
292
|
+
self.status = "idle"
|
293
|
+
|
294
|
+
if callable(done := callbacks.get("done")):
|
295
|
+
done({"status": "ok"})
|
296
|
+
|
297
|
+
async def complete_async(self, source: str, cursor_pos: int) -> list[dict]:
|
298
|
+
"""Get code completions."""
|
299
|
+
import rlcompleter
|
300
|
+
|
301
|
+
completer = rlcompleter.Completer(self.locals)
|
302
|
+
|
303
|
+
# Find the last word before cursor
|
304
|
+
tokens = source[:cursor_pos].split()
|
305
|
+
if not tokens:
|
306
|
+
return []
|
307
|
+
|
308
|
+
word = tokens[-1]
|
309
|
+
completions = []
|
310
|
+
|
311
|
+
# Get all possible completions
|
312
|
+
i = 0
|
313
|
+
while True:
|
314
|
+
completion = completer.complete(word, i)
|
315
|
+
if completion is None:
|
316
|
+
break
|
317
|
+
completions.append({"text": completion, "start_position": -len(word)})
|
318
|
+
i += 1
|
319
|
+
|
320
|
+
return completions
|
321
|
+
|
322
|
+
async def inspect_async(
|
323
|
+
self,
|
324
|
+
source: str,
|
325
|
+
cursor_pos: int,
|
326
|
+
detail_level: int = 0,
|
327
|
+
timeout: int = 2,
|
328
|
+
) -> dict[str, Any]:
|
329
|
+
"""Get code inspection/documentation."""
|
330
|
+
import inspect
|
331
|
+
|
332
|
+
if not source:
|
333
|
+
return {}
|
334
|
+
|
335
|
+
# Find the start of the word (going backwards from cursor)
|
336
|
+
start = cursor_pos
|
337
|
+
while (start >= 0 and source[start - 1].isalnum()) or source[start - 1] in "._":
|
338
|
+
start -= 1
|
339
|
+
# Find the end of the word (going forwards from cursor)
|
340
|
+
end = cursor_pos
|
341
|
+
while end < len(source) and (source[end].isalnum() or source[end] in "._"):
|
342
|
+
end += 1
|
343
|
+
# Extract the complete word
|
344
|
+
obj_name = source[start:end].strip()
|
345
|
+
if not obj_name:
|
346
|
+
return {}
|
347
|
+
try:
|
348
|
+
obj = eval(obj_name, self.locals) # noqa: S307
|
349
|
+
except Exception:
|
350
|
+
return {}
|
351
|
+
else:
|
352
|
+
if doc := inspect.getdoc(obj):
|
353
|
+
return {"text/plain": doc}
|
354
|
+
return {}
|
355
|
+
|
356
|
+
async def is_complete_async(
|
357
|
+
self,
|
358
|
+
source: str,
|
359
|
+
timeout: int | float = 0.1,
|
360
|
+
) -> dict[str, Any]:
|
361
|
+
"""Check if code is complete."""
|
362
|
+
try:
|
363
|
+
compiled = code.compile_command(
|
364
|
+
source, f"<input_{self.execution_count}>", "exec"
|
365
|
+
)
|
366
|
+
except Exception:
|
367
|
+
status = "invalid"
|
368
|
+
else:
|
369
|
+
status = "incomplete" if compiled is None else "complete"
|
370
|
+
|
371
|
+
result = {"status": status, "indent": " " if source[-1:] in ":({[" else ""}
|
372
|
+
return result
|
373
|
+
|
374
|
+
def input(self, text: str) -> None:
|
375
|
+
"""Send input to the kernel.
|
376
|
+
|
377
|
+
Args:
|
378
|
+
text: The input text to provide
|
379
|
+
"""
|
380
|
+
self._input_buffer = text
|
381
|
+
self._input_event.set()
|
382
|
+
|
383
|
+
def interrupt(self) -> None:
|
384
|
+
"""Interrupt the kernel."""
|
385
|
+
log.warning("Cannot interrupt kernel %r", self)
|
386
|
+
|
387
|
+
async def restart_async(
|
388
|
+
self, wait: bool = False, cb: Callable | None = None
|
389
|
+
) -> None:
|
390
|
+
"""Restart the kernel."""
|
391
|
+
await self.start_async()
|
392
|
+
if callable(cb):
|
393
|
+
cb({"status": "ok"})
|
394
|
+
|
395
|
+
async def shutdown_async(self, wait: bool = False) -> None:
|
396
|
+
"""Shutdown the kernel."""
|
397
|
+
self.stop()
|
398
|
+
|
399
|
+
|
400
|
+
def get_display_data(obj: Any) -> tuple[dict[str, Any], dict[str, Any]]:
|
401
|
+
"""Get display data and metadata for an object.
|
402
|
+
|
403
|
+
Args:
|
404
|
+
obj: Object to get display data for
|
405
|
+
|
406
|
+
Returns:
|
407
|
+
Tuple of (data, metadata) dictionaries
|
408
|
+
"""
|
409
|
+
data = {}
|
410
|
+
metadata = {}
|
411
|
+
|
412
|
+
if hasattr(obj, "_repr_mimebundle_"):
|
413
|
+
output = obj._repr_mimebundle_()
|
414
|
+
if isinstance(output, tuple):
|
415
|
+
data, metadata = output
|
416
|
+
else:
|
417
|
+
data = output
|
418
|
+
else:
|
419
|
+
for method, mime in [
|
420
|
+
("_repr_latex_", "text/latex"),
|
421
|
+
("_repr_html_", "text/html"),
|
422
|
+
("_repr_markdown_", "text/markdown"),
|
423
|
+
("_repr_svg_", "image/svg+xml"),
|
424
|
+
("_repr_jpeg_", "image/jpeg"),
|
425
|
+
("_repr_png_", "image/png"),
|
426
|
+
]:
|
427
|
+
if hasattr(obj, method) and (output := getattr(obj, method)()) is not None:
|
428
|
+
data[mime] = output
|
429
|
+
if not data:
|
430
|
+
data = {"text/plain": repr(obj)}
|
431
|
+
|
432
|
+
return data, metadata
|
433
|
+
|
434
|
+
|
435
|
+
class BaseHook:
|
436
|
+
"""Base class providing access to thread-specific callbacks."""
|
437
|
+
|
438
|
+
def __init__(self, kernel: LocalPythonKernel) -> None:
|
439
|
+
"""Initialize the base hook.
|
440
|
+
|
441
|
+
Args:
|
442
|
+
kernel: The kernel instance to hook
|
443
|
+
"""
|
444
|
+
self._kernel = kernel
|
445
|
+
|
446
|
+
@property
|
447
|
+
def callbacks(self) -> MsgCallbacks:
|
448
|
+
"""Get callbacks for current thread."""
|
449
|
+
return getattr(
|
450
|
+
self._kernel._thread_local, "callbacks", self._kernel.default_callbacks
|
451
|
+
)
|
452
|
+
|
453
|
+
|
454
|
+
class DisplayHook(BaseHook):
|
455
|
+
"""Hook for sys.displayhook that dispatches to thread-specific callbacks."""
|
456
|
+
|
457
|
+
def __call__(self, value: Any) -> None:
|
458
|
+
"""Handle display of values."""
|
459
|
+
if value is None:
|
460
|
+
return
|
461
|
+
|
462
|
+
if callbacks := self.callbacks:
|
463
|
+
# Store value in kernel locals
|
464
|
+
self._kernel.locals[f"_{self._kernel.execution_count}"] = value
|
465
|
+
|
466
|
+
# Get display data and metadata
|
467
|
+
data, metadata = get_display_data(value)
|
468
|
+
|
469
|
+
if callable(callback := callbacks.get("add_output")):
|
470
|
+
callback(
|
471
|
+
{
|
472
|
+
"output_type": "execute_result",
|
473
|
+
"execution_count": self._kernel.execution_count,
|
474
|
+
"data": data,
|
475
|
+
"metadata": metadata,
|
476
|
+
},
|
477
|
+
True,
|
478
|
+
)
|
479
|
+
|
480
|
+
|
481
|
+
class DisplayGlobal(BaseHook):
|
482
|
+
"""A display() function that dispatches to thread-specific callbacks.
|
483
|
+
|
484
|
+
This class implements the global display() function used to show rich output
|
485
|
+
in notebooks. It routes display calls to the appropriate output callbacks.
|
486
|
+
"""
|
487
|
+
|
488
|
+
def __call__(
|
489
|
+
self,
|
490
|
+
*objs: Any,
|
491
|
+
include: list[str] | None = None,
|
492
|
+
exclude: list[str] | None = None,
|
493
|
+
metadata: dict[str, Any] | None = None,
|
494
|
+
transient: dict[str, Any] | None = None,
|
495
|
+
display_id: str | None = None,
|
496
|
+
raw: bool = False,
|
497
|
+
clear: bool = False,
|
498
|
+
**kwargs: Any,
|
499
|
+
) -> None:
|
500
|
+
"""Handle display of values in the notebook.
|
501
|
+
|
502
|
+
Args:
|
503
|
+
*objs: Objects to display. Each object will be displayed in sequence.
|
504
|
+
include: List of MIME types to include in the output. If specified,
|
505
|
+
only these MIME types will be included.
|
506
|
+
exclude: List of MIME types to exclude from the output. These MIME
|
507
|
+
types will be filtered out.
|
508
|
+
metadata: Additional metadata to attach to the display data.
|
509
|
+
transient: Transient data that is used for display but not persisted
|
510
|
+
in the notebook document.
|
511
|
+
display_id: Unique identifier for the display. Can be used to update
|
512
|
+
this display output later.
|
513
|
+
raw: If True, skip MIME type transformation/formatting of the objects.
|
514
|
+
clear: If True, clear the output before displaying new content.
|
515
|
+
**kwargs: Additional display arguments passed to the frontend.
|
516
|
+
"""
|
517
|
+
add_output = self.callbacks.get("add_output")
|
518
|
+
if not callable(add_output):
|
519
|
+
return
|
520
|
+
|
521
|
+
for obj in objs:
|
522
|
+
data, obj_metadata = get_display_data(obj)
|
523
|
+
|
524
|
+
# Filter mime types
|
525
|
+
if include:
|
526
|
+
data = {k: v for k, v in data.items() if k in include}
|
527
|
+
if exclude:
|
528
|
+
data = {k: v for k, v in data.items() if k not in exclude}
|
529
|
+
|
530
|
+
# Merge metadata
|
531
|
+
if metadata:
|
532
|
+
obj_metadata.update(metadata)
|
533
|
+
|
534
|
+
add_output(
|
535
|
+
{
|
536
|
+
"output_type": "display_data",
|
537
|
+
"data": data,
|
538
|
+
"metadata": obj_metadata,
|
539
|
+
"transient": transient,
|
540
|
+
"display_id": display_id,
|
541
|
+
},
|
542
|
+
True,
|
543
|
+
)
|
544
|
+
|
545
|
+
|
546
|
+
class InputBuiltin(BaseHook):
|
547
|
+
"""Hook for input and getpass that dispatches to thread-specific callbacks."""
|
548
|
+
|
549
|
+
def __init__(self, kernel: LocalPythonKernel, is_password: bool = False) -> None:
|
550
|
+
"""Initialize the input hook.
|
551
|
+
|
552
|
+
Args:
|
553
|
+
kernel: The kernel instance to hook
|
554
|
+
is_password: Whether this is for password input
|
555
|
+
"""
|
556
|
+
super().__init__(kernel)
|
557
|
+
self._is_password = is_password
|
558
|
+
update_wrapper(self, input)
|
559
|
+
|
560
|
+
def __call__(self, prompt: str = "", stream: Any = None) -> str:
|
561
|
+
"""Get input from user via callback."""
|
562
|
+
if (callbacks := self.callbacks) and (get_input := callbacks.get("get_input")):
|
563
|
+
# Clear any previous input
|
564
|
+
self._kernel._input_event.clear()
|
565
|
+
self._kernel._input_buffer = None
|
566
|
+
|
567
|
+
# Request input via callback
|
568
|
+
get_input(prompt, self._is_password)
|
569
|
+
|
570
|
+
# Wait for input to be provided
|
571
|
+
self._kernel._input_event.wait()
|
572
|
+
|
573
|
+
# Return the input, or empty string if none provided
|
574
|
+
return self._kernel._input_buffer or ""
|
575
|
+
return ""
|
576
|
+
|
577
|
+
|
578
|
+
class StreamWrapper(BaseHook):
|
579
|
+
"""Hook for stdout/stderr that dispatches to thread-specific callbacks."""
|
580
|
+
|
581
|
+
def __init__(self, name: str, kernel: LocalPythonKernel) -> None:
|
582
|
+
"""Initialize the stream hook.
|
583
|
+
|
584
|
+
Args:
|
585
|
+
name: Name of the stream (stdout/stderr)
|
586
|
+
kernel: The kernel instance to hook
|
587
|
+
"""
|
588
|
+
BaseHook.__init__(self, kernel)
|
589
|
+
self.name = name
|
590
|
+
self._thread_local = threading.local()
|
591
|
+
|
592
|
+
@property
|
593
|
+
def buffer(self) -> str:
|
594
|
+
"""Get the thread-local buffer for this stream."""
|
595
|
+
if not hasattr(self._thread_local, "buffer"):
|
596
|
+
self._thread_local.buffer = ""
|
597
|
+
return self._thread_local.buffer
|
598
|
+
|
599
|
+
@buffer.setter
|
600
|
+
def buffer(self, value: str) -> None:
|
601
|
+
self._thread_local.buffer = value
|
602
|
+
|
603
|
+
def _send(self, text: str, callbacks: MsgCallbacks) -> None:
|
604
|
+
"""Send text to the frontend via callback."""
|
605
|
+
if callable(callback := callbacks.get("add_output")):
|
606
|
+
callback(
|
607
|
+
{"output_type": "stream", "name": self.name, "text": text},
|
608
|
+
True,
|
609
|
+
)
|
610
|
+
|
611
|
+
def write(self, text: str) -> int:
|
612
|
+
"""Write output using callback for current thread."""
|
613
|
+
if not isinstance(text, str):
|
614
|
+
raise TypeError(f"write() argument must be str, not {type(text)}")
|
615
|
+
if not text or not (callbacks := self.callbacks):
|
616
|
+
return 0
|
617
|
+
|
618
|
+
# Handle any buffered content plus new text
|
619
|
+
all_text = self.buffer + text
|
620
|
+
lines = all_text.splitlines(keepends=True)
|
621
|
+
if lines[-1].endswith("\n"):
|
622
|
+
self.buffer = ""
|
623
|
+
else:
|
624
|
+
self.buffer = lines[-1]
|
625
|
+
lines = lines[:-1]
|
626
|
+
if lines:
|
627
|
+
# Send complete lines immediately
|
628
|
+
self._send("".join(lines), callbacks)
|
629
|
+
|
630
|
+
return len(text)
|
631
|
+
|
632
|
+
def flush(self) -> None:
|
633
|
+
"""Flush any buffered content."""
|
634
|
+
if self.buffer and (callbacks := self.callbacks):
|
635
|
+
self._send(self.buffer, callbacks)
|
636
|
+
self.buffer = ""
|
637
|
+
|
638
|
+
|
639
|
+
class HookManager:
|
640
|
+
"""Context manager for hooking stdout/stderr/displayhook."""
|
641
|
+
|
642
|
+
def __init__(self, kernel: LocalPythonKernel) -> None:
|
643
|
+
"""Initialize the hook manager.
|
644
|
+
|
645
|
+
Args:
|
646
|
+
kernel: The kernel instance to hook
|
647
|
+
"""
|
648
|
+
# Create hook instances
|
649
|
+
self.stdout = StreamWrapper("stdout", kernel)
|
650
|
+
self.stderr = StreamWrapper("stderr", kernel)
|
651
|
+
self.displayhook = DisplayHook(kernel)
|
652
|
+
self.display = DisplayGlobal(kernel)
|
653
|
+
self.input = InputBuiltin(kernel, is_password=False)
|
654
|
+
self.getpass = InputBuiltin(kernel, is_password=True)
|
655
|
+
# Store original objects
|
656
|
+
self.og_stdout = sys.stdout
|
657
|
+
self.og_stderr = sys.stderr
|
658
|
+
self.og_displayhook = sys.displayhook
|
659
|
+
self.og_getpass = getpass.getpass
|
660
|
+
# Track hook depth
|
661
|
+
self._depth = 0
|
662
|
+
self._kernel = kernel
|
663
|
+
|
664
|
+
def __enter__(self) -> None:
|
665
|
+
"""Replace objects with hooks."""
|
666
|
+
if self._depth == 0:
|
667
|
+
# Replace system streams
|
668
|
+
sys.stdout = self.stdout
|
669
|
+
sys.stderr = self.stderr
|
670
|
+
sys.displayhook = self.displayhook
|
671
|
+
# Replace getpass
|
672
|
+
getpass.getpass = self.getpass
|
673
|
+
# Add input to kernel locals
|
674
|
+
self._kernel.locals["input"] = self.input
|
675
|
+
# Add display to kernel locals
|
676
|
+
self._kernel.locals["display"] = self.display
|
677
|
+
self._depth += 1
|
678
|
+
|
679
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
680
|
+
"""Restore original objects."""
|
681
|
+
self._depth -= 1
|
682
|
+
if self._depth == 0:
|
683
|
+
# Restore system streams
|
684
|
+
sys.stdout = self.og_stdout
|
685
|
+
sys.stderr = self.og_stderr
|
686
|
+
sys.displayhook = self.og_displayhook
|
687
|
+
# Restore getpass
|
688
|
+
getpass.getpass = self.og_getpass
|
689
|
+
# Remove input from kernel locals
|
690
|
+
self._kernel.locals.pop("input", None)
|
691
|
+
self._kernel.locals.pop("display", None)
|
692
|
+
# Flush any remaining stream outputs
|
693
|
+
self.stdout.flush()
|
694
|
+
self.stderr.flush()
|