nb-lab-runtime 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.
- nb_lab/__init__.py +28 -0
- nb_lab/cell_model.py +299 -0
- nb_lab/cli.py +310 -0
- nb_lab/executor.py +397 -0
- nb_lab/models.py +25 -0
- nb_lab/notebook_edit.py +85 -0
- nb_lab/notebook_state.py +71 -0
- nb_lab/plot_meta.py +162 -0
- nb_lab/server.py +279 -0
- nb_lab/watcher.py +119 -0
- nb_lab_runtime-0.1.0.dist-info/METADATA +276 -0
- nb_lab_runtime-0.1.0.dist-info/RECORD +15 -0
- nb_lab_runtime-0.1.0.dist-info/WHEEL +4 -0
- nb_lab_runtime-0.1.0.dist-info/entry_points.txt +2 -0
- nb_lab_runtime-0.1.0.dist-info/licenses/LICENSE +21 -0
nb_lab/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""nb-lab core package."""
|
|
2
|
+
|
|
3
|
+
from .cell_model import Cell, ParsedCell, assign_ids, load_sidecar, parse_cells, save_sidecar
|
|
4
|
+
from .executor import PythonExecutor
|
|
5
|
+
from .models import ExecutionResult, OutputItem
|
|
6
|
+
from .notebook_state import NotebookState
|
|
7
|
+
from .plot_meta import PlotMeta, capture_plot
|
|
8
|
+
from .server import create_app
|
|
9
|
+
from .watcher import PollingWatcher, WatchdogWatcher, build_watcher
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Cell",
|
|
13
|
+
"ParsedCell",
|
|
14
|
+
"assign_ids",
|
|
15
|
+
"ExecutionResult",
|
|
16
|
+
"load_sidecar",
|
|
17
|
+
"OutputItem",
|
|
18
|
+
"parse_cells",
|
|
19
|
+
"PythonExecutor",
|
|
20
|
+
"save_sidecar",
|
|
21
|
+
"PlotMeta",
|
|
22
|
+
"capture_plot",
|
|
23
|
+
"create_app",
|
|
24
|
+
"NotebookState",
|
|
25
|
+
"PollingWatcher",
|
|
26
|
+
"WatchdogWatcher",
|
|
27
|
+
"build_watcher",
|
|
28
|
+
]
|
nb_lab/cell_model.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from difflib import SequenceMatcher
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
CellKind = Literal["code", "markdown"]
|
|
14
|
+
|
|
15
|
+
_MARKER_RE = re.compile(r"^\s*#\s*%%(?:\s*\[(?P<header>.+?)\])?\s*$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Cell:
|
|
20
|
+
id: str
|
|
21
|
+
kind: CellKind
|
|
22
|
+
source: str
|
|
23
|
+
start_line: int
|
|
24
|
+
end_line: int
|
|
25
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ParsedCell:
|
|
30
|
+
kind: CellKind
|
|
31
|
+
source: str
|
|
32
|
+
start_line: int
|
|
33
|
+
end_line: int
|
|
34
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class CellSnapshot:
|
|
39
|
+
id: str
|
|
40
|
+
kind: CellKind
|
|
41
|
+
source: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_cells(text: str) -> list[ParsedCell]:
|
|
45
|
+
lines = text.splitlines(keepends=True)
|
|
46
|
+
if not lines:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
markers: list[tuple[int, CellKind, dict[str, Any]]] = []
|
|
50
|
+
for idx, line in enumerate(lines):
|
|
51
|
+
match = _MARKER_RE.match(line)
|
|
52
|
+
if not match:
|
|
53
|
+
continue
|
|
54
|
+
header = (match.group("header") or "").strip().lower()
|
|
55
|
+
kind: CellKind = "markdown" if header in {"markdown", "md"} else "code"
|
|
56
|
+
metadata: dict[str, Any] = {}
|
|
57
|
+
if header:
|
|
58
|
+
metadata["header"] = header
|
|
59
|
+
markers.append((idx, kind, metadata))
|
|
60
|
+
|
|
61
|
+
if not markers:
|
|
62
|
+
return [
|
|
63
|
+
ParsedCell(
|
|
64
|
+
kind="code",
|
|
65
|
+
source="".join(lines),
|
|
66
|
+
start_line=1,
|
|
67
|
+
end_line=len(lines),
|
|
68
|
+
metadata={},
|
|
69
|
+
)
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
cells: list[ParsedCell] = []
|
|
73
|
+
|
|
74
|
+
def build_cell(
|
|
75
|
+
kind: CellKind,
|
|
76
|
+
metadata: dict[str, Any],
|
|
77
|
+
start_idx: int,
|
|
78
|
+
end_idx: int,
|
|
79
|
+
fallback_line: int,
|
|
80
|
+
) -> ParsedCell:
|
|
81
|
+
source = "".join(lines[start_idx:end_idx])
|
|
82
|
+
if source:
|
|
83
|
+
start_line = start_idx + 1
|
|
84
|
+
end_line = end_idx
|
|
85
|
+
else:
|
|
86
|
+
start_line = fallback_line
|
|
87
|
+
end_line = fallback_line
|
|
88
|
+
return ParsedCell(
|
|
89
|
+
kind=kind,
|
|
90
|
+
source=source,
|
|
91
|
+
start_line=start_line,
|
|
92
|
+
end_line=end_line,
|
|
93
|
+
metadata=metadata,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Handle any content before the first marker as an implicit code cell.
|
|
97
|
+
first_marker_idx, first_kind, first_meta = markers[0]
|
|
98
|
+
if first_marker_idx > 0:
|
|
99
|
+
cells.append(
|
|
100
|
+
build_cell(
|
|
101
|
+
kind="code",
|
|
102
|
+
metadata={},
|
|
103
|
+
start_idx=0,
|
|
104
|
+
end_idx=first_marker_idx,
|
|
105
|
+
fallback_line=1,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
for marker_pos, (marker_idx, kind, metadata) in enumerate(markers):
|
|
110
|
+
start_idx = marker_idx + 1
|
|
111
|
+
if marker_pos + 1 < len(markers):
|
|
112
|
+
end_idx = markers[marker_pos + 1][0]
|
|
113
|
+
else:
|
|
114
|
+
end_idx = len(lines)
|
|
115
|
+
fallback_line = marker_idx + 1
|
|
116
|
+
cells.append(build_cell(kind, metadata, start_idx, end_idx, fallback_line))
|
|
117
|
+
|
|
118
|
+
return cells
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def assign_ids(
|
|
122
|
+
parsed_cells: list[ParsedCell],
|
|
123
|
+
previous: list[CellSnapshot],
|
|
124
|
+
similarity_threshold: float = 0.55,
|
|
125
|
+
) -> list[Cell]:
|
|
126
|
+
remaining = list(enumerate(previous))
|
|
127
|
+
used_prev: set[int] = set()
|
|
128
|
+
cells: list[Cell] = []
|
|
129
|
+
|
|
130
|
+
for idx, parsed in enumerate(parsed_cells):
|
|
131
|
+
best_prev_index = None
|
|
132
|
+
best_score = 0.0
|
|
133
|
+
for prev_idx, prev in remaining:
|
|
134
|
+
if prev_idx in used_prev:
|
|
135
|
+
continue
|
|
136
|
+
if prev.kind != parsed.kind:
|
|
137
|
+
continue
|
|
138
|
+
score = _similarity_score(parsed.source, prev.source, parsed.kind, idx, prev_idx)
|
|
139
|
+
if score > best_score:
|
|
140
|
+
best_score = score
|
|
141
|
+
best_prev_index = prev_idx
|
|
142
|
+
if best_prev_index is not None and best_score >= similarity_threshold:
|
|
143
|
+
prev_cell = previous[best_prev_index]
|
|
144
|
+
used_prev.add(best_prev_index)
|
|
145
|
+
cell_id = prev_cell.id
|
|
146
|
+
else:
|
|
147
|
+
cell_id = uuid.uuid4().hex
|
|
148
|
+
cells.append(
|
|
149
|
+
Cell(
|
|
150
|
+
id=cell_id,
|
|
151
|
+
kind=parsed.kind,
|
|
152
|
+
source=parsed.source,
|
|
153
|
+
start_line=parsed.start_line,
|
|
154
|
+
end_line=parsed.end_line,
|
|
155
|
+
metadata=parsed.metadata,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return cells
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _similarity_score(
|
|
163
|
+
new_source: str, old_source: str, kind: CellKind, new_idx: int, old_idx: int
|
|
164
|
+
) -> float:
|
|
165
|
+
normalized_new = _normalize_source(new_source, kind)
|
|
166
|
+
normalized_old = _normalize_source(old_source, kind)
|
|
167
|
+
ratio = SequenceMatcher(None, normalized_new, normalized_old).ratio()
|
|
168
|
+
# Favor local positional continuity for stability.
|
|
169
|
+
position_penalty = 0.02 * abs(new_idx - old_idx)
|
|
170
|
+
return max(0.0, ratio - position_penalty)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _normalize_source(source: str, kind: CellKind) -> str:
|
|
174
|
+
lines = source.splitlines()
|
|
175
|
+
if kind == "code":
|
|
176
|
+
lines = [line for line in lines if not re.match(r"^\s*#", line)]
|
|
177
|
+
trimmed = [line.rstrip() for line in lines]
|
|
178
|
+
return "\n".join(trimmed).strip()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def sidecar_path(notebook_path: str | Path) -> Path:
|
|
182
|
+
path = Path(notebook_path)
|
|
183
|
+
digest = _cache_key(path)
|
|
184
|
+
cache_root = Path(
|
|
185
|
+
os.environ.get("NB_LAB_CACHE_DIR", Path.home() / ".cache" / "nb-lab")
|
|
186
|
+
)
|
|
187
|
+
return cache_root / f"{digest}.json"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _cache_root() -> Path:
|
|
191
|
+
default_root = Path.home() / ".cache" / "nb-lab"
|
|
192
|
+
root = Path(os.environ.get("NB_LAB_CACHE_DIR", default_root))
|
|
193
|
+
if _is_writable(root):
|
|
194
|
+
return root
|
|
195
|
+
fallback = Path("/tmp/nb-lab-cache")
|
|
196
|
+
os.environ["NB_LAB_CACHE_DIR"] = str(fallback)
|
|
197
|
+
return fallback
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _is_writable(path: Path) -> bool:
|
|
201
|
+
try:
|
|
202
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
test_file = path / ".write_test"
|
|
204
|
+
test_file.write_text("ok", encoding="utf-8")
|
|
205
|
+
test_file.unlink(missing_ok=True)
|
|
206
|
+
return True
|
|
207
|
+
except Exception:
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _cache_key(path: Path) -> str:
|
|
212
|
+
return uuid.uuid5(uuid.NAMESPACE_URL, str(path.resolve())).hex
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def load_sidecar(path: str | Path) -> list[CellSnapshot]:
|
|
216
|
+
sidecar = sidecar_path(path)
|
|
217
|
+
if not sidecar.exists():
|
|
218
|
+
return []
|
|
219
|
+
raw = json.loads(sidecar.read_text(encoding="utf-8"))
|
|
220
|
+
snapshots: list[CellSnapshot] = []
|
|
221
|
+
for item in raw.get("cells", []):
|
|
222
|
+
snapshots.append(
|
|
223
|
+
CellSnapshot(
|
|
224
|
+
id=item["id"],
|
|
225
|
+
kind=item["kind"],
|
|
226
|
+
source=item["source"],
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
return snapshots
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def save_sidecar(path: str | Path, cells: list[Cell]) -> None:
|
|
233
|
+
path = Path(path)
|
|
234
|
+
sidecar = sidecar_path(path)
|
|
235
|
+
sidecar.parent.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
payload = {
|
|
237
|
+
"cells": [
|
|
238
|
+
{"id": cell.id, "kind": cell.kind, "source": cell.source}
|
|
239
|
+
for cell in cells
|
|
240
|
+
]
|
|
241
|
+
}
|
|
242
|
+
sidecar.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
243
|
+
meta = {"path": str(path.resolve())}
|
|
244
|
+
meta_path = sidecar.with_suffix(".meta.json")
|
|
245
|
+
meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def cache_info() -> dict[str, object]:
|
|
249
|
+
root = _cache_root()
|
|
250
|
+
if not root.exists():
|
|
251
|
+
return {"dir": str(root), "entries": 0, "size_bytes": 0}
|
|
252
|
+
entries = [p for p in root.glob("*.json") if not p.name.endswith(".meta.json")]
|
|
253
|
+
size = sum(p.stat().st_size for p in entries)
|
|
254
|
+
return {"dir": str(root), "entries": len(entries), "size_bytes": size}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def cache_list() -> list[dict[str, object]]:
|
|
258
|
+
root = _cache_root()
|
|
259
|
+
results = []
|
|
260
|
+
if not root.exists():
|
|
261
|
+
return results
|
|
262
|
+
for sidecar in root.glob("*.json"):
|
|
263
|
+
if sidecar.name.endswith(".meta.json"):
|
|
264
|
+
continue
|
|
265
|
+
meta_path = sidecar.with_suffix(".meta.json")
|
|
266
|
+
meta = {}
|
|
267
|
+
if meta_path.exists():
|
|
268
|
+
try:
|
|
269
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
270
|
+
except Exception:
|
|
271
|
+
meta = {}
|
|
272
|
+
results.append(
|
|
273
|
+
{
|
|
274
|
+
"key": sidecar.stem,
|
|
275
|
+
"path": meta.get("path"),
|
|
276
|
+
"size_bytes": sidecar.stat().st_size,
|
|
277
|
+
"mtime": sidecar.stat().st_mtime,
|
|
278
|
+
}
|
|
279
|
+
)
|
|
280
|
+
return results
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def cache_clear(path: str | Path | None = None) -> int:
|
|
284
|
+
root = _cache_root()
|
|
285
|
+
if not root.exists():
|
|
286
|
+
return 0
|
|
287
|
+
deleted = 0
|
|
288
|
+
if path is None:
|
|
289
|
+
for item in root.glob("*.json"):
|
|
290
|
+
item.unlink(missing_ok=True)
|
|
291
|
+
deleted += 1
|
|
292
|
+
return deleted
|
|
293
|
+
target = sidecar_path(path)
|
|
294
|
+
meta = target.with_suffix(".meta.json")
|
|
295
|
+
for item in (target, meta):
|
|
296
|
+
if item.exists():
|
|
297
|
+
item.unlink()
|
|
298
|
+
deleted += 1
|
|
299
|
+
return deleted
|
nb_lab/cli.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib import request
|
|
8
|
+
|
|
9
|
+
import uvicorn
|
|
10
|
+
|
|
11
|
+
from .cell_model import cache_clear, cache_info, cache_list
|
|
12
|
+
from .server import create_app
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _request_json(method: str, url: str, payload: dict | None = None) -> dict:
|
|
16
|
+
data = None
|
|
17
|
+
headers = {"Content-Type": "application/json"}
|
|
18
|
+
if payload is not None:
|
|
19
|
+
data = json.dumps(payload).encode("utf-8")
|
|
20
|
+
req = request.Request(url, data=data, headers=headers, method=method)
|
|
21
|
+
try:
|
|
22
|
+
with request.urlopen(req) as resp:
|
|
23
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
24
|
+
except Exception as exc:
|
|
25
|
+
raise SystemExit(f"Request failed. Is the server running at {url}? ({exc})")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _latest_version(base_url: str) -> int:
|
|
29
|
+
data = _request_json("GET", f"{base_url}/cells")
|
|
30
|
+
return int(data.get("version", 0))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _cell_id_by_index(base_url: str, index: int) -> str:
|
|
34
|
+
data = _request_json("GET", f"{base_url}/cells")
|
|
35
|
+
cells = data.get("cells", [])
|
|
36
|
+
if index < 0 or index >= len(cells):
|
|
37
|
+
raise SystemExit(f"cell index {index} out of range")
|
|
38
|
+
return cells[index]["id"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cmd_serve(args: argparse.Namespace) -> None:
|
|
42
|
+
_ensure_mpl_config_dir()
|
|
43
|
+
app = create_app(args.path)
|
|
44
|
+
try:
|
|
45
|
+
uvicorn.run(app, host=args.host, port=args.port)
|
|
46
|
+
except OSError as exc:
|
|
47
|
+
if args.port != 0:
|
|
48
|
+
print(f"Port {args.port} unavailable ({exc}). Retrying on an ephemeral port.")
|
|
49
|
+
uvicorn.run(app, host=args.host, port=0)
|
|
50
|
+
else:
|
|
51
|
+
raise
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_list(args: argparse.Namespace) -> None:
|
|
55
|
+
data = _request_json("GET", f"{args.base_url}/cells")
|
|
56
|
+
if args.compact:
|
|
57
|
+
print(json.dumps({"version": data.get("version"), "cells": data.get("cells", [])}, indent=2))
|
|
58
|
+
else:
|
|
59
|
+
print(json.dumps(data, indent=2))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cmd_run(args: argparse.Namespace) -> None:
|
|
63
|
+
if args.cell_id is None and args.cell_index is None:
|
|
64
|
+
raise SystemExit("Provide either cell_id or --cell-index")
|
|
65
|
+
if args.cell_index is not None:
|
|
66
|
+
cell_id = _cell_id_by_index(args.base_url, args.cell_index)
|
|
67
|
+
else:
|
|
68
|
+
cell_id = args.cell_id
|
|
69
|
+
base_version = (
|
|
70
|
+
args.base_version if args.base_version is not None else _latest_version(args.base_url)
|
|
71
|
+
)
|
|
72
|
+
if args.force:
|
|
73
|
+
base_version = _latest_version(args.base_url)
|
|
74
|
+
payload = {
|
|
75
|
+
"mode": args.mode,
|
|
76
|
+
"timeout_ms": args.timeout_ms,
|
|
77
|
+
"base_version": base_version,
|
|
78
|
+
"include_images": not args.no_images,
|
|
79
|
+
}
|
|
80
|
+
data = _request_json("POST", f"{args.base_url}/cells/{cell_id}/run", payload)
|
|
81
|
+
print(json.dumps(data, indent=2))
|
|
82
|
+
|
|
83
|
+
def cmd_edit(args: argparse.Namespace) -> None:
|
|
84
|
+
if args.cell_id is None and args.cell_index is None:
|
|
85
|
+
raise SystemExit("Provide either cell_id or --cell-index")
|
|
86
|
+
if args.cell_index is not None:
|
|
87
|
+
cell_id = _cell_id_by_index(args.base_url, args.cell_index)
|
|
88
|
+
else:
|
|
89
|
+
cell_id = args.cell_id
|
|
90
|
+
base_version = (
|
|
91
|
+
args.base_version if args.base_version is not None else _latest_version(args.base_url)
|
|
92
|
+
)
|
|
93
|
+
if args.force:
|
|
94
|
+
base_version = _latest_version(args.base_url)
|
|
95
|
+
new_source = _resolve_new_source(args)
|
|
96
|
+
payload = {
|
|
97
|
+
"new_source": new_source,
|
|
98
|
+
"base_version": base_version,
|
|
99
|
+
}
|
|
100
|
+
data = _request_json("POST", f"{args.base_url}/cells/{cell_id}/edit", payload)
|
|
101
|
+
print(json.dumps(data, indent=2))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_insert(args: argparse.Namespace) -> None:
|
|
105
|
+
base_version = args.base_version if args.base_version is not None else _latest_version(args.base_url)
|
|
106
|
+
payload = {
|
|
107
|
+
"after_id": args.after_id,
|
|
108
|
+
"source": args.source,
|
|
109
|
+
"kind": args.kind,
|
|
110
|
+
"base_version": base_version,
|
|
111
|
+
}
|
|
112
|
+
data = _request_json("POST", f"{args.base_url}/cells/insert_after", payload)
|
|
113
|
+
print(json.dumps(data, indent=2))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cmd_delete(args: argparse.Namespace) -> None:
|
|
117
|
+
if args.cell_id is None and args.cell_index is None:
|
|
118
|
+
raise SystemExit("Provide either cell_id or --cell-index")
|
|
119
|
+
if args.cell_index is not None:
|
|
120
|
+
cell_id = _cell_id_by_index(args.base_url, args.cell_index)
|
|
121
|
+
else:
|
|
122
|
+
cell_id = args.cell_id
|
|
123
|
+
base_version = (
|
|
124
|
+
args.base_version if args.base_version is not None else _latest_version(args.base_url)
|
|
125
|
+
)
|
|
126
|
+
if args.force:
|
|
127
|
+
base_version = _latest_version(args.base_url)
|
|
128
|
+
payload = {"base_version": base_version}
|
|
129
|
+
data = _request_json("DELETE", f"{args.base_url}/cells/{cell_id}", payload)
|
|
130
|
+
print(json.dumps(data, indent=2))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cmd_show(args: argparse.Namespace) -> None:
|
|
134
|
+
data = _request_json("GET", f"{args.base_url}/outputs/{args.cell_id}")
|
|
135
|
+
if args.compact:
|
|
136
|
+
print(json.dumps(_compact_outputs(data), indent=2))
|
|
137
|
+
else:
|
|
138
|
+
print(json.dumps(data, indent=2))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cmd_status(args: argparse.Namespace) -> None:
|
|
142
|
+
cells_data = _request_json("GET", f"{args.base_url}/cells")
|
|
143
|
+
status_data = _request_json("GET", f"{args.base_url}/status")
|
|
144
|
+
version = cells_data.get("version", 0)
|
|
145
|
+
cells = cells_data.get("cells", [])
|
|
146
|
+
dirty_ids = cells_data.get("dirty_ids", [])
|
|
147
|
+
output = {
|
|
148
|
+
"version": version,
|
|
149
|
+
"cell_count": len(cells),
|
|
150
|
+
"dirty_ids": dirty_ids,
|
|
151
|
+
"running_cell_id": status_data.get("running_cell_id"),
|
|
152
|
+
"running_since": status_data.get("running_since"),
|
|
153
|
+
}
|
|
154
|
+
print(json.dumps(output, indent=2))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_cache(args: argparse.Namespace) -> None:
|
|
158
|
+
if args.cache_action == "info":
|
|
159
|
+
data = cache_info()
|
|
160
|
+
print(json.dumps(data, indent=2))
|
|
161
|
+
return
|
|
162
|
+
if args.cache_action == "list":
|
|
163
|
+
data = cache_list()
|
|
164
|
+
print(json.dumps(data, indent=2))
|
|
165
|
+
return
|
|
166
|
+
if args.cache_action == "clear":
|
|
167
|
+
if args.all and args.path:
|
|
168
|
+
raise SystemExit("Use either --all or --path, not both.")
|
|
169
|
+
if args.all:
|
|
170
|
+
args.yes = True
|
|
171
|
+
args.path = None
|
|
172
|
+
if args.path is None:
|
|
173
|
+
if not args.yes:
|
|
174
|
+
raise SystemExit("Pass --yes to clear the entire cache.")
|
|
175
|
+
deleted = cache_clear()
|
|
176
|
+
else:
|
|
177
|
+
deleted = cache_clear(args.path)
|
|
178
|
+
print(json.dumps({"deleted": deleted}, indent=2))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
182
|
+
parser = argparse.ArgumentParser(prog="nb-lab")
|
|
183
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
184
|
+
|
|
185
|
+
serve = sub.add_parser("serve")
|
|
186
|
+
serve.add_argument("path", type=str)
|
|
187
|
+
serve.add_argument("--host", default="127.0.0.1")
|
|
188
|
+
serve.add_argument("--port", default=8787, type=int)
|
|
189
|
+
serve.set_defaults(func=cmd_serve)
|
|
190
|
+
|
|
191
|
+
list_cmd = sub.add_parser("list")
|
|
192
|
+
list_cmd.add_argument("--base-url", default="http://127.0.0.1:8787")
|
|
193
|
+
list_cmd.add_argument("--compact", action="store_true")
|
|
194
|
+
list_cmd.set_defaults(func=cmd_list)
|
|
195
|
+
|
|
196
|
+
run_cmd = sub.add_parser("run")
|
|
197
|
+
run_cmd.add_argument("cell_id", nargs="?")
|
|
198
|
+
run_cmd.add_argument("--cell-index", type=int)
|
|
199
|
+
run_cmd.add_argument("--base-url", default="http://127.0.0.1:8787")
|
|
200
|
+
run_cmd.add_argument("--base-version", type=int)
|
|
201
|
+
run_cmd.add_argument("--timeout-ms", type=int, default=60000)
|
|
202
|
+
run_cmd.add_argument(
|
|
203
|
+
"--mode",
|
|
204
|
+
default="incremental",
|
|
205
|
+
choices=["incremental", "run_above", "single"],
|
|
206
|
+
)
|
|
207
|
+
run_cmd.add_argument("--no-images", action="store_true")
|
|
208
|
+
run_cmd.add_argument("--force", action="store_true")
|
|
209
|
+
run_cmd.set_defaults(func=cmd_run)
|
|
210
|
+
|
|
211
|
+
show_cmd = sub.add_parser("show")
|
|
212
|
+
show_cmd.add_argument("cell_id")
|
|
213
|
+
show_cmd.add_argument("--base-url", default="http://127.0.0.1:8787")
|
|
214
|
+
show_cmd.add_argument("--compact", action="store_true")
|
|
215
|
+
show_cmd.set_defaults(func=cmd_show)
|
|
216
|
+
|
|
217
|
+
status_cmd = sub.add_parser("status")
|
|
218
|
+
status_cmd.add_argument("--base-url", default="http://127.0.0.1:8787")
|
|
219
|
+
status_cmd.set_defaults(func=cmd_status)
|
|
220
|
+
|
|
221
|
+
cache_cmd = sub.add_parser("cache")
|
|
222
|
+
cache_cmd.add_argument("cache_action", choices=["info", "list", "clear"])
|
|
223
|
+
cache_cmd.add_argument("--path", default=None)
|
|
224
|
+
cache_cmd.add_argument("--yes", action="store_true")
|
|
225
|
+
cache_cmd.add_argument("--all", action="store_true")
|
|
226
|
+
cache_cmd.set_defaults(func=cmd_cache)
|
|
227
|
+
|
|
228
|
+
edit_cmd = sub.add_parser("edit")
|
|
229
|
+
edit_cmd.add_argument("cell_id", nargs="?")
|
|
230
|
+
edit_cmd.add_argument("--cell-index", type=int)
|
|
231
|
+
edit_cmd.add_argument("new_source", nargs="?")
|
|
232
|
+
edit_cmd.add_argument("--from-stdin", action="store_true")
|
|
233
|
+
edit_cmd.add_argument("--base-url", default="http://127.0.0.1:8787")
|
|
234
|
+
edit_cmd.add_argument("--base-version", type=int)
|
|
235
|
+
edit_cmd.add_argument("--force", action="store_true")
|
|
236
|
+
edit_cmd.set_defaults(func=cmd_edit)
|
|
237
|
+
|
|
238
|
+
insert_cmd = sub.add_parser("insert")
|
|
239
|
+
insert_cmd.add_argument("--after-id", default=None)
|
|
240
|
+
insert_cmd.add_argument("--kind", default="code", choices=["code", "markdown"])
|
|
241
|
+
insert_cmd.add_argument("--base-url", default="http://127.0.0.1:8787")
|
|
242
|
+
insert_cmd.add_argument("--base-version", type=int)
|
|
243
|
+
insert_cmd.add_argument("source")
|
|
244
|
+
insert_cmd.set_defaults(func=cmd_insert)
|
|
245
|
+
|
|
246
|
+
delete_cmd = sub.add_parser("delete")
|
|
247
|
+
delete_cmd.add_argument("cell_id", nargs="?")
|
|
248
|
+
delete_cmd.add_argument("--cell-index", type=int)
|
|
249
|
+
delete_cmd.add_argument("--base-url", default="http://127.0.0.1:8787")
|
|
250
|
+
delete_cmd.add_argument("--base-version", type=int)
|
|
251
|
+
delete_cmd.add_argument("--force", action="store_true")
|
|
252
|
+
delete_cmd.set_defaults(func=cmd_delete)
|
|
253
|
+
|
|
254
|
+
return parser
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def main() -> None:
|
|
258
|
+
parser = build_parser()
|
|
259
|
+
args = parser.parse_args()
|
|
260
|
+
args.func(args)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _resolve_new_source(args: argparse.Namespace) -> str:
|
|
264
|
+
if args.from_stdin:
|
|
265
|
+
return sys.stdin.read()
|
|
266
|
+
if args.new_source is None:
|
|
267
|
+
raise SystemExit("Provide new_source or use --from-stdin")
|
|
268
|
+
return args.new_source
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _ensure_mpl_config_dir() -> None:
|
|
272
|
+
import os
|
|
273
|
+
from pathlib import Path
|
|
274
|
+
|
|
275
|
+
if os.environ.get("MPLCONFIGDIR"):
|
|
276
|
+
return
|
|
277
|
+
default_dir = Path.home() / ".cache" / "matplotlib"
|
|
278
|
+
if _is_writable(default_dir):
|
|
279
|
+
os.environ["MPLCONFIGDIR"] = str(default_dir)
|
|
280
|
+
return
|
|
281
|
+
fallback = Path("/tmp/mpl-cache")
|
|
282
|
+
os.environ["MPLCONFIGDIR"] = str(fallback)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _is_writable(path: Path) -> bool:
|
|
286
|
+
try:
|
|
287
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
288
|
+
test_file = path / ".write_test"
|
|
289
|
+
test_file.write_text("ok", encoding="utf-8")
|
|
290
|
+
test_file.unlink(missing_ok=True)
|
|
291
|
+
return True
|
|
292
|
+
except Exception:
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _compact_outputs(data: dict) -> dict:
|
|
297
|
+
result = data.get("result", {})
|
|
298
|
+
outputs = []
|
|
299
|
+
for item in result.get("outputs", []):
|
|
300
|
+
if item.get("type") == "image_png":
|
|
301
|
+
outputs.append({"type": "image_png", "data": "<omitted>"})
|
|
302
|
+
else:
|
|
303
|
+
outputs.append(item)
|
|
304
|
+
result["outputs"] = outputs
|
|
305
|
+
data["result"] = result
|
|
306
|
+
return data
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
if __name__ == "__main__":
|
|
310
|
+
main()
|