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.
- snapshot_kernel/__init__.py +3 -0
- snapshot_kernel/kernel.py +461 -0
- snapshot_kernel/main.py +105 -0
- snapshot_kernel-0.1.0.dist-info/METADATA +13 -0
- snapshot_kernel-0.1.0.dist-info/RECORD +8 -0
- snapshot_kernel-0.1.0.dist-info/WHEEL +5 -0
- snapshot_kernel-0.1.0.dist-info/licenses/LICENSE +28 -0
- snapshot_kernel-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
snapshot_kernel/main.py
ADDED
|
@@ -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,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
|