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.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +336 -0
  3. package/dist/domain/cell.d.ts +50 -0
  4. package/dist/domain/cell.d.ts.map +1 -0
  5. package/dist/domain/errors.d.ts +119 -0
  6. package/dist/domain/errors.d.ts.map +1 -0
  7. package/dist/domain/execution.d.ts +87 -0
  8. package/dist/domain/execution.d.ts.map +1 -0
  9. package/dist/domain/notebook.d.ts +65 -0
  10. package/dist/domain/notebook.d.ts.map +1 -0
  11. package/dist/domain/output.d.ts +50 -0
  12. package/dist/domain/output.d.ts.map +1 -0
  13. package/dist/format/diagnostics.d.ts +15 -0
  14. package/dist/format/diagnostics.d.ts.map +1 -0
  15. package/dist/format/diff.d.ts +20 -0
  16. package/dist/format/diff.d.ts.map +1 -0
  17. package/dist/format/markdown.d.ts +21 -0
  18. package/dist/format/markdown.d.ts.map +1 -0
  19. package/dist/format/outputs.d.ts +21 -0
  20. package/dist/format/outputs.d.ts.map +1 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +31862 -0
  24. package/dist/plugin-options.d.ts +13 -0
  25. package/dist/plugin-options.d.ts.map +1 -0
  26. package/dist/plugin.d.ts +4 -0
  27. package/dist/plugin.d.ts.map +1 -0
  28. package/dist/services/DiffService.d.ts +17 -0
  29. package/dist/services/DiffService.d.ts.map +1 -0
  30. package/dist/services/NotebookCleanService.d.ts +34 -0
  31. package/dist/services/NotebookCleanService.d.ts.map +1 -0
  32. package/dist/services/NotebookEditService.d.ts +63 -0
  33. package/dist/services/NotebookEditService.d.ts.map +1 -0
  34. package/dist/services/NotebookExecutionService.d.ts +54 -0
  35. package/dist/services/NotebookExecutionService.d.ts.map +1 -0
  36. package/dist/services/NotebookExportService.d.ts +33 -0
  37. package/dist/services/NotebookExportService.d.ts.map +1 -0
  38. package/dist/services/NotebookFileService.d.ts +18 -0
  39. package/dist/services/NotebookFileService.d.ts.map +1 -0
  40. package/dist/services/NotebookInspectService.d.ts +62 -0
  41. package/dist/services/NotebookInspectService.d.ts.map +1 -0
  42. package/dist/services/NotebookOutputService.d.ts +62 -0
  43. package/dist/services/NotebookOutputService.d.ts.map +1 -0
  44. package/dist/services/NotebookReadService.d.ts +36 -0
  45. package/dist/services/NotebookReadService.d.ts.map +1 -0
  46. package/dist/services/PathService.d.ts +26 -0
  47. package/dist/services/PathService.d.ts.map +1 -0
  48. package/dist/services/PermissionService.d.ts +20 -0
  49. package/dist/services/PermissionService.d.ts.map +1 -0
  50. package/dist/services/PythonService.d.ts +80 -0
  51. package/dist/services/PythonService.d.ts.map +1 -0
  52. package/dist/services/index.d.ts +55 -0
  53. package/dist/services/index.d.ts.map +1 -0
  54. package/dist/tools/_resolveOptions.d.ts +2 -0
  55. package/dist/tools/_resolveOptions.d.ts.map +1 -0
  56. package/dist/tools/cell_delete.d.ts +14 -0
  57. package/dist/tools/cell_delete.d.ts.map +1 -0
  58. package/dist/tools/cell_insert.d.ts +22 -0
  59. package/dist/tools/cell_insert.d.ts.map +1 -0
  60. package/dist/tools/cell_move.d.ts +16 -0
  61. package/dist/tools/cell_move.d.ts.map +1 -0
  62. package/dist/tools/clean.d.ts +22 -0
  63. package/dist/tools/clean.d.ts.map +1 -0
  64. package/dist/tools/doctor.d.ts +12 -0
  65. package/dist/tools/doctor.d.ts.map +1 -0
  66. package/dist/tools/edit.d.ts +22 -0
  67. package/dist/tools/edit.d.ts.map +1 -0
  68. package/dist/tools/export.d.ts +24 -0
  69. package/dist/tools/export.d.ts.map +1 -0
  70. package/dist/tools/inspect.d.ts +20 -0
  71. package/dist/tools/inspect.d.ts.map +1 -0
  72. package/dist/tools/kernel.d.ts +36 -0
  73. package/dist/tools/kernel.d.ts.map +1 -0
  74. package/dist/tools/outputs.d.ts +30 -0
  75. package/dist/tools/outputs.d.ts.map +1 -0
  76. package/dist/tools/read.d.ts +30 -0
  77. package/dist/tools/read.d.ts.map +1 -0
  78. package/dist/tools/repro.d.ts +42 -0
  79. package/dist/tools/repro.d.ts.map +1 -0
  80. package/dist/tools/run.d.ts +35 -0
  81. package/dist/tools/run.d.ts.map +1 -0
  82. package/dist/utils/ansi.d.ts +3 -0
  83. package/dist/utils/ansi.d.ts.map +1 -0
  84. package/dist/utils/attachments.d.ts +11 -0
  85. package/dist/utils/attachments.d.ts.map +1 -0
  86. package/dist/utils/fiber.d.ts +2 -0
  87. package/dist/utils/fiber.d.ts.map +1 -0
  88. package/dist/utils/i18n.d.ts +8 -0
  89. package/dist/utils/i18n.d.ts.map +1 -0
  90. package/dist/utils/imports.d.ts +4 -0
  91. package/dist/utils/imports.d.ts.map +1 -0
  92. package/dist/utils/json.d.ts +5 -0
  93. package/dist/utils/json.d.ts.map +1 -0
  94. package/dist/utils/limits.d.ts +9 -0
  95. package/dist/utils/limits.d.ts.map +1 -0
  96. package/dist/utils/mime.d.ts +4 -0
  97. package/dist/utils/mime.d.ts.map +1 -0
  98. package/dist/utils/paths.d.ts +22 -0
  99. package/dist/utils/paths.d.ts.map +1 -0
  100. package/dist/utils/truncate.d.ts +17 -0
  101. package/dist/utils/truncate.d.ts.map +1 -0
  102. package/package.json +69 -0
  103. package/python/ipynb_runner.py +952 -0
  104. 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())