jcollab 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jcollab/__init__.py +2 -0
- jcollab/discovery.py +133 -0
- jcollab/doc.py +325 -0
- jcollab/kernel.py +236 -0
- jcollab/rest.py +58 -0
- jcollab/server.py +463 -0
- jcollab-0.1.0.dist-info/METADATA +14 -0
- jcollab-0.1.0.dist-info/RECORD +10 -0
- jcollab-0.1.0.dist-info/WHEEL +4 -0
- jcollab-0.1.0.dist-info/entry_points.txt +2 -0
jcollab/__init__.py
ADDED
jcollab/discovery.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Find running Jupyter servers of the current user via Jupyter's own runtime files.
|
|
2
|
+
|
|
3
|
+
Jupyter Server writes ``jpserver-<pid>.json`` (url, port, root_dir, pid, token; mode 600) into the
|
|
4
|
+
runtime dir for every server and removes it on clean shutdown. Crashed servers leave stale files, so
|
|
5
|
+
every candidate is checked for a live pid and probed on ``/api/status``.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from jupyter_core.paths import jupyter_runtime_dir
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Instance:
|
|
21
|
+
url: str # always ends with "/" (includes base_url)
|
|
22
|
+
token: str
|
|
23
|
+
root_dir: str
|
|
24
|
+
pid: int
|
|
25
|
+
port: int
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def name(self) -> str:
|
|
29
|
+
return f"{Path(self.root_dir).name or self.root_dir}:{self.port}"
|
|
30
|
+
|
|
31
|
+
def public(self) -> dict:
|
|
32
|
+
return {"name": self.name, "url": self.url, "root_dir": self.root_dir, "pid": self.pid, "port": self.port,
|
|
33
|
+
"token_protected": bool(self.token)}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _pid_alive(pid: int) -> bool:
|
|
37
|
+
try:
|
|
38
|
+
os.kill(pid, 0)
|
|
39
|
+
except ProcessLookupError:
|
|
40
|
+
return False
|
|
41
|
+
except PermissionError:
|
|
42
|
+
return True
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _candidates() -> list[Instance]:
|
|
47
|
+
out = []
|
|
48
|
+
for f in sorted(Path(jupyter_runtime_dir()).glob("jpserver-*.json")):
|
|
49
|
+
try:
|
|
50
|
+
d = json.loads(f.read_text())
|
|
51
|
+
if not _pid_alive(int(d["pid"])):
|
|
52
|
+
continue
|
|
53
|
+
url = d["url"] if d["url"].endswith("/") else d["url"] + "/"
|
|
54
|
+
out.append(Instance(url=url, token=d.get("token") or "", root_dir=d.get("root_dir") or d.get("notebook_dir") or "",
|
|
55
|
+
pid=int(d["pid"]), port=int(d["port"])))
|
|
56
|
+
except (OSError, ValueError, KeyError):
|
|
57
|
+
continue
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _probe(inst: Instance) -> bool:
|
|
62
|
+
"""Reach the server on loopback first: the advertised hostname (e.g. 0.0.0.0 or the host name) may not resolve."""
|
|
63
|
+
try:
|
|
64
|
+
async with httpx.AsyncClient(timeout=2) as c:
|
|
65
|
+
r = await c.get(local_url(inst) + "api/status", headers=auth_headers(inst))
|
|
66
|
+
return r.status_code == 200
|
|
67
|
+
except httpx.HTTPError:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def local_url(inst: Instance) -> str:
|
|
72
|
+
"""URL on 127.0.0.1 with the instance's base_url path (servers bound to 0.0.0.0 or a host name are still reachable here)."""
|
|
73
|
+
from urllib.parse import urlparse
|
|
74
|
+
p = urlparse(inst.url)
|
|
75
|
+
return f"http://127.0.0.1:{inst.port}{p.path}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def auth_headers(inst: Instance) -> dict:
|
|
79
|
+
return {"Authorization": f"token {inst.token}"} if inst.token else {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def discover() -> list[Instance]:
|
|
83
|
+
cands = _candidates()
|
|
84
|
+
ok = await asyncio.gather(*[_probe(i) for i in cands])
|
|
85
|
+
live = [i for i, good in zip(cands, ok) if good]
|
|
86
|
+
# A stale file can share a port with a live server only if the pid is alive but is a different process; keep the
|
|
87
|
+
# entry that answered, drop duplicates by port.
|
|
88
|
+
seen, out = set(), []
|
|
89
|
+
for i in live:
|
|
90
|
+
if i.port not in seen:
|
|
91
|
+
seen.add(i.port)
|
|
92
|
+
out.append(i)
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def resolve(selector: str | int | None) -> Instance:
|
|
97
|
+
"""Pick an instance by port, name (``Trading:8889``), root_dir basename or substring. Optional if exactly one is running."""
|
|
98
|
+
live = await discover()
|
|
99
|
+
if not live:
|
|
100
|
+
raise RuntimeError("No running Jupyter server found (runtime dir has no live jpserver-*.json).")
|
|
101
|
+
if selector in (None, ""):
|
|
102
|
+
if len(live) == 1:
|
|
103
|
+
return live[0]
|
|
104
|
+
raise ValueError("Several Jupyter servers are running, pass `instance`: " + ", ".join(i.name for i in live))
|
|
105
|
+
s = str(selector)
|
|
106
|
+
exact = [i for i in live if s in (i.name, str(i.port), i.root_dir, Path(i.root_dir).name)]
|
|
107
|
+
hits = exact or [i for i in live if s.lower() in i.name.lower() or s in i.root_dir]
|
|
108
|
+
if len(hits) == 1:
|
|
109
|
+
return hits[0]
|
|
110
|
+
raise ValueError(f"Instance {s!r} " + ("is ambiguous: " if hits else "not found. Running: ") + ", ".join(i.name for i in (hits or live)))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_VERSIONS: dict[int, dict] = {}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def server_versions(inst: Instance) -> dict | None:
|
|
117
|
+
"""jupyter-ydoc / pycrdt versions of the Jupyter server process (same machine only), via its own interpreter."""
|
|
118
|
+
import subprocess
|
|
119
|
+
if inst.pid in _VERSIONS:
|
|
120
|
+
return _VERSIONS[inst.pid]
|
|
121
|
+
try:
|
|
122
|
+
py = os.readlink(f"/proc/{inst.pid}/exe")
|
|
123
|
+
out = subprocess.run([py, "-c", "import importlib.metadata as m;print(m.version('jupyter-ydoc'),m.version('pycrdt'))"],
|
|
124
|
+
capture_output=True, text=True, timeout=15).stdout.split()
|
|
125
|
+
_VERSIONS[inst.pid] = {"jupyter-ydoc": out[0], "pycrdt": out[1], "python": py}
|
|
126
|
+
except (OSError, IndexError, subprocess.SubprocessError):
|
|
127
|
+
return None
|
|
128
|
+
return _VERSIONS[inst.pid]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def client_versions() -> dict:
|
|
132
|
+
import importlib.metadata as m
|
|
133
|
+
return {"jupyter-ydoc": m.version("jupyter-ydoc"), "pycrdt": m.version("pycrdt")}
|
jcollab/doc.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Live notebook document: one Yjs connection per (server, notebook), with health checks and verified writes.
|
|
2
|
+
|
|
3
|
+
Why the care: a Yjs peer can silently stop being a peer (websocket closed, session expired, document room recreated)
|
|
4
|
+
while its in-memory copy keeps accepting edits that nobody ever sees. ``DocSession.ensure`` therefore re-checks the
|
|
5
|
+
connection before every operation, and ``DocSession.persisted`` confirms that an edit reached the file on the server.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import copy
|
|
11
|
+
import difflib
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from jupyter_nbmodel_client import NbModelClient, get_jupyter_notebook_websocket_url
|
|
16
|
+
from pycrdt import Assoc, YMessageType, read_message
|
|
17
|
+
|
|
18
|
+
from .discovery import client_versions, server_versions
|
|
19
|
+
from .rest import Rest
|
|
20
|
+
|
|
21
|
+
CLIENT_NAME = "metis"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DocError(RuntimeError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def set_text(ytext, new: str) -> None:
|
|
29
|
+
"""Replace a Yjs text with ``new`` using minimal edits, so open editors keep cursor and scroll position."""
|
|
30
|
+
old = str(ytext)
|
|
31
|
+
if old == new:
|
|
32
|
+
return
|
|
33
|
+
if len(old) + len(new) > 20000:
|
|
34
|
+
ytext.clear()
|
|
35
|
+
ytext += new
|
|
36
|
+
return
|
|
37
|
+
ops = difflib.SequenceMatcher(None, old, new, autojunk=False).get_opcodes()
|
|
38
|
+
for tag, i1, i2, j1, j2 in reversed(ops):
|
|
39
|
+
if tag == "equal":
|
|
40
|
+
continue
|
|
41
|
+
if i2 > i1:
|
|
42
|
+
del ytext[i1:i2]
|
|
43
|
+
if j2 > j1:
|
|
44
|
+
ytext.insert(i1, new[j1:j2])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def youtput(o: dict):
|
|
48
|
+
"""One nbformat output as the Yjs value JupyterLab expects (stream text is a shared Text)."""
|
|
49
|
+
from pycrdt import Map, Text
|
|
50
|
+
o = copy.deepcopy(o)
|
|
51
|
+
if o.get("output_type") == "stream":
|
|
52
|
+
t = o.get("text", "")
|
|
53
|
+
o["text"] = Text(t if isinstance(t, str) else "".join(t))
|
|
54
|
+
return Map(o)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def set_outputs(yc, outputs: list[dict]) -> None:
|
|
58
|
+
from pycrdt import Array
|
|
59
|
+
if not isinstance(yc.get("outputs"), Array):
|
|
60
|
+
yc["outputs"] = Array()
|
|
61
|
+
youts = yc["outputs"]
|
|
62
|
+
youts.clear()
|
|
63
|
+
youts.extend([youtput(o) for o in outputs])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LiveClient(NbModelClient):
|
|
67
|
+
"""NbModelClient that also applies incoming awareness messages (the stock client drops them), so the
|
|
68
|
+
cursors/selections JupyterLab tabs publish are visible."""
|
|
69
|
+
|
|
70
|
+
async def _on_message(self, websocket, message: bytes) -> None:
|
|
71
|
+
if message and message[0] == YMessageType.AWARENESS:
|
|
72
|
+
try:
|
|
73
|
+
self._doc.awareness.apply_awareness_update(read_message(message[1:]), "remote")
|
|
74
|
+
except Exception: # noqa: BLE001 - awareness is advisory, never break syncing over it
|
|
75
|
+
pass
|
|
76
|
+
return
|
|
77
|
+
await super()._on_message(websocket, message)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class DocSession:
|
|
81
|
+
def __init__(self, rest: Rest, path: str):
|
|
82
|
+
self.rest = rest
|
|
83
|
+
self.path = path
|
|
84
|
+
self._nb: NbModelClient | None = None
|
|
85
|
+
self.lock = asyncio.Lock()
|
|
86
|
+
self.reconnects = 0
|
|
87
|
+
self._pointer: asyncio.Task | None = None
|
|
88
|
+
|
|
89
|
+
# -- connection ------------------------------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _running(nb: NbModelClient) -> bool:
|
|
93
|
+
task = getattr(nb, "_NbModelClient__run", None)
|
|
94
|
+
return task is not None and not task.done()
|
|
95
|
+
|
|
96
|
+
async def ensure(self) -> NbModelClient:
|
|
97
|
+
nb = self._nb
|
|
98
|
+
if nb is not None and nb.synced and self._running(nb):
|
|
99
|
+
return nb
|
|
100
|
+
await self.close()
|
|
101
|
+
hdr = await self.rest.xsrf_headers()
|
|
102
|
+
token = self.rest.inst.token or None
|
|
103
|
+
url = await asyncio.to_thread(get_jupyter_notebook_websocket_url, self.rest.base.rstrip("/"), self.path, token,
|
|
104
|
+
10.0, None, hdr or None)
|
|
105
|
+
nb = LiveClient(url, path=self.path, username=CLIENT_NAME, additional_headers=hdr or None)
|
|
106
|
+
await nb.start()
|
|
107
|
+
if not nb.synced:
|
|
108
|
+
await nb.stop()
|
|
109
|
+
raise DocError(f"Could not sync the shared document for {self.path} (is jupyter-collaboration installed and enabled?)")
|
|
110
|
+
self._nb = nb
|
|
111
|
+
self.reconnects += 1
|
|
112
|
+
return nb
|
|
113
|
+
|
|
114
|
+
async def close(self) -> None:
|
|
115
|
+
nb, self._nb = self._nb, None
|
|
116
|
+
if nb is not None:
|
|
117
|
+
try:
|
|
118
|
+
await nb.stop()
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
async def doc(self):
|
|
123
|
+
"""The ``jupyter_ydoc.YNotebook`` behind the live connection."""
|
|
124
|
+
return (await self.ensure())._doc
|
|
125
|
+
|
|
126
|
+
# -- addressing ------------------------------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
async def index(self, ref: int | str) -> int:
|
|
129
|
+
"""Resolve a cell reference: index (negative counts from the end), digit string, or cell id."""
|
|
130
|
+
doc = await self.doc()
|
|
131
|
+
n = doc.cell_number
|
|
132
|
+
if isinstance(ref, str): # an exact id wins, also when it looks like a number
|
|
133
|
+
for i in range(n):
|
|
134
|
+
if doc.ycells[i].get("id") == ref:
|
|
135
|
+
return i
|
|
136
|
+
if not ref.lstrip("-").isdigit():
|
|
137
|
+
raise DocError(f"No cell with id {ref!r} in {self.path}")
|
|
138
|
+
i = int(ref)
|
|
139
|
+
if i < 0:
|
|
140
|
+
i += n
|
|
141
|
+
if not 0 <= i < n:
|
|
142
|
+
raise DocError(f"Cell index {ref} out of range (notebook has {n} cells)")
|
|
143
|
+
return i
|
|
144
|
+
|
|
145
|
+
async def ycell(self, ref: int | str):
|
|
146
|
+
return (await self.doc()).ycells[await self.index(ref)]
|
|
147
|
+
|
|
148
|
+
# -- reading ---------------------------------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
async def cell(self, ref: int | str, outputs: bool = False) -> dict[str, Any]:
|
|
151
|
+
doc = await self.doc()
|
|
152
|
+
i = await self.index(ref)
|
|
153
|
+
return self.describe(doc.ycells[i], i, outputs)
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def describe(yc, i: int, outputs: bool = False) -> dict[str, Any]:
|
|
157
|
+
d = {"index": i, "id": yc.get("id"), "cell_type": yc.get("cell_type"), "source": str(yc["source"]),
|
|
158
|
+
"metadata": yc["metadata"].to_py() if yc.get("metadata") is not None else {}}
|
|
159
|
+
if d["cell_type"] == "code":
|
|
160
|
+
ec = yc.get("execution_count")
|
|
161
|
+
d["execution_count"] = int(ec) if ec is not None else None
|
|
162
|
+
d["execution_state"] = yc.get("execution_state")
|
|
163
|
+
outs = yc.get("outputs")
|
|
164
|
+
d["n_outputs"] = len(outs) if outs is not None else 0
|
|
165
|
+
if outputs:
|
|
166
|
+
d["outputs"] = outs.to_py() if outs is not None else []
|
|
167
|
+
return d
|
|
168
|
+
|
|
169
|
+
async def cells(self, outputs: bool = False) -> list[dict[str, Any]]:
|
|
170
|
+
doc = await self.doc()
|
|
171
|
+
return [self.describe(doc.ycells[i], i, outputs) for i in range(doc.cell_number)]
|
|
172
|
+
|
|
173
|
+
# -- verification ----------------------------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
async def persisted(self, timeout: float = 4.0) -> tuple[bool, str]:
|
|
176
|
+
"""Wait until the file on the server shows the same cell sources as the live document."""
|
|
177
|
+
doc = await self.doc()
|
|
178
|
+
ids = [doc.ycells[i].get("id") for i in range(doc.cell_number)]
|
|
179
|
+
if len(set(ids)) != len(ids):
|
|
180
|
+
return False, ("live document has duplicate cell ids: client/server Yjs version skew. "
|
|
181
|
+
+ self.skew_hint())
|
|
182
|
+
want = [str(doc.ycells[i]["source"]) for i in range(doc.cell_number)]
|
|
183
|
+
deadline = asyncio.get_running_loop().time() + timeout
|
|
184
|
+
have: list[str] = []
|
|
185
|
+
while True:
|
|
186
|
+
try:
|
|
187
|
+
nb = (await self.rest.contents(self.path))["content"]
|
|
188
|
+
have = ["".join(c["source"]) if isinstance(c["source"], list) else c["source"] for c in nb["cells"]]
|
|
189
|
+
except Exception as e: # noqa: BLE001 - report, do not hide
|
|
190
|
+
return False, f"could not read file back: {e}"
|
|
191
|
+
if have == want:
|
|
192
|
+
return True, "ok"
|
|
193
|
+
if asyncio.get_running_loop().time() > deadline:
|
|
194
|
+
diff = [i for i in range(max(len(have), len(want)))
|
|
195
|
+
if i >= len(have) or i >= len(want) or have[i] != want[i]][:5]
|
|
196
|
+
ids = [str(doc.ycells[i].get("id")) for i in range(doc.cell_number)]
|
|
197
|
+
return False, f"file on server differs from the live document (cells {diff}, file has {len(have)} cells, live has {len(want)}: ids {ids})"
|
|
198
|
+
await asyncio.sleep(0.25)
|
|
199
|
+
|
|
200
|
+
async def point_at(self, ref: int | str, seconds: float = 30.0, select: bool = True, scroll: bool = True,
|
|
201
|
+
force_scroll: bool = False, label: str | None = None) -> dict[str, Any]:
|
|
202
|
+
"""Show a labelled "Metis" cursor/selection in a cell for a while (visible in every open tab, and reachable via
|
|
203
|
+
the Collaborators sidebar of JupyterLab)."""
|
|
204
|
+
nb = await self.ensure()
|
|
205
|
+
doc = nb._doc
|
|
206
|
+
i = await self.index(ref)
|
|
207
|
+
t = doc.ycells[i]["source"]
|
|
208
|
+
n = len(t)
|
|
209
|
+
if n == 0:
|
|
210
|
+
raise DocError(f"Cell {i} is empty, nothing to point at (put some text in it first)")
|
|
211
|
+
anchor = t.sticky_index(0, Assoc.AFTER).to_json()
|
|
212
|
+
head = t.sticky_index(n - 1 if select else 0, Assoc.AFTER).to_json()
|
|
213
|
+
nb.set_local_state_field("user", {"username": "metis", "name": "Metis", "display_name": "Metis", "initials": "M",
|
|
214
|
+
"avatar_url": None, "color": "#d97757"})
|
|
215
|
+
nb.set_local_state_field("cursors", [{"anchor": anchor, "head": head, "primary": True, "empty": not (select and n > 1)}])
|
|
216
|
+
if scroll:
|
|
217
|
+
nb.set_local_state_field("scrollTo", {"cell": doc.ycells[i].get("id"), "nonce": str(time.time_ns()), "force": force_scroll, "label": label})
|
|
218
|
+
if self._pointer is not None:
|
|
219
|
+
self._pointer.cancel()
|
|
220
|
+
|
|
221
|
+
async def clear():
|
|
222
|
+
await asyncio.sleep(seconds)
|
|
223
|
+
try:
|
|
224
|
+
nb.set_local_state_field("cursors", [])
|
|
225
|
+
nb.set_local_state_field("scrollTo", None)
|
|
226
|
+
except Exception: # noqa: BLE001
|
|
227
|
+
pass
|
|
228
|
+
self._pointer = asyncio.create_task(clear())
|
|
229
|
+
return {"pointing_at": i, "id": doc.ycells[i].get("id"), "seconds": seconds}
|
|
230
|
+
|
|
231
|
+
async def user_focus(self) -> list[dict[str, Any]]:
|
|
232
|
+
"""Where other people (browser tabs) have their cursor/selection: cell, line, selected text.
|
|
233
|
+
|
|
234
|
+
JupyterLab publishes cursors through Yjs awareness as relative positions naming a character item. The offset
|
|
235
|
+
is resolved with pycrdt; the cell is the one whose text holds the same item at that offset."""
|
|
236
|
+
from pycrdt import StickyIndex
|
|
237
|
+
nb = await self.ensure()
|
|
238
|
+
doc, aw = nb._doc, nb._doc.awareness
|
|
239
|
+
out: list[dict[str, Any]] = []
|
|
240
|
+
for cid, st in list(aw.states.items()):
|
|
241
|
+
if cid == aw.client_id or not st.get("cursors"):
|
|
242
|
+
continue
|
|
243
|
+
user = st.get("user", {})
|
|
244
|
+
who = user.get("display_name") or user.get("name") or user.get("owner") or str(cid)
|
|
245
|
+
for cur in st["cursors"]:
|
|
246
|
+
try:
|
|
247
|
+
a = StickyIndex.from_json(cur["anchor"]).get_index()
|
|
248
|
+
h = StickyIndex.from_json(cur["head"]).get_index()
|
|
249
|
+
except Exception: # noqa: BLE001
|
|
250
|
+
continue
|
|
251
|
+
item = cur["anchor"].get("item")
|
|
252
|
+
cands = []
|
|
253
|
+
for i in range(doc.cell_number):
|
|
254
|
+
t = doc.ycells[i]["source"]
|
|
255
|
+
if a is None or len(t) < a:
|
|
256
|
+
continue
|
|
257
|
+
if item is None:
|
|
258
|
+
cands.append(i) # cursor at the very end/start: no item to match
|
|
259
|
+
continue
|
|
260
|
+
if a >= len(t):
|
|
261
|
+
continue
|
|
262
|
+
try:
|
|
263
|
+
if t.sticky_index(a, Assoc(cur["anchor"].get("assoc", 0))).to_json().get("item") == item:
|
|
264
|
+
cands.append(i)
|
|
265
|
+
except BaseException: # noqa: BLE001 - pycrdt panics on some positions
|
|
266
|
+
continue
|
|
267
|
+
if not cands:
|
|
268
|
+
continue
|
|
269
|
+
i = cands[0]
|
|
270
|
+
src = str(doc.ycells[i]["source"])
|
|
271
|
+
lo, hi = sorted((a, h if h is not None else a))
|
|
272
|
+
out.append({"user": who, "cell_index": i, "cell_id": doc.ycells[i].get("id"),
|
|
273
|
+
"cell_type": doc.ycells[i].get("cell_type"),
|
|
274
|
+
"line": src.count("\n", 0, lo), "column": lo - (src.rfind("\n", 0, lo) + 1),
|
|
275
|
+
"selection": src[lo:hi] if hi > lo else None,
|
|
276
|
+
**({"ambiguous_cells": cands} if len(cands) > 1 else {})})
|
|
277
|
+
return out
|
|
278
|
+
|
|
279
|
+
def skew_hint(self) -> str:
|
|
280
|
+
srv, cli = server_versions(self.rest.inst), client_versions()
|
|
281
|
+
if srv is None:
|
|
282
|
+
return f"Client has {cli}; install the same jupyter-ydoc and pycrdt versions as the Jupyter server."
|
|
283
|
+
fix = f"{self.rest.inst.name} runs jupyter-ydoc {srv['jupyter-ydoc']}, pycrdt {srv['pycrdt']}; client has {cli}."
|
|
284
|
+
return fix + f" Fix: pip install jupyter-ydoc=={srv['jupyter-ydoc']} pycrdt=={srv['pycrdt']} in the jcollab venv."
|
|
285
|
+
|
|
286
|
+
async def health(self) -> dict[str, Any]:
|
|
287
|
+
nb = await self.ensure()
|
|
288
|
+
peers = []
|
|
289
|
+
try:
|
|
290
|
+
for cid in nb.get_connected_peers():
|
|
291
|
+
u = (nb.get_peer_state(cid) or {}).get("user", {})
|
|
292
|
+
peers.append(u.get("display_name") or u.get("agent") or u.get("owner") or str(cid))
|
|
293
|
+
except Exception: # noqa: BLE001
|
|
294
|
+
pass
|
|
295
|
+
viewers = None
|
|
296
|
+
sess = await self.rest.session_for(self.path)
|
|
297
|
+
if sess: # awareness peers are not reliably reported; open frontends show up as kernel websocket connections
|
|
298
|
+
viewers = (await self.rest.request("GET", f"api/kernels/{sess['kernel']['id']}")).json().get("connections")
|
|
299
|
+
ok, detail = await self.persisted()
|
|
300
|
+
srv, cli = server_versions(self.rest.inst), client_versions()
|
|
301
|
+
return {"yjs_versions": {"client": cli, "server": srv and {k: srv[k] for k in ("jupyter-ydoc", "pycrdt")},
|
|
302
|
+
"match": bool(srv) and all(srv[k] == cli[k] for k in cli)},
|
|
303
|
+
"synced": nb.synced, "connections_made": self.reconnects, "awareness_peers": peers,
|
|
304
|
+
"frontends_attached_to_kernel": viewers, "persisted": ok, "detail": detail}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class Sessions:
|
|
308
|
+
"""Cache of DocSession per (instance, notebook path)."""
|
|
309
|
+
|
|
310
|
+
def __init__(self):
|
|
311
|
+
self._rest: dict[int, Rest] = {}
|
|
312
|
+
self._docs: dict[tuple[int, str], DocSession] = {}
|
|
313
|
+
|
|
314
|
+
def rest(self, inst) -> Rest:
|
|
315
|
+
r = self._rest.get(inst.port)
|
|
316
|
+
if r is None or r.inst != inst:
|
|
317
|
+
r = self._rest[inst.port] = Rest(inst)
|
|
318
|
+
return r
|
|
319
|
+
|
|
320
|
+
def doc(self, inst, path: str) -> DocSession:
|
|
321
|
+
key = (inst.port, path)
|
|
322
|
+
d = self._docs.get(key)
|
|
323
|
+
if d is None or d.rest.inst != inst:
|
|
324
|
+
d = self._docs[key] = DocSession(self.rest(inst), path)
|
|
325
|
+
return d
|
jcollab/kernel.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Kernel execution that leaves cells exactly as if the user had run them in JupyterLab.
|
|
2
|
+
|
|
3
|
+
A cell run by the user shows: ``[*]`` while busy (shared cell field ``execution_state``), outputs streaming in, the
|
|
4
|
+
kernel's own ``execution_count``, and ``metadata.execution`` with the iopub/shell timestamps that
|
|
5
|
+
jupyterlab-execute-time displays. ``run_cell`` writes all of that into the shared document while the kernel runs.
|
|
6
|
+
|
|
7
|
+
Code that is only inspected (``run_code``) uses ``store_history=False`` so it never consumes an execution count.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import websockets
|
|
18
|
+
|
|
19
|
+
from .doc import DocError, DocSession, set_outputs, youtput
|
|
20
|
+
from .discovery import auth_headers
|
|
21
|
+
from .rest import Rest
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class KernelError(RuntimeError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _ws_url(rest: Rest, kernel_id: str) -> str:
|
|
29
|
+
base = rest.base.replace("http://", "ws://", 1)
|
|
30
|
+
return f"{base}api/kernels/{kernel_id}/channels"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _request(code: str, store_history: bool, session: str) -> tuple[str, dict]:
|
|
34
|
+
msg_id = uuid.uuid4().hex
|
|
35
|
+
return msg_id, {
|
|
36
|
+
"header": {"msg_id": msg_id, "msg_type": "execute_request", "username": "metis", "session": session,
|
|
37
|
+
"version": "5.3", "date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())},
|
|
38
|
+
"parent_header": {}, "metadata": {}, "channel": "shell", "buffers": [],
|
|
39
|
+
"content": {"code": code, "silent": False, "store_history": store_history, "user_expressions": {},
|
|
40
|
+
"allow_stdin": False, "stop_on_error": True},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _nb_output(mt: str, ct: dict) -> dict | None:
|
|
45
|
+
if mt == "stream":
|
|
46
|
+
return {"output_type": "stream", "name": ct.get("name", "stdout"), "text": ct.get("text", "")}
|
|
47
|
+
if mt == "display_data":
|
|
48
|
+
return {"output_type": "display_data", "data": ct.get("data", {}), "metadata": ct.get("metadata", {})}
|
|
49
|
+
if mt == "execute_result":
|
|
50
|
+
return {"output_type": "execute_result", "data": ct.get("data", {}), "metadata": ct.get("metadata", {}),
|
|
51
|
+
"execution_count": ct.get("execution_count")}
|
|
52
|
+
if mt == "error":
|
|
53
|
+
return {"output_type": "error", "ename": ct.get("ename", ""), "evalue": ct.get("evalue", ""),
|
|
54
|
+
"traceback": ct.get("traceback", [])}
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _Sink:
|
|
59
|
+
"""Receives kernel messages for one execute_request; optionally mirrors them into a shared-document cell."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, doc=None, ycell=None):
|
|
62
|
+
self.doc, self.yc = doc, ycell
|
|
63
|
+
self.outputs: list[dict] = []
|
|
64
|
+
self.timing: dict[str, str] = {}
|
|
65
|
+
self.count: int | None = None
|
|
66
|
+
self.status = "unknown"
|
|
67
|
+
self.display_ids: dict[str, int] = {}
|
|
68
|
+
self._clear_next = False
|
|
69
|
+
|
|
70
|
+
# ydoc helpers -----------------------------------------------------------------------------------------------
|
|
71
|
+
def _youts(self):
|
|
72
|
+
return self.yc["outputs"]
|
|
73
|
+
|
|
74
|
+
def _push_timing(self):
|
|
75
|
+
if self.yc is not None:
|
|
76
|
+
with self.doc._ydoc.transaction():
|
|
77
|
+
self.yc["metadata"]["execution"] = dict(self.timing)
|
|
78
|
+
|
|
79
|
+
def _add(self, out: dict):
|
|
80
|
+
if self._clear_next:
|
|
81
|
+
self._clear_next = False
|
|
82
|
+
self.outputs.clear()
|
|
83
|
+
if self.yc is not None:
|
|
84
|
+
with self.doc._ydoc.transaction():
|
|
85
|
+
self._youts().clear()
|
|
86
|
+
last = self.outputs[-1] if self.outputs else None
|
|
87
|
+
merged = out["output_type"] == "stream" and last and last["output_type"] == "stream" and last["name"] == out["name"]
|
|
88
|
+
if merged:
|
|
89
|
+
last["text"] += out["text"]
|
|
90
|
+
else:
|
|
91
|
+
self.outputs.append(out)
|
|
92
|
+
if self.yc is None:
|
|
93
|
+
return
|
|
94
|
+
with self.doc._ydoc.transaction():
|
|
95
|
+
youts = self._youts()
|
|
96
|
+
if merged:
|
|
97
|
+
youts[len(youts) - 1]["text"] += out["text"]
|
|
98
|
+
else:
|
|
99
|
+
youts.append(youtput(out))
|
|
100
|
+
|
|
101
|
+
def _replace(self, idx: int, out: dict):
|
|
102
|
+
self.outputs[idx] = out
|
|
103
|
+
if self.yc is not None:
|
|
104
|
+
with self.doc._ydoc.transaction():
|
|
105
|
+
self._youts()[idx] = youtput(out)
|
|
106
|
+
|
|
107
|
+
def clear(self):
|
|
108
|
+
self.outputs.clear()
|
|
109
|
+
if self.yc is not None:
|
|
110
|
+
with self.doc._ydoc.transaction():
|
|
111
|
+
self._youts().clear()
|
|
112
|
+
|
|
113
|
+
# message handling -------------------------------------------------------------------------------------------
|
|
114
|
+
def on_iopub(self, msg: dict):
|
|
115
|
+
mt, ct = msg["msg_type"], msg.get("content", {})
|
|
116
|
+
date = msg.get("header", {}).get("date")
|
|
117
|
+
if mt == "execute_input":
|
|
118
|
+
self.count = ct.get("execution_count")
|
|
119
|
+
if date:
|
|
120
|
+
self.timing["iopub.execute_input"] = date
|
|
121
|
+
self._push_timing()
|
|
122
|
+
elif mt == "status":
|
|
123
|
+
state = ct.get("execution_state")
|
|
124
|
+
if state == "busy" and date and "iopub.status.busy" not in self.timing:
|
|
125
|
+
self.timing["iopub.status.busy"] = date
|
|
126
|
+
self._push_timing()
|
|
127
|
+
elif state == "idle":
|
|
128
|
+
if date:
|
|
129
|
+
self.timing["iopub.status.idle"] = date
|
|
130
|
+
self.status = "idle"
|
|
131
|
+
elif mt == "clear_output":
|
|
132
|
+
if ct.get("wait"):
|
|
133
|
+
self._clear_next = True
|
|
134
|
+
else:
|
|
135
|
+
self.clear()
|
|
136
|
+
elif mt == "update_display_data":
|
|
137
|
+
did = ct.get("transient", {}).get("display_id")
|
|
138
|
+
if did in self.display_ids:
|
|
139
|
+
self._replace(self.display_ids[did], {"output_type": "display_data", "data": ct.get("data", {}),
|
|
140
|
+
"metadata": ct.get("metadata", {})})
|
|
141
|
+
else:
|
|
142
|
+
out = _nb_output(mt, ct)
|
|
143
|
+
if out:
|
|
144
|
+
if mt == "execute_result":
|
|
145
|
+
self.count = ct.get("execution_count", self.count)
|
|
146
|
+
did = ct.get("transient", {}).get("display_id")
|
|
147
|
+
self._add(out)
|
|
148
|
+
if did:
|
|
149
|
+
self.display_ids[did] = len(self.outputs) - 1
|
|
150
|
+
|
|
151
|
+
def on_reply(self, msg: dict):
|
|
152
|
+
date = msg.get("header", {}).get("date")
|
|
153
|
+
if date:
|
|
154
|
+
self.timing["shell.execute_reply"] = date
|
|
155
|
+
started = msg.get("metadata", {}).get("started")
|
|
156
|
+
if started:
|
|
157
|
+
self.timing["shell.execute_reply.started"] = started
|
|
158
|
+
ct = msg.get("content", {})
|
|
159
|
+
self.reply_status = ct.get("status")
|
|
160
|
+
if ct.get("execution_count") is not None:
|
|
161
|
+
self.count = ct["execution_count"]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def _execute(rest: Rest, kernel_id: str, code: str, sink: _Sink, store_history: bool, timeout: float) -> bool:
|
|
165
|
+
"""Run code, feed messages into ``sink``. Returns False if the timeout hit (the kernel is interrupted then)."""
|
|
166
|
+
msg_id, req = _request(code, store_history, uuid.uuid4().hex)
|
|
167
|
+
sink.reply_status = None
|
|
168
|
+
async with websockets.connect(_ws_url(rest, kernel_id), additional_headers=auth_headers(rest.inst) or None,
|
|
169
|
+
max_size=64 * 1024 * 1024) as ws:
|
|
170
|
+
await ws.send(json.dumps(req))
|
|
171
|
+
deadline = time.monotonic() + timeout
|
|
172
|
+
got_reply = False
|
|
173
|
+
while not (got_reply and sink.status == "idle"):
|
|
174
|
+
left = deadline - time.monotonic()
|
|
175
|
+
if left <= 0:
|
|
176
|
+
await rest.kernel_action(kernel_id, "interrupt")
|
|
177
|
+
return False
|
|
178
|
+
try:
|
|
179
|
+
msg = json.loads(await asyncio.wait_for(ws.recv(), min(left, 5)))
|
|
180
|
+
except asyncio.TimeoutError:
|
|
181
|
+
continue
|
|
182
|
+
if msg.get("parent_header", {}).get("msg_id") != msg_id:
|
|
183
|
+
continue
|
|
184
|
+
if msg.get("channel") == "shell" or msg["msg_type"] == "execute_reply":
|
|
185
|
+
sink.on_reply(msg)
|
|
186
|
+
got_reply = True
|
|
187
|
+
else:
|
|
188
|
+
sink.on_iopub(msg)
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def kernel_for(rest: Rest, ds_path: str, doc=None) -> str:
|
|
193
|
+
name = "python3"
|
|
194
|
+
if doc is not None:
|
|
195
|
+
try:
|
|
196
|
+
name = doc._ymeta["metadata"].to_py().get("kernelspec", {}).get("name") or name
|
|
197
|
+
except Exception: # noqa: BLE001
|
|
198
|
+
pass
|
|
199
|
+
sess = await rest.ensure_session(ds_path, name)
|
|
200
|
+
return sess["kernel"]["id"]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def run_cell(ds: DocSession, ref: int | str, timeout: float = 300) -> dict[str, Any]:
|
|
204
|
+
doc = await ds.doc()
|
|
205
|
+
i = await ds.index(ref)
|
|
206
|
+
yc = doc.ycells[i]
|
|
207
|
+
if yc.get("cell_type") != "code":
|
|
208
|
+
raise DocError(f"Cell {i} is a {yc.get('cell_type')} cell, only code cells can run")
|
|
209
|
+
code = str(yc["source"])
|
|
210
|
+
kid = await kernel_for(ds.rest, ds.path, doc)
|
|
211
|
+
sink = _Sink(doc, yc)
|
|
212
|
+
t0 = time.monotonic()
|
|
213
|
+
with doc._ydoc.transaction(): # same state a user click produces: [*], no outputs, no old timing
|
|
214
|
+
yc["execution_state"] = "running"
|
|
215
|
+
yc["execution_count"] = None
|
|
216
|
+
set_outputs(yc, [])
|
|
217
|
+
if "execution" in yc["metadata"]:
|
|
218
|
+
del yc["metadata"]["execution"]
|
|
219
|
+
finished = False
|
|
220
|
+
try:
|
|
221
|
+
finished = await _execute(ds.rest, kid, code, sink, True, timeout)
|
|
222
|
+
finally:
|
|
223
|
+
with doc._ydoc.transaction():
|
|
224
|
+
yc["execution_count"] = sink.count
|
|
225
|
+
yc["execution_state"] = "idle"
|
|
226
|
+
sink._push_timing()
|
|
227
|
+
return {"index": i, "id": yc.get("id"), "execution_count": int(sink.count) if sink.count is not None else None, "status": sink.reply_status,
|
|
228
|
+
"timed_out": not finished, "seconds": round(time.monotonic() - t0, 2), "outputs": sink.outputs}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def run_code(rest: Rest, path: str, code: str, timeout: float = 60, doc=None) -> dict[str, Any]:
|
|
232
|
+
"""Inspection run: not stored in kernel history, no execution count consumed, nothing written to the notebook."""
|
|
233
|
+
kid = await kernel_for(rest, path, doc)
|
|
234
|
+
sink = _Sink()
|
|
235
|
+
finished = await _execute(rest, kid, code, sink, False, timeout)
|
|
236
|
+
return {"status": sink.reply_status, "timed_out": not finished, "outputs": sink.outputs}
|
jcollab/rest.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Async REST access to one Jupyter server (contents, sessions, kernels), XSRF-safe for token-less servers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from .discovery import Instance, auth_headers, local_url
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Rest:
|
|
10
|
+
def __init__(self, inst: Instance):
|
|
11
|
+
self.inst = inst
|
|
12
|
+
self.base = local_url(inst)
|
|
13
|
+
self._c = httpx.AsyncClient(timeout=15)
|
|
14
|
+
|
|
15
|
+
async def _headers(self, write: bool) -> dict:
|
|
16
|
+
h = auth_headers(self.inst)
|
|
17
|
+
if write and not self.inst.token:
|
|
18
|
+
if not self._c.cookies.get("_xsrf"):
|
|
19
|
+
await self._c.get(self.base + "login", follow_redirects=True) # sets the _xsrf cookie even if the page 404s
|
|
20
|
+
xs = self._c.cookies.get("_xsrf")
|
|
21
|
+
if xs:
|
|
22
|
+
h["X-XSRFToken"] = xs
|
|
23
|
+
h["Cookie"] = f"_xsrf={xs}"
|
|
24
|
+
return h
|
|
25
|
+
|
|
26
|
+
async def request(self, method: str, path: str, *, write: bool = False, **kw) -> httpx.Response:
|
|
27
|
+
r = await self._c.request(method, self.base + path.lstrip("/"), headers=await self._headers(write), **kw)
|
|
28
|
+
r.raise_for_status()
|
|
29
|
+
return r
|
|
30
|
+
|
|
31
|
+
async def xsrf_headers(self) -> dict:
|
|
32
|
+
return await self._headers(True)
|
|
33
|
+
|
|
34
|
+
async def contents(self, path: str, content: bool = True) -> dict:
|
|
35
|
+
return (await self.request("GET", f"api/contents/{path}", params={"content": int(content)})).json()
|
|
36
|
+
|
|
37
|
+
async def sessions(self) -> list[dict]:
|
|
38
|
+
return (await self.request("GET", "api/sessions")).json()
|
|
39
|
+
|
|
40
|
+
async def session_for(self, path: str) -> dict | None:
|
|
41
|
+
for s in await self.sessions():
|
|
42
|
+
if s.get("path") == path or s.get("notebook", {}).get("path") == path:
|
|
43
|
+
return s
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
async def ensure_session(self, path: str, kernel_name: str = "python3") -> dict:
|
|
47
|
+
s = await self.session_for(path)
|
|
48
|
+
if s:
|
|
49
|
+
return s
|
|
50
|
+
r = await self.request("POST", "api/sessions", write=True, json={
|
|
51
|
+
"kernel": {"name": kernel_name}, "path": path, "name": path, "type": "notebook"})
|
|
52
|
+
return r.json()
|
|
53
|
+
|
|
54
|
+
async def kernel_action(self, kernel_id: str, action: str) -> None:
|
|
55
|
+
await self.request("POST", f"api/kernels/{kernel_id}/{action}", write=True)
|
|
56
|
+
|
|
57
|
+
async def aclose(self) -> None:
|
|
58
|
+
await self._c.aclose()
|
jcollab/server.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""jcollab MCP server (stdio). Tools address a notebook by ``instance`` (optional if only one server runs) and ``path``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import copy
|
|
6
|
+
import re
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import PurePosixPath
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from mcp.server.mcpserver import Image, MCPServer
|
|
12
|
+
|
|
13
|
+
from . import kernel
|
|
14
|
+
from .discovery import discover, resolve
|
|
15
|
+
from .doc import DocError, DocSession, Sessions, set_outputs, set_text
|
|
16
|
+
|
|
17
|
+
INSTRUCTIONS = """Live co-editing of JupyterLab notebooks. Edits go through the shared Yjs document, so an open browser
|
|
18
|
+
tab shows them immediately. Every mutating tool reports `persisted` (the file on the server matches the live document);
|
|
19
|
+
if it is false, tell the user instead of assuming the edit is visible. Cells are addressed by index or cell id.
|
|
20
|
+
Rules: small inspections go through `run_code` (not stored in history, no execution count used). Anything big or
|
|
21
|
+
worth keeping runs as a visible code cell (`insert_cell` + `run_cell`) so the user sees the code. Mentions ("@claude" in a cell): handle only when the user asks (find_mentions, answer via add_comment). Put every markdown
|
|
22
|
+
heading in its own cell. Pass cell_type='markdown' explicitly for prose."""
|
|
23
|
+
|
|
24
|
+
mcp = MCPServer("jcollab", instructions=INSTRUCTIONS)
|
|
25
|
+
S = Sessions()
|
|
26
|
+
MAX_TEXT = 6000
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def _ds(instance: str | None, path: str) -> DocSession:
|
|
30
|
+
inst = await resolve(instance)
|
|
31
|
+
path = path.lstrip("/")
|
|
32
|
+
root = inst.root_dir.rstrip("/") + "/"
|
|
33
|
+
if path.startswith(root):
|
|
34
|
+
path = path[len(root):]
|
|
35
|
+
return S.doc(inst, path)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _clip(s: str) -> str:
|
|
39
|
+
return s if len(s) <= MAX_TEXT else s[: MAX_TEXT // 2] + f"\n…[{len(s) - MAX_TEXT} chars cut]…\n" + s[-MAX_TEXT // 2:]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def render(outputs: list[dict]) -> list[Any]:
|
|
46
|
+
"""Outputs as MCP content: text, and real images for image/png|jpeg."""
|
|
47
|
+
parts: list[Any] = []
|
|
48
|
+
for o in outputs:
|
|
49
|
+
t = o.get("output_type")
|
|
50
|
+
if t == "stream":
|
|
51
|
+
parts.append(_clip(o["text"] if isinstance(o["text"], str) else "".join(o["text"])))
|
|
52
|
+
elif t == "error":
|
|
53
|
+
tb = _ANSI.sub("", "\n".join(o.get("traceback") or [])[-1500:])
|
|
54
|
+
parts.append(f"{o['ename']}: {o['evalue']}\n{tb}")
|
|
55
|
+
elif t in ("execute_result", "display_data"):
|
|
56
|
+
data = o.get("data", {})
|
|
57
|
+
for mime, fmt in (("image/png", "png"), ("image/jpeg", "jpeg")):
|
|
58
|
+
if mime in data:
|
|
59
|
+
parts.append(Image(data=base64.b64decode(data[mime]), format=fmt))
|
|
60
|
+
break
|
|
61
|
+
txt = data.get("text/plain")
|
|
62
|
+
if txt is not None and not any(isinstance(p, Image) for p in parts[-1:]):
|
|
63
|
+
parts.append(_clip("".join(txt) if isinstance(txt, list) else txt))
|
|
64
|
+
return parts or ["(no output)"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def _done(ds: DocSession, **info) -> dict:
|
|
68
|
+
ok, detail = await ds.persisted()
|
|
69
|
+
return {**info, "persisted": ok, **({} if ok else {"warning": detail})}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _brief(c: dict, width: int = 160) -> dict:
|
|
73
|
+
src = c["source"]
|
|
74
|
+
c = {k: v for k, v in c.items() if k != "source"}
|
|
75
|
+
c["source"] = src if len(src) <= width else src[:width] + "…"
|
|
76
|
+
return c
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── instances / files ──────────────────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
@mcp.tool()
|
|
82
|
+
async def list_instances() -> list[dict]:
|
|
83
|
+
"""Running Jupyter servers of this user (found via Jupyter's runtime files, no setup needed)."""
|
|
84
|
+
return [i.public() for i in await discover()]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@mcp.tool()
|
|
88
|
+
async def list_notebooks(instance: str | None = None, subdir: str = "") -> list[str]:
|
|
89
|
+
"""All .ipynb paths (relative to the server root) below `subdir`."""
|
|
90
|
+
inst = await resolve(instance)
|
|
91
|
+
rest = S.rest(inst)
|
|
92
|
+
out: list[str] = []
|
|
93
|
+
|
|
94
|
+
async def walk(d: str):
|
|
95
|
+
for e in (await rest.contents(d, content=True))["content"]:
|
|
96
|
+
if e["type"] == "directory" and not e["name"].startswith("."):
|
|
97
|
+
await walk(e["path"])
|
|
98
|
+
elif e["name"].endswith(".ipynb"):
|
|
99
|
+
out.append(e["path"])
|
|
100
|
+
await walk(subdir.strip("/"))
|
|
101
|
+
return sorted(out)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── reading ────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
@mcp.tool()
|
|
107
|
+
async def read_notebook(path: str, instance: str | None = None, start: int = 0, end: int | None = None,
|
|
108
|
+
full_source: bool = False) -> dict:
|
|
109
|
+
"""Overview of cells (index, id, type, execution count/state, metadata, source, number of outputs)."""
|
|
110
|
+
ds = await _ds(instance, path)
|
|
111
|
+
doc = await ds.doc()
|
|
112
|
+
cells = (await ds.cells())[start:end]
|
|
113
|
+
return {"path": ds.path, "n_cells": doc.cell_number, "notebook_metadata": doc._ymeta["metadata"].to_py(),
|
|
114
|
+
"cells": cells if full_source else [_brief(c) for c in cells]}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@mcp.tool()
|
|
118
|
+
async def read_cell(path: str, cell: int | str, instance: str | None = None, include_outputs: bool = True) -> list[Any]:
|
|
119
|
+
"""One cell with full source, metadata and (rendered, incl. images) outputs."""
|
|
120
|
+
ds = await _ds(instance, path)
|
|
121
|
+
c = await ds.cell(cell, outputs=include_outputs)
|
|
122
|
+
outs = c.pop("outputs", [])
|
|
123
|
+
return [c] + (render(outs) if include_outputs and outs else [])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ── editing ────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
def _new_ycell(doc, cell_type: str, source: str, metadata: dict | None):
|
|
129
|
+
cell = {"cell_type": cell_type, "source": source, "metadata": metadata or {}, "id": uuid.uuid4().hex[:8]}
|
|
130
|
+
if cell_type == "code":
|
|
131
|
+
cell.update(outputs=[], execution_count=None)
|
|
132
|
+
return doc.create_ycell(cell)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@mcp.tool()
|
|
136
|
+
async def insert_cell(path: str, source: str, cell_type: str = "code", after: int | str | None = None,
|
|
137
|
+
instance: str | None = None, metadata: dict | None = None) -> dict:
|
|
138
|
+
"""Insert a cell after `after` (index or id); appends at the end if `after` is None (use insert_cell_at_start for the top).
|
|
139
|
+
Always pass cell_type='markdown' for prose; keep each heading in its own cell."""
|
|
140
|
+
if cell_type not in ("code", "markdown", "raw"):
|
|
141
|
+
raise DocError("cell_type must be code, markdown or raw")
|
|
142
|
+
ds = await _ds(instance, path)
|
|
143
|
+
async with ds.lock:
|
|
144
|
+
doc = await ds.doc()
|
|
145
|
+
pos = doc.cell_number if after is None else await ds.index(after) + 1
|
|
146
|
+
with doc._ydoc.transaction():
|
|
147
|
+
doc.ycells.insert(pos, _new_ycell(doc, cell_type, source, metadata))
|
|
148
|
+
return await _done(ds, index=pos, id=doc.ycells[pos].get("id"))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
RESPONSE_MARKER = "<!-- jcollab-response -->"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@mcp.tool()
|
|
155
|
+
async def find_mentions(path: str, instance: str | None = None) -> list[dict]:
|
|
156
|
+
"""List cells containing "@claude" that are not answered yet (no response cell directly below).
|
|
157
|
+
Only look when the user asks; never answer mentions on your own initiative."""
|
|
158
|
+
ds = await _ds(instance, path)
|
|
159
|
+
doc = await ds.doc()
|
|
160
|
+
out, n = [], doc.cell_number
|
|
161
|
+
for i in range(n):
|
|
162
|
+
src = str(doc.ycells[i]["source"])
|
|
163
|
+
if "@claude" not in src or RESPONSE_MARKER in src:
|
|
164
|
+
continue
|
|
165
|
+
nxt = str(doc.ycells[i + 1]["source"]) if i + 1 < n else ""
|
|
166
|
+
if RESPONSE_MARKER in nxt:
|
|
167
|
+
continue
|
|
168
|
+
head = next((str(doc.ycells[j]["source"]).splitlines()[0] for j in range(i, -1, -1)
|
|
169
|
+
if doc.ycells[j].get("cell_type") == "markdown" and str(doc.ycells[j]["source"]).startswith("#")), None)
|
|
170
|
+
out.append({"index": i, "id": doc.ycells[i].get("id"), "cell_type": doc.ycells[i].get("cell_type"),
|
|
171
|
+
"heading_above": head, "source": src})
|
|
172
|
+
return out
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@mcp.tool()
|
|
176
|
+
async def add_comment(path: str, after: int | str, response: str, instance: str | None = None) -> dict:
|
|
177
|
+
"""Answer a mention: insert a markdown response cell (carrying the jcollab-response marker) below cell `after`."""
|
|
178
|
+
ds = await _ds(instance, path)
|
|
179
|
+
async with ds.lock:
|
|
180
|
+
doc = await ds.doc()
|
|
181
|
+
pos = await ds.index(after) + 1
|
|
182
|
+
with doc._ydoc.transaction():
|
|
183
|
+
doc.ycells.insert(pos, _new_ycell(doc, "markdown", f"{RESPONSE_MARKER}\n{response}", None))
|
|
184
|
+
return await _done(ds, index=pos, id=doc.ycells[pos].get("id"))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@mcp.tool()
|
|
188
|
+
async def insert_cell_at_start(path: str, source: str, cell_type: str = "markdown", instance: str | None = None) -> dict:
|
|
189
|
+
"""Insert a cell as the first cell of the notebook."""
|
|
190
|
+
ds = await _ds(instance, path)
|
|
191
|
+
async with ds.lock:
|
|
192
|
+
doc = await ds.doc()
|
|
193
|
+
with doc._ydoc.transaction():
|
|
194
|
+
doc.ycells.insert(0, _new_ycell(doc, cell_type, source, None))
|
|
195
|
+
return await _done(ds, index=0, id=doc.ycells[0].get("id"))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@mcp.tool()
|
|
199
|
+
async def edit_cell(path: str, cell: int | str, source: str, instance: str | None = None) -> dict:
|
|
200
|
+
"""Replace a cell's source with minimal text edits (open editors keep cursor/scroll). Does not change the cell type."""
|
|
201
|
+
ds = await _ds(instance, path)
|
|
202
|
+
async with ds.lock:
|
|
203
|
+
yc = await ds.ycell(cell)
|
|
204
|
+
with (await ds.doc())._ydoc.transaction():
|
|
205
|
+
set_text(yc["source"], source)
|
|
206
|
+
return await _done(ds, index=await ds.index(cell))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@mcp.tool()
|
|
210
|
+
async def change_cell_type(path: str, cell: int | str, cell_type: str, instance: str | None = None) -> dict:
|
|
211
|
+
"""Convert a cell to code/markdown/raw (keeps id, source and metadata; drops outputs when leaving code)."""
|
|
212
|
+
ds = await _ds(instance, path)
|
|
213
|
+
async with ds.lock:
|
|
214
|
+
doc = await ds.doc()
|
|
215
|
+
i = await ds.index(cell)
|
|
216
|
+
old = doc.ycells[i]
|
|
217
|
+
new = _new_ycell(doc, cell_type, str(old["source"]), old["metadata"].to_py())
|
|
218
|
+
new["id"] = old.get("id")
|
|
219
|
+
with doc._ydoc.transaction():
|
|
220
|
+
del doc.ycells[i]
|
|
221
|
+
doc.ycells.insert(i, new)
|
|
222
|
+
return await _done(ds, index=i)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@mcp.tool()
|
|
226
|
+
async def delete_cell(path: str, cell: int | str, instance: str | None = None) -> dict:
|
|
227
|
+
ds = await _ds(instance, path)
|
|
228
|
+
async with ds.lock:
|
|
229
|
+
doc = await ds.doc()
|
|
230
|
+
i = await ds.index(cell)
|
|
231
|
+
with doc._ydoc.transaction():
|
|
232
|
+
del doc.ycells[i]
|
|
233
|
+
return await _done(ds, deleted=i)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@mcp.tool()
|
|
237
|
+
async def move_cell(path: str, cell: int | str, to: int, instance: str | None = None) -> dict:
|
|
238
|
+
"""Move a cell so that it ends up at index `to` (counted after removal)."""
|
|
239
|
+
ds = await _ds(instance, path)
|
|
240
|
+
async with ds.lock:
|
|
241
|
+
doc = await ds.doc()
|
|
242
|
+
i = await ds.index(cell)
|
|
243
|
+
data = doc.get_cell(i)
|
|
244
|
+
with doc._ydoc.transaction():
|
|
245
|
+
del doc.ycells[i]
|
|
246
|
+
doc.ycells.insert(max(0, min(to, doc.cell_number)), doc.create_ycell(data))
|
|
247
|
+
return await _done(ds, moved_from=i, to=to)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@mcp.tool()
|
|
251
|
+
async def split_cell(path: str, cell: int | str, at_line: int, instance: str | None = None) -> dict:
|
|
252
|
+
"""Split a cell before line number `at_line` (0-based); the second part becomes a new cell right after."""
|
|
253
|
+
ds = await _ds(instance, path)
|
|
254
|
+
async with ds.lock:
|
|
255
|
+
doc = await ds.doc()
|
|
256
|
+
i = await ds.index(cell)
|
|
257
|
+
yc = doc.ycells[i]
|
|
258
|
+
lines = str(yc["source"]).split("\n")
|
|
259
|
+
head, tail = "\n".join(lines[:at_line]), "\n".join(lines[at_line:])
|
|
260
|
+
with doc._ydoc.transaction():
|
|
261
|
+
set_text(yc["source"], head)
|
|
262
|
+
doc.ycells.insert(i + 1, _new_ycell(doc, yc["cell_type"], tail, yc["metadata"].to_py()))
|
|
263
|
+
return await _done(ds, first=i, second=i + 1)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@mcp.tool()
|
|
267
|
+
async def merge_cells(path: str, first: int | str, last: int | str, instance: str | None = None) -> dict:
|
|
268
|
+
"""Merge cells first..last (inclusive) into the first one, joined by blank lines."""
|
|
269
|
+
ds = await _ds(instance, path)
|
|
270
|
+
async with ds.lock:
|
|
271
|
+
doc = await ds.doc()
|
|
272
|
+
a, b = await ds.index(first), await ds.index(last)
|
|
273
|
+
if b <= a:
|
|
274
|
+
raise DocError("last must be after first")
|
|
275
|
+
joined = "\n\n".join(str(doc.ycells[k]["source"]) for k in range(a, b + 1))
|
|
276
|
+
with doc._ydoc.transaction():
|
|
277
|
+
set_text(doc.ycells[a]["source"], joined)
|
|
278
|
+
for k in range(b, a, -1):
|
|
279
|
+
del doc.ycells[k]
|
|
280
|
+
return await _done(ds, merged_into=a)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ── metadata ───────────────────────────────────────────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
@mcp.tool()
|
|
286
|
+
async def get_metadata(path: str, cell: int | str | None = None, key: str | None = None, instance: str | None = None) -> Any:
|
|
287
|
+
"""Metadata of a cell (cell given) or of the notebook (cell=None, e.g. key='scenes_data'). Whole dict if key is None."""
|
|
288
|
+
ds = await _ds(instance, path)
|
|
289
|
+
doc = await ds.doc()
|
|
290
|
+
meta = doc._ymeta["metadata"].to_py() if cell is None else (await ds.cell(cell))["metadata"]
|
|
291
|
+
return meta if key is None else meta.get(key)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@mcp.tool()
|
|
295
|
+
async def set_metadata(path: str, key: str, value: Any = None, cell: int | str | None = None, delete: bool = False,
|
|
296
|
+
instance: str | None = None) -> dict:
|
|
297
|
+
"""Set (or delete) one metadata key on a cell (cell given) or the notebook (cell=None). Values are JSON."""
|
|
298
|
+
ds = await _ds(instance, path)
|
|
299
|
+
async with ds.lock:
|
|
300
|
+
doc = await ds.doc()
|
|
301
|
+
meta = doc._ymeta["metadata"] if cell is None else (await ds.ycell(cell))["metadata"]
|
|
302
|
+
with doc._ydoc.transaction():
|
|
303
|
+
if delete:
|
|
304
|
+
if key in meta:
|
|
305
|
+
del meta[key]
|
|
306
|
+
else:
|
|
307
|
+
meta[key] = copy.deepcopy(value)
|
|
308
|
+
return await _done(ds, scope="notebook" if cell is None else f"cell {cell}", key=key, deleted=delete)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ── execution ──────────────────────────────────────────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
@mcp.tool()
|
|
314
|
+
async def run_cell(path: str, cell: int | str, instance: str | None = None, timeout: float = 300) -> list[Any]:
|
|
315
|
+
"""Run a code cell like the user would: shows [*] while busy, streams outputs, sets the kernel's execution count
|
|
316
|
+
and the execute-time timing metadata. Returns the outputs (images included)."""
|
|
317
|
+
ds = await _ds(instance, path)
|
|
318
|
+
r = await kernel.run_cell(ds, cell, timeout)
|
|
319
|
+
head = {k: v for k, v in r.items() if k != "outputs"}
|
|
320
|
+
head.update(await _done(ds))
|
|
321
|
+
return [head] + render(r["outputs"])
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@mcp.tool()
|
|
325
|
+
async def run_cells(path: str, start: int | str, end: int | str, instance: str | None = None, timeout: float = 300,
|
|
326
|
+
stop_on_error: bool = True) -> list[dict]:
|
|
327
|
+
"""Run code cells start..end (inclusive) one after another. Returns one status line per cell (no outputs)."""
|
|
328
|
+
ds = await _ds(instance, path)
|
|
329
|
+
a, b = await ds.index(start), await ds.index(end)
|
|
330
|
+
res = []
|
|
331
|
+
for i in range(a, b + 1):
|
|
332
|
+
if (await ds.cell(i))["cell_type"] != "code":
|
|
333
|
+
continue
|
|
334
|
+
r = await kernel.run_cell(ds, i, timeout)
|
|
335
|
+
res.append({k: v for k, v in r.items() if k != "outputs"} | {"errors": [o["ename"] for o in r["outputs"] if o["output_type"] == "error"]})
|
|
336
|
+
if stop_on_error and (r["status"] != "ok" or r["timed_out"]):
|
|
337
|
+
break
|
|
338
|
+
return res
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@mcp.tool()
|
|
342
|
+
async def run_code(path: str, code: str, instance: str | None = None, timeout: float = 60) -> list[Any]:
|
|
343
|
+
"""Quick inspection in the notebook's kernel (variables, shapes, small checks). Not recorded in history, uses no
|
|
344
|
+
execution count, leaves the notebook untouched. Do NOT use for real computations; put those in a visible cell."""
|
|
345
|
+
ds = await _ds(instance, path)
|
|
346
|
+
r = await kernel.run_code(ds.rest, ds.path, code, timeout, await ds.doc())
|
|
347
|
+
return render(r["outputs"]) + (["[timed out, kernel interrupted]"] if r["timed_out"] else [])
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@mcp.tool()
|
|
351
|
+
async def clear_outputs(path: str, cell: int | str | None = None, instance: str | None = None) -> dict:
|
|
352
|
+
"""Clear outputs and execution count of one cell, or of all cells if `cell` is None."""
|
|
353
|
+
ds = await _ds(instance, path)
|
|
354
|
+
async with ds.lock:
|
|
355
|
+
doc = await ds.doc()
|
|
356
|
+
idx = range(doc.cell_number) if cell is None else [await ds.index(cell)]
|
|
357
|
+
with doc._ydoc.transaction():
|
|
358
|
+
for i in idx:
|
|
359
|
+
yc = doc.ycells[i]
|
|
360
|
+
if yc.get("cell_type") == "code":
|
|
361
|
+
set_outputs(yc, [])
|
|
362
|
+
yc["execution_count"] = None
|
|
363
|
+
if "execution" in yc["metadata"]:
|
|
364
|
+
del yc["metadata"]["execution"]
|
|
365
|
+
return await _done(ds, cleared=len(idx))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@mcp.tool()
|
|
369
|
+
async def inspect_variables(path: str, instance: str | None = None, pattern: str = "") -> list[Any]:
|
|
370
|
+
"""Names, types and shapes of user variables in the kernel (silent, no history entry)."""
|
|
371
|
+
code = (
|
|
372
|
+
"import re as _re\n"
|
|
373
|
+
"_rows=[]\n"
|
|
374
|
+
"for _n,_v in list(globals().items()):\n"
|
|
375
|
+
" if _n.startswith('_') or _n in ('In','Out','get_ipython','exit','quit'): continue\n"
|
|
376
|
+
f" if not _re.search({pattern!r}, _n): continue\n"
|
|
377
|
+
" if type(_v).__name__ in ('module','function','builtin_function_or_method','type'): continue\n"
|
|
378
|
+
" _s=getattr(_v,'shape',None); _l=len(_v) if hasattr(_v,'__len__') else None\n"
|
|
379
|
+
" _rows.append(f'{_n}: {type(_v).__name__}'+(f' shape={_s}' if _s is not None else (f' len={_l}' if _l is not None else f' = {_v!r:.60}')))\n"
|
|
380
|
+
"print('\\n'.join(_rows) or '(no user variables)')\n"
|
|
381
|
+
"del _re,_rows")
|
|
382
|
+
return await run_code(path, code, instance)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ── kernel ─────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
async def _kid(ds: DocSession) -> str:
|
|
388
|
+
s = await ds.rest.session_for(ds.path)
|
|
389
|
+
if not s:
|
|
390
|
+
raise kernel.KernelError(f"{ds.path} has no kernel session (run a cell first)")
|
|
391
|
+
return s["kernel"]["id"]
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@mcp.tool()
|
|
395
|
+
async def kernel_status(path: str, instance: str | None = None) -> dict:
|
|
396
|
+
ds = await _ds(instance, path)
|
|
397
|
+
s = await ds.rest.session_for(ds.path)
|
|
398
|
+
if not s:
|
|
399
|
+
return {"kernel": None}
|
|
400
|
+
k = (await ds.rest.request("GET", f"api/kernels/{s['kernel']['id']}")).json()
|
|
401
|
+
return {"id": k["id"], "name": k["name"], "execution_state": k["execution_state"], "connections": k["connections"],
|
|
402
|
+
"last_activity": k["last_activity"]}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@mcp.tool()
|
|
406
|
+
async def interrupt_kernel(path: str, instance: str | None = None) -> str:
|
|
407
|
+
ds = await _ds(instance, path)
|
|
408
|
+
await ds.rest.kernel_action(await _kid(ds), "interrupt")
|
|
409
|
+
return "interrupted"
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@mcp.tool()
|
|
413
|
+
async def restart_kernel(path: str, instance: str | None = None, run_all: bool = False, clear: bool = False,
|
|
414
|
+
timeout: float = 300) -> Any:
|
|
415
|
+
"""Restart the notebook's kernel (all in-memory state is lost). Optional: clear outputs first, run all cells after."""
|
|
416
|
+
ds = await _ds(instance, path)
|
|
417
|
+
if clear:
|
|
418
|
+
await clear_outputs(path, None, instance)
|
|
419
|
+
await ds.rest.kernel_action(await _kid(ds), "restart")
|
|
420
|
+
doc = await ds.doc()
|
|
421
|
+
with doc._ydoc.transaction(): # a restart abandons whatever was running
|
|
422
|
+
for i in range(doc.cell_number):
|
|
423
|
+
if doc.ycells[i].get("execution_state") == "running":
|
|
424
|
+
doc.ycells[i]["execution_state"] = "idle"
|
|
425
|
+
if run_all:
|
|
426
|
+
return await run_cells(path, 0, -1, instance, timeout)
|
|
427
|
+
return "restarted"
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
@mcp.tool()
|
|
431
|
+
async def point_at(path: str, cell: int | str, seconds: float = 30, select: bool = True, instance: str | None = None,
|
|
432
|
+
scroll: bool = True, force_scroll: bool = False, label: str | None = None) -> dict:
|
|
433
|
+
"""Show the user which cell you mean: a labelled "Metis" selection appears in that cell in every open tab (and the
|
|
434
|
+
user can jump to it via JupyterLab's Collaborators sidebar). Still also name the cell in words (heading above it,
|
|
435
|
+
first line), the user may not have the notebook scrolled there. With the jcollab-scroll lab extension, the user's
|
|
436
|
+
notebook scrolls to the cell (a toast instead while they type elsewhere, unless force_scroll); label is shown in that toast."""
|
|
437
|
+
return await (await _ds(instance, path)).point_at(cell, seconds=seconds, select=select, scroll=scroll,
|
|
438
|
+
force_scroll=force_scroll, label=label)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@mcp.tool()
|
|
442
|
+
async def user_focus(path: str, instance: str | None = None) -> list[dict]:
|
|
443
|
+
"""Where the user is: for every open browser tab the cell (index + id) holding the cursor, line/column and the
|
|
444
|
+
selected text. Use this to resolve "this cell" / "the selected code" / "here". Empty if nobody has a cursor in the
|
|
445
|
+
notebook (the user must have clicked into a cell; cursors expire after ~30 s without activity)."""
|
|
446
|
+
return await (await _ds(instance, path)).user_focus()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@mcp.tool()
|
|
450
|
+
async def sync_health(path: str, instance: str | None = None) -> dict:
|
|
451
|
+
"""Is the shared document alive? Connection state, peers (e.g. browser tabs), and whether the file matches."""
|
|
452
|
+
return await (await _ds(instance, path)).health()
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def main() -> None:
|
|
456
|
+
import logging
|
|
457
|
+
for n in ("httpx", "httpcore", "websockets", "jupyter_nbmodel_client"):
|
|
458
|
+
logging.getLogger(n).setLevel(logging.WARNING)
|
|
459
|
+
mcp.run()
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
if __name__ == "__main__":
|
|
463
|
+
main()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: jcollab
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for live co-editing and running of JupyterLab notebooks (Yjs, verified writes, multi-instance discovery)
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: jupyter-core
|
|
9
|
+
Requires-Dist: jupyter-nbmodel-client<2,>=1.5.1
|
|
10
|
+
Requires-Dist: jupyter-ydoc<4,>=3.0
|
|
11
|
+
Requires-Dist: mcp<3,>=2
|
|
12
|
+
Requires-Dist: nbformat
|
|
13
|
+
Requires-Dist: pycrdt<0.14,>=0.12.50
|
|
14
|
+
Requires-Dist: websockets>=13
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
jcollab/__init__.py,sha256=Jc1fuH5VdKyb1DjytZYAZzWqp2augtJH6xHL6zS6AVw,93
|
|
2
|
+
jcollab/discovery.py,sha256=gDnbAlYOumjtvhpVOUHapoFByNUzslN5t7vp749_Aq0,4996
|
|
3
|
+
jcollab/doc.py,sha256=Bnu7It3EWRQqtgy6YsjSGIWUWrTZrwGNmQbumUELunw,14916
|
|
4
|
+
jcollab/kernel.py,sha256=U00M5pmm1UI5oOBOpP22Il_n5JOHaiHjqi9NrwuAKkA,9897
|
|
5
|
+
jcollab/rest.py,sha256=-XorHuN-8iWo82zSinjsEXNXZ34UKXZTC-NKQAD4mMc,2293
|
|
6
|
+
jcollab/server.py,sha256=giveriIKclwIBuBPqsxDrTZ6X_r4_It6eyFFNvWfX0k,22222
|
|
7
|
+
jcollab-0.1.0.dist-info/METADATA,sha256=2C2jBid80EZxV1WX10MVtV2IavqVGxlT4OvOoeaOZ0o,479
|
|
8
|
+
jcollab-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
jcollab-0.1.0.dist-info/entry_points.txt,sha256=h-5SuMaiVTElQLLUQ9ayftx-hi8iGTYycO1eucSPE2c,48
|
|
10
|
+
jcollab-0.1.0.dist-info/RECORD,,
|