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.
Files changed (74) hide show
  1. euporie/console/app.py +2 -0
  2. euporie/console/tabs/console.py +27 -17
  3. euporie/core/__init__.py +2 -2
  4. euporie/core/__main__.py +2 -2
  5. euporie/core/_settings.py +7 -2
  6. euporie/core/app/_commands.py +20 -12
  7. euporie/core/app/_settings.py +34 -4
  8. euporie/core/app/app.py +31 -18
  9. euporie/core/bars/command.py +53 -27
  10. euporie/core/bars/search.py +43 -2
  11. euporie/core/border.py +7 -2
  12. euporie/core/comm/base.py +2 -2
  13. euporie/core/comm/ipywidgets.py +3 -3
  14. euporie/core/commands.py +44 -24
  15. euporie/core/completion.py +14 -6
  16. euporie/core/convert/datum.py +7 -7
  17. euporie/core/data_structures.py +20 -1
  18. euporie/core/filters.py +40 -9
  19. euporie/core/format.py +2 -3
  20. euporie/core/ft/html.py +47 -40
  21. euporie/core/graphics.py +199 -31
  22. euporie/core/history.py +15 -5
  23. euporie/core/inspection.py +16 -9
  24. euporie/core/kernel/__init__.py +53 -1
  25. euporie/core/kernel/base.py +571 -0
  26. euporie/core/kernel/{client.py → jupyter.py} +173 -430
  27. euporie/core/kernel/{manager.py → jupyter_manager.py} +4 -3
  28. euporie/core/kernel/local.py +694 -0
  29. euporie/core/key_binding/bindings/basic.py +6 -3
  30. euporie/core/keys.py +26 -25
  31. euporie/core/layout/cache.py +31 -7
  32. euporie/core/layout/containers.py +88 -13
  33. euporie/core/layout/scroll.py +69 -170
  34. euporie/core/log.py +2 -5
  35. euporie/core/path.py +61 -13
  36. euporie/core/style.py +2 -1
  37. euporie/core/suggest.py +155 -74
  38. euporie/core/tabs/__init__.py +12 -4
  39. euporie/core/tabs/_commands.py +76 -0
  40. euporie/core/tabs/_settings.py +16 -0
  41. euporie/core/tabs/base.py +89 -9
  42. euporie/core/tabs/kernel.py +83 -38
  43. euporie/core/tabs/notebook.py +28 -76
  44. euporie/core/utils.py +2 -19
  45. euporie/core/validation.py +8 -8
  46. euporie/core/widgets/_settings.py +19 -2
  47. euporie/core/widgets/cell.py +32 -32
  48. euporie/core/widgets/cell_outputs.py +10 -1
  49. euporie/core/widgets/dialog.py +60 -76
  50. euporie/core/widgets/display.py +2 -2
  51. euporie/core/widgets/forms.py +71 -59
  52. euporie/core/widgets/inputs.py +7 -4
  53. euporie/core/widgets/layout.py +281 -93
  54. euporie/core/widgets/menu.py +56 -16
  55. euporie/core/widgets/palette.py +3 -1
  56. euporie/core/widgets/tree.py +86 -76
  57. euporie/notebook/app.py +35 -16
  58. euporie/notebook/tabs/display.py +2 -2
  59. euporie/notebook/tabs/edit.py +11 -46
  60. euporie/notebook/tabs/json.py +8 -4
  61. euporie/notebook/tabs/notebook.py +26 -8
  62. euporie/preview/tabs/notebook.py +17 -13
  63. euporie/web/__init__.py +1 -0
  64. euporie/web/tabs/__init__.py +14 -0
  65. euporie/web/tabs/web.py +30 -5
  66. euporie/web/widgets/__init__.py +1 -0
  67. euporie/web/widgets/webview.py +5 -4
  68. {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/METADATA +4 -2
  69. {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/RECORD +74 -68
  70. {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/entry_points.txt +1 -1
  71. {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/licenses/LICENSE +1 -1
  72. {euporie-2.8.5.data → euporie-2.8.7.data}/data/share/applications/euporie-console.desktop +0 -0
  73. {euporie-2.8.5.data → euporie-2.8.7.data}/data/share/applications/euporie-notebook.desktop +0 -0
  74. {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
- with pipe:
36
- for line in iter(pipe.readline, ""):
37
- log_func(line.rstrip())
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()