snapshot-kernel 0.1.0__tar.gz

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,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,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,16 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "snapshot-kernel"
7
+ version = "0.1.0"
8
+ description = "A snapshotting Python kernel with REST API"
9
+ license = "BSD-3-Clause"
10
+ license-files = ["LICENSE"]
11
+ authors = [{name = "Luca de Alfaro", email = "dealfaro@acm.org"}]
12
+ dependencies = ["bottle", "cheroot"]
13
+ requires-python = ">=3.9"
14
+
15
+ [project.optional-dependencies]
16
+ test = ["pytest"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,12 @@
1
+ LICENSE
2
+ pyproject.toml
3
+ snapshot_kernel/__init__.py
4
+ snapshot_kernel/kernel.py
5
+ snapshot_kernel/main.py
6
+ snapshot_kernel.egg-info/PKG-INFO
7
+ snapshot_kernel.egg-info/SOURCES.txt
8
+ snapshot_kernel.egg-info/dependency_links.txt
9
+ snapshot_kernel.egg-info/requires.txt
10
+ snapshot_kernel.egg-info/top_level.txt
11
+ tests/test_api.py
12
+ tests/test_kernel.py
@@ -0,0 +1,5 @@
1
+ bottle
2
+ cheroot
3
+
4
+ [test]
5
+ pytest
@@ -0,0 +1 @@
1
+ snapshot_kernel
@@ -0,0 +1,229 @@
1
+ """Integration tests for the Bottle REST API — starts a real Cheroot server."""
2
+
3
+ import json
4
+ import socket
5
+ import threading
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+
10
+ import pytest
11
+
12
+ import bottle
13
+ from snapshot_kernel import main as main_module
14
+
15
+
16
+ # ------------------------------------------------------------------
17
+ # Helpers
18
+ # ------------------------------------------------------------------
19
+
20
+ TOKEN = "test123"
21
+
22
+
23
+ def _free_port():
24
+ """Find an available TCP port on localhost."""
25
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
26
+ s.bind(("127.0.0.1", 0))
27
+ return s.getsockname()[1]
28
+
29
+
30
+ def _wait_for_port(port, timeout=5):
31
+ """Block until the server is accepting connections."""
32
+ deadline = time.monotonic() + timeout
33
+ while time.monotonic() < deadline:
34
+ try:
35
+ with socket.create_connection(("127.0.0.1", port), timeout=0.1):
36
+ return
37
+ except OSError:
38
+ time.sleep(0.05)
39
+ raise RuntimeError(f"Server never started on port {port}")
40
+
41
+
42
+ def _request(port, method, path, body=None, token=TOKEN):
43
+ """Send an HTTP request and return (status_code, parsed_json_or_None)."""
44
+ url = f"http://127.0.0.1:{port}{path}"
45
+ if token is not None:
46
+ url += f"?token={token}"
47
+
48
+ data = None
49
+ if body is not None:
50
+ data = json.dumps(body).encode()
51
+
52
+ req = urllib.request.Request(url, data=data, method=method)
53
+ if data is not None:
54
+ req.add_header("Content-Type", "application/json")
55
+
56
+ try:
57
+ with urllib.request.urlopen(req) as resp:
58
+ raw = resp.read()
59
+ try:
60
+ return resp.status, json.loads(raw)
61
+ except (json.JSONDecodeError, ValueError):
62
+ return resp.status, None
63
+ except urllib.error.HTTPError as exc:
64
+ raw = exc.read()
65
+ try:
66
+ return exc.code, json.loads(raw)
67
+ except (json.JSONDecodeError, ValueError):
68
+ return exc.code, None
69
+
70
+
71
+ # ------------------------------------------------------------------
72
+ # Fixtures
73
+ # ------------------------------------------------------------------
74
+
75
+ @pytest.fixture(scope="module")
76
+ def server():
77
+ """Start the Cheroot-backed Bottle server once for the whole module."""
78
+ port = _free_port()
79
+ main_module._token = TOKEN
80
+
81
+ def run():
82
+ bottle.run(main_module.app, server="cheroot",
83
+ host="127.0.0.1", port=port, quiet=True)
84
+
85
+ t = threading.Thread(target=run, daemon=True)
86
+ t.start()
87
+ _wait_for_port(port)
88
+ return port
89
+
90
+
91
+ @pytest.fixture(autouse=True)
92
+ def _reset_kernel(server):
93
+ """Reset the kernel before every test so tests are independent."""
94
+ _request(server, "POST", "/reset")
95
+
96
+
97
+ # ------------------------------------------------------------------
98
+ # Tests
99
+ # ------------------------------------------------------------------
100
+
101
+ def test_list_states(server):
102
+ """GET /states returns only 'initial' after reset."""
103
+ status, body = _request(server, "GET", "/states")
104
+ assert status == 200
105
+ assert body["states"] == ["initial"]
106
+
107
+
108
+ def test_execute_and_retrieve(server):
109
+ """POST /execute creates a state retrievable via GET /states/<name>."""
110
+ status, body = _request(server, "POST", "/execute", {
111
+ "code": "x = 42",
112
+ "exec_id": "e1",
113
+ "state_name": "initial",
114
+ "new_state_name": "s1",
115
+ })
116
+ assert status == 200
117
+ assert body["state_name"] == "s1"
118
+ assert body["error"] is None
119
+
120
+ status, state = _request(server, "GET", "/states/s1")
121
+ assert status == 200
122
+ assert state["variables"]["x"]["repr"] == "42"
123
+
124
+
125
+ def test_chained_execution(server):
126
+ """Two sequential executions where the second reads the first's variable."""
127
+ _request(server, "POST", "/execute", {
128
+ "code": "x = 10",
129
+ "exec_id": "e1",
130
+ "state_name": "initial",
131
+ "new_state_name": "s1",
132
+ })
133
+ status, body = _request(server, "POST", "/execute", {
134
+ "code": "x + 5",
135
+ "exec_id": "e2",
136
+ "state_name": "s1",
137
+ })
138
+ assert status == 200
139
+ expr_items = [o for o in body["output"]
140
+ if o["output_type"] == "execute_result"]
141
+ assert expr_items[0]["data"]["text/plain"] == "15"
142
+
143
+
144
+ def test_delete_state(server):
145
+ """DELETE /states/<name> removes the state."""
146
+ _request(server, "POST", "/execute", {
147
+ "code": "1",
148
+ "exec_id": "e1",
149
+ "state_name": "initial",
150
+ "new_state_name": "tmp",
151
+ })
152
+ status, _ = _request(server, "DELETE", "/states/tmp")
153
+ assert status == 200
154
+
155
+ status, _ = _request(server, "GET", "/states/tmp")
156
+ assert status == 404
157
+
158
+
159
+ def test_reset(server):
160
+ """POST /reset removes all derived states."""
161
+ _request(server, "POST", "/execute", {
162
+ "code": "1",
163
+ "exec_id": "e1",
164
+ "state_name": "initial",
165
+ "new_state_name": "s1",
166
+ })
167
+ _request(server, "POST", "/reset")
168
+
169
+ status, body = _request(server, "GET", "/states")
170
+ assert status == 200
171
+ assert body["states"] == ["initial"]
172
+
173
+
174
+ def test_auth_wrong_token(server):
175
+ """A request with the wrong token gets 401."""
176
+ status, _ = _request(server, "GET", "/states", token="wrong")
177
+ assert status == 401
178
+
179
+
180
+ def test_auth_missing_token(server):
181
+ """A request with no token gets 401."""
182
+ status, _ = _request(server, "GET", "/states", token=None)
183
+ assert status == 401
184
+
185
+
186
+ def test_state_not_found(server):
187
+ """GET /states/<name> returns 404 for unknown names."""
188
+ status, _ = _request(server, "GET", "/states/nope")
189
+ assert status == 404
190
+
191
+
192
+ def test_execute_error(server):
193
+ """POST /execute with bad code returns an error payload."""
194
+ status, body = _request(server, "POST", "/execute", {
195
+ "code": "1/0",
196
+ "exec_id": "e1",
197
+ "state_name": "initial",
198
+ })
199
+ assert status == 200
200
+ assert body["error"] is not None
201
+ assert body["error"]["ename"] == "ZeroDivisionError"
202
+ assert body["state_name"] is None
203
+
204
+
205
+ def test_interrupt(server):
206
+ """POST /interrupt stops a long-running execution."""
207
+ result_holder = {}
208
+ code = "import time\nfor _ in range(200):\n time.sleep(0.1)"
209
+
210
+ def run_long():
211
+ _, body = _request(server, "POST", "/execute", {
212
+ "code": code,
213
+ "exec_id": "long_run",
214
+ "state_name": "initial",
215
+ })
216
+ result_holder["body"] = body
217
+
218
+ t = threading.Thread(target=run_long)
219
+ t.start()
220
+
221
+ # Give the execution a moment to enter the loop.
222
+ time.sleep(1.0)
223
+ _request(server, "POST", "/interrupt", {"exec_id": "long_run"})
224
+ t.join(timeout=5)
225
+
226
+ body = result_holder.get("body")
227
+ assert body is not None, "Execution request did not finish"
228
+ assert body["error"] is not None
229
+ assert body["error"]["ename"] == "KeyboardInterrupt"
@@ -0,0 +1,398 @@
1
+ """Unit tests for SnapshotKernel — exercises the kernel class directly."""
2
+
3
+ import base64
4
+ import threading
5
+ import time
6
+
7
+ import pytest
8
+
9
+ from snapshot_kernel.kernel import SnapshotKernel
10
+
11
+
12
+ @pytest.fixture()
13
+ def kernel():
14
+ """Provide a fresh kernel for every test."""
15
+ k = SnapshotKernel()
16
+ k.reset()
17
+ return k
18
+
19
+
20
+ # ------------------------------------------------------------------
21
+ # Basic state management
22
+ # ------------------------------------------------------------------
23
+
24
+ def test_initial_state(kernel):
25
+ """After init, only 'initial' exists with no user variables."""
26
+ assert kernel.list_states() == ["initial"]
27
+ state = kernel.get_state("initial")
28
+ assert state is not None
29
+ assert state["variables"] == {}
30
+
31
+
32
+ # ------------------------------------------------------------------
33
+ # Execution: output capture
34
+ # ------------------------------------------------------------------
35
+
36
+ def test_execute_print(kernel):
37
+ """print() output is captured as stream/stdout."""
38
+ result = kernel.execute("x = 42\nprint(x)", "e1", "initial")
39
+ assert result["error"] is None
40
+ assert result["state_name"] is not None
41
+
42
+ stdout_items = [o for o in result["output"]
43
+ if o["output_type"] == "stream" and o["name"] == "stdout"]
44
+ assert len(stdout_items) == 1
45
+ assert "42\n" == stdout_items[0]["text"]
46
+
47
+
48
+ def test_execute_last_expression(kernel):
49
+ """A trailing expression produces an execute_result."""
50
+ result = kernel.execute("1 + 2", "e1", "initial")
51
+ assert result["error"] is None
52
+
53
+ expr_items = [o for o in result["output"]
54
+ if o["output_type"] == "execute_result"]
55
+ assert len(expr_items) == 1
56
+ assert expr_items[0]["data"]["text/plain"] == "3"
57
+
58
+
59
+ def test_execute_named_state(kernel):
60
+ """Providing new_state_name stores under that exact name."""
61
+ result = kernel.execute("x = 1", "e1", "initial", new_state_name="my_state")
62
+ assert result["state_name"] == "my_state"
63
+ assert "my_state" in kernel.list_states()
64
+
65
+
66
+ def test_chained_execution(kernel):
67
+ """Executing from a derived state sees variables set earlier."""
68
+ r1 = kernel.execute("x = 42", "e1", "initial")
69
+ r2 = kernel.execute("x + 1", "e2", r1["state_name"])
70
+ assert r2["error"] is None
71
+
72
+ expr_items = [o for o in r2["output"]
73
+ if o["output_type"] == "execute_result"]
74
+ assert expr_items[0]["data"]["text/plain"] == "43"
75
+
76
+
77
+ def test_import_module(kernel):
78
+ """Imports are available and persisted in the state."""
79
+ result = kernel.execute("import math\nmath.sqrt(144)", "e1", "initial")
80
+ assert result["error"] is None
81
+
82
+ expr_items = [o for o in result["output"]
83
+ if o["output_type"] == "execute_result"]
84
+ assert expr_items[0]["data"]["text/plain"] == "12.0"
85
+
86
+ state = kernel.get_state(result["state_name"])
87
+ assert "math" in state["variables"]
88
+ assert state["variables"]["math"]["type"] == "module"
89
+
90
+
91
+ def test_stderr_capture(kernel):
92
+ """sys.stderr.write() output is captured as stream/stderr."""
93
+ result = kernel.execute("import sys; sys.stderr.write('err')", "e1", "initial")
94
+
95
+ stderr_items = [o for o in result["output"]
96
+ if o["output_type"] == "stream" and o["name"] == "stderr"]
97
+ assert len(stderr_items) == 1
98
+ assert stderr_items[0]["text"] == "err"
99
+
100
+
101
+ # ------------------------------------------------------------------
102
+ # Error handling
103
+ # ------------------------------------------------------------------
104
+
105
+ def test_execute_error(kernel):
106
+ """A runtime error is reported; no new state is created."""
107
+ result = kernel.execute("1/0", "e1", "initial")
108
+ assert result["error"] is not None
109
+ assert result["error"]["ename"] == "ZeroDivisionError"
110
+ assert result["state_name"] is None
111
+
112
+
113
+ def test_execute_error_no_state_stored(kernel):
114
+ """After a failed execution the state list has not grown."""
115
+ before = set(kernel.list_states())
116
+ kernel.execute("1/0", "e1", "initial")
117
+ after = set(kernel.list_states())
118
+ assert before == after
119
+
120
+
121
+ def test_state_not_found(kernel):
122
+ """Executing against a non-existent state returns a StateNotFound error."""
123
+ result = kernel.execute("1", "e1", "no_such_state")
124
+ assert result["error"]["ename"] == "StateNotFound"
125
+ assert result["state_name"] is None
126
+
127
+
128
+ # ------------------------------------------------------------------
129
+ # State retrieval & deletion
130
+ # ------------------------------------------------------------------
131
+
132
+ def test_get_state_details(kernel):
133
+ """get_state returns variable types, reprs, and a timestamp."""
134
+ r = kernel.execute("x = 42", "e1", "initial")
135
+ state = kernel.get_state(r["state_name"])
136
+ assert state is not None
137
+ assert "x" in state["variables"]
138
+ assert state["variables"]["x"]["type"] == "int"
139
+ assert state["variables"]["x"]["repr"] == "42"
140
+ assert "timestamp" in state
141
+
142
+
143
+ def test_get_state_nonexistent(kernel):
144
+ """get_state returns None for unknown names."""
145
+ assert kernel.get_state("nope") is None
146
+
147
+
148
+ def test_delete_state(kernel):
149
+ """A deleted state is removed from list_states."""
150
+ r = kernel.execute("x = 1", "e1", "initial", new_state_name="tmp")
151
+ assert "tmp" in kernel.list_states()
152
+ assert kernel.delete_state("tmp") is True
153
+ assert "tmp" not in kernel.list_states()
154
+
155
+
156
+ def test_delete_nonexistent(kernel):
157
+ """Deleting an unknown state returns False."""
158
+ assert kernel.delete_state("nope") is False
159
+
160
+
161
+ def test_reset(kernel):
162
+ """reset() removes all states except a fresh 'initial'."""
163
+ kernel.execute("x = 1", "e1", "initial", new_state_name="a")
164
+ kernel.execute("x = 2", "e2", "initial", new_state_name="b")
165
+ kernel.reset()
166
+ assert kernel.list_states() == ["initial"]
167
+ assert kernel.get_state("initial")["variables"] == {}
168
+
169
+
170
+ # ------------------------------------------------------------------
171
+ # State isolation
172
+ # ------------------------------------------------------------------
173
+
174
+ def test_state_isolation(kernel):
175
+ """Forking from the same state produces independent snapshots."""
176
+ kernel.execute("x = 1", "e1", "initial", new_state_name="a")
177
+ kernel.execute("x = 2", "e2", "initial", new_state_name="b")
178
+
179
+ a = kernel.get_state("a")
180
+ b = kernel.get_state("b")
181
+ initial = kernel.get_state("initial")
182
+
183
+ assert a["variables"]["x"]["repr"] == "1"
184
+ assert b["variables"]["x"]["repr"] == "2"
185
+ assert "x" not in initial["variables"]
186
+
187
+
188
+ # ------------------------------------------------------------------
189
+ # Interrupt
190
+ # ------------------------------------------------------------------
191
+
192
+ def test_interrupt(kernel):
193
+ """Interrupting a long-running execution raises KeyboardInterrupt."""
194
+ result_holder = {}
195
+ code = "import time\nfor _ in range(200):\n time.sleep(0.1)"
196
+
197
+ def run():
198
+ result_holder["result"] = kernel.execute(code, "long", "initial")
199
+
200
+ t = threading.Thread(target=run)
201
+ t.start()
202
+
203
+ # Give the execution a moment to start the loop.
204
+ time.sleep(0.5)
205
+ kernel.interrupt("long")
206
+ t.join(timeout=5)
207
+
208
+ result = result_holder.get("result")
209
+ assert result is not None, "Execution thread did not finish"
210
+ assert result["error"] is not None
211
+ assert result["error"]["ename"] == "KeyboardInterrupt"
212
+ assert result["state_name"] is None
213
+
214
+
215
+ # ------------------------------------------------------------------
216
+ # Rich output capture
217
+ # ------------------------------------------------------------------
218
+
219
+ def test_rich_repr_html(kernel):
220
+ """An object with _repr_html_() produces text/html in execute_result."""
221
+ code = (
222
+ "class RichObj:\n"
223
+ " def _repr_html_(self):\n"
224
+ " return '<b>hello</b>'\n"
225
+ " def __repr__(self):\n"
226
+ " return 'RichObj()'\n"
227
+ "RichObj()"
228
+ )
229
+ result = kernel.execute(code, "e1", "initial")
230
+ assert result["error"] is None
231
+ expr_items = [o for o in result["output"] if o["output_type"] == "execute_result"]
232
+ assert len(expr_items) == 1
233
+ assert expr_items[0]["data"]["text/html"] == "<b>hello</b>"
234
+ assert expr_items[0]["data"]["text/plain"] == "RichObj()"
235
+
236
+
237
+ def test_rich_repr_png(kernel):
238
+ """An object with _repr_png_() returns base64-encoded image/png."""
239
+ code = (
240
+ "import base64\n"
241
+ "class PngObj:\n"
242
+ " def _repr_png_(self):\n"
243
+ " return b'\\x89PNG_fake'\n"
244
+ " def __repr__(self):\n"
245
+ " return 'PngObj()'\n"
246
+ "PngObj()"
247
+ )
248
+ result = kernel.execute(code, "e1", "initial")
249
+ assert result["error"] is None
250
+ expr_items = [o for o in result["output"] if o["output_type"] == "execute_result"]
251
+ assert len(expr_items) == 1
252
+ png_data = expr_items[0]["data"]["image/png"]
253
+ # Verify it is valid base64 that decodes to our bytes.
254
+ assert base64.b64decode(png_data) == b"\x89PNG_fake"
255
+
256
+
257
+ def test_repr_html_returns_none(kernel):
258
+ """If _repr_html_() returns None, text/html should not appear in data."""
259
+ code = (
260
+ "class NoneHtml:\n"
261
+ " def _repr_html_(self):\n"
262
+ " return None\n"
263
+ " def __repr__(self):\n"
264
+ " return 'NoneHtml()'\n"
265
+ "NoneHtml()"
266
+ )
267
+ result = kernel.execute(code, "e1", "initial")
268
+ assert result["error"] is None
269
+ expr_items = [o for o in result["output"] if o["output_type"] == "execute_result"]
270
+ assert len(expr_items) == 1
271
+ assert "text/html" not in expr_items[0]["data"]
272
+ assert expr_items[0]["data"]["text/plain"] == "NoneHtml()"
273
+
274
+
275
+ def test_repr_mimebundle(kernel):
276
+ """_repr_mimebundle_() returning a dict populates data correctly."""
277
+ code = (
278
+ "class BundleObj:\n"
279
+ " def _repr_mimebundle_(self):\n"
280
+ " return {'text/html': '<i>bundle</i>', 'text/plain': 'bundle'}\n"
281
+ " def __repr__(self):\n"
282
+ " return 'BundleObj()'\n"
283
+ "BundleObj()"
284
+ )
285
+ result = kernel.execute(code, "e1", "initial")
286
+ assert result["error"] is None
287
+ expr_items = [o for o in result["output"] if o["output_type"] == "execute_result"]
288
+ assert len(expr_items) == 1
289
+ assert expr_items[0]["data"]["text/html"] == "<i>bundle</i>"
290
+
291
+
292
+ def test_repr_mimebundle_tuple(kernel):
293
+ """_repr_mimebundle_() returning (data, metadata) tuple."""
294
+ code = (
295
+ "class TupleBundle:\n"
296
+ " def _repr_mimebundle_(self):\n"
297
+ " return ({'text/html': '<em>t</em>'}, {'text/html': {'isolated': True}})\n"
298
+ " def __repr__(self):\n"
299
+ " return 'TupleBundle()'\n"
300
+ "TupleBundle()"
301
+ )
302
+ result = kernel.execute(code, "e1", "initial")
303
+ assert result["error"] is None
304
+ expr_items = [o for o in result["output"] if o["output_type"] == "execute_result"]
305
+ assert len(expr_items) == 1
306
+ assert expr_items[0]["data"]["text/html"] == "<em>t</em>"
307
+ assert expr_items[0]["metadata"]["text/html"] == {"isolated": True}
308
+
309
+
310
+ def test_display_function(kernel):
311
+ """Calling display() inside code produces display_data output."""
312
+ code = (
313
+ "class Html:\n"
314
+ " def _repr_html_(self):\n"
315
+ " return '<p>hi</p>'\n"
316
+ " def __repr__(self):\n"
317
+ " return 'Html()'\n"
318
+ "display(Html())"
319
+ )
320
+ result = kernel.execute(code, "e1", "initial")
321
+ assert result["error"] is None
322
+ display_items = [o for o in result["output"] if o["output_type"] == "display_data"]
323
+ assert len(display_items) == 1
324
+ assert display_items[0]["data"]["text/html"] == "<p>hi</p>"
325
+ assert display_items[0]["data"]["text/plain"] == "Html()"
326
+
327
+
328
+ def test_display_multiple_calls(kernel):
329
+ """Multiple display() calls produce multiple display_data entries."""
330
+ code = "display('first')\ndisplay('second')"
331
+ result = kernel.execute(code, "e1", "initial")
332
+ assert result["error"] is None
333
+ display_items = [o for o in result["output"] if o["output_type"] == "display_data"]
334
+ assert len(display_items) == 2
335
+ assert display_items[0]["data"]["text/plain"] == "'first'"
336
+ assert display_items[1]["data"]["text/plain"] == "'second'"
337
+
338
+
339
+ def test_display_multiple_args(kernel):
340
+ """display('a', 'b') produces two display_data entries."""
341
+ code = "display('a', 'b')"
342
+ result = kernel.execute(code, "e1", "initial")
343
+ assert result["error"] is None
344
+ display_items = [o for o in result["output"] if o["output_type"] == "display_data"]
345
+ assert len(display_items) == 2
346
+ assert display_items[0]["data"]["text/plain"] == "'a'"
347
+ assert display_items[1]["data"]["text/plain"] == "'b'"
348
+
349
+
350
+ def test_display_not_in_state(kernel):
351
+ """The injected display function must not persist in the saved state."""
352
+ result = kernel.execute("x = 1", "e1", "initial")
353
+ assert result["error"] is None
354
+ state = kernel.get_state(result["state_name"])
355
+ assert "display" not in state["variables"]
356
+
357
+
358
+ def test_matplotlib_figure_capture(kernel):
359
+ """A matplotlib figure is captured as display_data with image/png."""
360
+ pytest.importorskip("matplotlib")
361
+ code = (
362
+ "import matplotlib\n"
363
+ "matplotlib.use('Agg')\n"
364
+ "import matplotlib.pyplot as plt\n"
365
+ "fig, ax = plt.subplots()\n"
366
+ "ax.plot([1, 2, 3])\n"
367
+ )
368
+ result = kernel.execute(code, "e1", "initial")
369
+ assert result["error"] is None
370
+ display_items = [o for o in result["output"] if o["output_type"] == "display_data"]
371
+ assert len(display_items) >= 1
372
+ png_item = display_items[0]
373
+ assert "image/png" in png_item["data"]
374
+ # Verify it's valid base64.
375
+ raw = base64.b64decode(png_item["data"]["image/png"])
376
+ assert raw[:4] == b"\x89PNG"
377
+
378
+
379
+ def test_matplotlib_show_capture(kernel):
380
+ """plt.show() captures the current figure; a second figure is captured at end."""
381
+ pytest.importorskip("matplotlib")
382
+ code = (
383
+ "import matplotlib\n"
384
+ "matplotlib.use('Agg')\n"
385
+ "import matplotlib.pyplot as plt\n"
386
+ "plt.figure()\n"
387
+ "plt.plot([1, 2])\n"
388
+ "plt.show()\n"
389
+ "plt.figure()\n"
390
+ "plt.plot([3, 4])\n"
391
+ )
392
+ result = kernel.execute(code, "e1", "initial")
393
+ assert result["error"] is None
394
+ display_items = [o for o in result["output"] if o["output_type"] == "display_data"]
395
+ # First figure from plt.show(), second from end-of-cell capture.
396
+ assert len(display_items) >= 2
397
+ for item in display_items:
398
+ assert "image/png" in item["data"]