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.
Files changed (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. 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)]