notebook-terminal 0.1.1__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.
- notebook_terminal/__init__.py +26 -0
- notebook_terminal/notebook.py +403 -0
- notebook_terminal/rich_runner.py +159 -0
- notebook_terminal/session.py +383 -0
- notebook_terminal/static/all.css +319 -0
- notebook_terminal/static/dataframe.css +1 -0
- notebook_terminal/static/dataframe.js +23 -0
- notebook_terminal/static/plotly_widget.js +3892 -0
- notebook_terminal/static/widget.js +89 -0
- notebook_terminal/styling.py +48 -0
- notebook_terminal/web.py +58 -0
- notebook_terminal-0.1.1.dist-info/METADATA +387 -0
- notebook_terminal-0.1.1.dist-info/RECORD +16 -0
- notebook_terminal-0.1.1.dist-info/WHEEL +5 -0
- notebook_terminal-0.1.1.dist-info/licenses/LICENSE +15 -0
- notebook_terminal-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from .session import TerminalSession
|
|
2
|
+
from .styling import Color, ansi_text
|
|
3
|
+
from .web import TerminalManager
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from .notebook import ManagedTerminalWidget, TerminalWidget, terminal
|
|
7
|
+
except ImportError:
|
|
8
|
+
TerminalWidget = None
|
|
9
|
+
ManagedTerminalWidget = None
|
|
10
|
+
|
|
11
|
+
def terminal(*args, **kwargs):
|
|
12
|
+
raise RuntimeError(
|
|
13
|
+
"Jupyter UI requires anywidget, ipython and traitlets. "
|
|
14
|
+
"Install the normal package dependencies."
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"TerminalSession",
|
|
19
|
+
"Color",
|
|
20
|
+
"ansi_text",
|
|
21
|
+
"TerminalWidget",
|
|
22
|
+
"ManagedTerminalWidget",
|
|
23
|
+
"TerminalManager",
|
|
24
|
+
"terminal",
|
|
25
|
+
]
|
|
26
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import base64
|
|
5
|
+
import contextlib
|
|
6
|
+
import io
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import traceback
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import anywidget
|
|
14
|
+
import ipywidgets as widgets
|
|
15
|
+
import traitlets
|
|
16
|
+
from IPython.display import display
|
|
17
|
+
|
|
18
|
+
from .session import TerminalSession
|
|
19
|
+
from .styling import Color, ansi_text
|
|
20
|
+
|
|
21
|
+
_STATIC = Path(__file__).with_name("static")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _TerminalCanvas(anywidget.AnyWidget):
|
|
25
|
+
_esm = _STATIC / "widget.js"
|
|
26
|
+
_css = _STATIC / "all.css"
|
|
27
|
+
|
|
28
|
+
height = traitlets.Int(420).tag(sync=True)
|
|
29
|
+
font_size = traitlets.Int(14).tag(sync=True)
|
|
30
|
+
scrollback = traitlets.Int(5000).tag(sync=True)
|
|
31
|
+
interactive = traitlets.Bool(True).tag(sync=True)
|
|
32
|
+
allow_copy = traitlets.Bool(True).tag(sync=True)
|
|
33
|
+
allow_paste = traitlets.Bool(True).tag(sync=True)
|
|
34
|
+
|
|
35
|
+
def push(self, data: bytes | str) -> None:
|
|
36
|
+
if isinstance(data, str):
|
|
37
|
+
data = data.encode("utf-8", "replace")
|
|
38
|
+
if data:
|
|
39
|
+
self.send({"type": "output", "data": base64.b64encode(data).decode("ascii")})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _DataFrameView(anywidget.AnyWidget):
|
|
45
|
+
_esm = _STATIC / "dataframe.js"
|
|
46
|
+
_css = _STATIC / "dataframe.css"
|
|
47
|
+
columns = traitlets.List(trait=traitlets.Unicode()).tag(sync=True)
|
|
48
|
+
rows = traitlets.List(trait=traitlets.Dict()).tag(sync=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _PlotlyView(anywidget.AnyWidget):
|
|
52
|
+
_esm = _STATIC / "plotly_widget.js"
|
|
53
|
+
spec = traitlets.Dict().tag(sync=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _TerminalStream(io.TextIOBase):
|
|
57
|
+
def __init__(self, widget: "TerminalWidget"):
|
|
58
|
+
self.widget = widget
|
|
59
|
+
def write(self, value: str) -> int:
|
|
60
|
+
self.widget._queue_output(value)
|
|
61
|
+
return len(value)
|
|
62
|
+
def flush(self) -> None:
|
|
63
|
+
self.widget._flush_output()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TerminalWidget(widgets.VBox):
|
|
67
|
+
"""Terminal plus native Jupyter tabs for rich output and widgets."""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
session: TerminalSession | None = None,
|
|
72
|
+
*,
|
|
73
|
+
interactive: bool = True,
|
|
74
|
+
auto_display: bool = True,
|
|
75
|
+
command: str | None = None,
|
|
76
|
+
height: int = 420,
|
|
77
|
+
font_size: int = 14,
|
|
78
|
+
scrollback: int = 5000,
|
|
79
|
+
allow_copy: bool = True,
|
|
80
|
+
allow_paste: bool = True,
|
|
81
|
+
rich_tabs: bool = True,
|
|
82
|
+
**session_kwargs,
|
|
83
|
+
):
|
|
84
|
+
self.session = session or TerminalSession(**session_kwargs)
|
|
85
|
+
self.rich_tabs = bool(rich_tabs)
|
|
86
|
+
self._canvas = _TerminalCanvas(
|
|
87
|
+
height=height, font_size=font_size, scrollback=scrollback,
|
|
88
|
+
interactive=interactive, allow_copy=allow_copy, allow_paste=allow_paste,
|
|
89
|
+
)
|
|
90
|
+
self._tab_items: list[tuple[str, widgets.Widget]] = []
|
|
91
|
+
self._tab_buttons = widgets.HBox(layout=widgets.Layout(display="none", flex_flow="row wrap"))
|
|
92
|
+
self._tab_stack = widgets.Stack(children=())
|
|
93
|
+
self._tab_stack.layout.display = "none"
|
|
94
|
+
self._tabs_box = widgets.VBox([self._tab_buttons, self._tab_stack])
|
|
95
|
+
super().__init__([self._canvas, self._tabs_box])
|
|
96
|
+
|
|
97
|
+
self._output_buffer: list[str] = []
|
|
98
|
+
self._flush_timer: threading.Timer | None = None
|
|
99
|
+
self._flush_lock = threading.Lock()
|
|
100
|
+
self._namespace: dict[str, Any] = {"__name__": "__main__"}
|
|
101
|
+
self._unsubscribe = self.session.subscribe(self._on_output, replay=True)
|
|
102
|
+
self._canvas.on_msg(self._on_message)
|
|
103
|
+
self._initial_command = command
|
|
104
|
+
if auto_display:
|
|
105
|
+
display(self)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def interactive(self) -> bool: return bool(self._canvas.interactive)
|
|
109
|
+
@property
|
|
110
|
+
def height(self) -> int: return int(self._canvas.height)
|
|
111
|
+
@property
|
|
112
|
+
def font_size(self) -> int: return int(self._canvas.font_size)
|
|
113
|
+
@property
|
|
114
|
+
def scrollback(self) -> int: return int(self._canvas.scrollback)
|
|
115
|
+
@property
|
|
116
|
+
def allow_copy(self) -> bool: return bool(self._canvas.allow_copy)
|
|
117
|
+
@property
|
|
118
|
+
def allow_paste(self) -> bool: return bool(self._canvas.allow_paste)
|
|
119
|
+
|
|
120
|
+
def _queue_output(self, data: bytes | str) -> None:
|
|
121
|
+
text = data.decode("utf-8", "replace") if isinstance(data, bytes) else data
|
|
122
|
+
if not text: return
|
|
123
|
+
with self._flush_lock:
|
|
124
|
+
self._output_buffer.append(text)
|
|
125
|
+
size = sum(map(len, self._output_buffer))
|
|
126
|
+
if size >= 65536:
|
|
127
|
+
self._flush_output_locked()
|
|
128
|
+
elif self._flush_timer is None:
|
|
129
|
+
self._flush_timer = threading.Timer(.025, self._flush_output)
|
|
130
|
+
self._flush_timer.daemon = True
|
|
131
|
+
self._flush_timer.start()
|
|
132
|
+
|
|
133
|
+
def _flush_output_locked(self) -> None:
|
|
134
|
+
text = "".join(self._output_buffer)
|
|
135
|
+
self._output_buffer.clear()
|
|
136
|
+
self._flush_timer = None
|
|
137
|
+
if text: self._canvas.push(text)
|
|
138
|
+
|
|
139
|
+
def _flush_output(self) -> None:
|
|
140
|
+
with self._flush_lock: self._flush_output_locked()
|
|
141
|
+
|
|
142
|
+
def _on_output(self, data: bytes) -> None:
|
|
143
|
+
self._queue_output(data)
|
|
144
|
+
|
|
145
|
+
def _on_message(self, _widget, message, _buffers) -> None:
|
|
146
|
+
kind = message.get("type")
|
|
147
|
+
if kind == "ready":
|
|
148
|
+
self.session.resize(int(message.get("cols", 80)), int(message.get("rows", 24)))
|
|
149
|
+
if self._initial_command:
|
|
150
|
+
self.session.run(self._initial_command); self._initial_command = None
|
|
151
|
+
elif kind == "input" and self.interactive:
|
|
152
|
+
self.session.write(base64.b64decode(message.get("data", "")))
|
|
153
|
+
elif kind == "resize":
|
|
154
|
+
self.session.resize(int(message.get("cols", 80)), int(message.get("rows", 24)))
|
|
155
|
+
|
|
156
|
+
def _refresh_tabs(self, selected: int | None = None) -> None:
|
|
157
|
+
buttons = []
|
|
158
|
+
for idx, (title, _child) in enumerate(self._tab_items):
|
|
159
|
+
select = widgets.Button(description=title, layout=widgets.Layout(width="auto"))
|
|
160
|
+
close = widgets.Button(description="×", tooltip=f"Zamknij {title}", layout=widgets.Layout(width="32px"))
|
|
161
|
+
select.on_click(lambda _b, i=idx: setattr(self._tab_stack, "selected_index", i))
|
|
162
|
+
close.on_click(lambda _b, i=idx: self.close_tab(i))
|
|
163
|
+
buttons.append(widgets.HBox([select, close], layout=widgets.Layout(width="auto")))
|
|
164
|
+
self._tab_buttons.children = tuple(buttons)
|
|
165
|
+
self._tab_stack.children = tuple(child for _title, child in self._tab_items)
|
|
166
|
+
visible = bool(self._tab_items)
|
|
167
|
+
self._tab_buttons.layout.display = "flex" if visible else "none"
|
|
168
|
+
self._tab_stack.layout.display = "" if visible else "none"
|
|
169
|
+
if visible:
|
|
170
|
+
self._tab_stack.selected_index = min(selected if selected is not None else len(self._tab_items)-1, len(self._tab_items)-1)
|
|
171
|
+
|
|
172
|
+
def close_tab(self, index: int) -> None:
|
|
173
|
+
if 0 <= index < len(self._tab_items):
|
|
174
|
+
_title, child = self._tab_items.pop(index)
|
|
175
|
+
try: child.close()
|
|
176
|
+
except Exception: pass
|
|
177
|
+
self._refresh_tabs(max(0, index-1))
|
|
178
|
+
|
|
179
|
+
def _add_tab(self, value: Any, title: str | None = None) -> None:
|
|
180
|
+
if not self.rich_tabs:
|
|
181
|
+
return
|
|
182
|
+
if isinstance(value, dict) and value.get("kind") == "dataframe":
|
|
183
|
+
child = _DataFrameView(columns=[str(x) for x in value.get("columns", [])], rows=value.get("rows", []))
|
|
184
|
+
title = title or value.get("title") or "DataFrame"
|
|
185
|
+
elif isinstance(value, dict) and value.get("kind") == "plotly":
|
|
186
|
+
child = _PlotlyView(spec=value.get("spec", {}), layout=widgets.Layout(width="100%", min_height="340px"))
|
|
187
|
+
title = title or value.get("title") or "Plotly"
|
|
188
|
+
elif isinstance(value, dict) and value.get("kind") == "image":
|
|
189
|
+
raw = base64.b64decode(value.get("data", "")); mime = str(value.get("mime", "image/png")); fmt=mime.split("/",1)[-1].replace("jpeg","jpg")
|
|
190
|
+
child = widgets.Image(value=raw, format=fmt, layout=widgets.Layout(max_width="100%", width="auto")); title=title or value.get("title") or "Plot"
|
|
191
|
+
elif isinstance(value, dict) and value.get("kind") == "html":
|
|
192
|
+
child = widgets.HTML(value=str(value.get("html", "")), layout=widgets.Layout(width="100%", overflow="auto")); title=title or value.get("title") or "Output"
|
|
193
|
+
elif isinstance(value, widgets.Widget):
|
|
194
|
+
child=value; title=title or type(value).__name__
|
|
195
|
+
else:
|
|
196
|
+
try:
|
|
197
|
+
import pandas as pd
|
|
198
|
+
if isinstance(value, pd.DataFrame):
|
|
199
|
+
child=_DataFrameView(columns=[str(c) for c in value.reset_index().columns], rows=value.reset_index().where(value.reset_index().notna(), None).to_dict(orient="records")); title=title or "DataFrame"
|
|
200
|
+
elif isinstance(value, pd.Series):
|
|
201
|
+
frame=value.to_frame().reset_index(); child=_DataFrameView(columns=[str(c) for c in frame.columns], rows=frame.where(frame.notna(),None).to_dict(orient="records")); title=title or "Series"
|
|
202
|
+
else: raise TypeError
|
|
203
|
+
except Exception:
|
|
204
|
+
try:
|
|
205
|
+
import plotly.graph_objects as go
|
|
206
|
+
if isinstance(value, go.Figure):
|
|
207
|
+
import json
|
|
208
|
+
from plotly.utils import PlotlyJSONEncoder
|
|
209
|
+
spec=json.loads(json.dumps(value.to_plotly_json(), cls=PlotlyJSONEncoder))
|
|
210
|
+
child=_PlotlyView(spec=spec); title=title or "Plotly"
|
|
211
|
+
else: raise TypeError
|
|
212
|
+
except Exception:
|
|
213
|
+
out=widgets.Output(layout=widgets.Layout(width="100%"))
|
|
214
|
+
with out: display(value)
|
|
215
|
+
child=out; title=title or type(value).__name__
|
|
216
|
+
self._tab_items.append((str(title or f"Output {len(self._tab_items)+1}"), child))
|
|
217
|
+
self._refresh_tabs(len(self._tab_items)-1)
|
|
218
|
+
|
|
219
|
+
def show(self): display(self); return self
|
|
220
|
+
|
|
221
|
+
def _new_window(self, command: str | None = None):
|
|
222
|
+
return TerminalWidget(shell=self.session.shell, cwd=self.session.cwd, env=self.session.env,
|
|
223
|
+
cols=self.session.cols, rows=self.session.rows, height=self.height,
|
|
224
|
+
font_size=self.font_size, scrollback=self.scrollback, interactive=self.interactive,
|
|
225
|
+
allow_copy=self.allow_copy, allow_paste=self.allow_paste, rich_tabs=self.rich_tabs,
|
|
226
|
+
command=command, auto_display=True)
|
|
227
|
+
|
|
228
|
+
def run(self, command: str, *, new_window: bool = False):
|
|
229
|
+
if new_window: return self._new_window(command)
|
|
230
|
+
self.session.run(command); return None
|
|
231
|
+
|
|
232
|
+
def send_text(self, text: str, *, new_window: bool = False):
|
|
233
|
+
if new_window:
|
|
234
|
+
w=self._new_window(); w.session.send(text); return w
|
|
235
|
+
self.session.send(text); return None
|
|
236
|
+
|
|
237
|
+
def run_python(self, code: str, executable: str | None = None, *, rich_output: bool = False,
|
|
238
|
+
new_window: bool = False, clear_previous: bool = False):
|
|
239
|
+
if new_window:
|
|
240
|
+
w=self._new_window(); w.run_python(code, executable, rich_output=rich_output, clear_previous=clear_previous); return w
|
|
241
|
+
if clear_previous: self.clear_tabs()
|
|
242
|
+
self.session.run_python(code, executable, rich_output=rich_output,
|
|
243
|
+
artifact_callback=self._add_tab if rich_output else None)
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
def run_python_file(self, path: str, executable: str | None = None, *, rich_output: bool = False,
|
|
247
|
+
args: list[str] | None = None, new_window: bool = False,
|
|
248
|
+
clear_previous: bool = False):
|
|
249
|
+
if new_window:
|
|
250
|
+
w=self._new_window(); w.run_python_file(path, executable, rich_output=rich_output, args=args, clear_previous=clear_previous); return w
|
|
251
|
+
if clear_previous: self.clear_tabs()
|
|
252
|
+
self.session.run_python_file(path, executable, rich_output=rich_output, args=args,
|
|
253
|
+
artifact_callback=self._add_tab if rich_output else None)
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
def run_kernel(self, code: str, *, clear_previous: bool = False, background: bool = False,
|
|
257
|
+
namespace: dict[str, Any] | None = None):
|
|
258
|
+
"""Execute in the current kernel. Supports live ipywidgets in tabs.
|
|
259
|
+
|
|
260
|
+
This is intentionally separate from subprocess execution: Jupyter widgets
|
|
261
|
+
need the current kernel's Comm channel and cannot be made interactive from
|
|
262
|
+
an unrelated child Python process.
|
|
263
|
+
"""
|
|
264
|
+
if clear_previous: self.clear_tabs()
|
|
265
|
+
ns = namespace if namespace is not None else self._namespace
|
|
266
|
+
|
|
267
|
+
def execute():
|
|
268
|
+
old_display = None
|
|
269
|
+
try:
|
|
270
|
+
import IPython.display as ipd
|
|
271
|
+
old_display = ipd.display
|
|
272
|
+
def tab_display(*objects, **kwargs):
|
|
273
|
+
title = kwargs.get("display_id")
|
|
274
|
+
for obj in objects: self._add_tab(obj, str(title) if title else None)
|
|
275
|
+
ipd.display = tab_display
|
|
276
|
+
ns["display"] = tab_display
|
|
277
|
+
tree = ast.parse(code, mode="exec")
|
|
278
|
+
if tree.body and isinstance(tree.body[-1], ast.Expr):
|
|
279
|
+
last = tree.body[-1]
|
|
280
|
+
tree.body[-1] = ast.Expr(value=ast.Call(func=ast.Name("display", ast.Load()), args=[last.value], keywords=[]))
|
|
281
|
+
ast.fix_missing_locations(tree)
|
|
282
|
+
stream = _TerminalStream(self)
|
|
283
|
+
with contextlib.redirect_stdout(stream), contextlib.redirect_stderr(stream):
|
|
284
|
+
exec(compile(tree, "<notebook-terminal>", "exec"), ns, ns)
|
|
285
|
+
# Capture standard matplotlib figures not explicitly displayed.
|
|
286
|
+
try:
|
|
287
|
+
import matplotlib.pyplot as plt
|
|
288
|
+
for idx, num in enumerate(list(plt.get_fignums()), 1):
|
|
289
|
+
self._add_tab(plt.figure(num), f"Plot {idx}")
|
|
290
|
+
if plt.get_fignums(): plt.close("all")
|
|
291
|
+
except Exception: pass
|
|
292
|
+
except BaseException:
|
|
293
|
+
self._queue_output(traceback.format_exc())
|
|
294
|
+
finally:
|
|
295
|
+
if old_display is not None:
|
|
296
|
+
try:
|
|
297
|
+
import IPython.display as ipd
|
|
298
|
+
ipd.display = old_display
|
|
299
|
+
except Exception: pass
|
|
300
|
+
self._flush_output()
|
|
301
|
+
if background:
|
|
302
|
+
threading.Thread(target=execute, daemon=True).start(); return None
|
|
303
|
+
execute(); return None
|
|
304
|
+
|
|
305
|
+
def write_line(self, text: object = "", *, color=None, bold: bool = False,
|
|
306
|
+
icon: str | None = None):
|
|
307
|
+
"""Render a display-only line directly in xterm.js.
|
|
308
|
+
|
|
309
|
+
This path never touches the PowerShell/CMD/Bash input stream, preventing
|
|
310
|
+
ANSI reset sequences from being interpreted as commands on Windows.
|
|
311
|
+
"""
|
|
312
|
+
prefix = f"{icon} " if icon else ""
|
|
313
|
+
value = ansi_text(prefix + str(text), color=color, bold=bold) + "\r\n"
|
|
314
|
+
self._queue_output(value)
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
def success(self, text: object, *, bold: bool = False, icon: bool | str = True):
|
|
318
|
+
marker = "✔" if icon is True else (str(icon) if icon else None)
|
|
319
|
+
return self.write_line(text, color=Color.BRIGHT_GREEN, bold=bold, icon=marker)
|
|
320
|
+
|
|
321
|
+
def error(self, text: object, *, bold: bool = False, icon: bool | str = True):
|
|
322
|
+
marker = "✖" if icon is True else (str(icon) if icon else None)
|
|
323
|
+
return self.write_line(text, color=Color.BRIGHT_RED, bold=bold, icon=marker)
|
|
324
|
+
|
|
325
|
+
def warning(self, text: object, *, bold: bool = False, icon: bool | str = True):
|
|
326
|
+
marker = "⚠" if icon is True else (str(icon) if icon else None)
|
|
327
|
+
return self.write_line(text, color=Color.BRIGHT_YELLOW, bold=bold, icon=marker)
|
|
328
|
+
|
|
329
|
+
def info(self, text: object, *, bold: bool = False, icon: bool | str = True):
|
|
330
|
+
marker = "ℹ" if icon is True else (str(icon) if icon else None)
|
|
331
|
+
return self.write_line(text, color=Color.BRIGHT_CYAN, bold=bold, icon=marker)
|
|
332
|
+
|
|
333
|
+
def debug(self, text: object, *, bold: bool = False, icon: bool | str = True):
|
|
334
|
+
marker = "•" if icon is True else (str(icon) if icon else None)
|
|
335
|
+
return self.write_line(text, color=Color.BRIGHT_BLACK, bold=bold, icon=marker)
|
|
336
|
+
|
|
337
|
+
@property
|
|
338
|
+
def command_history(self) -> tuple[str, ...]:
|
|
339
|
+
return self.session.command_history
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def last_command(self) -> str | None:
|
|
343
|
+
return self.session.last_command
|
|
344
|
+
|
|
345
|
+
def history(self, limit: int | None = None) -> list[str]:
|
|
346
|
+
return self.session.history(limit)
|
|
347
|
+
|
|
348
|
+
def clear_history(self):
|
|
349
|
+
self.session.clear_history()
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
def rerun(self, index: int = -1):
|
|
353
|
+
self.session.rerun(index)
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
def run_many(self, commands, *, stop_on_error: bool = False):
|
|
357
|
+
self.session.run_many(commands, stop_on_error=stop_on_error)
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
def send_key(self, key: str):
|
|
361
|
+
self.session.send_key(key)
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
def wait_for(self, text: str, timeout: float = 10.0) -> bool:
|
|
365
|
+
return self.session.wait_for(text, timeout)
|
|
366
|
+
|
|
367
|
+
def wait_until_idle(self, idle_for: float = 0.25, timeout: float = 30.0) -> bool:
|
|
368
|
+
return self.session.wait_until_idle(idle_for, timeout)
|
|
369
|
+
|
|
370
|
+
def restart(self, *, clear: bool = True):
|
|
371
|
+
self.session.restart()
|
|
372
|
+
if clear:
|
|
373
|
+
self.clear()
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
def interrupt(self): self.session.interrupt(); return None
|
|
377
|
+
def clear(self, *, clear_tabs: bool=False):
|
|
378
|
+
self._canvas.send({"type":"clear"})
|
|
379
|
+
if clear_tabs: self.clear_tabs()
|
|
380
|
+
return None
|
|
381
|
+
def clear_tabs(self):
|
|
382
|
+
for _title, child in self._tab_items:
|
|
383
|
+
try: child.close()
|
|
384
|
+
except Exception: pass
|
|
385
|
+
self._tab_items.clear(); self._refresh_tabs(); return None
|
|
386
|
+
def focus(self):
|
|
387
|
+
if self.interactive: self._canvas.send({"type":"focus"})
|
|
388
|
+
return None
|
|
389
|
+
def set_interactive(self, enabled: bool): self._canvas.interactive=bool(enabled); return None
|
|
390
|
+
@property
|
|
391
|
+
def output(self) -> str: return self.session.output
|
|
392
|
+
def close_terminal(self):
|
|
393
|
+
self._flush_output(); self._unsubscribe(); self.session.close(); self.close()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class ManagedTerminalWidget(TerminalWidget):
|
|
397
|
+
def __init__(self, *args, **kwargs): kwargs["interactive"] = False; super().__init__(*args, **kwargs)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def terminal(command: str | None = None, *, interactive: bool = True,
|
|
401
|
+
auto_display: bool = True, **kwargs):
|
|
402
|
+
return TerminalWidget(command=command, interactive=interactive,
|
|
403
|
+
auto_display=auto_display, **kwargs)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import base64
|
|
5
|
+
import html
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import runpy
|
|
9
|
+
import sys
|
|
10
|
+
import traceback
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, TextIO
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _writer(path: Path):
|
|
16
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
handle: TextIO = path.open("a", encoding="utf-8", buffering=1)
|
|
18
|
+
|
|
19
|
+
def emit(payload: dict[str, Any]) -> None:
|
|
20
|
+
handle.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
21
|
+
handle.flush()
|
|
22
|
+
|
|
23
|
+
return handle, emit
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _capture_factory(emit):
|
|
27
|
+
def emit_html(value: str, title: str = "Output") -> None:
|
|
28
|
+
emit({"kind": "html", "title": title, "html": value})
|
|
29
|
+
|
|
30
|
+
def emit_png(data: bytes, title: str = "Plot") -> None:
|
|
31
|
+
emit({
|
|
32
|
+
"kind": "image",
|
|
33
|
+
"title": title,
|
|
34
|
+
"mime": "image/png",
|
|
35
|
+
"data": base64.b64encode(data).decode("ascii"),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
def capture(value: Any, *, title: str | None = None) -> None:
|
|
39
|
+
if value is None:
|
|
40
|
+
return
|
|
41
|
+
try:
|
|
42
|
+
import pandas as pd # type: ignore
|
|
43
|
+
if isinstance(value, pd.DataFrame):
|
|
44
|
+
frame = value.reset_index()
|
|
45
|
+
frame = frame.where(frame.notna(), None)
|
|
46
|
+
emit({"kind": "dataframe", "title": title or "DataFrame", "columns": [str(c) for c in frame.columns], "rows": frame.to_dict(orient="records")})
|
|
47
|
+
return
|
|
48
|
+
if isinstance(value, pd.Series):
|
|
49
|
+
frame = value.to_frame().reset_index().where(lambda x: x.notna(), None)
|
|
50
|
+
emit({"kind": "dataframe", "title": title or "Series", "columns": [str(c) for c in frame.columns], "rows": frame.to_dict(orient="records")})
|
|
51
|
+
return
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
try:
|
|
55
|
+
import plotly.graph_objects as go # type: ignore
|
|
56
|
+
if isinstance(value, go.Figure):
|
|
57
|
+
from plotly.utils import PlotlyJSONEncoder # type: ignore
|
|
58
|
+
spec = json.loads(json.dumps(value.to_plotly_json(), cls=PlotlyJSONEncoder))
|
|
59
|
+
emit({"kind": "plotly", "title": title or "Plotly", "spec": spec})
|
|
60
|
+
return
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
try:
|
|
64
|
+
from PIL import Image # type: ignore
|
|
65
|
+
if isinstance(value, Image.Image):
|
|
66
|
+
import io
|
|
67
|
+
buf = io.BytesIO(); value.save(buf, format="PNG")
|
|
68
|
+
emit_png(buf.getvalue(), title or "Image")
|
|
69
|
+
return
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
repr_html = getattr(value, "_repr_html_", None)
|
|
73
|
+
if callable(repr_html):
|
|
74
|
+
try:
|
|
75
|
+
rendered = repr_html()
|
|
76
|
+
if rendered:
|
|
77
|
+
emit_html(str(rendered), title or type(value).__name__)
|
|
78
|
+
return
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
repr_png = getattr(value, "_repr_png_", None)
|
|
82
|
+
if callable(repr_png):
|
|
83
|
+
try:
|
|
84
|
+
rendered = repr_png()
|
|
85
|
+
if rendered:
|
|
86
|
+
emit_png(rendered, title or type(value).__name__)
|
|
87
|
+
return
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
emit_html(f"<pre>{html.escape(repr(value))}</pre>", title or type(value).__name__)
|
|
91
|
+
|
|
92
|
+
return capture, emit_png
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _execute_code(path: Path, capture) -> None:
|
|
96
|
+
source = path.read_text(encoding="utf-8")
|
|
97
|
+
tree = ast.parse(source, filename=str(path), mode="exec")
|
|
98
|
+
if tree.body and isinstance(tree.body[-1], ast.Expr):
|
|
99
|
+
last = tree.body[-1]
|
|
100
|
+
tree.body[-1] = ast.Expr(value=ast.Call(
|
|
101
|
+
func=ast.Name(id="__nbterm_display__", ctx=ast.Load()),
|
|
102
|
+
args=[last.value], keywords=[]))
|
|
103
|
+
ast.fix_missing_locations(tree)
|
|
104
|
+
namespace = {
|
|
105
|
+
"__name__": "__main__", "__file__": str(path),
|
|
106
|
+
"__nbterm_display__": capture, "display": capture,
|
|
107
|
+
}
|
|
108
|
+
exec(compile(tree, str(path), "exec"), namespace, namespace)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main() -> int:
|
|
112
|
+
if len(sys.argv) < 5 or sys.argv[1] not in {"--code", "--file"} or sys.argv[3] != "--artifacts":
|
|
113
|
+
print("Usage: rich_runner.py (--code|--file) PATH --artifacts JSONL [args...]", file=sys.stderr)
|
|
114
|
+
return 2
|
|
115
|
+
mode, target, artifact_path = sys.argv[1], Path(sys.argv[2]).resolve(), Path(sys.argv[4]).resolve()
|
|
116
|
+
handle, emit = _writer(artifact_path)
|
|
117
|
+
capture, emit_png = _capture_factory(emit)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
import IPython.display as ipd # type: ignore
|
|
121
|
+
ipd.display = lambda *objects, **kwargs: [capture(obj, title=str(kwargs.get("display_id")) if kwargs.get("display_id") else None) for obj in objects]
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
import matplotlib
|
|
127
|
+
matplotlib.use("Agg", force=True)
|
|
128
|
+
import matplotlib.pyplot as plt # type: ignore
|
|
129
|
+
def show(*args: Any, **kwargs: Any) -> None:
|
|
130
|
+
import io
|
|
131
|
+
nums = list(plt.get_fignums())
|
|
132
|
+
for index, num in enumerate(nums, 1):
|
|
133
|
+
fig = plt.figure(num); buf = io.BytesIO()
|
|
134
|
+
fig.savefig(buf, format="png", bbox_inches="tight")
|
|
135
|
+
emit_png(buf.getvalue(), f"Plot {index}")
|
|
136
|
+
if nums: plt.close("all")
|
|
137
|
+
plt.show = show
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
if mode == "--code":
|
|
143
|
+
_execute_code(target, capture)
|
|
144
|
+
else:
|
|
145
|
+
old_argv = sys.argv[:]
|
|
146
|
+
sys.argv = [str(target), *sys.argv[5:]]
|
|
147
|
+
try: runpy.run_path(str(target), run_name="__main__")
|
|
148
|
+
finally: sys.argv = old_argv
|
|
149
|
+
return 0
|
|
150
|
+
except SystemExit as exc:
|
|
151
|
+
return int(exc.code or 0)
|
|
152
|
+
except BaseException:
|
|
153
|
+
traceback.print_exc(); return 1
|
|
154
|
+
finally:
|
|
155
|
+
handle.close()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
raise SystemExit(main())
|