snapshot-kernel 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ from .kernel import SnapshotKernel
2
+
3
+ __all__ = ["SnapshotKernel"]
@@ -0,0 +1,461 @@
1
+ """Snapshot Checkpointing Python Kernel.
2
+
3
+ Stores immutable execution states (snapshots of variables, modules, timestamps)
4
+ and creates new states by executing code against existing ones.
5
+ """
6
+
7
+ import ast
8
+ import base64
9
+ import copy
10
+ import ctypes
11
+ import datetime
12
+ import io
13
+ import os
14
+ import sys
15
+ import threading
16
+ import traceback
17
+ import types
18
+ import uuid
19
+
20
+
21
+ def _snapshot_namespace(namespace):
22
+ """Create a snapshot of a namespace dict.
23
+
24
+ Modules are stored by reference (they are singletons).
25
+ Everything else is deep-copied when possible, with a fallback
26
+ to storing a direct reference for non-copyable objects.
27
+ """
28
+ snapshot = {}
29
+ for key, value in namespace.items():
30
+ if key.startswith("__") and key.endswith("__"):
31
+ continue
32
+ if isinstance(value, types.ModuleType):
33
+ snapshot[key] = value
34
+ else:
35
+ try:
36
+ snapshot[key] = copy.deepcopy(value)
37
+ except Exception:
38
+ snapshot[key] = value
39
+ return snapshot
40
+
41
+
42
+ def _format_object(obj):
43
+ """Extract all available rich representations from a Python object.
44
+
45
+ Returns (data_dict, metadata_dict) where data_dict maps MIME types
46
+ to their string content. Binary representations (PNG, JPEG, PDF) are
47
+ base64-encoded.
48
+ """
49
+ data = {}
50
+ metadata = {}
51
+
52
+ # Always provide text/plain.
53
+ try:
54
+ data["text/plain"] = repr(obj)
55
+ except Exception:
56
+ data["text/plain"] = "<repr failed>"
57
+
58
+ # Prefer _repr_mimebundle_() when available.
59
+ mimebundle_method = getattr(obj, "_repr_mimebundle_", None)
60
+ if callable(mimebundle_method):
61
+ try:
62
+ result = mimebundle_method()
63
+ if isinstance(result, tuple):
64
+ bundle_data, bundle_meta = result
65
+ data.update(bundle_data)
66
+ metadata.update(bundle_meta)
67
+ elif isinstance(result, dict):
68
+ data.update(result)
69
+ return data, metadata
70
+ except Exception:
71
+ pass
72
+
73
+ # Fall back to individual _repr_*_() methods.
74
+ _repr_methods = [
75
+ ("_repr_html_", "text/html", False),
76
+ ("_repr_markdown_", "text/markdown", False),
77
+ ("_repr_latex_", "text/latex", False),
78
+ ("_repr_json_", "application/json", False),
79
+ ("_repr_svg_", "image/svg+xml", False),
80
+ ("_repr_png_", "image/png", True),
81
+ ("_repr_jpeg_", "image/jpeg", True),
82
+ ("_repr_pdf_", "application/pdf", True),
83
+ ]
84
+ for method_name, mime_type, is_binary in _repr_methods:
85
+ method = getattr(obj, method_name, None)
86
+ if not callable(method):
87
+ continue
88
+ try:
89
+ result = method()
90
+ except Exception:
91
+ continue
92
+ if result is None:
93
+ continue
94
+ if is_binary and isinstance(result, bytes):
95
+ result = base64.b64encode(result).decode("ascii")
96
+ data[mime_type] = result
97
+
98
+ return data, metadata
99
+
100
+
101
+ class State:
102
+ """An immutable snapshot of an execution environment."""
103
+
104
+ def __init__(self, name, namespace):
105
+ self.name = name
106
+ self.namespace = namespace
107
+ self.timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
108
+
109
+
110
+ class ThreadSafeWriter:
111
+ """A writer that redirects writes to per-thread StringIO buffers.
112
+
113
+ When a per-thread buffer is set, writes go there. Otherwise, writes
114
+ go to the original stream. This allows capturing stdout/stderr per
115
+ execution thread without interfering with other threads.
116
+ """
117
+
118
+ def __init__(self, original):
119
+ self._original = original
120
+ self._local = threading.local()
121
+
122
+ def set_buffer(self, buf):
123
+ """Set a StringIO buffer for the current thread."""
124
+ self._local.buffer = buf
125
+
126
+ def clear_buffer(self):
127
+ """Remove the buffer for the current thread."""
128
+ self._local.buffer = None
129
+
130
+ def get_buffer(self):
131
+ """Return the current thread's buffer, or None."""
132
+ return getattr(self._local, "buffer", None)
133
+
134
+ def write(self, text):
135
+ buf = self.get_buffer()
136
+ if buf is not None:
137
+ buf.write(text)
138
+ else:
139
+ self._original.write(text)
140
+
141
+ def flush(self):
142
+ buf = self.get_buffer()
143
+ if buf is not None:
144
+ buf.flush()
145
+ else:
146
+ self._original.flush()
147
+
148
+ # Pass through attributes like encoding, isatty, etc.
149
+ def __getattr__(self, name):
150
+ return getattr(self._original, name)
151
+
152
+
153
+ class DisplayCollector:
154
+ """Thread-safe collector for display_data outputs.
155
+
156
+ Each execution thread gets its own list of outputs so that
157
+ concurrent executions do not interfere with each other.
158
+ """
159
+
160
+ def __init__(self):
161
+ self._local = threading.local()
162
+
163
+ def set_list(self):
164
+ """Initialise an empty output list for the current thread."""
165
+ self._local.outputs = []
166
+
167
+ def clear_list(self):
168
+ """Remove the output list for the current thread."""
169
+ self._local.outputs = None
170
+
171
+ def get_outputs(self):
172
+ """Return the current thread's collected outputs."""
173
+ return getattr(self._local, "outputs", None) or []
174
+
175
+ def add(self, obj):
176
+ """Format *obj* via _format_object and append a display_data entry."""
177
+ data, metadata = _format_object(obj)
178
+ outputs = getattr(self._local, "outputs", None)
179
+ if outputs is not None:
180
+ outputs.append({
181
+ "output_type": "display_data",
182
+ "data": data,
183
+ "metadata": metadata,
184
+ })
185
+
186
+ def add_raw(self, entry):
187
+ """Append a pre-formatted output entry directly."""
188
+ outputs = getattr(self._local, "outputs", None)
189
+ if outputs is not None:
190
+ outputs.append(entry)
191
+
192
+
193
+ def _capture_figures(collector):
194
+ """If matplotlib.pyplot is loaded, save all open figures as PNG and
195
+ append them to *collector*, then close them."""
196
+ plt = sys.modules.get("matplotlib.pyplot")
197
+ if plt is None:
198
+ return
199
+ for fig_num in plt.get_fignums():
200
+ fig = plt.figure(fig_num)
201
+ buf = io.BytesIO()
202
+ fig.savefig(buf, format="png", bbox_inches="tight")
203
+ buf.seek(0)
204
+ png_b64 = base64.b64encode(buf.read()).decode("ascii")
205
+ collector.add_raw({
206
+ "output_type": "display_data",
207
+ "data": {"image/png": png_b64, "text/plain": "<Figure>"},
208
+ "metadata": {},
209
+ })
210
+ plt.close("all")
211
+
212
+
213
+ class SnapshotKernel:
214
+ """Checkpointing Python kernel that stores and forks execution states."""
215
+
216
+ def __init__(self):
217
+ self._states = {}
218
+ self._lock = threading.Lock()
219
+ self._executions = {}
220
+ self._exec_lock = threading.Lock()
221
+ self._io_lock = threading.Lock()
222
+ self._display_collector = DisplayCollector()
223
+ os.environ.setdefault("MPLBACKEND", "Agg")
224
+ self.reset()
225
+
226
+ def _ensure_writers(self):
227
+ """Install ThreadSafeWriter wrappers if they are not already in place.
228
+
229
+ This is called at the start of every execution so that
230
+ external code (e.g. pytest) that replaces sys.stdout/stderr
231
+ does not break per-thread capture.
232
+ """
233
+ with self._io_lock:
234
+ if not isinstance(sys.stdout, ThreadSafeWriter):
235
+ sys.stdout = ThreadSafeWriter(sys.stdout)
236
+ if not isinstance(sys.stderr, ThreadSafeWriter):
237
+ sys.stderr = ThreadSafeWriter(sys.stderr)
238
+
239
+ def reset(self):
240
+ """Clear all states and create a fresh 'initial' state."""
241
+ with self._lock:
242
+ self._states.clear()
243
+ self._states["initial"] = State("initial", {})
244
+
245
+ def list_states(self):
246
+ """Return a list of all stored state names."""
247
+ with self._lock:
248
+ return list(self._states.keys())
249
+
250
+ def get_state(self, state_name):
251
+ """Return a serializable dict describing the given state, or None."""
252
+ with self._lock:
253
+ state = self._states.get(state_name)
254
+ if state is None:
255
+ return None
256
+ variables = {}
257
+ for key, value in state.namespace.items():
258
+ variables[key] = {
259
+ "type": type(value).__name__,
260
+ "repr": repr(value),
261
+ }
262
+ return {
263
+ "name": state.name,
264
+ "timestamp": state.timestamp,
265
+ "variables": variables,
266
+ }
267
+
268
+ def delete_state(self, state_name):
269
+ """Remove the named state. Returns True if it existed."""
270
+ with self._lock:
271
+ return self._states.pop(state_name, None) is not None
272
+
273
+ def _make_display_func(self):
274
+ """Create a ``display(*objs)`` function for use inside executed code.
275
+
276
+ The function formats each object and appends it to the
277
+ thread-local DisplayCollector.
278
+ """
279
+ collector = self._display_collector
280
+
281
+ def display(*objs):
282
+ for obj in objs:
283
+ ipython_display = getattr(obj, "_ipython_display_", None)
284
+ if callable(ipython_display):
285
+ ipython_display(display=display)
286
+ else:
287
+ collector.add(obj)
288
+
289
+ return display
290
+
291
+ def _install_matplotlib_hook(self):
292
+ """Replace ``plt.show()`` with a wrapper that captures figures.
293
+
294
+ Returns a cleanup function that restores the original ``plt.show()``,
295
+ or *None* if matplotlib is not loaded.
296
+ """
297
+ plt = sys.modules.get("matplotlib.pyplot")
298
+ if plt is None:
299
+ return None
300
+ original_show = plt.show
301
+ collector = self._display_collector
302
+
303
+ def _hooked_show(*args, **kwargs):
304
+ _capture_figures(collector)
305
+
306
+ plt.show = _hooked_show
307
+ return lambda: setattr(plt, "show", original_show)
308
+
309
+ def execute(self, code, exec_id, state_name, new_state_name=None):
310
+ """Execute code against a state and store the resulting state.
311
+
312
+ Returns a dict with keys: output, state_name, error.
313
+ """
314
+ if new_state_name is None:
315
+ new_state_name = uuid.uuid4().hex
316
+
317
+ # Snapshot the source state's namespace.
318
+ with self._lock:
319
+ source = self._states.get(state_name)
320
+ if source is None:
321
+ return {
322
+ "output": [],
323
+ "state_name": None,
324
+ "error": {"ename": "StateNotFound", "evalue": state_name, "traceback": []},
325
+ }
326
+ namespace = _snapshot_namespace(source.namespace)
327
+ namespace["__builtins__"] = __builtins__
328
+
329
+ # Register this execution for possible interruption.
330
+ with self._exec_lock:
331
+ self._executions[exec_id] = threading.current_thread().ident
332
+
333
+ # Ensure ThreadSafeWriter wrappers are in place.
334
+ self._ensure_writers()
335
+
336
+ # Set up per-thread output capture.
337
+ stdout_writer = sys.stdout
338
+ stderr_writer = sys.stderr
339
+ stdout_buf = io.StringIO()
340
+ stderr_buf = io.StringIO()
341
+ stdout_writer.set_buffer(stdout_buf)
342
+ stderr_writer.set_buffer(stderr_buf)
343
+
344
+ # Set up display collector and inject display() into namespace.
345
+ self._display_collector.set_list()
346
+ display_func = self._make_display_func()
347
+ namespace["display"] = display_func
348
+
349
+ # Hook plt.show() if matplotlib is already imported.
350
+ restore_show = self._install_matplotlib_hook()
351
+
352
+ output = []
353
+ error = None
354
+ last_expr_value = None
355
+
356
+ try:
357
+ # Parse the code and split out the last expression if applicable.
358
+ tree = ast.parse(code)
359
+ last_expr_node = None
360
+ if tree.body and isinstance(tree.body[-1], ast.Expr):
361
+ last_expr_node = tree.body.pop()
362
+
363
+ # Execute all statements except the last expression.
364
+ if tree.body:
365
+ exec(compile(tree, "<cell>", "exec"), namespace)
366
+
367
+ # Evaluate the last expression to capture its value.
368
+ if last_expr_node is not None:
369
+ expr_code = compile(
370
+ ast.Expression(body=last_expr_node.value), "<cell>", "eval"
371
+ )
372
+ last_expr_value = eval(expr_code, namespace)
373
+
374
+ # If matplotlib was imported during execution, install hook and
375
+ # capture any remaining open figures.
376
+ if restore_show is None:
377
+ restore_show = self._install_matplotlib_hook()
378
+ _capture_figures(self._display_collector)
379
+
380
+ except KeyboardInterrupt:
381
+ error = {
382
+ "ename": "KeyboardInterrupt",
383
+ "evalue": "",
384
+ "traceback": ["KeyboardInterrupt"],
385
+ }
386
+ except Exception as exc:
387
+ error = {
388
+ "ename": type(exc).__name__,
389
+ "evalue": str(exc),
390
+ "traceback": traceback.format_exception(type(exc), exc, exc.__traceback__),
391
+ }
392
+ finally:
393
+ # Collect captured output.
394
+ stdout_writer.clear_buffer()
395
+ stderr_writer.clear_buffer()
396
+
397
+ stdout_text = stdout_buf.getvalue()
398
+ stderr_text = stderr_buf.getvalue()
399
+
400
+ if stdout_text:
401
+ output.append({"output_type": "stream", "name": "stdout", "text": stdout_text})
402
+ if stderr_text:
403
+ output.append({"output_type": "stream", "name": "stderr", "text": stderr_text})
404
+
405
+ # Display data outputs collected via display() and matplotlib.
406
+ collected = self._display_collector.get_outputs()
407
+ prev_count = len(collected)
408
+ output.extend(collected)
409
+
410
+ # Last expression result with rich MIME types.
411
+ if last_expr_value is not None:
412
+ ipython_display = getattr(last_expr_value, "_ipython_display_", None)
413
+ if callable(ipython_display):
414
+ ipython_display(display=display_func)
415
+ output.extend(self._display_collector.get_outputs()[prev_count:])
416
+ else:
417
+ data, metadata = _format_object(last_expr_value)
418
+ output.append({
419
+ "output_type": "execute_result",
420
+ "data": data,
421
+ "metadata": metadata,
422
+ })
423
+
424
+ # Clean up display collector and matplotlib hook.
425
+ self._display_collector.clear_list()
426
+ if restore_show is not None:
427
+ restore_show()
428
+
429
+ # Remove display from namespace so it doesn't persist in state.
430
+ namespace.pop("display", None)
431
+
432
+ # Unregister execution.
433
+ with self._exec_lock:
434
+ self._executions.pop(exec_id, None)
435
+
436
+ # On success, store the new state; on error, do not.
437
+ result_state_name = None
438
+ if error is None:
439
+ new_ns = _snapshot_namespace(namespace)
440
+ new_state = State(new_state_name, new_ns)
441
+ with self._lock:
442
+ self._states[new_state_name] = new_state
443
+ result_state_name = new_state_name
444
+
445
+ return {
446
+ "output": output,
447
+ "state_name": result_state_name,
448
+ "error": error,
449
+ }
450
+
451
+ def interrupt(self, exec_id):
452
+ """Interrupt a running execution by raising KeyboardInterrupt in its thread."""
453
+ with self._exec_lock:
454
+ thread_ident = self._executions.get(exec_id)
455
+ if thread_ident is None:
456
+ return False
457
+ res = ctypes.pythonapi.PyThreadState_SetAsyncExc(
458
+ ctypes.c_ulong(thread_ident),
459
+ ctypes.py_object(KeyboardInterrupt),
460
+ )
461
+ return res == 1
@@ -0,0 +1,105 @@
1
+ """Bottle REST API server for the Snapshot Kernel."""
2
+
3
+ import argparse
4
+ import json
5
+
6
+ import bottle
7
+
8
+ from .kernel import SnapshotKernel
9
+
10
+ app = bottle.Bottle()
11
+ kernel = SnapshotKernel()
12
+ _token = None
13
+
14
+
15
+ @app.hook("before_request")
16
+ def check_auth():
17
+ """Verify the token URL parameter on every request."""
18
+ if _token is not None:
19
+ if bottle.request.params.get("token") != _token:
20
+ bottle.abort(401, "Invalid or missing token.")
21
+
22
+
23
+ @app.post("/execute")
24
+ def execute():
25
+ """Execute code against a state."""
26
+ body = bottle.request.json
27
+ if not body:
28
+ bottle.abort(400, "Request body must be JSON.")
29
+ code = body.get("code", "")
30
+ exec_id = body.get("exec_id")
31
+ state_name = body.get("state_name")
32
+ new_state_name = body.get("new_state_name")
33
+ if exec_id is None or state_name is None:
34
+ bottle.abort(400, "exec_id and state_name are required.")
35
+ result = kernel.execute(code, exec_id, state_name, new_state_name)
36
+ bottle.response.content_type = "application/json"
37
+ return json.dumps(result)
38
+
39
+
40
+ @app.get("/states")
41
+ def list_states():
42
+ """Return all stored state names."""
43
+ bottle.response.content_type = "application/json"
44
+ return json.dumps({"states": kernel.list_states()})
45
+
46
+
47
+ @app.get("/states/<name>")
48
+ def get_state(name):
49
+ """Return details of a single state."""
50
+ state = kernel.get_state(name)
51
+ if state is None:
52
+ bottle.abort(404, "State not found.")
53
+ bottle.response.content_type = "application/json"
54
+ return json.dumps(state)
55
+
56
+
57
+ @app.delete("/states/<name>")
58
+ def delete_state(name):
59
+ """Delete a state."""
60
+ if kernel.delete_state(name):
61
+ return json.dumps({"deleted": name})
62
+ bottle.abort(404, "State not found.")
63
+
64
+
65
+ @app.post("/reset")
66
+ def reset():
67
+ """Reset the kernel to its initial state."""
68
+ kernel.reset()
69
+ bottle.response.content_type = "application/json"
70
+ return json.dumps({"status": "ok"})
71
+
72
+
73
+ @app.post("/interrupt")
74
+ def interrupt():
75
+ """Interrupt a running execution."""
76
+ body = bottle.request.json
77
+ if not body or "exec_id" not in body:
78
+ bottle.abort(400, "exec_id is required.")
79
+ success = kernel.interrupt(body["exec_id"])
80
+ bottle.response.content_type = "application/json"
81
+ return json.dumps({"interrupted": success})
82
+
83
+
84
+ def main():
85
+ """Entry point: parse arguments and start the Cheroot-backed server."""
86
+ parser = argparse.ArgumentParser(description="Snapshot Kernel Server")
87
+ parser.add_argument(
88
+ "--bind", default="127.0.0.1:8080",
89
+ help="Address to bind to (default: 127.0.0.1:8080)",
90
+ )
91
+ parser.add_argument(
92
+ "--token", required=True,
93
+ help="Secret token for request authentication",
94
+ )
95
+ args = parser.parse_args()
96
+
97
+ global _token
98
+ _token = args.token
99
+
100
+ host, port = args.bind.rsplit(":", 1)
101
+ bottle.run(app, server="cheroot", host=host, port=int(port))
102
+
103
+
104
+ if __name__ == "__main__":
105
+ main()
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: snapshot-kernel
3
+ Version: 0.1.0
4
+ Summary: A snapshotting Python kernel with REST API
5
+ Author-email: Luca de Alfaro <dealfaro@acm.org>
6
+ License-Expression: BSD-3-Clause
7
+ Requires-Python: >=3.9
8
+ License-File: LICENSE
9
+ Requires-Dist: bottle
10
+ Requires-Dist: cheroot
11
+ Provides-Extra: test
12
+ Requires-Dist: pytest; extra == "test"
13
+ Dynamic: license-file
@@ -0,0 +1,8 @@
1
+ snapshot_kernel/__init__.py,sha256=tTvJrVBpYAtUHKQ47WRptNUd859fhMEbZiwN_fhbDxk,65
2
+ snapshot_kernel/kernel.py,sha256=PX1HRYWgQcQMXFWKHebbiq3AQJW-C28g_KqImC9e7Kk,15633
3
+ snapshot_kernel/main.py,sha256=LHcqGKmwyn-y86-OXcw884oMKxxyIYBjtneRoNNmoks,2924
4
+ snapshot_kernel-0.1.0.dist-info/licenses/LICENSE,sha256=vlR4G6ILJJmaYsJO6da8Fj_975mlJs0zwf3PC3lHBGo,1501
5
+ snapshot_kernel-0.1.0.dist-info/METADATA,sha256=S0s-1sEtyNzk3DfZ0AQA1mKdYtFyuniIC0sqFRDaBY4,364
6
+ snapshot_kernel-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
7
+ snapshot_kernel-0.1.0.dist-info/top_level.txt,sha256=iAF0G67ln8vQXahArFHB-qTayiS-7-wwvsDh6eQHNdI,16
8
+ snapshot_kernel-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Luca de Alfaro
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1 @@
1
+ snapshot_kernel