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.
- snapshot_kernel-0.1.0/LICENSE +28 -0
- snapshot_kernel-0.1.0/PKG-INFO +13 -0
- snapshot_kernel-0.1.0/pyproject.toml +16 -0
- snapshot_kernel-0.1.0/setup.cfg +4 -0
- snapshot_kernel-0.1.0/snapshot_kernel/__init__.py +3 -0
- snapshot_kernel-0.1.0/snapshot_kernel/kernel.py +461 -0
- snapshot_kernel-0.1.0/snapshot_kernel/main.py +105 -0
- snapshot_kernel-0.1.0/snapshot_kernel.egg-info/PKG-INFO +13 -0
- snapshot_kernel-0.1.0/snapshot_kernel.egg-info/SOURCES.txt +12 -0
- snapshot_kernel-0.1.0/snapshot_kernel.egg-info/dependency_links.txt +1 -0
- snapshot_kernel-0.1.0/snapshot_kernel.egg-info/requires.txt +5 -0
- snapshot_kernel-0.1.0/snapshot_kernel.egg-info/top_level.txt +1 -0
- snapshot_kernel-0.1.0/tests/test_api.py +229 -0
- snapshot_kernel-0.1.0/tests/test_kernel.py +398 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|