beatrina 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- package/package.json +32 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""debugger.py — breakpoints and stepping for a Python chunk, INSIDE the kernel.
|
|
2
|
+
|
|
3
|
+
Shipped into the ipykernel as SOURCE by `bootstrap.py` (the way `dataview.py`
|
|
4
|
+
is), because the kernel may be any Python the user chose and Beatrix's engine
|
|
5
|
+
package need not be importable there. It is the Python half of the contract
|
|
6
|
+
`spike/worker.R`'s browser() debugger defines (docs/wp/wp8b-python-debugger.md):
|
|
7
|
+
|
|
8
|
+
breakpoints addressed by the chunk's STABLE srcname, "chunk:<stableId>",
|
|
9
|
+
and a 1-based line inside that chunk's source
|
|
10
|
+
a pause one `debug` frame: {event: "paused", file, line, reason,
|
|
11
|
+
stack: [{call, vars, file, line}] innermost LAST, locals}
|
|
12
|
+
the commands continue · over · into · out · where · abort · eval <expr>
|
|
13
|
+
|
|
14
|
+
WHY A TRACER OF OUR OWN AND NOT DEBUGPY, OR BDB. Measured 2026-09-14 against
|
|
15
|
+
ipykernel 7.3.0 / debugpy 1.8.21 / Python 3.14.4 (the record has the numbers):
|
|
16
|
+
|
|
17
|
+
* debugpy over ipykernel's control channel stops at a breakpoint in 0.19 s,
|
|
18
|
+
but a kernel interrupt sent while it is stopped did not end the run in
|
|
19
|
+
8 s — Stop, the one control the notebook promises works at every moment,
|
|
20
|
+
would be a lie exactly while paused. Its breakpoints also address a temp
|
|
21
|
+
file named by a HASH OF THE CELL'S TEXT, so the page's stable chunk
|
|
22
|
+
identity would have to be re-derived on every edit.
|
|
23
|
+
* `bdb` works, but its dispatch internals changed in 3.12, 3.13 and 3.14
|
|
24
|
+
(`disable_current_event`, a `monitoring` backend, `cmdframe`), and the
|
|
25
|
+
user's interpreter is any Python 3. A subclass that overrides
|
|
26
|
+
`break_here` inherits three versions of stop logic to be correct against.
|
|
27
|
+
|
|
28
|
+
So: `sys.settrace` with a stop rule this file owns, a pause that is an ordinary
|
|
29
|
+
`input()` — which ipykernel turns into an `input_request` the worker already
|
|
30
|
+
drains, and which a kernel interrupt ends with KeyboardInterrupt (measured) —
|
|
31
|
+
and a prompt that carries its state as JSON behind a marker no user prompt
|
|
32
|
+
would begin with.
|
|
33
|
+
|
|
34
|
+
THE PERFORMANCE RULE. Tracing is installed only while at least one breakpoint
|
|
35
|
+
exists or a step is pending, and only for the cell being run; a notebook with
|
|
36
|
+
no breakpoints runs with no trace function at all.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
import json as _json
|
|
40
|
+
import sys as _sys
|
|
41
|
+
|
|
42
|
+
#: The prompt marker. ESC bytes: nothing a person types into `input()` starts
|
|
43
|
+
#: with it, and the worker strips it before anything reaches the page.
|
|
44
|
+
PROMPT = "\x1bcarmar-debug\x1b"
|
|
45
|
+
|
|
46
|
+
MAX_VARS = 40
|
|
47
|
+
MAX_CALL = 300
|
|
48
|
+
MAX_VALUE = 60
|
|
49
|
+
HIDDEN = {"In", "Out", "get_ipython", "exit", "quit", "open"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CarmarDebugAbort(BaseException):
|
|
53
|
+
"""The debugger's Stop. BASE exception, not Exception: a user's
|
|
54
|
+
`except Exception:` around the paused line must not swallow it and carry
|
|
55
|
+
on, which is the promise R keeps with its `carmar_abort_cell` restart."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _State:
|
|
59
|
+
def __init__(self):
|
|
60
|
+
self.breaks = {} # srcname -> set of 1-based lines
|
|
61
|
+
self.next_name = None # the srcname the NEXT compiled cell gets
|
|
62
|
+
self.mode = None # None | "step" | "next" | "out" | "file"
|
|
63
|
+
self.target = None # the frame a next/out is relative to
|
|
64
|
+
self.target_file = None # "file": stop at the next line of this chunk
|
|
65
|
+
self.paused = False # never re-enter a pause from inside one
|
|
66
|
+
self.installed = False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
S = _State()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_user_file(filename):
|
|
73
|
+
"""Code the notebook wrote: a chunk, or a cell with no chunk identity."""
|
|
74
|
+
return isinstance(filename, str) and (
|
|
75
|
+
filename.startswith("chunk:") or filename.startswith("<ipython-input"))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── what a frame looks like on the wire ──────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
def _shape(v):
|
|
81
|
+
"""R's frame_vars `value` column, in Python: a table's dimensions, the word
|
|
82
|
+
function, a short scalar, or a length — never the object's whole repr."""
|
|
83
|
+
try:
|
|
84
|
+
if v is None or isinstance(v, (bool, int, float, complex, str, bytes)):
|
|
85
|
+
t = repr(v)
|
|
86
|
+
return t if len(t) <= MAX_VALUE else t[:MAX_VALUE - 3] + "..."
|
|
87
|
+
shape = getattr(v, "shape", None)
|
|
88
|
+
if isinstance(shape, tuple) and len(shape) == 2:
|
|
89
|
+
return "%d x %d" % (int(shape[0]), int(shape[1]))
|
|
90
|
+
if callable(v):
|
|
91
|
+
return "function"
|
|
92
|
+
if hasattr(v, "__len__"):
|
|
93
|
+
return "length %d" % len(v)
|
|
94
|
+
return type(v).__name__
|
|
95
|
+
except Exception:
|
|
96
|
+
return "?"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _vars(mapping, module_level):
|
|
100
|
+
import types
|
|
101
|
+
rows = []
|
|
102
|
+
try:
|
|
103
|
+
names = sorted(k for k in mapping.keys() if isinstance(k, str))
|
|
104
|
+
except Exception:
|
|
105
|
+
return rows
|
|
106
|
+
for name in names:
|
|
107
|
+
if module_level and (name.startswith("_") or name in HIDDEN):
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
v = mapping[name]
|
|
111
|
+
except Exception:
|
|
112
|
+
continue
|
|
113
|
+
if module_level and isinstance(v, types.ModuleType):
|
|
114
|
+
continue
|
|
115
|
+
rows.append({"name": name, "class": type(v).__name__, "value": _shape(v)})
|
|
116
|
+
if len(rows) >= MAX_VARS:
|
|
117
|
+
break
|
|
118
|
+
return rows
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _call_text(frame):
|
|
122
|
+
code = frame.f_code
|
|
123
|
+
name = getattr(code, "co_qualname", code.co_name)
|
|
124
|
+
args = []
|
|
125
|
+
for arg in code.co_varnames[:code.co_argcount + code.co_kwonlyargcount]:
|
|
126
|
+
if arg in frame.f_locals:
|
|
127
|
+
args.append("%s = %s" % (arg, _shape(frame.f_locals[arg])))
|
|
128
|
+
text = "%s(%s)" % (name, ", ".join(args))
|
|
129
|
+
return text if len(text) <= MAX_CALL else text[:MAX_CALL - 3] + "..."
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _stack(frame):
|
|
133
|
+
"""User frames, innermost LAST. A chunk's own top level is not a frame in
|
|
134
|
+
R's stack either (a top-level breakpoint shows "(top level)"), so module
|
|
135
|
+
frames are the position, not a row."""
|
|
136
|
+
rows = []
|
|
137
|
+
f = frame
|
|
138
|
+
while f is not None:
|
|
139
|
+
code = f.f_code
|
|
140
|
+
if is_user_file(code.co_filename) and code.co_name != "<module>":
|
|
141
|
+
rows.append({"call": _call_text(f), "vars": _vars(f.f_locals, False),
|
|
142
|
+
"file": code.co_filename if code.co_filename.startswith("chunk:") else None,
|
|
143
|
+
"line": f.f_lineno})
|
|
144
|
+
f = f.f_back
|
|
145
|
+
rows.reverse()
|
|
146
|
+
return rows[-40:]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _locals(frame):
|
|
150
|
+
module = frame.f_code.co_name == "<module>"
|
|
151
|
+
return _vars(frame.f_globals if module else frame.f_locals, module)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _position(frame):
|
|
155
|
+
name = frame.f_code.co_filename
|
|
156
|
+
return {"file": name if name.startswith("chunk:") else None, "line": frame.f_lineno}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ── the stop rule ────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
def _on_stack(target, frame):
|
|
162
|
+
f = frame
|
|
163
|
+
while f is not None:
|
|
164
|
+
if f is target:
|
|
165
|
+
return True
|
|
166
|
+
f = f.f_back
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _should_stop(frame):
|
|
171
|
+
name = frame.f_code.co_filename
|
|
172
|
+
lines = S.breaks.get(name)
|
|
173
|
+
if lines and frame.f_lineno in lines:
|
|
174
|
+
return "breakpoint"
|
|
175
|
+
if S.mode == "step":
|
|
176
|
+
return "step"
|
|
177
|
+
if S.mode == "next":
|
|
178
|
+
if frame is S.target or not _on_stack(S.target, frame):
|
|
179
|
+
return "step"
|
|
180
|
+
if S.mode == "out":
|
|
181
|
+
if not _on_stack(S.target, frame):
|
|
182
|
+
return "step"
|
|
183
|
+
if S.mode == "file" and name == S.target_file:
|
|
184
|
+
return "step"
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _local(frame, event, arg):
|
|
189
|
+
if S.paused:
|
|
190
|
+
return _local
|
|
191
|
+
if event == "line":
|
|
192
|
+
reason = _should_stop(frame)
|
|
193
|
+
if reason:
|
|
194
|
+
_pause(frame, reason)
|
|
195
|
+
elif event == "return" and S.mode in ("next", "out") and frame is S.target:
|
|
196
|
+
caller = frame.f_back
|
|
197
|
+
if caller is not None and is_user_file(caller.f_code.co_filename):
|
|
198
|
+
# Stop at the caller's next line: make sure the caller is traced.
|
|
199
|
+
S.target = caller
|
|
200
|
+
S.mode = "next"
|
|
201
|
+
caller.f_trace = _local
|
|
202
|
+
else:
|
|
203
|
+
# A chunk's top-level statement finished and control went back to
|
|
204
|
+
# IPython, which runs the NEXT statement as its own code object:
|
|
205
|
+
# "execute next line" means the chunk's next line, not the cell's end.
|
|
206
|
+
S.mode = "file"
|
|
207
|
+
S.target_file = frame.f_code.co_filename
|
|
208
|
+
S.target = None
|
|
209
|
+
return _local
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _global(frame, event, arg):
|
|
213
|
+
if S.paused or event != "call":
|
|
214
|
+
return None
|
|
215
|
+
name = frame.f_code.co_filename
|
|
216
|
+
if not is_user_file(name):
|
|
217
|
+
return None
|
|
218
|
+
if name in S.breaks or S.mode is not None:
|
|
219
|
+
return _local
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _install():
|
|
224
|
+
if not S.installed and (S.breaks or S.mode is not None):
|
|
225
|
+
_sys.settrace(_global)
|
|
226
|
+
S.installed = True
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _uninstall():
|
|
230
|
+
if S.installed:
|
|
231
|
+
_sys.settrace(None)
|
|
232
|
+
S.installed = False
|
|
233
|
+
S.mode = None
|
|
234
|
+
S.target = None
|
|
235
|
+
S.target_file = None
|
|
236
|
+
S.paused = False
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ── a pause ──────────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
def _eval_in(frame, expr):
|
|
242
|
+
"""Evaluate one console line IN THE PAUSED FRAME, the way R's Browse
|
|
243
|
+
prompt does: an expression prints its value, a statement runs."""
|
|
244
|
+
import traceback
|
|
245
|
+
glb = frame.f_globals
|
|
246
|
+
loc = frame.f_locals
|
|
247
|
+
try:
|
|
248
|
+
try:
|
|
249
|
+
code = compile(expr, "<debug>", "eval")
|
|
250
|
+
except SyntaxError:
|
|
251
|
+
exec(compile(expr, "<debug>", "exec"), glb, loc)
|
|
252
|
+
return
|
|
253
|
+
value = eval(code, glb, loc)
|
|
254
|
+
if value is not None:
|
|
255
|
+
print(repr(value))
|
|
256
|
+
except (KeyboardInterrupt, CarmarDebugAbort):
|
|
257
|
+
raise
|
|
258
|
+
except BaseException:
|
|
259
|
+
text = traceback.format_exception_only(*_sys.exc_info()[:2])
|
|
260
|
+
_sys.stderr.write("".join(text))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _pause(frame, reason):
|
|
264
|
+
"""Emit the pause, then read commands until one resumes execution.
|
|
265
|
+
|
|
266
|
+
`input()` is the transport both ways: its prompt carries the frame (the
|
|
267
|
+
worker turns it into `debug`), its answer carries the command and the
|
|
268
|
+
current breakpoints (the worker writes them). A Stop pressed while paused
|
|
269
|
+
arrives as KeyboardInterrupt out of this `input()` and unwinds the chunk.
|
|
270
|
+
"""
|
|
271
|
+
S.paused = True
|
|
272
|
+
try:
|
|
273
|
+
payload = {"event": "paused", "reason": reason, **_position(frame),
|
|
274
|
+
"stack": _stack(frame), "locals": _locals(frame)}
|
|
275
|
+
while True:
|
|
276
|
+
answer = input(PROMPT + _json.dumps(payload, default=str, separators=(",", ":")))
|
|
277
|
+
payload = {"event": "prompt"}
|
|
278
|
+
try:
|
|
279
|
+
cmd = _json.loads(answer)
|
|
280
|
+
except Exception:
|
|
281
|
+
cmd = {"action": "eval", "expr": answer}
|
|
282
|
+
if isinstance(cmd.get("breaks"), dict):
|
|
283
|
+
S.breaks = {k: set(v) for k, v in cmd["breaks"].items() if v}
|
|
284
|
+
action = cmd.get("action")
|
|
285
|
+
if action == "continue":
|
|
286
|
+
S.mode, S.target = None, None
|
|
287
|
+
return
|
|
288
|
+
if action == "over":
|
|
289
|
+
S.mode, S.target = "next", frame
|
|
290
|
+
return
|
|
291
|
+
if action == "into":
|
|
292
|
+
S.mode, S.target = "step", None
|
|
293
|
+
return
|
|
294
|
+
if action == "out":
|
|
295
|
+
S.mode, S.target = "out", frame
|
|
296
|
+
return
|
|
297
|
+
if action == "abort":
|
|
298
|
+
raise CarmarDebugAbort("Stopped from the debugger")
|
|
299
|
+
if action == "where":
|
|
300
|
+
payload = {"event": "where", **_position(frame),
|
|
301
|
+
"stack": _stack(frame), "locals": _locals(frame)}
|
|
302
|
+
continue
|
|
303
|
+
if action == "eval" and isinstance(cmd.get("expr"), str):
|
|
304
|
+
_eval_in(frame, cmd["expr"])
|
|
305
|
+
finally:
|
|
306
|
+
S.paused = False
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ── the worker's entry points ────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
def prepare(spec_json):
|
|
312
|
+
"""Called by the worker immediately before a chunk runs: the chunk's
|
|
313
|
+
srcname for the next compile, and every breakpoint the page has set."""
|
|
314
|
+
spec = _json.loads(spec_json)
|
|
315
|
+
name = spec.get("srcname")
|
|
316
|
+
S.next_name = name if isinstance(name, str) and name else None
|
|
317
|
+
breaks = spec.get("breaks")
|
|
318
|
+
if isinstance(breaks, dict):
|
|
319
|
+
S.breaks = {k: set(int(x) for x in v) for k, v in breaks.items()
|
|
320
|
+
if isinstance(k, str) and isinstance(v, list) and v}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def install(ip):
|
|
324
|
+
"""Hook the kernel: name the next cell after its chunk, and trace a cell
|
|
325
|
+
only while a breakpoint or a step exists."""
|
|
326
|
+
compiler = ip.compile
|
|
327
|
+
original = compiler.get_code_name
|
|
328
|
+
|
|
329
|
+
def get_code_name(raw_code, transformed_code, number):
|
|
330
|
+
name = S.next_name
|
|
331
|
+
if name:
|
|
332
|
+
S.next_name = None
|
|
333
|
+
return name
|
|
334
|
+
return original(raw_code, transformed_code, number)
|
|
335
|
+
|
|
336
|
+
compiler.get_code_name = get_code_name
|
|
337
|
+
|
|
338
|
+
def pre(*a, **k):
|
|
339
|
+
S.mode, S.target, S.target_file = None, None, None
|
|
340
|
+
_install()
|
|
341
|
+
|
|
342
|
+
def post(*a, **k):
|
|
343
|
+
_uninstall()
|
|
344
|
+
|
|
345
|
+
ip.events.register("pre_run_cell", pre)
|
|
346
|
+
ip.events.register("post_run_cell", post)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""document_exec.py — the notebook exec envelope: setup chunks as a Ready snapshot.
|
|
2
|
+
|
|
3
|
+
The page sends every chunk run with a `document` descriptor
|
|
4
|
+
(`page/lib/document-execution.js`, `executionDocument()`), and refuses to run
|
|
5
|
+
at all against a kernel that does not announce `document_exec_v1` on its ready
|
|
6
|
+
frame. So carmapy must either implement what the envelope asks for or be a
|
|
7
|
+
kernel no page can run a chunk on. This module is the implementation, ported
|
|
8
|
+
from `spike/knitr-run.R`'s `run_document_cell()` — the DECISIONS, kept pure so
|
|
9
|
+
they can be tested on plain dicts; the running is `serve.py`'s.
|
|
10
|
+
|
|
11
|
+
What the R side does with the envelope, and what carmapy keeps:
|
|
12
|
+
|
|
13
|
+
* A document's `setup` chunk runs ONCE per (document, setup source) and is
|
|
14
|
+
then a "Ready snapshot": dependent chunks reuse it without re-running it.
|
|
15
|
+
A changed setup source, an explicit run of the setup chunk (`isSetup`), or
|
|
16
|
+
a previously failed setup re-runs it. KEPT — same rules, same messages.
|
|
17
|
+
* A setup that failed BLOCKS dependent chunks until it is fixed, re-run
|
|
18
|
+
explicitly, or retried (`retrySetup`). KEPT.
|
|
19
|
+
* The setup's own output is not shown (knitr `include = FALSE`); its
|
|
20
|
+
tracebacks are. KEPT — `serve.py` forwards a setup run's error frames only.
|
|
21
|
+
* knitr chunk options — `cache`, `fig.width`, `comment`, `warning`, … — are
|
|
22
|
+
applied to the chunk's evaluation. NOT KEPT, by decision: chunk options
|
|
23
|
+
are render-time for Python (`docs/python-plan.md` §8; knitr has no Python
|
|
24
|
+
equivalent and a session-side option cache was refused on the R side).
|
|
25
|
+
The done frame therefore reports `cacheHit: false` and
|
|
26
|
+
`effectiveOptions: null` — nothing was applied, so nothing is claimed.
|
|
27
|
+
* `optionExpressions` are R expressions evaluated at run time. NOT KEPT
|
|
28
|
+
(they would be evaluated as Python by a kernel that never promised to).
|
|
29
|
+
|
|
30
|
+
The error sentences are R's, verbatim, because the page shows them to the
|
|
31
|
+
user and its tests assert them.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import re
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
|
|
39
|
+
LABEL_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
40
|
+
|
|
41
|
+
#: The one feature this module lets the ready frame claim.
|
|
42
|
+
FEATURE = "document_exec_v1"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DocumentExecError(Exception):
|
|
46
|
+
"""The envelope cannot be run. The message is the user-facing sentence."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Plan:
|
|
51
|
+
"""What one exec envelope asks for, decided before anything runs.
|
|
52
|
+
|
|
53
|
+
doc_id the document identity (`document.id`)
|
|
54
|
+
label the chunk label (validated when the chunk itself will run)
|
|
55
|
+
setup_source the setup chunk's source, or None when the document has none
|
|
56
|
+
setup_srcname the setup chunk's `srcname`, for the `setup` frames
|
|
57
|
+
run_setup run the setup chunk first (changed, explicit, or retrying)
|
|
58
|
+
run_chunk run the chunk itself (False for an explicit setup-only run)
|
|
59
|
+
execution the provenance stamp the page stores with the result
|
|
60
|
+
"""
|
|
61
|
+
doc_id: str
|
|
62
|
+
label: str | None
|
|
63
|
+
setup_source: str | None
|
|
64
|
+
setup_srcname: str | None
|
|
65
|
+
run_setup: bool
|
|
66
|
+
run_chunk: bool
|
|
67
|
+
execution: dict = field(default_factory=dict)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class _Snapshot:
|
|
72
|
+
source: str # the setup source this snapshot is FOR ("" for none)
|
|
73
|
+
status: str # "ok" | "failed"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DocumentSetups:
|
|
77
|
+
"""Per-document setup state for ONE evaluating session.
|
|
78
|
+
|
|
79
|
+
Port of `KNIT_DOCS` in knitr-run.R: keyed by document id, remembering
|
|
80
|
+
which setup source last ran and whether it succeeded. A fresh worker has
|
|
81
|
+
no Ready snapshots — `reset()` on every restart.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self) -> None:
|
|
85
|
+
self._docs: dict[str, _Snapshot] = {}
|
|
86
|
+
|
|
87
|
+
def reset(self) -> None:
|
|
88
|
+
self._docs.clear()
|
|
89
|
+
|
|
90
|
+
def snapshot(self, doc_id: str) -> tuple[str, str] | None:
|
|
91
|
+
"""`(setup_source, status)` for a document, or None (never ran)."""
|
|
92
|
+
snap = self._docs.get(doc_id)
|
|
93
|
+
return (snap.source, snap.status) if snap else None
|
|
94
|
+
|
|
95
|
+
def plan(self, source: str, document: object) -> Plan:
|
|
96
|
+
"""Decide what an envelope asks for. Raises `DocumentExecError`.
|
|
97
|
+
|
|
98
|
+
Mirrors the validation and the setup rules of `run_document_cell()`
|
|
99
|
+
in the order R applies them, so the same bad envelope gets the same
|
|
100
|
+
sentence.
|
|
101
|
+
"""
|
|
102
|
+
if not isinstance(document, dict) or not isinstance(document.get("id"), str) \
|
|
103
|
+
or not document["id"]:
|
|
104
|
+
raise DocumentExecError("A notebook run needs its document identity.")
|
|
105
|
+
doc_id = document["id"]
|
|
106
|
+
setup = document.get("setup")
|
|
107
|
+
if setup is not None and (not isinstance(setup, dict)
|
|
108
|
+
or not isinstance(setup.get("source"), str)):
|
|
109
|
+
raise DocumentExecError("Invalid setup chunk descriptor.")
|
|
110
|
+
is_setup = document.get("isSetup") is True
|
|
111
|
+
retry = document.get("retrySetup") is True
|
|
112
|
+
selection = document.get("selection") is True
|
|
113
|
+
setup_key = setup["source"] if setup is not None else ""
|
|
114
|
+
previous = self._docs.get(doc_id)
|
|
115
|
+
if previous is not None and previous.source == setup_key \
|
|
116
|
+
and previous.status == "failed" and not is_setup and not retry:
|
|
117
|
+
raise DocumentExecError("Setup previously failed. Fix it or explicitly run the "
|
|
118
|
+
"setup chunk before running dependent chunks.")
|
|
119
|
+
setup_changed = previous is None or previous.source != setup_key
|
|
120
|
+
run_setup = setup is not None and (
|
|
121
|
+
setup_changed or is_setup or previous.status == "failed")
|
|
122
|
+
label = None
|
|
123
|
+
if not is_setup:
|
|
124
|
+
label = document.get("label")
|
|
125
|
+
if not isinstance(label, str) or not LABEL_RE.match(label):
|
|
126
|
+
raise DocumentExecError("Use a unique chunk label containing letters, "
|
|
127
|
+
"numbers, dots, hyphens or underscores.")
|
|
128
|
+
options = document.get("options", {})
|
|
129
|
+
expressions = document.get("expressions", {})
|
|
130
|
+
if not isinstance(options, dict) or not isinstance(expressions, dict):
|
|
131
|
+
raise DocumentExecError("Invalid chunk options.")
|
|
132
|
+
execution = {
|
|
133
|
+
"source": setup["source"] if (is_setup and setup is not None) else source,
|
|
134
|
+
"setupSource": setup["source"] if setup is not None else "",
|
|
135
|
+
"optionsKey": document.get("optionsKey"),
|
|
136
|
+
"full": not selection,
|
|
137
|
+
}
|
|
138
|
+
return Plan(doc_id=doc_id, label=label, setup_source=setup_key if setup else None,
|
|
139
|
+
setup_srcname=setup.get("srcname") if setup else None,
|
|
140
|
+
run_setup=run_setup, run_chunk=not is_setup, execution=execution)
|
|
141
|
+
|
|
142
|
+
# ---- the ledger, written by serve.py as the run proceeds ---------------
|
|
143
|
+
|
|
144
|
+
def setup_started(self, doc_id: str) -> None:
|
|
145
|
+
"""Invalidate BEFORE running: a failed partial setup must never leave
|
|
146
|
+
a previous Ready snapshot available to dependent chunks."""
|
|
147
|
+
self._docs.pop(doc_id, None)
|
|
148
|
+
|
|
149
|
+
def setup_ok(self, doc_id: str, setup_source: str) -> None:
|
|
150
|
+
self._docs[doc_id] = _Snapshot(source=setup_source, status="ok")
|
|
151
|
+
|
|
152
|
+
def setup_failed(self, doc_id: str, setup_source: str) -> None:
|
|
153
|
+
self._docs[doc_id] = _Snapshot(source=setup_source, status="failed")
|
|
154
|
+
|
|
155
|
+
def no_setup(self, doc_id: str) -> None:
|
|
156
|
+
"""A document with no setup chunk: its snapshot is the empty source,
|
|
157
|
+
so a setup chunk added later counts as a CHANGE and runs."""
|
|
158
|
+
self._docs[doc_id] = _Snapshot(source="", status="ok")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "python",
|
|
3
|
+
"label": "Python",
|
|
4
|
+
"language": "python",
|
|
5
|
+
"fences": ["python", "py"],
|
|
6
|
+
"adapter": "host/engine-python.mjs",
|
|
7
|
+
"worker": "engines/python/worker.py",
|
|
8
|
+
"boot": "on-demand",
|
|
9
|
+
"runtime": {
|
|
10
|
+
"env": "CARMAR_PYTHON",
|
|
11
|
+
"ladder": [
|
|
12
|
+
"VIRTUAL_ENV",
|
|
13
|
+
"CONDA_PREFIX",
|
|
14
|
+
"PYENV",
|
|
15
|
+
"PATH",
|
|
16
|
+
"/opt/homebrew/bin/python3",
|
|
17
|
+
"/usr/local/bin/python3",
|
|
18
|
+
"/usr/bin/python3"
|
|
19
|
+
],
|
|
20
|
+
"requires": ["ipykernel", "jupyter_client"]
|
|
21
|
+
},
|
|
22
|
+
"controls": {
|
|
23
|
+
"interrupt": "signal:SIGINT:group",
|
|
24
|
+
"console": "stdin:tagged-json:input_reply",
|
|
25
|
+
"commands": "stdin:tagged-json"
|
|
26
|
+
},
|
|
27
|
+
"note": "The engine process is worker.py; it evaluates nothing itself — an ipykernel one process further out does, and jupyter_client starts that kernel in its OWN session, which is why the host's group SIGINT reaches worker.py and worker.py forwards interrupt_request. R is spawned at boot; this engine starts the first time a {python} chunk runs."
|
|
28
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""handoff.py — share() and use() for the Python engine.
|
|
2
|
+
|
|
3
|
+
The explicit bridge between a Beatrix session's languages (engines/r/handoff.R,
|
|
4
|
+
engines/js/worker.mjs): a value crosses only when a chunk names it. The format
|
|
5
|
+
is one JSON file per name in CARMAR_HANDOFF_DIR:
|
|
6
|
+
|
|
7
|
+
{"format": "beatrix-columns-v1", "name": ..., "from": "python",
|
|
8
|
+
"columns": [...], "data": {"<column>": [values]}}
|
|
9
|
+
|
|
10
|
+
Python's json writes a float with repr(), the shortest string that reads back
|
|
11
|
+
as the same double, so numbers cross exactly. NaN, inf and None travel as null.
|
|
12
|
+
|
|
13
|
+
Installed into the kernel's namespace by bootstrap.py as SOURCE (the kernel may
|
|
14
|
+
be any Python the user chose), so it imports nothing that is not the standard
|
|
15
|
+
library; pandas is used when it is there and never required.
|
|
16
|
+
|
|
17
|
+
share(name, value) and share(value, name) are both accepted: R's natural order
|
|
18
|
+
is share(df) with the name taken from the symbol, and a language that cannot
|
|
19
|
+
see the symbol has to be told the name — whichever order the author reaches for.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json as _json
|
|
23
|
+
import math as _math
|
|
24
|
+
import os as _os
|
|
25
|
+
import re as _re
|
|
26
|
+
|
|
27
|
+
_NAME_RE = _re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _handoff_path(name):
|
|
31
|
+
folder = _os.environ.get("CARMAR_HANDOFF_DIR", "")
|
|
32
|
+
if not folder or not _os.path.isdir(folder):
|
|
33
|
+
raise RuntimeError("share()/use() need the Beatrina host's hand-off directory; "
|
|
34
|
+
"this Python was started without one.")
|
|
35
|
+
if not isinstance(name, str) or not _NAME_RE.match(name):
|
|
36
|
+
raise TypeError("A shared name is letters, digits, dots and underscores, "
|
|
37
|
+
"starting with a letter or underscore.")
|
|
38
|
+
return _os.path.join(folder, name + ".json")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _plain(v):
|
|
42
|
+
if v is None:
|
|
43
|
+
return None
|
|
44
|
+
item = getattr(v, "item", None) # numpy scalars
|
|
45
|
+
if callable(item) and not isinstance(v, (str, bytes)):
|
|
46
|
+
try:
|
|
47
|
+
v = item()
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
if isinstance(v, bool):
|
|
51
|
+
return v
|
|
52
|
+
if isinstance(v, float):
|
|
53
|
+
return v if _math.isfinite(v) else None
|
|
54
|
+
if isinstance(v, int):
|
|
55
|
+
return v
|
|
56
|
+
if isinstance(v, str):
|
|
57
|
+
return v
|
|
58
|
+
try:
|
|
59
|
+
if v != v: # pandas NA / NaT
|
|
60
|
+
return None
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
iso = getattr(v, "isoformat", None)
|
|
64
|
+
return iso() if callable(iso) else str(v)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _columns(value):
|
|
68
|
+
to_dict = getattr(value, "to_dict", None)
|
|
69
|
+
if callable(to_dict) and hasattr(value, "columns"):
|
|
70
|
+
cols = [str(c) for c in value.columns]
|
|
71
|
+
return cols, {str(c): [_plain(x) for x in value[c].tolist()] for c in value.columns}
|
|
72
|
+
if isinstance(value, dict) and value and all(isinstance(v, (list, tuple)) for v in value.values()):
|
|
73
|
+
lengths = {len(v) for v in value.values()}
|
|
74
|
+
if len(lengths) != 1:
|
|
75
|
+
raise ValueError("share(): every column must have the same length.")
|
|
76
|
+
return [str(k) for k in value], {str(k): [_plain(x) for x in v] for k, v in value.items()}
|
|
77
|
+
if isinstance(value, (list, tuple)) and all(isinstance(r, dict) for r in value):
|
|
78
|
+
cols = []
|
|
79
|
+
for r in value:
|
|
80
|
+
for k in r:
|
|
81
|
+
if str(k) not in cols:
|
|
82
|
+
cols.append(str(k))
|
|
83
|
+
return cols, {c: [_plain(r.get(c)) for r in value] for c in cols}
|
|
84
|
+
raise TypeError("share() takes a pandas DataFrame, a dict of equal-length columns, "
|
|
85
|
+
"or a list of row dicts.")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def share(a, b=None):
|
|
89
|
+
"""Share a table with the other languages of this session. Returns None."""
|
|
90
|
+
name, value = (a, b) if isinstance(a, str) else (b, a)
|
|
91
|
+
path = _handoff_path(name)
|
|
92
|
+
cols, data = _columns(value)
|
|
93
|
+
tmp = "%s.%d.tmp" % (path, _os.getpid())
|
|
94
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
95
|
+
_json.dump({"format": "beatrix-columns-v1", "name": name, "from": "python",
|
|
96
|
+
"columns": cols, "data": data}, fh, allow_nan=False)
|
|
97
|
+
_os.chmod(tmp, 0o600)
|
|
98
|
+
_os.replace(tmp, path)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def use(name):
|
|
102
|
+
"""A table another language shared: a pandas DataFrame when pandas is
|
|
103
|
+
installed, otherwise a list of row dicts."""
|
|
104
|
+
path = _handoff_path(name)
|
|
105
|
+
if not _os.path.exists(path):
|
|
106
|
+
raise LookupError('Nothing has been shared as "%s" in this session. Share it first '
|
|
107
|
+
'with share("%s", ...) here or share(%s) in R.' % (name, name, name))
|
|
108
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
109
|
+
rec = _json.load(fh)
|
|
110
|
+
if rec.get("format") != "beatrix-columns-v1":
|
|
111
|
+
raise ValueError('"%s" is not a Beatrina hand-off table.' % name)
|
|
112
|
+
cols = rec["columns"]
|
|
113
|
+
try:
|
|
114
|
+
import pandas as _pd
|
|
115
|
+
return _pd.DataFrame({c: rec["data"][c] for c in cols}, columns=cols)
|
|
116
|
+
except ImportError:
|
|
117
|
+
n = len(rec["data"][cols[0]]) if cols else 0
|
|
118
|
+
return [{c: rec["data"][c][i] for c in cols} for i in range(n)]
|