pi-repl-py 0.7.1 → 0.8.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/bridge.py ADDED
@@ -0,0 +1,495 @@
1
+ #!/usr/bin/env python3
2
+ """pi-repl bridge: owns ipykernel and the Jupyter protocol; talks to the TS host over one stdio pipe.
3
+
4
+ Protocol (JSON lines; host -> bridge on stdin, bridge -> host on stdout):
5
+ in: {"op":"boot","helpers":[{"name":str,"source":str}]} first message, before anything
6
+ {"op":"exec","id":str,"code":str} run a cell (streams + one result)
7
+ {"op":"snapshot","id":str,"path":str,"max_bytes":int} snapshot the namespace to path
8
+ {"op":"restore","id":str,"path":str} revive path into the namespace
9
+ {"op":"listNames","id":str} current public names
10
+ {"op":"interrupt"} KeyboardInterrupt in the kernel
11
+ {"op":"shutdown","id":str} graceful teardown, then exit 0
12
+ out: {"type":"ready","helpers":[{"name":str,"ok":bool,"error":str?}]} kernel up, helpers loaded
13
+ {"type":"stream","id":str,"name":"stdout"|"stderr","text":str} cell output, streamed
14
+ {"type":"result","id":str,"status":"ok"|"error"|"aborted", cell settled
15
+ "result":str?,"error":{"name":str,"message":str,"stack":[str]}?}
16
+ {"type":"reply","id":str,"..."} snapshot/restore/names/shutdown
17
+ {"type":"error","id":str?,"message":str} protocol-level failure
18
+ {"type":"dying","message":str?} best-effort before exit(1)
19
+
20
+ The bridge is single-threaded over the kernel (one op at a time); the host serializes ops anyway.
21
+ A stdin reader thread only relays lines; all kernel I/O stays on the main thread. When the kernel
22
+ dies (os._exit, SIGKILL, crash) the bridge exits: the host treats bridge exit as kernel death and
23
+ rebuilds from the last snapshot. stdin EOF (host gone) shuts the kernel down cleanly.
24
+ """
25
+ import json
26
+ import os
27
+ import queue
28
+ import sys
29
+ import threading
30
+ import time
31
+
32
+ from jupyter_client import KernelManager
33
+ from jupyter_client.kernelspec import NoSuchKernel
34
+
35
+ SNAPSHOT_MIME = "application/vnd.pi-repl.snapshot+json"
36
+ RESTORE_MIME = "application/vnd.pi-repl.restore+json"
37
+ NAMES_MIME = "application/vnd.pi-repl.names+json"
38
+ READY_TIMEOUT_MS = 30_000
39
+
40
+ # --- kernel-side programs. These run inside ipykernel, so they stay strings (now plain Python). ---
41
+
42
+ def skip_set_literal(helper_names):
43
+ skip = list(helper_names) + ["helper_description", "In", "Out", "get_ipython", "exit", "quit", "open"]
44
+ return json.dumps(skip)
45
+
46
+
47
+ def snapshot_code(helper_names, max_bytes):
48
+ # v4: cloudpickle serializes functions and classes by value — no source-capture machinery.
49
+ # Entries are zlib-compressed cloudpickle streams; one unpicklable binding costs itself only.
50
+ return ("import cloudpickle as _cp, base64 as _b64, json as _js, zlib as _zl\n"
51
+ + "__repl_skip = set(" + skip_set_literal(helper_names) + ")\n"
52
+ + "__repl_max = " + str(max_bytes) + "\n"
53
+ + """__repl_e = []
54
+ __repl_f = []
55
+ __repl_total = 0
56
+ for _k, _v in list(globals().items()):
57
+ if _k.startswith('_') or _k in __repl_skip:
58
+ continue
59
+ try:
60
+ __repl_p = _b64.b64encode(_zl.compress(_cp.dumps(_v), 1)).decode()
61
+ __repl_b = len(__repl_p)
62
+ if __repl_b > __repl_max:
63
+ __repl_f.append({'name': _k, 'reason': 'exceeds per-entry snapshot cap'})
64
+ elif __repl_total + __repl_b > __repl_max:
65
+ __repl_f.append({'name': _k, 'reason': 'exceeds total snapshot cap'})
66
+ else:
67
+ __repl_e.append({'name': _k, 'kind': 'value', 'payload': __repl_p})
68
+ __repl_total += __repl_b
69
+ except Exception as _e:
70
+ __repl_f.append({'name': _k, 'reason': str(_e)})
71
+ """
72
+ + "get_ipython().display_pub.publish({" + json.dumps(SNAPSHOT_MIME) + ": _js.dumps({'version': 4, 'entries': __repl_e, 'failed': __repl_f})})\n")
73
+
74
+
75
+ def _restore_body(name, kind, payload, version):
76
+ n = json.dumps(name)
77
+ pl = json.dumps(payload)
78
+ if version >= 4:
79
+ body = "globals()[" + n + "] = _cp.loads(_zl.decompress(_b64.b64decode(" + pl + ")))"
80
+ elif version == 3 and kind == "def":
81
+ # re-execute captured source and register it in linecache under the code
82
+ # so a later snapshot can capture it again
83
+ body = (
84
+ "__repl_src = _b64.b64decode(" + pl + ").decode()\n"
85
+ + " exec(__repl_src, globals())\n"
86
+ + " __repl_obj = globals().get(" + n + ")\n"
87
+ + " if __repl_obj is not None:\n"
88
+ + " __repl_fname = getattr(getattr(__repl_obj, '__code__', None), 'co_filename', None)\n"
89
+ + " if __repl_fname is None:\n"
90
+ + " __repl_init = getattr(__repl_obj, '__init__', None)\n"
91
+ + " __repl_fname = getattr(getattr(__repl_init, '__code__', None), 'co_filename', None)\n"
92
+ + " if __repl_fname:\n"
93
+ + " _lc.cache[__repl_fname] = (len(__repl_src.splitlines()), None, __repl_src.splitlines(True), __repl_fname)"
94
+ )
95
+ else:
96
+ inner = "_b64.b64decode(" + pl + ")"
97
+ if version == 3:
98
+ inner = "_zl.decompress(" + inner + ")"
99
+ body = "globals()[" + n + "] = _pk.loads(" + inner + ")"
100
+ return ("try:\n " + body + "\n __repl_r['restored'].append(" + n + ")\n"
101
+ + "except Exception as _e:\n __repl_r['failed'].append({'name': " + n + ", 'reason': str(_e)})\n")
102
+
103
+
104
+ def restore_code(entries, version):
105
+ if version >= 4:
106
+ head = "import cloudpickle as _cp, base64 as _b64, json as _js, zlib as _zl\n"
107
+ elif version == 3:
108
+ head = "import pickle as _pk, base64 as _b64, json as _js, zlib as _zl, linecache as _lc\n"
109
+ else:
110
+ head = "import pickle as _pk, base64 as _b64, json as _js\n"
111
+ per = "".join(_restore_body(e["name"], e.get("kind", "value"), e["payload"], version) for e in entries)
112
+ return (head
113
+ + "__repl_r = {'restored': [], 'failed': []}\n"
114
+ + per
115
+ + "get_ipython().display_pub.publish({" + json.dumps(RESTORE_MIME) + ": _js.dumps(__repl_r)})\n")
116
+
117
+
118
+ def names_code(helper_names):
119
+ return ("import json as _js\n"
120
+ + "__repl_skip = set(" + skip_set_literal(helper_names) + ")\n"
121
+ + "__repl_n = sorted(n for n in globals() if not n.startswith('_') and n not in __repl_skip)\n"
122
+ + "get_ipython().display_pub.publish({" + json.dumps(NAMES_MIME) + ": _js.dumps(__repl_n)})\n")
123
+
124
+
125
+ class Bridge:
126
+ def __init__(self):
127
+ self.km = None
128
+ self.kc = None
129
+ self.helper_names = []
130
+ self.requests = queue.Queue()
131
+ self.op_id = None # id of the exec currently streaming, for stream routing
132
+ self.op_payload_mime = None # private MIME awaited for the current internal cell
133
+ # snapshot policy + name-diff gate: the bridge schedules snapshots itself in quiet gaps,
134
+ # so a pickle can never land in front of a user cell (the bridge IS the queue)
135
+ self.policy = None # {"path": str, "max_bytes": int, "period_ms": int} | None
136
+ self.last_names = None # names at the last persisted write; None = never written
137
+ self.last_persisted_at = 0.0
138
+ self.last_bytes = 0
139
+ self.pending_check = False # a cell just completed; the gate owes a name diff
140
+ self.deferred_restore = None # background revive: runs at the first quiet gap
141
+
142
+ # ----- IO -----
143
+
144
+ def emit(self, msg):
145
+ sys.stdout.write(json.dumps(msg) + "\n")
146
+ sys.stdout.flush()
147
+
148
+ def _stdin_reader(self):
149
+ # BufferedReader.read(n) blocks until n bytes arrive; line iteration returns per line
150
+ for line in sys.stdin:
151
+ line = line.strip()
152
+ if not line:
153
+ continue
154
+ try:
155
+ self.requests.put(json.loads(line))
156
+ except Exception as e:
157
+ self.emit({"type": "error", "message": "bad request line: %s" % e})
158
+ self.requests.put({"op": "shutdown", "id": "bad-line"})
159
+ self.requests.put({"op": "__eof__"})
160
+
161
+ # ----- kernel lifecycle -----
162
+
163
+ def start_kernel(self):
164
+ km = KernelManager(kernel_name="python3")
165
+ try:
166
+ km.start_kernel()
167
+ except NoSuchKernel:
168
+ km = KernelManager(kernel_name="python3", kernel_cmd=[
169
+ sys.executable, "-m", "ipykernel_launcher", "-f", "{connection_file}"])
170
+ km.start_kernel()
171
+ kc = km.client()
172
+ kc.start_channels()
173
+ try:
174
+ kc.wait_for_ready(timeout=READY_TIMEOUT_MS / 1000)
175
+ except Exception:
176
+ km.shutdown_kernel(now=True)
177
+ raise
178
+ self.km, self.kc = km, kc
179
+
180
+ def stop_kernel(self):
181
+ try:
182
+ self.kc.stop_channels()
183
+ except Exception:
184
+ pass
185
+ try:
186
+ self.km.shutdown_kernel(now=True)
187
+ except Exception:
188
+ pass
189
+
190
+ # ----- cells -----
191
+
192
+ def run_cell(self, code, emit_stream, payload_mime=None, emit_id=None):
193
+ """Run one cell; settle on shell reply AND iopub idle (a reply alone can beat big output).
194
+ Returns {status, result?, error?, payload?}. Raises when the kernel died."""
195
+ kc = self.kc
196
+ msg_id = kc.execute(code, silent=False, store_history=False,
197
+ user_expressions={}, allow_stdin=False, stop_on_error=True)
198
+ self.op_id = msg_id
199
+ self.op_payload_mime = payload_mime
200
+ result = None
201
+ error = None
202
+ payload = None
203
+ quiet_for = 0.0
204
+ try:
205
+ while True:
206
+ # pending interrupts/shutdowns act within one poll cadence
207
+ self._drain_control_queue()
208
+ try:
209
+ msg = kc.get_iopub_msg(timeout=0.25)
210
+ except Exception:
211
+ # timeout: poll the heartbeat instead of timing out the whole bridge
212
+ quiet_for += 0.25
213
+ if quiet_for >= 2.0:
214
+ if not self.kernel_alive():
215
+ self.emit({"type": "dying", "message": "kernel heartbeat lost"})
216
+ sys.exit(1)
217
+ quiet_for = 0.0
218
+ continue
219
+ if msg.get("parent_header", {}).get("msg_id") != msg_id:
220
+ continue
221
+ quiet_for = 0.0
222
+ ctype = msg["msg_type"]
223
+ content = msg["content"]
224
+ if ctype == "stream":
225
+ if emit_stream:
226
+ self.emit({"type": "stream", "id": emit_id or msg_id, "name": "stderr" if content.get("name") == "stderr" else "stdout", "text": content.get("text") or ""})
227
+ elif ctype == "execute_result":
228
+ data = content.get("data") or {}
229
+ if "text/plain" in data:
230
+ result = data["text/plain"]
231
+ payload = self._take_payload(content)
232
+ elif ctype == "display_data":
233
+ payload = self._take_payload(content)
234
+ elif ctype == "error":
235
+ error = {
236
+ "name": content.get("ename") or "Error",
237
+ "message": content.get("evalue") or "",
238
+ "stack": content.get("traceback") or [],
239
+ }
240
+ elif ctype == "status" and content.get("execution_state") == "idle":
241
+ break
242
+ except Exception as e:
243
+ if not self.kernel_alive():
244
+ self.emit({"type": "dying", "message": "kernel died mid-cell"})
245
+ sys.exit(1)
246
+ raise
247
+ status = "ok"
248
+ reply = None
249
+ for _ in range(20):
250
+ try:
251
+ candidate = kc.get_shell_msg(timeout=10)
252
+ except Exception:
253
+ break
254
+ if candidate.get("parent_header", {}).get("msg_id") == msg_id:
255
+ reply = candidate
256
+ break
257
+ # stale reply from an earlier op (a late kernel_info, or an "incomplete
258
+ # input" error flushed by this request): drain it, keep looking
259
+ if reply:
260
+ status = reply.get("content", {}).get("status", "ok")
261
+ if status != "aborted" and error is not None:
262
+ # iopub error wins over an "ok" reply (ipykernel: compile-phase failures reply ok)
263
+ status = "error"
264
+ if status == "error" and error is None:
265
+ c = reply.get("content", {})
266
+ error = {"name": c.get("ename") or "Error", "message": c.get("evalue") or "cell failed", "stack": []}
267
+ if status == "aborted" and error is None:
268
+ error = {"name": "KeyboardInterrupt", "message": "cell interrupted", "stack": []}
269
+ self.op_id = None
270
+ self.op_payload_mime = None
271
+ return {"status": status, "result": result, "error": error, "payload": payload}
272
+
273
+ def _take_payload(self, content):
274
+ data = content.get("data") or {}
275
+ mime = self.op_payload_mime
276
+ if mime is not None and mime in data:
277
+ return data[mime]
278
+ return None
279
+
280
+ def _drain_control_queue(self):
281
+ # control-plane ops may jump the queue mid-cell; anything else is left in order
282
+ while True:
283
+ try:
284
+ op = self.requests.get_nowait()
285
+ except queue.Empty:
286
+ break
287
+ if op["op"] == "interrupt":
288
+ self.interrupt()
289
+ elif op["op"] in ("shutdown", "__eof__"):
290
+ self.stop_kernel()
291
+ sys.exit(0)
292
+ else:
293
+ self.requests.put(op)
294
+ break
295
+
296
+ def interrupt(self):
297
+ # BlockingKernelClient has no interrupt() in jupyter_client 8.x; the old host sent
298
+ # interrupt_request on the control channel directly — do the same
299
+ msg = self.kc.session.msg("interrupt_request", content={})
300
+ self.kc.control_channel.send(msg)
301
+
302
+ def kernel_alive(self):
303
+ try:
304
+ return self.km.is_alive()
305
+ except Exception:
306
+ return False
307
+
308
+ # ----- ops -----
309
+
310
+ FORCED_SNAPSHOT_MAX_BYTES = 8 * 1024 * 1024
311
+ QUIET_WINDOW_S = 0.15
312
+
313
+ def _names(self):
314
+ res = self.run_cell(names_code(self.helper_names), emit_stream=False, payload_mime=NAMES_MIME)
315
+ return json.loads(res["payload"]) if res["payload"] else []
316
+
317
+ def _snapshot_due(self, names):
318
+ changed = self.last_names is None or names != self.last_names
319
+ period = (self.policy or {}).get("period_ms") or 0
320
+ stale = (period > 0 and self.last_persisted_at > 0
321
+ and (time.time() - self.last_persisted_at) * 1000 >= period
322
+ and self.last_bytes <= self.FORCED_SNAPSHOT_MAX_BYTES)
323
+ return changed or stale
324
+
325
+ def quiet_seconds(self):
326
+ """How long the serve loop may block waiting for ops before the gap counts as quiet.
327
+ None = nothing is due; block indefinitely."""
328
+ if self.deferred_restore is not None:
329
+ return self.QUIET_WINDOW_S
330
+ if self.pending_check and self.policy:
331
+ return self.QUIET_WINDOW_S
332
+ return None
333
+
334
+ def quiet_tick(self):
335
+ """The queue stayed empty for the quiet window: run the deferred revive, then the
336
+ owed snapshot. The gate advances only on a persisted write, so a failed write
337
+ retries at the next gap."""
338
+ if self.deferred_restore is not None:
339
+ op, self.deferred_restore = self.deferred_restore, None
340
+ self.op_restore(op)
341
+ return
342
+ if not (self.pending_check and self.policy):
343
+ return
344
+ self.pending_check = False
345
+ names = self._names()
346
+ if not self._snapshot_due(names):
347
+ return
348
+ self._write_snapshot(self.policy["path"], self.policy["max_bytes"], names)
349
+
350
+ def op_boot(self, boot):
351
+ self.helper_names = [h["name"] for h in boot.get("helpers") or []]
352
+ self.policy = boot.get("snapshot")
353
+ self.start_kernel()
354
+ report = []
355
+ for h in boot.get("helpers") or []:
356
+ res = self.run_cell(h["source"], emit_stream=False)
357
+ if res["status"] == "ok":
358
+ report.append({"name": h["name"], "ok": True})
359
+ else:
360
+ err = res["error"] or {}
361
+ report.append({"name": h["name"], "ok": False,
362
+ "error": (err.get("name") + ": " + err.get("message", "")).strip() or "failed to load"})
363
+ self.emit({"type": "ready", "helpers": report})
364
+
365
+ def op_exec(self, op):
366
+ res = self.run_cell(op["code"], emit_stream=True, emit_id=op["id"])
367
+ out = {"type": "result", "id": op["id"], "status": res["status"]}
368
+ if res["result"] is not None:
369
+ out["result"] = res["result"]
370
+ if res["error"] is not None:
371
+ out["error"] = res["error"]
372
+ self.emit(out)
373
+
374
+ def _write_snapshot(self, path, max_bytes, names=None):
375
+ import cloudpickle # noqa: F401 — fail loudly here, not at bridge boot
376
+ res = self.run_cell(snapshot_code(self.helper_names, max_bytes), emit_stream=False,
377
+ payload_mime=SNAPSHOT_MIME)
378
+ payload = res["payload"]
379
+ if payload is None:
380
+ return {"saved": [], "failed": [], "complete": False, "bytes": 0}
381
+ body = json.loads(payload)
382
+ entries, failed = body.get("entries", []), body.get("failed", [])
383
+ # atomic write: temp + rename, so a crash can't corrupt the last good snapshot
384
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
385
+ tmp = path + ".tmp"
386
+ with open(tmp, "w", encoding="utf-8") as fh:
387
+ json.dump({"version": 4, "entries": entries, "failed": failed}, fh)
388
+ os.replace(tmp, path)
389
+ # only a persisted write advances the gate
390
+ self.last_names = names if names is not None else [e["name"] for e in entries]
391
+ self.last_persisted_at = time.time()
392
+ self.last_bytes = sum(len(e["payload"]) for e in entries)
393
+ return {"saved": [e["name"] for e in entries], "failed": failed,
394
+ "complete": True, "bytes": self.last_bytes}
395
+
396
+ def op_snapshot(self, op):
397
+ try:
398
+ import cloudpickle # noqa: F401 — fail loudly here, not at bridge boot
399
+ except ImportError as e:
400
+ self.emit({"type": "reply", "id": op["id"], "error":
401
+ "cloudpickle is missing from the evaluator venv: %s" % e})
402
+ return
403
+ out = self._write_snapshot(op["path"], op["max_bytes"])
404
+ self.emit({"type": "reply", "id": op["id"], **out})
405
+
406
+ def op_restore(self, op):
407
+ try:
408
+ with open(op["path"], encoding="utf-8") as fh:
409
+ body = json.load(fh)
410
+ except Exception as e:
411
+ self.emit({"type": "reply", "id": op["id"], "error": "snapshot unreadable: %s" % e})
412
+ return
413
+ version = body.get("version")
414
+ if version is not None and version >= 2:
415
+ entries = body.get("entries") or []
416
+ failed_at_save = body.get("failed") or []
417
+ else:
418
+ # v1 files (pre-source-capture) restore via plain pickles
419
+ entries = [{"name": n, "kind": "value", "payload": p} for n, p in (body.get("vars") or {}).items()]
420
+ failed_at_save = []
421
+ res = self.run_cell(restore_code(entries, version or 4), emit_stream=False, payload_mime=RESTORE_MIME)
422
+ restored, failed = [], []
423
+ if res["payload"] is not None:
424
+ body2 = json.loads(res["payload"])
425
+ restored, failed = body2.get("restored", []), body2.get("failed", [])
426
+ # merge save-time skips (oversized bindings) so the resume notice names every loss
427
+ seen = {f["name"] for f in failed}
428
+ for f in failed_at_save:
429
+ if f["name"] not in seen:
430
+ failed.append(f)
431
+ seen.add(f["name"])
432
+ self.emit({"type": "reply", "id": op["id"], "restored": restored, "failed": failed})
433
+
434
+ def op_names(self, op):
435
+ res = self.run_cell(names_code(self.helper_names), emit_stream=False, payload_mime=NAMES_MIME)
436
+ names = json.loads(res["payload"]) if res["payload"] is not None else []
437
+ self.emit({"type": "reply", "id": op["id"], "names": names})
438
+
439
+ # ----- main loop -----
440
+
441
+ def serve(self):
442
+ threading.Thread(target=self._stdin_reader, daemon=True).start()
443
+ boot = self.requests.get()
444
+ if boot.get("op") == "__eof__":
445
+ return
446
+ try:
447
+ self.op_boot(boot)
448
+ except Exception as e:
449
+ self.emit({"type": "error", "message": "boot failed: %s" % e})
450
+ sys.exit(1)
451
+ while True:
452
+ try:
453
+ op = self.requests.get(timeout=self.quiet_seconds())
454
+ except queue.Empty:
455
+ self.quiet_tick()
456
+ continue
457
+ o = op.get("op")
458
+ if o == "__eof__":
459
+ self.stop_kernel()
460
+ sys.exit(0)
461
+ try:
462
+ if o == "exec":
463
+ self.op_exec(op)
464
+ self.pending_check = True
465
+ elif o == "snapshot":
466
+ self.op_snapshot(op)
467
+ elif o == "restore":
468
+ if op.get("defer"):
469
+ # background revive: run at the first quiet gap, never ahead of a cell;
470
+ # the reply (same id) settles the host's restoreResult promise
471
+ self.deferred_restore = op
472
+ else:
473
+ self.op_restore(op)
474
+ elif o == "listNames":
475
+ self.op_names(op)
476
+ elif o == "interrupt":
477
+ self.interrupt()
478
+ elif o == "shutdown":
479
+ self.stop_kernel()
480
+ sys.exit(0)
481
+ else:
482
+ self.emit({"type": "error", "id": op.get("id"), "message": "unknown op: %s" % o})
483
+ except Exception as e:
484
+ self.emit({"type": "error", "id": op.get("id"), "message": "%s" % e})
485
+
486
+
487
+ def main():
488
+ try:
489
+ Bridge().serve()
490
+ except KeyboardInterrupt:
491
+ pass
492
+
493
+
494
+ if __name__ == "__main__":
495
+ main()