opencode-ipynb 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +336 -0
- package/dist/domain/cell.d.ts +50 -0
- package/dist/domain/cell.d.ts.map +1 -0
- package/dist/domain/errors.d.ts +119 -0
- package/dist/domain/errors.d.ts.map +1 -0
- package/dist/domain/execution.d.ts +87 -0
- package/dist/domain/execution.d.ts.map +1 -0
- package/dist/domain/notebook.d.ts +65 -0
- package/dist/domain/notebook.d.ts.map +1 -0
- package/dist/domain/output.d.ts +50 -0
- package/dist/domain/output.d.ts.map +1 -0
- package/dist/format/diagnostics.d.ts +15 -0
- package/dist/format/diagnostics.d.ts.map +1 -0
- package/dist/format/diff.d.ts +20 -0
- package/dist/format/diff.d.ts.map +1 -0
- package/dist/format/markdown.d.ts +21 -0
- package/dist/format/markdown.d.ts.map +1 -0
- package/dist/format/outputs.d.ts +21 -0
- package/dist/format/outputs.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31862 -0
- package/dist/plugin-options.d.ts +13 -0
- package/dist/plugin-options.d.ts.map +1 -0
- package/dist/plugin.d.ts +4 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/services/DiffService.d.ts +17 -0
- package/dist/services/DiffService.d.ts.map +1 -0
- package/dist/services/NotebookCleanService.d.ts +34 -0
- package/dist/services/NotebookCleanService.d.ts.map +1 -0
- package/dist/services/NotebookEditService.d.ts +63 -0
- package/dist/services/NotebookEditService.d.ts.map +1 -0
- package/dist/services/NotebookExecutionService.d.ts +54 -0
- package/dist/services/NotebookExecutionService.d.ts.map +1 -0
- package/dist/services/NotebookExportService.d.ts +33 -0
- package/dist/services/NotebookExportService.d.ts.map +1 -0
- package/dist/services/NotebookFileService.d.ts +18 -0
- package/dist/services/NotebookFileService.d.ts.map +1 -0
- package/dist/services/NotebookInspectService.d.ts +62 -0
- package/dist/services/NotebookInspectService.d.ts.map +1 -0
- package/dist/services/NotebookOutputService.d.ts +62 -0
- package/dist/services/NotebookOutputService.d.ts.map +1 -0
- package/dist/services/NotebookReadService.d.ts +36 -0
- package/dist/services/NotebookReadService.d.ts.map +1 -0
- package/dist/services/PathService.d.ts +26 -0
- package/dist/services/PathService.d.ts.map +1 -0
- package/dist/services/PermissionService.d.ts +20 -0
- package/dist/services/PermissionService.d.ts.map +1 -0
- package/dist/services/PythonService.d.ts +80 -0
- package/dist/services/PythonService.d.ts.map +1 -0
- package/dist/services/index.d.ts +55 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/tools/_resolveOptions.d.ts +2 -0
- package/dist/tools/_resolveOptions.d.ts.map +1 -0
- package/dist/tools/cell_delete.d.ts +14 -0
- package/dist/tools/cell_delete.d.ts.map +1 -0
- package/dist/tools/cell_insert.d.ts +22 -0
- package/dist/tools/cell_insert.d.ts.map +1 -0
- package/dist/tools/cell_move.d.ts +16 -0
- package/dist/tools/cell_move.d.ts.map +1 -0
- package/dist/tools/clean.d.ts +22 -0
- package/dist/tools/clean.d.ts.map +1 -0
- package/dist/tools/doctor.d.ts +12 -0
- package/dist/tools/doctor.d.ts.map +1 -0
- package/dist/tools/edit.d.ts +22 -0
- package/dist/tools/edit.d.ts.map +1 -0
- package/dist/tools/export.d.ts +24 -0
- package/dist/tools/export.d.ts.map +1 -0
- package/dist/tools/inspect.d.ts +20 -0
- package/dist/tools/inspect.d.ts.map +1 -0
- package/dist/tools/kernel.d.ts +36 -0
- package/dist/tools/kernel.d.ts.map +1 -0
- package/dist/tools/outputs.d.ts +30 -0
- package/dist/tools/outputs.d.ts.map +1 -0
- package/dist/tools/read.d.ts +30 -0
- package/dist/tools/read.d.ts.map +1 -0
- package/dist/tools/repro.d.ts +42 -0
- package/dist/tools/repro.d.ts.map +1 -0
- package/dist/tools/run.d.ts +35 -0
- package/dist/tools/run.d.ts.map +1 -0
- package/dist/utils/ansi.d.ts +3 -0
- package/dist/utils/ansi.d.ts.map +1 -0
- package/dist/utils/attachments.d.ts +11 -0
- package/dist/utils/attachments.d.ts.map +1 -0
- package/dist/utils/fiber.d.ts +2 -0
- package/dist/utils/fiber.d.ts.map +1 -0
- package/dist/utils/i18n.d.ts +8 -0
- package/dist/utils/i18n.d.ts.map +1 -0
- package/dist/utils/imports.d.ts +4 -0
- package/dist/utils/imports.d.ts.map +1 -0
- package/dist/utils/json.d.ts +5 -0
- package/dist/utils/json.d.ts.map +1 -0
- package/dist/utils/limits.d.ts +9 -0
- package/dist/utils/limits.d.ts.map +1 -0
- package/dist/utils/mime.d.ts +4 -0
- package/dist/utils/mime.d.ts.map +1 -0
- package/dist/utils/paths.d.ts +22 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/truncate.d.ts +17 -0
- package/dist/utils/truncate.d.ts.map +1 -0
- package/package.json +69 -0
- package/python/ipynb_runner.py +952 -0
- package/python/requirements.txt +30 -0
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
"""Real notebook runner for the opencode-ipynb plugin (v0.3 + v0.4 + v1.0).
|
|
2
|
+
|
|
3
|
+
Reads a JSON request from stdin and writes a JSON response to stdout. Modes:
|
|
4
|
+
|
|
5
|
+
- ``cell`` / ``range`` / ``all`` / ``from``: execute notebook cells via
|
|
6
|
+
``nbformat`` + ``nbclient.NotebookClient`` and return a per-cell summary.
|
|
7
|
+
- ``env``: read the notebook's kernelspec / language_info, dump the
|
|
8
|
+
current Python interpreter version + executable + platform, and run
|
|
9
|
+
``pip freeze --local``. Does NOT require nbformat / nbclient.
|
|
10
|
+
- ``serve`` (v1.0, warm-kernel mode): enter a long-lived loop. The first
|
|
11
|
+
request carries ``filePath`` (and optional ``kernel`` / ``timeoutMs``);
|
|
12
|
+
subsequent requests are normal ``cell`` / ``all`` / ``range`` / ``from``
|
|
13
|
+
/ ``env`` requests. Each request has an integer ``id``; the response
|
|
14
|
+
echoes the same ``id``. The request ``{"id": N, "mode": "shutdown"}``
|
|
15
|
+
triggers a clean kernel teardown and exit 0.
|
|
16
|
+
|
|
17
|
+
The plugin does NOT install Python dependencies. The wrapper (TypeScript)
|
|
18
|
+
verifies the interpreter and the four required deps before spawning this
|
|
19
|
+
script for the execution modes; if anything is missing it returns a clear
|
|
20
|
+
error without ever calling us. We re-check here defensively so a wrong
|
|
21
|
+
venv still produces a structured error instead of a traceback.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
import traceback
|
|
32
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
PROBE_DEPS = ("nbformat", "nbclient", "jupyter_client", "ipykernel")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_request() -> Dict[str, Any]:
|
|
39
|
+
raw = sys.stdin.read()
|
|
40
|
+
if not raw.strip():
|
|
41
|
+
raise ValueError("empty stdin; expected JSON request")
|
|
42
|
+
return json.loads(raw)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read_first_request() -> Dict[str, Any]:
|
|
46
|
+
"""Read the first line of stdin. Used by the main dispatcher to decide
|
|
47
|
+
between one-shot and serve mode without consuming the rest of stdin.
|
|
48
|
+
"""
|
|
49
|
+
line = sys.stdin.readline()
|
|
50
|
+
if not line:
|
|
51
|
+
raise ValueError("empty stdin; expected JSON request")
|
|
52
|
+
line = line.strip()
|
|
53
|
+
if not line:
|
|
54
|
+
raise ValueError("empty first line; expected JSON request")
|
|
55
|
+
return json.loads(line)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read_request_line() -> Dict[str, Any]:
|
|
59
|
+
"""Read a single JSON line from stdin (used by the serve loop)."""
|
|
60
|
+
line = sys.stdin.readline()
|
|
61
|
+
if not line:
|
|
62
|
+
raise EOFError("stdin closed")
|
|
63
|
+
line = line.strip()
|
|
64
|
+
if not line:
|
|
65
|
+
raise ValueError("empty request line")
|
|
66
|
+
return json.loads(line)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _write_response(response: Dict[str, Any]) -> None:
|
|
70
|
+
sys.stdout.write(json.dumps(response))
|
|
71
|
+
sys.stdout.write("\n")
|
|
72
|
+
sys.stdout.flush()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _read_notebook_raw(file_path: str) -> Dict[str, Any]:
|
|
76
|
+
"""Read the notebook JSON without going through nbformat. Used by the
|
|
77
|
+
``env`` mode which does not need to validate cells or imports.
|
|
78
|
+
"""
|
|
79
|
+
with open(file_path, "r", encoding="utf-8") as fh:
|
|
80
|
+
data = json.load(fh)
|
|
81
|
+
if not isinstance(data, dict):
|
|
82
|
+
raise ValueError("notebook root must be a JSON object")
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _run_pip_freeze(python_executable: str, timeout_s: int = 30) -> List[str]:
|
|
87
|
+
"""Run ``pip freeze --local`` and return a list of ``package==version`` strings."""
|
|
88
|
+
raw = subprocess.check_output(
|
|
89
|
+
[python_executable, "-m", "pip", "freeze", "--local"],
|
|
90
|
+
stderr=subprocess.PIPE,
|
|
91
|
+
timeout=timeout_s,
|
|
92
|
+
)
|
|
93
|
+
text = raw.decode("utf-8", errors="replace")
|
|
94
|
+
return [line.strip() for line in text.splitlines() if line.strip()]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _build_env_report(
|
|
98
|
+
file_path: str,
|
|
99
|
+
started: float,
|
|
100
|
+
) -> Dict[str, Any]:
|
|
101
|
+
"""Build the env report for the ``env`` mode. Returns a response dict
|
|
102
|
+
in the standard shape (may be a success or error response).
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
nb = _read_notebook_raw(file_path)
|
|
106
|
+
except FileNotFoundError as exc:
|
|
107
|
+
return _error_response(
|
|
108
|
+
None,
|
|
109
|
+
started,
|
|
110
|
+
"PythonRunnerError",
|
|
111
|
+
"FileNotFoundError",
|
|
112
|
+
f"Notebook not found: {file_path}",
|
|
113
|
+
-1,
|
|
114
|
+
)
|
|
115
|
+
except json.JSONDecodeError as exc:
|
|
116
|
+
return _error_response(
|
|
117
|
+
None,
|
|
118
|
+
started,
|
|
119
|
+
"PythonRunnerError",
|
|
120
|
+
"JSONDecodeError",
|
|
121
|
+
f"Notebook is not valid JSON ({file_path}): {exc.msg} at line {exc.lineno} col {exc.colno}",
|
|
122
|
+
-1,
|
|
123
|
+
)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
return _error_response(
|
|
126
|
+
None,
|
|
127
|
+
started,
|
|
128
|
+
"PythonRunnerError",
|
|
129
|
+
type(exc).__name__,
|
|
130
|
+
f"Failed to read notebook {file_path}: {exc}",
|
|
131
|
+
-1,
|
|
132
|
+
traceback.format_exception(exc),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
kernel_display_name: Optional[str] = None
|
|
136
|
+
kernel_name: Optional[str] = None
|
|
137
|
+
language: Optional[str] = None
|
|
138
|
+
metadata = nb.get("metadata") or {}
|
|
139
|
+
if isinstance(metadata, dict):
|
|
140
|
+
ks = metadata.get("kernelspec") or {}
|
|
141
|
+
if isinstance(ks, dict):
|
|
142
|
+
dn = ks.get("display_name")
|
|
143
|
+
if isinstance(dn, str):
|
|
144
|
+
kernel_display_name = dn
|
|
145
|
+
kn = ks.get("name")
|
|
146
|
+
if isinstance(kn, str):
|
|
147
|
+
kernel_name = kn
|
|
148
|
+
li = metadata.get("language_info") or {}
|
|
149
|
+
if isinstance(li, dict):
|
|
150
|
+
ln = li.get("name")
|
|
151
|
+
if isinstance(ln, str):
|
|
152
|
+
language = ln
|
|
153
|
+
|
|
154
|
+
python_executable = sys.executable
|
|
155
|
+
try:
|
|
156
|
+
pip_freeze = _run_pip_freeze(python_executable)
|
|
157
|
+
except subprocess.TimeoutExpired as exc:
|
|
158
|
+
return _error_response(
|
|
159
|
+
None,
|
|
160
|
+
started,
|
|
161
|
+
"PythonRunnerError",
|
|
162
|
+
"TimeoutExpired",
|
|
163
|
+
f"`pip freeze --local` timed out after {exc.timeout}s",
|
|
164
|
+
-1,
|
|
165
|
+
)
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
return _error_response(
|
|
168
|
+
None,
|
|
169
|
+
started,
|
|
170
|
+
"PythonRunnerError",
|
|
171
|
+
type(exc).__name__,
|
|
172
|
+
f"Failed to run `pip freeze --local`: {exc}",
|
|
173
|
+
-1,
|
|
174
|
+
traceback.format_exception(exc),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
"success": True,
|
|
179
|
+
"executedCells": [],
|
|
180
|
+
"durationMs": int((time.time() - started) * 1000),
|
|
181
|
+
"outputs": [],
|
|
182
|
+
"env": {
|
|
183
|
+
"kernelDisplayName": kernel_display_name,
|
|
184
|
+
"kernelName": kernel_name,
|
|
185
|
+
"language": language,
|
|
186
|
+
"pythonVersion": sys.version.split()[0],
|
|
187
|
+
"pythonExecutable": python_executable,
|
|
188
|
+
"platform": sys.platform,
|
|
189
|
+
"pipFreeze": pip_freeze,
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _validate_request(req: Dict[str, Any]) -> None:
|
|
195
|
+
required = {"filePath", "mode"}
|
|
196
|
+
missing = required - req.keys()
|
|
197
|
+
if missing:
|
|
198
|
+
raise ValueError(f"missing required fields: {sorted(missing)}")
|
|
199
|
+
if req["mode"] not in {"cell", "range", "all", "from", "env", "serve"}:
|
|
200
|
+
raise ValueError(f"invalid mode: {req['mode']!r}")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _select_cell_indexes(req: Dict[str, Any], total: int) -> List[int]:
|
|
204
|
+
"""Return the cell indexes (across the WHOLE notebook) the caller wants to run."""
|
|
205
|
+
mode = req["mode"]
|
|
206
|
+
if total <= 0:
|
|
207
|
+
return []
|
|
208
|
+
if mode == "all":
|
|
209
|
+
return list(range(total))
|
|
210
|
+
if mode == "cell":
|
|
211
|
+
idx = req.get("cellIndex")
|
|
212
|
+
if not isinstance(idx, int):
|
|
213
|
+
raise ValueError("mode='cell' requires integer cellIndex")
|
|
214
|
+
if idx < 0 or idx >= total:
|
|
215
|
+
raise ValueError(
|
|
216
|
+
f"cellIndex {idx} out of range (notebook has {total} cells)"
|
|
217
|
+
)
|
|
218
|
+
return [idx]
|
|
219
|
+
if mode == "range":
|
|
220
|
+
start = req.get("start")
|
|
221
|
+
end = req.get("end")
|
|
222
|
+
if not isinstance(start, int) or not isinstance(end, int):
|
|
223
|
+
raise ValueError("mode='range' requires integer start and end")
|
|
224
|
+
if start < 0 or end < 0 or start > end:
|
|
225
|
+
raise ValueError(f"invalid range start={start} end={end}")
|
|
226
|
+
return list(range(start, min(end, total - 1) + 1))
|
|
227
|
+
if mode == "from":
|
|
228
|
+
idx = req.get("cellIndex")
|
|
229
|
+
if not isinstance(idx, int):
|
|
230
|
+
raise ValueError("mode='from' requires integer cellIndex")
|
|
231
|
+
if idx < 0 or idx >= total:
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f"cellIndex {idx} out of range (notebook has {total} cells)"
|
|
234
|
+
)
|
|
235
|
+
return list(range(idx, total))
|
|
236
|
+
raise ValueError(f"unsupported mode: {mode!r}")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _summarize_output(out: Dict[str, Any]) -> Dict[str, Any]:
|
|
240
|
+
"""Translate one Jupyter output into the plugin's CellExecutionSummary shape."""
|
|
241
|
+
ot = out.get("output_type")
|
|
242
|
+
summary: Dict[str, Any] = {}
|
|
243
|
+
if ot == "stream":
|
|
244
|
+
name = out.get("name") or "stdout"
|
|
245
|
+
text = out.get("text")
|
|
246
|
+
if isinstance(text, list):
|
|
247
|
+
text = "".join(str(t) for t in text)
|
|
248
|
+
if name == "stderr":
|
|
249
|
+
summary["stderr"] = str(text or "")
|
|
250
|
+
else:
|
|
251
|
+
summary["stdout"] = str(text or "")
|
|
252
|
+
return summary
|
|
253
|
+
if ot == "error":
|
|
254
|
+
ename = str(out.get("ename") or "Error")
|
|
255
|
+
evalue = str(out.get("evalue") or "")
|
|
256
|
+
tb = out.get("traceback")
|
|
257
|
+
if not isinstance(tb, list):
|
|
258
|
+
tb = []
|
|
259
|
+
summary.setdefault("errors", []).append(
|
|
260
|
+
{"ename": ename, "evalue": evalue, "traceback": [str(t) for t in tb]}
|
|
261
|
+
)
|
|
262
|
+
return summary
|
|
263
|
+
if ot in ("execute_result", "display_data"):
|
|
264
|
+
data = out.get("data") or {}
|
|
265
|
+
if isinstance(data, dict):
|
|
266
|
+
if "text/plain" in data:
|
|
267
|
+
plain = data["text/plain"]
|
|
268
|
+
if isinstance(plain, list):
|
|
269
|
+
plain = "".join(str(t) for t in plain)
|
|
270
|
+
summary["resultPreview"] = str(plain)
|
|
271
|
+
for mime, value in data.items():
|
|
272
|
+
if mime.startswith("image/"):
|
|
273
|
+
if isinstance(value, str):
|
|
274
|
+
size_bytes = max(0, (len(value) * 3) // 4)
|
|
275
|
+
else:
|
|
276
|
+
size_bytes = 0
|
|
277
|
+
summary.setdefault("displayData", []).append(
|
|
278
|
+
{"mime": mime, "sizeBytes": size_bytes}
|
|
279
|
+
)
|
|
280
|
+
return summary
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _merge_summaries(target: Dict[str, Any], addition: Dict[str, Any]) -> None:
|
|
284
|
+
"""Merge a per-output summary into the per-cell accumulator."""
|
|
285
|
+
for key, value in addition.items():
|
|
286
|
+
if key in ("stdout", "stderr", "resultPreview"):
|
|
287
|
+
target[key] = (target.get(key, "") or "") + (value or "")
|
|
288
|
+
elif key == "displayData":
|
|
289
|
+
target.setdefault("displayData", []).extend(value)
|
|
290
|
+
elif key == "errors":
|
|
291
|
+
target.setdefault("errors", []).extend(value)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _build_cell_summary(
|
|
295
|
+
cell_index: int,
|
|
296
|
+
cell: Dict[str, Any],
|
|
297
|
+
status: str,
|
|
298
|
+
max_output_chars: int,
|
|
299
|
+
duration_ms: int,
|
|
300
|
+
) -> Dict[str, Any]:
|
|
301
|
+
summary: Dict[str, Any] = {
|
|
302
|
+
"cellIndex": cell_index,
|
|
303
|
+
"status": status,
|
|
304
|
+
"durationMs": duration_ms,
|
|
305
|
+
}
|
|
306
|
+
if "execution_count" in cell:
|
|
307
|
+
ec = cell.get("execution_count")
|
|
308
|
+
if ec is not None:
|
|
309
|
+
summary["executionCount"] = int(ec)
|
|
310
|
+
raw_outputs = list(cell.get("outputs", []) or [])
|
|
311
|
+
if raw_outputs:
|
|
312
|
+
summary["rawOutputs"] = raw_outputs
|
|
313
|
+
for out in raw_outputs:
|
|
314
|
+
addition = _summarize_output(out)
|
|
315
|
+
_merge_summaries(summary, addition)
|
|
316
|
+
if "errors" in summary and summary["errors"]:
|
|
317
|
+
summary["status"] = "error"
|
|
318
|
+
for key in ("stdout", "stderr", "resultPreview"):
|
|
319
|
+
if key in summary and len(summary[key]) > max_output_chars:
|
|
320
|
+
summary[key] = summary[key][:max_output_chars] + (
|
|
321
|
+
f"\n... (truncated, use maxOutputChars to increase)"
|
|
322
|
+
)
|
|
323
|
+
return summary
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _error_response(
|
|
327
|
+
req: Optional[Dict[str, Any]],
|
|
328
|
+
started: float,
|
|
329
|
+
kind: str,
|
|
330
|
+
ename: str,
|
|
331
|
+
evalue: str,
|
|
332
|
+
cell_index: int,
|
|
333
|
+
traceback_lines: Optional[Iterable[str]] = None,
|
|
334
|
+
request_id: Optional[int] = None,
|
|
335
|
+
) -> Dict[str, Any]:
|
|
336
|
+
response: Dict[str, Any] = {
|
|
337
|
+
"success": False,
|
|
338
|
+
"executedCells": [],
|
|
339
|
+
"durationMs": int((time.time() - started) * 1000),
|
|
340
|
+
"saved": False,
|
|
341
|
+
"outputs": [],
|
|
342
|
+
"error": {
|
|
343
|
+
"kind": kind,
|
|
344
|
+
"cellIndex": cell_index,
|
|
345
|
+
"ename": ename,
|
|
346
|
+
"evalue": evalue,
|
|
347
|
+
"traceback": list(traceback_lines or []),
|
|
348
|
+
},
|
|
349
|
+
}
|
|
350
|
+
if request_id is not None:
|
|
351
|
+
response["id"] = request_id
|
|
352
|
+
return response
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _resolve_kernel_name(req: Dict[str, Any], nb: Any) -> Optional[str]:
|
|
356
|
+
kernel = req.get("kernel")
|
|
357
|
+
if kernel:
|
|
358
|
+
return kernel
|
|
359
|
+
if nb is not None and hasattr(nb, "metadata"):
|
|
360
|
+
ks = nb.metadata.get("kernelspec") or {}
|
|
361
|
+
if isinstance(ks, dict):
|
|
362
|
+
kn = ks.get("name")
|
|
363
|
+
if isinstance(kn, str):
|
|
364
|
+
return kn
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _run_nbclient(
|
|
369
|
+
req: Dict[str, Any],
|
|
370
|
+
nb: Any,
|
|
371
|
+
file_path: str,
|
|
372
|
+
started: float,
|
|
373
|
+
) -> Dict[str, Any]:
|
|
374
|
+
"""Run the request through nbclient. Shared by the one-shot path and
|
|
375
|
+
the serve path. The notebook object is mutated in place (outputs are
|
|
376
|
+
populated); the caller decides whether to save the result.
|
|
377
|
+
|
|
378
|
+
``nb`` may be either an ``nbformat.NotebookNode`` (one-shot) or the
|
|
379
|
+
cached one held by the serve loop. Both expose ``.cells`` /
|
|
380
|
+
``.metadata`` and accept ``NotebookClient``'s constructor.
|
|
381
|
+
"""
|
|
382
|
+
from nbclient import NotebookClient # type: ignore
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
cell_indexes = _select_cell_indexes(req, len(nb.cells))
|
|
386
|
+
except Exception as exc:
|
|
387
|
+
return _error_response(
|
|
388
|
+
req,
|
|
389
|
+
started,
|
|
390
|
+
"PythonRunnerError",
|
|
391
|
+
type(exc).__name__,
|
|
392
|
+
str(exc),
|
|
393
|
+
-1,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if not cell_indexes:
|
|
397
|
+
return {
|
|
398
|
+
"success": True,
|
|
399
|
+
"executedCells": [],
|
|
400
|
+
"durationMs": int((time.time() - started) * 1000),
|
|
401
|
+
"saved": False,
|
|
402
|
+
"outputs": [],
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
target_code_cells: List[int] = []
|
|
406
|
+
for i in cell_indexes:
|
|
407
|
+
if nb.cells[i].get("cell_type") == "code":
|
|
408
|
+
target_code_cells.append(i)
|
|
409
|
+
|
|
410
|
+
timeout_s = max(1, int(req.get("timeoutMs", 120_000) / 1000))
|
|
411
|
+
kernel_name = _resolve_kernel_name(req, nb) or "python3"
|
|
412
|
+
try:
|
|
413
|
+
client = NotebookClient(
|
|
414
|
+
nb,
|
|
415
|
+
timeout=timeout_s,
|
|
416
|
+
kernel_name=kernel_name,
|
|
417
|
+
resources={
|
|
418
|
+
"metadata": {"path": os.path.dirname(os.path.abspath(file_path)) or "."}
|
|
419
|
+
},
|
|
420
|
+
)
|
|
421
|
+
# nbclient 0.11's `execute(cells=...)` accidentally leaks the kwarg
|
|
422
|
+
# into the kernel subprocess Popen call. Execute the selected cells
|
|
423
|
+
# explicitly so mode=cell/range/from never runs cells outside the
|
|
424
|
+
# requested slice.
|
|
425
|
+
with client.setup_kernel():
|
|
426
|
+
for idx in target_code_cells:
|
|
427
|
+
client.execute_cell(nb.cells[idx], idx)
|
|
428
|
+
except Exception as exc:
|
|
429
|
+
failing_cell_index = getattr(exc, "cell_index", None)
|
|
430
|
+
if not isinstance(failing_cell_index, int) or failing_cell_index < 0:
|
|
431
|
+
msg = str(exc)
|
|
432
|
+
for idx in target_code_cells:
|
|
433
|
+
cell = nb.cells[idx]
|
|
434
|
+
src = cell.get("source", "")
|
|
435
|
+
if isinstance(src, list):
|
|
436
|
+
src = "".join(src)
|
|
437
|
+
if src and src.strip() in msg:
|
|
438
|
+
failing_cell_index = idx
|
|
439
|
+
break
|
|
440
|
+
else:
|
|
441
|
+
failing_cell_index = target_code_cells[0] if target_code_cells else -1
|
|
442
|
+
return _error_response(
|
|
443
|
+
req,
|
|
444
|
+
started,
|
|
445
|
+
"CellExecutionError",
|
|
446
|
+
type(exc).__name__,
|
|
447
|
+
str(exc),
|
|
448
|
+
failing_cell_index,
|
|
449
|
+
traceback.format_exception(exc),
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
outputs: List[Dict[str, Any]] = []
|
|
453
|
+
for i in cell_indexes:
|
|
454
|
+
cell = nb.cells[i]
|
|
455
|
+
if cell.get("cell_type") != "code":
|
|
456
|
+
continue
|
|
457
|
+
outputs.append(
|
|
458
|
+
_build_cell_summary(
|
|
459
|
+
cell_index=i,
|
|
460
|
+
cell=cell,
|
|
461
|
+
status="ok",
|
|
462
|
+
max_output_chars=int(req.get("maxOutputChars", 12_000)),
|
|
463
|
+
duration_ms=0,
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
"success": True,
|
|
469
|
+
"executedCells": cell_indexes,
|
|
470
|
+
"durationMs": int((time.time() - started) * 1000),
|
|
471
|
+
"saved": False,
|
|
472
|
+
"outputs": outputs,
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _run_serve_request(
|
|
477
|
+
req: Dict[str, Any],
|
|
478
|
+
client: Any,
|
|
479
|
+
nb: Any,
|
|
480
|
+
file_path: str,
|
|
481
|
+
) -> Dict[str, Any]:
|
|
482
|
+
"""Process one request inside the serve loop, using the already-warm
|
|
483
|
+
kernel. Errors are returned to the caller but the kernel is kept alive.
|
|
484
|
+
"""
|
|
485
|
+
started = time.time()
|
|
486
|
+
req_id = req.get("id") if isinstance(req.get("id"), int) else None
|
|
487
|
+
|
|
488
|
+
# ``env`` does not need the kernel; handle it before touching nbclient.
|
|
489
|
+
if req.get("mode") == "env":
|
|
490
|
+
response = _build_env_report(req.get("filePath", file_path), started)
|
|
491
|
+
if req_id is not None:
|
|
492
|
+
response["id"] = req_id
|
|
493
|
+
return response
|
|
494
|
+
|
|
495
|
+
# Validate the request up front so the caller gets a structured error
|
|
496
|
+
# (not a traceback) on malformed input. The serve loop already knows
|
|
497
|
+
# the filePath (from the init), so we inject it when the request
|
|
498
|
+
# omits it — keep the wire format terse for the hot path.
|
|
499
|
+
if "filePath" not in req and isinstance(file_path, str):
|
|
500
|
+
req["filePath"] = file_path
|
|
501
|
+
try:
|
|
502
|
+
_validate_request(req)
|
|
503
|
+
except Exception as exc:
|
|
504
|
+
return _error_response(
|
|
505
|
+
req,
|
|
506
|
+
started,
|
|
507
|
+
"PythonRunnerError",
|
|
508
|
+
type(exc).__name__,
|
|
509
|
+
str(exc),
|
|
510
|
+
-1,
|
|
511
|
+
request_id=req_id,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
if req["mode"] == "serve":
|
|
515
|
+
return _error_response(
|
|
516
|
+
req,
|
|
517
|
+
started,
|
|
518
|
+
"PythonRunnerError",
|
|
519
|
+
"InvalidRequest",
|
|
520
|
+
"mode='serve' is only valid as the first request of a session",
|
|
521
|
+
-1,
|
|
522
|
+
request_id=req_id,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
try:
|
|
526
|
+
cell_indexes = _select_cell_indexes(req, len(nb.cells))
|
|
527
|
+
except Exception as exc:
|
|
528
|
+
return _error_response(
|
|
529
|
+
req,
|
|
530
|
+
started,
|
|
531
|
+
"PythonRunnerError",
|
|
532
|
+
type(exc).__name__,
|
|
533
|
+
str(exc),
|
|
534
|
+
-1,
|
|
535
|
+
request_id=req_id,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
if not cell_indexes:
|
|
539
|
+
response = {
|
|
540
|
+
"success": True,
|
|
541
|
+
"executedCells": [],
|
|
542
|
+
"durationMs": int((time.time() - started) * 1000),
|
|
543
|
+
"outputs": [],
|
|
544
|
+
}
|
|
545
|
+
if req_id is not None:
|
|
546
|
+
response["id"] = req_id
|
|
547
|
+
return response
|
|
548
|
+
|
|
549
|
+
target_code_cells: List[int] = []
|
|
550
|
+
for i in cell_indexes:
|
|
551
|
+
if nb.cells[i].get("cell_type") == "code":
|
|
552
|
+
target_code_cells.append(i)
|
|
553
|
+
|
|
554
|
+
if not target_code_cells:
|
|
555
|
+
response = {
|
|
556
|
+
"success": True,
|
|
557
|
+
"executedCells": cell_indexes,
|
|
558
|
+
"durationMs": int((time.time() - started) * 1000),
|
|
559
|
+
"outputs": [],
|
|
560
|
+
}
|
|
561
|
+
if req_id is not None:
|
|
562
|
+
response["id"] = req_id
|
|
563
|
+
return response
|
|
564
|
+
|
|
565
|
+
timeout_s = max(1, int(req.get("timeoutMs", 120_000) / 1000))
|
|
566
|
+
# Per-cell durations: capture start/end around each `execute_cell` call so
|
|
567
|
+
# the `cell_duration_ms` field reflects what actually happened, not the
|
|
568
|
+
# batch total attributed to the first cell.
|
|
569
|
+
cell_durations_ms: Dict[int, int] = {}
|
|
570
|
+
try:
|
|
571
|
+
# Run each target cell through the existing kernel via execute_cell.
|
|
572
|
+
# We pass the kernel client explicitly so the kernel stays warm.
|
|
573
|
+
for idx in target_code_cells:
|
|
574
|
+
cell = nb.cells[idx]
|
|
575
|
+
cell_start = time.monotonic()
|
|
576
|
+
client.execute_cell(cell, idx)
|
|
577
|
+
cell_durations_ms[idx] = int((time.monotonic() - cell_start) * 1000)
|
|
578
|
+
except Exception as exc:
|
|
579
|
+
failing_cell_index = getattr(exc, "cell_index", None)
|
|
580
|
+
if not isinstance(failing_cell_index, int) or failing_cell_index < 0:
|
|
581
|
+
msg = str(exc)
|
|
582
|
+
for idx in target_code_cells:
|
|
583
|
+
cell = nb.cells[idx]
|
|
584
|
+
src = cell.get("source", "")
|
|
585
|
+
if isinstance(src, list):
|
|
586
|
+
src = "".join(src)
|
|
587
|
+
if src and src.strip() in msg:
|
|
588
|
+
failing_cell_index = idx
|
|
589
|
+
break
|
|
590
|
+
else:
|
|
591
|
+
failing_cell_index = target_code_cells[0] if target_code_cells else -1
|
|
592
|
+
return _error_response(
|
|
593
|
+
req,
|
|
594
|
+
started,
|
|
595
|
+
"CellExecutionError",
|
|
596
|
+
type(exc).__name__,
|
|
597
|
+
str(exc),
|
|
598
|
+
failing_cell_index,
|
|
599
|
+
traceback.format_exception(exc),
|
|
600
|
+
request_id=req_id,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
outputs: List[Dict[str, Any]] = []
|
|
604
|
+
for i in cell_indexes:
|
|
605
|
+
cell = nb.cells[i]
|
|
606
|
+
if cell.get("cell_type") != "code":
|
|
607
|
+
continue
|
|
608
|
+
outputs.append(
|
|
609
|
+
_build_cell_summary(
|
|
610
|
+
cell_index=i,
|
|
611
|
+
cell=cell,
|
|
612
|
+
status="ok",
|
|
613
|
+
max_output_chars=int(req.get("maxOutputChars", 12_000)),
|
|
614
|
+
duration_ms=cell_durations_ms.get(i, 0),
|
|
615
|
+
)
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
response = {
|
|
619
|
+
"success": True,
|
|
620
|
+
"executedCells": cell_indexes,
|
|
621
|
+
"durationMs": int((time.time() - started) * 1000),
|
|
622
|
+
"outputs": outputs,
|
|
623
|
+
}
|
|
624
|
+
if req_id is not None:
|
|
625
|
+
response["id"] = req_id
|
|
626
|
+
return response
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _serve_mode(init_req: Dict[str, Any]) -> int:
|
|
630
|
+
"""v1.0 warm-kernel loop. Initializes one kernel and serves NDJSON
|
|
631
|
+
requests until ``{"mode": "shutdown"}`` or EOF on stdin.
|
|
632
|
+
"""
|
|
633
|
+
file_path = init_req.get("filePath")
|
|
634
|
+
if not isinstance(file_path, str) or not file_path:
|
|
635
|
+
_write_response(
|
|
636
|
+
_error_response(
|
|
637
|
+
init_req,
|
|
638
|
+
time.time(),
|
|
639
|
+
"PythonRunnerError",
|
|
640
|
+
"InvalidRequest",
|
|
641
|
+
"mode='serve' requires filePath",
|
|
642
|
+
-1,
|
|
643
|
+
)
|
|
644
|
+
)
|
|
645
|
+
return 0
|
|
646
|
+
|
|
647
|
+
timeout_s = max(1, int(init_req.get("timeoutMs", 120_000) / 1000))
|
|
648
|
+
kernel_name = init_req.get("kernel")
|
|
649
|
+
if not isinstance(kernel_name, str):
|
|
650
|
+
kernel_name = None
|
|
651
|
+
|
|
652
|
+
try:
|
|
653
|
+
import nbformat # type: ignore
|
|
654
|
+
from nbclient import NotebookClient # type: ignore
|
|
655
|
+
except ImportError as exc:
|
|
656
|
+
_write_response(
|
|
657
|
+
_error_response(
|
|
658
|
+
init_req,
|
|
659
|
+
time.time(),
|
|
660
|
+
"PythonRunnerError",
|
|
661
|
+
"DependencyError",
|
|
662
|
+
f"Missing Python dependency: {exc.name}",
|
|
663
|
+
-1,
|
|
664
|
+
)
|
|
665
|
+
)
|
|
666
|
+
return 0
|
|
667
|
+
|
|
668
|
+
try:
|
|
669
|
+
nb = nbformat.read(file_path, as_version=4)
|
|
670
|
+
except Exception as exc:
|
|
671
|
+
_write_response(
|
|
672
|
+
_error_response(
|
|
673
|
+
init_req,
|
|
674
|
+
time.time(),
|
|
675
|
+
"PythonRunnerError",
|
|
676
|
+
type(exc).__name__,
|
|
677
|
+
f"Failed to read notebook {file_path}: {exc}",
|
|
678
|
+
-1,
|
|
679
|
+
traceback.format_exception(exc),
|
|
680
|
+
)
|
|
681
|
+
)
|
|
682
|
+
return 0
|
|
683
|
+
|
|
684
|
+
if kernel_name is None:
|
|
685
|
+
kernel_name = _resolve_kernel_name(init_req, nb) or "python3"
|
|
686
|
+
|
|
687
|
+
started = time.time()
|
|
688
|
+
try:
|
|
689
|
+
client = NotebookClient(
|
|
690
|
+
nb,
|
|
691
|
+
timeout=timeout_s,
|
|
692
|
+
kernel_name=kernel_name,
|
|
693
|
+
resources={
|
|
694
|
+
"metadata": {"path": os.path.dirname(os.path.abspath(file_path)) or "."}
|
|
695
|
+
},
|
|
696
|
+
)
|
|
697
|
+
# Start the kernel once for the whole serve loop. We deliberately do
|
|
698
|
+
# NOT use ``setup_kernel`` (a context manager) because we want the
|
|
699
|
+
# kernel to outlive the first request — subsequent requests reuse
|
|
700
|
+
# the same kernel client via ``client.execute_cell``.
|
|
701
|
+
client.km = client.create_kernel_manager()
|
|
702
|
+
client.start_new_kernel()
|
|
703
|
+
client.start_new_kernel_client()
|
|
704
|
+
except Exception as exc:
|
|
705
|
+
_write_response(
|
|
706
|
+
_error_response(
|
|
707
|
+
init_req,
|
|
708
|
+
started,
|
|
709
|
+
"PythonRunnerError",
|
|
710
|
+
type(exc).__name__,
|
|
711
|
+
f"Failed to start kernel for {file_path}: {exc}",
|
|
712
|
+
-1,
|
|
713
|
+
traceback.format_exception(exc),
|
|
714
|
+
)
|
|
715
|
+
)
|
|
716
|
+
return 0
|
|
717
|
+
|
|
718
|
+
ready_response: Dict[str, Any] = {
|
|
719
|
+
"ready": True,
|
|
720
|
+
"pid": os.getpid(),
|
|
721
|
+
"kernelName": kernel_name,
|
|
722
|
+
"durationMs": int((time.time() - started) * 1000),
|
|
723
|
+
}
|
|
724
|
+
if isinstance(init_req.get("id"), int):
|
|
725
|
+
ready_response["id"] = init_req["id"]
|
|
726
|
+
_write_response(ready_response)
|
|
727
|
+
|
|
728
|
+
requests_handled = 0
|
|
729
|
+
try:
|
|
730
|
+
while True:
|
|
731
|
+
try:
|
|
732
|
+
req = _read_request_line()
|
|
733
|
+
except EOFError:
|
|
734
|
+
break
|
|
735
|
+
except ValueError as exc:
|
|
736
|
+
_write_response(
|
|
737
|
+
_error_response(
|
|
738
|
+
None,
|
|
739
|
+
time.time(),
|
|
740
|
+
"PythonRunnerError",
|
|
741
|
+
"InvalidJSON",
|
|
742
|
+
f"could not parse request: {exc}",
|
|
743
|
+
-1,
|
|
744
|
+
)
|
|
745
|
+
)
|
|
746
|
+
continue
|
|
747
|
+
|
|
748
|
+
if not isinstance(req, dict):
|
|
749
|
+
_write_response(
|
|
750
|
+
_error_response(
|
|
751
|
+
None,
|
|
752
|
+
time.time(),
|
|
753
|
+
"PythonRunnerError",
|
|
754
|
+
"InvalidRequest",
|
|
755
|
+
"request must be a JSON object",
|
|
756
|
+
-1,
|
|
757
|
+
)
|
|
758
|
+
)
|
|
759
|
+
continue
|
|
760
|
+
|
|
761
|
+
mode = req.get("mode")
|
|
762
|
+
if mode == "shutdown":
|
|
763
|
+
break
|
|
764
|
+
|
|
765
|
+
try:
|
|
766
|
+
response = _run_serve_request(req, client, nb, file_path)
|
|
767
|
+
except Exception as exc:
|
|
768
|
+
req_id = req.get("id") if isinstance(req.get("id"), int) else None
|
|
769
|
+
response = _error_response(
|
|
770
|
+
req,
|
|
771
|
+
time.time(),
|
|
772
|
+
"PythonRunnerError",
|
|
773
|
+
type(exc).__name__,
|
|
774
|
+
f"unexpected error in serve loop: {exc}",
|
|
775
|
+
-1,
|
|
776
|
+
traceback.format_exception(exc),
|
|
777
|
+
request_id=req_id,
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
_write_response(response)
|
|
781
|
+
requests_handled += 1
|
|
782
|
+
except KeyboardInterrupt:
|
|
783
|
+
return 0
|
|
784
|
+
finally:
|
|
785
|
+
try:
|
|
786
|
+
client.shutdown_kernel()
|
|
787
|
+
except Exception:
|
|
788
|
+
pass
|
|
789
|
+
try:
|
|
790
|
+
client.cleanup_kernel()
|
|
791
|
+
except Exception:
|
|
792
|
+
pass
|
|
793
|
+
return 0
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _run() -> int:
|
|
797
|
+
started = time.time()
|
|
798
|
+
try:
|
|
799
|
+
req = _read_first_request()
|
|
800
|
+
except Exception as exc:
|
|
801
|
+
sys.stdout.write(
|
|
802
|
+
json.dumps(
|
|
803
|
+
_error_response(
|
|
804
|
+
None,
|
|
805
|
+
started,
|
|
806
|
+
"PythonRunnerError",
|
|
807
|
+
type(exc).__name__,
|
|
808
|
+
str(exc),
|
|
809
|
+
-1,
|
|
810
|
+
traceback.format_exception(exc),
|
|
811
|
+
)
|
|
812
|
+
)
|
|
813
|
+
)
|
|
814
|
+
sys.stdout.write("\n")
|
|
815
|
+
return 0
|
|
816
|
+
|
|
817
|
+
if isinstance(req, dict) and req.get("mode") == "serve":
|
|
818
|
+
# _serve_mode handles validation itself so it can produce structured
|
|
819
|
+
# errors that include the request id. Any exception falls through to
|
|
820
|
+
# a single error response.
|
|
821
|
+
try:
|
|
822
|
+
return _serve_mode(req)
|
|
823
|
+
except Exception as exc:
|
|
824
|
+
sys.stdout.write(
|
|
825
|
+
json.dumps(
|
|
826
|
+
_error_response(
|
|
827
|
+
req,
|
|
828
|
+
time.time(),
|
|
829
|
+
"PythonRunnerError",
|
|
830
|
+
type(exc).__name__,
|
|
831
|
+
f"failed to start serve mode: {exc}",
|
|
832
|
+
-1,
|
|
833
|
+
traceback.format_exception(exc),
|
|
834
|
+
)
|
|
835
|
+
)
|
|
836
|
+
)
|
|
837
|
+
sys.stdout.write("\n")
|
|
838
|
+
return 0
|
|
839
|
+
|
|
840
|
+
try:
|
|
841
|
+
_validate_request(req)
|
|
842
|
+
except Exception as exc:
|
|
843
|
+
sys.stdout.write(
|
|
844
|
+
json.dumps(
|
|
845
|
+
_error_response(
|
|
846
|
+
req if isinstance(req, dict) else None,
|
|
847
|
+
started,
|
|
848
|
+
"PythonRunnerError",
|
|
849
|
+
type(exc).__name__,
|
|
850
|
+
str(exc),
|
|
851
|
+
-1,
|
|
852
|
+
traceback.format_exception(exc),
|
|
853
|
+
)
|
|
854
|
+
)
|
|
855
|
+
)
|
|
856
|
+
sys.stdout.write("\n")
|
|
857
|
+
return 0
|
|
858
|
+
|
|
859
|
+
if req["mode"] == "env":
|
|
860
|
+
try:
|
|
861
|
+
response = _build_env_report(req["filePath"], started)
|
|
862
|
+
except Exception as exc:
|
|
863
|
+
response = _error_response(
|
|
864
|
+
req,
|
|
865
|
+
started,
|
|
866
|
+
"PythonRunnerError",
|
|
867
|
+
type(exc).__name__,
|
|
868
|
+
str(exc),
|
|
869
|
+
-1,
|
|
870
|
+
traceback.format_exception(exc),
|
|
871
|
+
)
|
|
872
|
+
sys.stdout.write(json.dumps(response))
|
|
873
|
+
sys.stdout.write("\n")
|
|
874
|
+
return 0
|
|
875
|
+
|
|
876
|
+
try:
|
|
877
|
+
import nbformat # type: ignore
|
|
878
|
+
from nbclient import NotebookClient # type: ignore
|
|
879
|
+
except ImportError as exc:
|
|
880
|
+
sys.stdout.write(
|
|
881
|
+
json.dumps(
|
|
882
|
+
_error_response(
|
|
883
|
+
req,
|
|
884
|
+
started,
|
|
885
|
+
"PythonRunnerError",
|
|
886
|
+
"DependencyError",
|
|
887
|
+
(
|
|
888
|
+
f"Missing Python dependency: {exc.name}. Install with "
|
|
889
|
+
f"`uv pip install nbformat nbclient jupyter_client ipykernel`."
|
|
890
|
+
),
|
|
891
|
+
-1,
|
|
892
|
+
traceback.format_exception(exc),
|
|
893
|
+
)
|
|
894
|
+
)
|
|
895
|
+
)
|
|
896
|
+
sys.stdout.write("\n")
|
|
897
|
+
return 0
|
|
898
|
+
|
|
899
|
+
file_path = req["filePath"]
|
|
900
|
+
try:
|
|
901
|
+
nb = nbformat.read(file_path, as_version=4)
|
|
902
|
+
except Exception as exc:
|
|
903
|
+
sys.stdout.write(
|
|
904
|
+
json.dumps(
|
|
905
|
+
_error_response(
|
|
906
|
+
req,
|
|
907
|
+
started,
|
|
908
|
+
"PythonRunnerError",
|
|
909
|
+
type(exc).__name__,
|
|
910
|
+
f"Failed to read notebook {file_path}: {exc}",
|
|
911
|
+
-1,
|
|
912
|
+
traceback.format_exception(exc),
|
|
913
|
+
)
|
|
914
|
+
)
|
|
915
|
+
)
|
|
916
|
+
sys.stdout.write("\n")
|
|
917
|
+
return 0
|
|
918
|
+
|
|
919
|
+
response = _run_nbclient(req, nb, file_path, started)
|
|
920
|
+
saved = bool(req.get("save", False))
|
|
921
|
+
if saved and response.get("success"):
|
|
922
|
+
try:
|
|
923
|
+
import nbformat # type: ignore
|
|
924
|
+
|
|
925
|
+
nbformat.write(nb, file_path)
|
|
926
|
+
response["saved"] = True
|
|
927
|
+
except Exception as exc:
|
|
928
|
+
sys.stdout.write(
|
|
929
|
+
json.dumps(
|
|
930
|
+
_error_response(
|
|
931
|
+
req,
|
|
932
|
+
started,
|
|
933
|
+
"PythonRunnerError",
|
|
934
|
+
type(exc).__name__,
|
|
935
|
+
f"Failed to save notebook {file_path}: {exc}",
|
|
936
|
+
-1,
|
|
937
|
+
traceback.format_exception(exc),
|
|
938
|
+
)
|
|
939
|
+
)
|
|
940
|
+
)
|
|
941
|
+
sys.stdout.write("\n")
|
|
942
|
+
return 0
|
|
943
|
+
elif saved:
|
|
944
|
+
response["saved"] = False
|
|
945
|
+
|
|
946
|
+
sys.stdout.write(json.dumps(response))
|
|
947
|
+
sys.stdout.write("\n")
|
|
948
|
+
return 0
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
if __name__ == "__main__":
|
|
952
|
+
raise SystemExit(_run())
|