nblean 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.
- nblean/SKILL.md +67 -0
- nblean/__init__.py +3 -0
- nblean/__main__.py +5 -0
- nblean/cli.py +289 -0
- nblean/kernel.py +158 -0
- nblean/notebook.py +101 -0
- nblean/process.py +47 -0
- nblean/py.typed +0 -0
- nblean/render.py +150 -0
- nblean/skill.py +21 -0
- nblean-0.1.0.dist-info/METADATA +283 -0
- nblean-0.1.0.dist-info/RECORD +15 -0
- nblean-0.1.0.dist-info/WHEEL +4 -0
- nblean-0.1.0.dist-info/entry_points.txt +3 -0
- nblean-0.1.0.dist-info/licenses/LICENSE +21 -0
nblean/SKILL.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nblean
|
|
3
|
+
description: Use whenever a task touches a Jupyter notebook (.ipynb), including reading, searching, editing, running cells, debugging tracebacks, and inspecting plots. Run the nblean CLI instead of reading the notebook, so only the cells you need enter context, with their outputs clipped.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Working with notebooks via nblean
|
|
7
|
+
|
|
8
|
+
Never `Read`, `cat`, `grep`, or `jq` an `.ipynb` file directly: it loads every output,
|
|
9
|
+
every base64 image, and every progress-bar line. Use `nblean`; if it is not on PATH, run it as `uvx nblean`.
|
|
10
|
+
|
|
11
|
+
## Navigate
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
nblean outline nb.ipynb # always first: one line per cell, output summary
|
|
15
|
+
nblean show nb.ipynb 12 15-18 # source + clipped outputs of those cells only
|
|
16
|
+
nblean show nb.ipynb 12 --no-output # source only, when you are about to edit
|
|
17
|
+
nblean grep nb.ipynb 'read_csv' # index:line hits; -o searches outputs too
|
|
18
|
+
nblean errors nb.ipynb # failing cells only, with your frame and the exception
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
CELL is an index, a range (`3-7`, `5-`), `all`, or a cell id prefix.
|
|
22
|
+
Outputs are clipped to 20 lines (`--lines N`, `--full` to disable). Ask for more
|
|
23
|
+
only when the clipped part is what you need.
|
|
24
|
+
|
|
25
|
+
## Edit
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
nblean set nb.ipynb 12 <<'PY'
|
|
29
|
+
df = load(path)
|
|
30
|
+
PY
|
|
31
|
+
nblean add nb.ipynb --at 13 <<'PY' # --md for markdown; omit --at to append
|
|
32
|
+
df.describe()
|
|
33
|
+
PY
|
|
34
|
+
nblean rm nb.ipynb 14
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Indices shift after `add`/`rm`; re-run `outline` or use ids before chaining edits.
|
|
38
|
+
`set` clears the cell's stale outputs.
|
|
39
|
+
|
|
40
|
+
## Execute
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
nblean run nb.ipynb 0-12 # persistent kernel, outputs saved into the notebook
|
|
44
|
+
nblean run nb.ipynb 12 # re-run one edited cell; earlier state is kept
|
|
45
|
+
nblean eval nb.ipynb 'df.dtypes' # scratch probe, not saved to the notebook
|
|
46
|
+
nblean kernel nb.ipynb restart # clean state; also status, stop
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- The kernel uses the nearest `.venv/bin/python` (override `--python`) and the
|
|
50
|
+
notebook's directory as cwd. It needs `ipykernel` in that env.
|
|
51
|
+
- A fresh kernel is empty: run the prerequisite cells before the one you changed.
|
|
52
|
+
- Investigate with `eval` instead of inserting throwaway print cells.
|
|
53
|
+
- Cells longer than a couple of minutes: run with Bash `run_in_background`.
|
|
54
|
+
- `run` stops at the first error and exits 1.
|
|
55
|
+
- Stop the kernel when the task is done.
|
|
56
|
+
|
|
57
|
+
## Images
|
|
58
|
+
|
|
59
|
+
Plots appear as `[image/png → /tmp/.../12_0.png]`. Do not open them by default.
|
|
60
|
+
Prefer numbers: `nblean eval nb.ipynb 'series.describe()'`. Open an image with `Read`
|
|
61
|
+
only when the judgement is visual, and downscale first since image tokens scale
|
|
62
|
+
with pixel area: `sips -Z 768 in.png --out /tmp/small.png` (macOS).
|
|
63
|
+
|
|
64
|
+
## Caveats
|
|
65
|
+
|
|
66
|
+
If the user has the notebook open in Jupyter or VS Code, tell them to reload it after
|
|
67
|
+
your writes and not to save over them from the editor.
|
nblean/__init__.py
ADDED
nblean/__main__.py
ADDED
nblean/cli.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import io
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
from nblean import kernel, skill
|
|
11
|
+
from nblean.notebook import Cell, NbleanError, Notebook, joined, source_of
|
|
12
|
+
from nblean.render import (
|
|
13
|
+
FULL,
|
|
14
|
+
Budget,
|
|
15
|
+
ImageSink,
|
|
16
|
+
Output,
|
|
17
|
+
cell_header,
|
|
18
|
+
clean,
|
|
19
|
+
image_suffix,
|
|
20
|
+
outline_row,
|
|
21
|
+
render_outputs,
|
|
22
|
+
shorten,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
type Handler = Callable[[argparse.Namespace], int]
|
|
26
|
+
|
|
27
|
+
GREP_WIDTH = 160
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def image_sink(notebook: Path, label: str) -> ImageSink:
|
|
31
|
+
def write(position: int, mime: str, payload: bytes) -> Path:
|
|
32
|
+
directory = kernel.state_dir(notebook) / "img"
|
|
33
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
target = directory / f"{label}_{position}{image_suffix(mime)}"
|
|
35
|
+
target.write_bytes(payload)
|
|
36
|
+
return target
|
|
37
|
+
|
|
38
|
+
return write
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def budget_of(args: argparse.Namespace) -> Budget:
|
|
42
|
+
return FULL if args.full else Budget(lines=args.lines)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_stdin() -> str:
|
|
46
|
+
if sys.stdin.isatty():
|
|
47
|
+
msg = "expected cell source on stdin"
|
|
48
|
+
raise NbleanError(msg)
|
|
49
|
+
return sys.stdin.read()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def outputs_of(cell: Cell) -> list[Output]:
|
|
53
|
+
return cast("list[Output]", cell.get("outputs", []))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_outline(args: argparse.Namespace) -> int:
|
|
57
|
+
notebook = Notebook.load(args.notebook)
|
|
58
|
+
kinds = Counter(cell["cell_type"] for cell in notebook.cells)
|
|
59
|
+
summary = ", ".join(f"{count} {kind}" for kind, count in kinds.items())
|
|
60
|
+
status = "live" if kernel.running_pid(notebook.path) else "off"
|
|
61
|
+
print(f"{notebook.path.name}: {len(notebook.cells)} cells ({summary}), kernel {status}")
|
|
62
|
+
for index, cell in enumerate(notebook.cells):
|
|
63
|
+
print(outline_row(index, cell))
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_show(args: argparse.Namespace) -> int:
|
|
68
|
+
notebook = Notebook.load(args.notebook)
|
|
69
|
+
budget = budget_of(args)
|
|
70
|
+
for index in notebook.select(args.cells):
|
|
71
|
+
cell = notebook.cells[index]
|
|
72
|
+
print(cell_header(index, cell))
|
|
73
|
+
print(source_of(cell))
|
|
74
|
+
if cell["cell_type"] == "code" and not args.no_output:
|
|
75
|
+
print("── out ──")
|
|
76
|
+
print(render_outputs(outputs_of(cell), budget, image_sink(notebook.path, str(index))))
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_grep(args: argparse.Namespace) -> int:
|
|
81
|
+
notebook = Notebook.load(args.notebook)
|
|
82
|
+
pattern = re.compile(args.pattern)
|
|
83
|
+
found = False
|
|
84
|
+
for index, cell in enumerate(notebook.cells):
|
|
85
|
+
texts = [("", source_of(cell))]
|
|
86
|
+
if args.outputs:
|
|
87
|
+
texts += [("out ", text) for text in output_texts(outputs_of(cell))]
|
|
88
|
+
for label, text in texts:
|
|
89
|
+
for number, line in enumerate(clean(text).splitlines(), 1):
|
|
90
|
+
if pattern.search(line):
|
|
91
|
+
found = True
|
|
92
|
+
print(f"{index}:{label}{number}: {shorten(line.strip(), GREP_WIDTH)}")
|
|
93
|
+
return 0 if found else 1
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def output_texts(outputs: list[Output]) -> list[str]:
|
|
97
|
+
texts = []
|
|
98
|
+
for output in outputs:
|
|
99
|
+
match output:
|
|
100
|
+
case {"output_type": "stream", "text": text} | {"data": {"text/plain": text}}:
|
|
101
|
+
texts.append(joined(text))
|
|
102
|
+
case {"output_type": "error", "traceback": traceback}:
|
|
103
|
+
texts.append("\n".join(traceback))
|
|
104
|
+
return texts
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cmd_errors(args: argparse.Namespace) -> int:
|
|
108
|
+
notebook = Notebook.load(args.notebook)
|
|
109
|
+
budget = budget_of(args)
|
|
110
|
+
failing = 0
|
|
111
|
+
for index, cell in enumerate(notebook.cells):
|
|
112
|
+
errors = [output for output in outputs_of(cell) if output["output_type"] == "error"]
|
|
113
|
+
if errors:
|
|
114
|
+
failing += 1
|
|
115
|
+
print(cell_header(index, cell))
|
|
116
|
+
print(render_outputs(errors, budget, image_sink(notebook.path, str(index))))
|
|
117
|
+
if not failing:
|
|
118
|
+
print("no errors")
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_set(args: argparse.Namespace) -> int:
|
|
123
|
+
notebook = Notebook.load(args.notebook)
|
|
124
|
+
match notebook.select([args.cell]):
|
|
125
|
+
case [index]:
|
|
126
|
+
notebook.replace_source(index, read_stdin())
|
|
127
|
+
case indices:
|
|
128
|
+
msg = f"set takes exactly one cell, {args.cell!r} selects {len(indices)}"
|
|
129
|
+
raise NbleanError(msg)
|
|
130
|
+
notebook.save()
|
|
131
|
+
print(f"set {index}")
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_add(args: argparse.Namespace) -> int:
|
|
136
|
+
notebook = Notebook.load(args.notebook)
|
|
137
|
+
index = notebook.insert(args.at, "markdown" if args.md else "code", read_stdin())
|
|
138
|
+
notebook.save()
|
|
139
|
+
print(f"added {index}")
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cmd_rm(args: argparse.Namespace) -> int:
|
|
144
|
+
notebook = Notebook.load(args.notebook)
|
|
145
|
+
indices = notebook.select(args.cells)
|
|
146
|
+
notebook.remove(indices)
|
|
147
|
+
notebook.save()
|
|
148
|
+
print(f"removed {', '.join(map(str, indices))}")
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
153
|
+
notebook = Notebook.load(args.notebook)
|
|
154
|
+
budget = budget_of(args)
|
|
155
|
+
code_cells = [
|
|
156
|
+
i for i in notebook.select(args.cells) if notebook.cells[i]["cell_type"] == "code"
|
|
157
|
+
]
|
|
158
|
+
client = kernel.connect(notebook.path, args.python)
|
|
159
|
+
try:
|
|
160
|
+
for index in code_cells:
|
|
161
|
+
cell = notebook.cells[index]
|
|
162
|
+
execution = kernel.execute(client, source_of(cell))
|
|
163
|
+
cell |= {"outputs": execution.outputs, "execution_count": execution.count}
|
|
164
|
+
notebook.save()
|
|
165
|
+
print(cell_header(index, cell))
|
|
166
|
+
print(render_outputs(execution.outputs, budget, image_sink(notebook.path, str(index))))
|
|
167
|
+
if execution.failed:
|
|
168
|
+
return 1
|
|
169
|
+
finally:
|
|
170
|
+
client.stop_channels()
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def cmd_eval(args: argparse.Namespace) -> int:
|
|
175
|
+
code = read_stdin() if args.code == "-" else args.code
|
|
176
|
+
client = kernel.connect(args.notebook, args.python)
|
|
177
|
+
try:
|
|
178
|
+
execution = kernel.execute(client, code, store_history=False)
|
|
179
|
+
finally:
|
|
180
|
+
client.stop_channels()
|
|
181
|
+
print(render_outputs(execution.outputs, budget_of(args), image_sink(args.notebook, "eval")))
|
|
182
|
+
return 1 if execution.failed else 0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def cmd_kernel(args: argparse.Namespace) -> int:
|
|
186
|
+
match args.action:
|
|
187
|
+
case "status":
|
|
188
|
+
pid = kernel.running_pid(args.notebook)
|
|
189
|
+
print(f"live, pid {pid}" if pid else "off")
|
|
190
|
+
case "stop":
|
|
191
|
+
print("stopped" if kernel.stop(args.notebook) else "was not running")
|
|
192
|
+
case "restart":
|
|
193
|
+
kernel.stop(args.notebook)
|
|
194
|
+
kernel.start(args.notebook, args.python)
|
|
195
|
+
print("restarted, state is empty")
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
200
|
+
for path in skill.install(args.agent or ["claude"], project=args.project):
|
|
201
|
+
print(f"wrote {path}")
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
206
|
+
parser = argparse.ArgumentParser(
|
|
207
|
+
prog="nblean",
|
|
208
|
+
description="Token-lean Jupyter notebook access for coding agents.",
|
|
209
|
+
epilog="CELL is an index, a range like 3-7 or 5-, 'all', or a cell id prefix.",
|
|
210
|
+
)
|
|
211
|
+
commands = parser.add_subparsers(required=True, metavar="COMMAND")
|
|
212
|
+
|
|
213
|
+
def command(name: str, handler: Handler, summary: str) -> argparse.ArgumentParser:
|
|
214
|
+
sub = commands.add_parser(name, help=summary, description=summary)
|
|
215
|
+
sub.add_argument("notebook", type=Path)
|
|
216
|
+
sub.set_defaults(handler=handler)
|
|
217
|
+
return sub
|
|
218
|
+
|
|
219
|
+
def output_options(sub: argparse.ArgumentParser) -> None:
|
|
220
|
+
sub.add_argument("--full", action="store_true", help="disable output clipping")
|
|
221
|
+
sub.add_argument("--lines", type=int, default=20, help="max lines per output (default 20)")
|
|
222
|
+
|
|
223
|
+
def kernel_options(sub: argparse.ArgumentParser) -> None:
|
|
224
|
+
sub.add_argument("--python", type=Path, help="kernel interpreter (default: nearest .venv)")
|
|
225
|
+
|
|
226
|
+
init = commands.add_parser(
|
|
227
|
+
"init", help="install the agent skill", description="Install the nblean agent skill."
|
|
228
|
+
)
|
|
229
|
+
init.add_argument(
|
|
230
|
+
"--agent",
|
|
231
|
+
action="append",
|
|
232
|
+
choices=[*skill.SKILL_DIRS, "all"],
|
|
233
|
+
help="target agent, repeatable (default: claude)",
|
|
234
|
+
)
|
|
235
|
+
init.add_argument("--project", action="store_true", help="install into ./ instead of ~/")
|
|
236
|
+
init.set_defaults(handler=cmd_init)
|
|
237
|
+
|
|
238
|
+
command("outline", cmd_outline, "one line per cell with output summary")
|
|
239
|
+
|
|
240
|
+
show = command("show", cmd_show, "source and clipped outputs of selected cells")
|
|
241
|
+
show.add_argument("cells", nargs="+", metavar="CELL")
|
|
242
|
+
show.add_argument("--no-output", action="store_true", help="source only")
|
|
243
|
+
output_options(show)
|
|
244
|
+
|
|
245
|
+
grep = command("grep", cmd_grep, "regex search over cell sources")
|
|
246
|
+
grep.add_argument("pattern")
|
|
247
|
+
grep.add_argument("-o", "--outputs", action="store_true", help="search outputs too")
|
|
248
|
+
|
|
249
|
+
output_options(command("errors", cmd_errors, "tracebacks of failing cells"))
|
|
250
|
+
|
|
251
|
+
command("set", cmd_set, "replace a cell's source from stdin").add_argument(
|
|
252
|
+
"cell", metavar="CELL"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
add = command("add", cmd_add, "insert a cell from stdin")
|
|
256
|
+
add.add_argument("--at", type=int, help="insert position (default: end)")
|
|
257
|
+
add.add_argument("--md", action="store_true", help="markdown cell")
|
|
258
|
+
|
|
259
|
+
command("rm", cmd_rm, "delete cells").add_argument("cells", nargs="+", metavar="CELL")
|
|
260
|
+
|
|
261
|
+
run = command("run", cmd_run, "execute cells in a persistent kernel and save outputs")
|
|
262
|
+
run.add_argument("cells", nargs="+", metavar="CELL")
|
|
263
|
+
output_options(run)
|
|
264
|
+
kernel_options(run)
|
|
265
|
+
|
|
266
|
+
evaluate = command("eval", cmd_eval, "run scratch code in the kernel without saving it")
|
|
267
|
+
evaluate.add_argument("code", help="code to run, or - for stdin")
|
|
268
|
+
output_options(evaluate)
|
|
269
|
+
kernel_options(evaluate)
|
|
270
|
+
|
|
271
|
+
manage = command("kernel", cmd_kernel, "inspect or control the notebook's kernel")
|
|
272
|
+
manage.add_argument("action", choices=["status", "stop", "restart"])
|
|
273
|
+
kernel_options(manage)
|
|
274
|
+
|
|
275
|
+
return parser
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
279
|
+
if sys.platform == "win32":
|
|
280
|
+
for stream in (sys.stdout, sys.stderr):
|
|
281
|
+
if isinstance(stream, io.TextIOWrapper):
|
|
282
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
283
|
+
args = build_parser().parse_args(argv)
|
|
284
|
+
try:
|
|
285
|
+
handler: Handler = args.handler
|
|
286
|
+
return handler(args)
|
|
287
|
+
except NbleanError as exc:
|
|
288
|
+
print(f"nblean: {exc}", file=sys.stderr)
|
|
289
|
+
return 2
|
nblean/kernel.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from jupyter_client.blocking.client import BlockingKernelClient
|
|
14
|
+
|
|
15
|
+
from nblean import process
|
|
16
|
+
from nblean.notebook import NbleanError
|
|
17
|
+
from nblean.render import Output
|
|
18
|
+
|
|
19
|
+
STATE_ROOT = Path(tempfile.gettempdir()) / "nblean"
|
|
20
|
+
STARTUP_TIMEOUT = 60.0
|
|
21
|
+
POLL_INTERVAL = 0.1
|
|
22
|
+
LOG_TAIL = 15
|
|
23
|
+
RESULT_KEYS = ("data", "execution_count", "metadata")
|
|
24
|
+
ERROR_KEYS = ("ename", "evalue", "traceback")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class Execution:
|
|
29
|
+
outputs: list[Output] = field(default_factory=list)
|
|
30
|
+
count: int | None = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def failed(self) -> bool:
|
|
34
|
+
return any(output["output_type"] == "error" for output in self.outputs)
|
|
35
|
+
|
|
36
|
+
def collect(self, message: dict[str, Any]) -> None:
|
|
37
|
+
content = message["content"]
|
|
38
|
+
match message["msg_type"]:
|
|
39
|
+
case "stream":
|
|
40
|
+
last = self.outputs[-1] if self.outputs else {}
|
|
41
|
+
if last.get("output_type") == "stream" and last["name"] == content["name"]:
|
|
42
|
+
last["text"] += content["text"]
|
|
43
|
+
else:
|
|
44
|
+
self.outputs.append(
|
|
45
|
+
{
|
|
46
|
+
"output_type": "stream",
|
|
47
|
+
"name": content["name"],
|
|
48
|
+
"text": content["text"],
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
case "display_data":
|
|
52
|
+
self.outputs.append(
|
|
53
|
+
{
|
|
54
|
+
"output_type": "display_data",
|
|
55
|
+
"data": content["data"],
|
|
56
|
+
"metadata": content["metadata"],
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
case "execute_result":
|
|
60
|
+
self.outputs.append(
|
|
61
|
+
{"output_type": "execute_result"} | {key: content[key] for key in RESULT_KEYS}
|
|
62
|
+
)
|
|
63
|
+
case "error":
|
|
64
|
+
self.outputs.append(
|
|
65
|
+
{"output_type": "error"} | {key: content[key] for key in ERROR_KEYS}
|
|
66
|
+
)
|
|
67
|
+
case "clear_output":
|
|
68
|
+
self.outputs.clear()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def state_dir(notebook: Path) -> Path:
|
|
72
|
+
digest = hashlib.sha256(str(notebook.resolve()).encode()).hexdigest()[:12]
|
|
73
|
+
return STATE_ROOT / f"{notebook.stem}-{digest}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def find_python(start: Path) -> Path:
|
|
77
|
+
for directory in (start.resolve(), *start.resolve().parents):
|
|
78
|
+
if (candidate := directory / ".venv" / process.VENV_PYTHON).exists():
|
|
79
|
+
return candidate
|
|
80
|
+
return Path(sys.executable)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def running_pid(notebook: Path) -> int | None:
|
|
84
|
+
state = state_dir(notebook)
|
|
85
|
+
try:
|
|
86
|
+
pid = int((state / "kernel.pid").read_text())
|
|
87
|
+
except (OSError, ValueError):
|
|
88
|
+
return None
|
|
89
|
+
return pid if process.is_running(pid) and (state / "kernel.json").exists() else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def start(notebook: Path, python: Path | None) -> None:
|
|
93
|
+
state = state_dir(notebook)
|
|
94
|
+
state.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
connection = state / "kernel.json"
|
|
96
|
+
connection.unlink(missing_ok=True)
|
|
97
|
+
interpreter = python or find_python(notebook.parent)
|
|
98
|
+
environment = {key: value for key, value in os.environ.items() if key != "JPY_PARENT_PID"}
|
|
99
|
+
log_path = state / "kernel.log"
|
|
100
|
+
with log_path.open("w") as log:
|
|
101
|
+
kernel = subprocess.Popen(
|
|
102
|
+
[str(interpreter), "-m", "ipykernel_launcher", "-f", str(connection)],
|
|
103
|
+
cwd=notebook.resolve().parent,
|
|
104
|
+
env=environment,
|
|
105
|
+
stdin=subprocess.DEVNULL,
|
|
106
|
+
stdout=log,
|
|
107
|
+
stderr=subprocess.STDOUT,
|
|
108
|
+
start_new_session=process.NEW_SESSION,
|
|
109
|
+
creationflags=process.CREATION_FLAGS,
|
|
110
|
+
)
|
|
111
|
+
(state / "kernel.pid").write_text(str(kernel.pid))
|
|
112
|
+
deadline = time.monotonic() + STARTUP_TIMEOUT
|
|
113
|
+
while time.monotonic() < deadline:
|
|
114
|
+
if kernel.poll() is not None:
|
|
115
|
+
tail = "\n".join(log_path.read_text().splitlines()[-LOG_TAIL:])
|
|
116
|
+
msg = (
|
|
117
|
+
f"kernel exited using {interpreter}:\n{tail}\n"
|
|
118
|
+
"is ipykernel installed there? `uv add --dev ipykernel`"
|
|
119
|
+
)
|
|
120
|
+
raise NbleanError(msg)
|
|
121
|
+
with suppress(OSError, ValueError):
|
|
122
|
+
json.loads(connection.read_text())
|
|
123
|
+
return
|
|
124
|
+
time.sleep(POLL_INTERVAL)
|
|
125
|
+
kernel.kill()
|
|
126
|
+
msg = f"kernel did not start within {STARTUP_TIMEOUT:.0f}s, see {log_path}"
|
|
127
|
+
raise NbleanError(msg)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def stop(notebook: Path) -> bool:
|
|
131
|
+
pid = running_pid(notebook)
|
|
132
|
+
if pid is not None:
|
|
133
|
+
process.terminate_tree(pid)
|
|
134
|
+
for name in ("kernel.json", "kernel.pid"):
|
|
135
|
+
(state_dir(notebook) / name).unlink(missing_ok=True)
|
|
136
|
+
return pid is not None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def connect(notebook: Path, python: Path | None) -> BlockingKernelClient:
|
|
140
|
+
if running_pid(notebook) is None:
|
|
141
|
+
start(notebook, python)
|
|
142
|
+
client = BlockingKernelClient()
|
|
143
|
+
client.load_connection_file(str(state_dir(notebook) / "kernel.json"))
|
|
144
|
+
client.start_channels()
|
|
145
|
+
client.wait_for_ready(timeout=STARTUP_TIMEOUT)
|
|
146
|
+
return client
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def execute(client: BlockingKernelClient, code: str, *, store_history: bool = True) -> Execution:
|
|
150
|
+
execution = Execution()
|
|
151
|
+
reply = client.execute_interactive(
|
|
152
|
+
code,
|
|
153
|
+
store_history=store_history,
|
|
154
|
+
allow_stdin=False,
|
|
155
|
+
output_hook=execution.collect,
|
|
156
|
+
)
|
|
157
|
+
execution.count = reply["content"].get("execution_count")
|
|
158
|
+
return execution
|
nblean/notebook.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import uuid
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from itertools import chain
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Self, cast
|
|
9
|
+
|
|
10
|
+
type Cell = dict[str, Any]
|
|
11
|
+
|
|
12
|
+
CELL_RANGE = re.compile(r"(\d+)-(\d*)")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NbleanError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def joined(value: str | list[str]) -> str:
|
|
20
|
+
return value if isinstance(value, str) else "".join(value)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def source_of(cell: Cell) -> str:
|
|
24
|
+
return joined(cell["source"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def lines_of(text: str) -> list[str]:
|
|
28
|
+
return text.removesuffix("\n").splitlines(keepends=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class Notebook:
|
|
33
|
+
path: Path
|
|
34
|
+
data: dict[str, Any]
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def load(cls, path: Path) -> Self:
|
|
38
|
+
try:
|
|
39
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
40
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
41
|
+
msg = f"{path}: {exc}"
|
|
42
|
+
raise NbleanError(msg) from exc
|
|
43
|
+
return cls(path, data)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def cells(self) -> list[Cell]:
|
|
47
|
+
return cast("list[Cell]", self.data["cells"])
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def has_cell_ids(self) -> bool:
|
|
51
|
+
return (self.data.get("nbformat", 4), self.data.get("nbformat_minor", 0)) >= (
|
|
52
|
+
4,
|
|
53
|
+
5,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def save(self) -> None:
|
|
57
|
+
text = json.dumps(self.data, sort_keys=True, indent=1, ensure_ascii=False) + "\n"
|
|
58
|
+
staging = self.path.with_name(f".{self.path.name}.nblean")
|
|
59
|
+
staging.write_text(text, encoding="utf-8", newline="\n")
|
|
60
|
+
staging.replace(self.path)
|
|
61
|
+
|
|
62
|
+
def select(self, specs: Iterable[str]) -> list[int]:
|
|
63
|
+
return list(dict.fromkeys(chain.from_iterable(map(self._resolve, specs))))
|
|
64
|
+
|
|
65
|
+
def _resolve(self, spec: str) -> Iterable[int]:
|
|
66
|
+
count = len(self.cells)
|
|
67
|
+
if spec == "all":
|
|
68
|
+
return range(count)
|
|
69
|
+
if spec.isdigit():
|
|
70
|
+
if (index := int(spec)) >= count:
|
|
71
|
+
msg = f"cell {index} out of range, notebook has {count} cells"
|
|
72
|
+
raise NbleanError(msg)
|
|
73
|
+
return [index]
|
|
74
|
+
if match := CELL_RANGE.fullmatch(spec):
|
|
75
|
+
stop = int(match[2]) + 1 if match[2] else count
|
|
76
|
+
return range(int(match[1]), min(stop, count))
|
|
77
|
+
hits = [i for i, cell in enumerate(self.cells) if cell.get("id", "").startswith(spec)]
|
|
78
|
+
if len(hits) != 1:
|
|
79
|
+
msg = f"cell id {spec!r} matched {len(hits)} cells"
|
|
80
|
+
raise NbleanError(msg)
|
|
81
|
+
return hits
|
|
82
|
+
|
|
83
|
+
def replace_source(self, index: int, text: str) -> None:
|
|
84
|
+
cell = self.cells[index]
|
|
85
|
+
cell["source"] = lines_of(text)
|
|
86
|
+
if cell["cell_type"] == "code":
|
|
87
|
+
cell |= {"execution_count": None, "outputs": []}
|
|
88
|
+
|
|
89
|
+
def insert(self, index: int | None, kind: str, text: str) -> int:
|
|
90
|
+
cell: Cell = {"cell_type": kind, "metadata": {}, "source": lines_of(text)}
|
|
91
|
+
if self.has_cell_ids:
|
|
92
|
+
cell["id"] = uuid.uuid4().hex[:8]
|
|
93
|
+
if kind == "code":
|
|
94
|
+
cell |= {"execution_count": None, "outputs": []}
|
|
95
|
+
position = len(self.cells) if index is None else min(index, len(self.cells))
|
|
96
|
+
self.cells.insert(position, cell)
|
|
97
|
+
return position
|
|
98
|
+
|
|
99
|
+
def remove(self, indices: Iterable[int]) -> None:
|
|
100
|
+
for index in sorted(indices, reverse=True):
|
|
101
|
+
del self.cells[index]
|
nblean/process.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from contextlib import suppress
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
if sys.platform == "win32":
|
|
8
|
+
import ctypes
|
|
9
|
+
|
|
10
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
11
|
+
STILL_ACTIVE = 259
|
|
12
|
+
VENV_PYTHON = Path("Scripts", "python.exe")
|
|
13
|
+
CREATION_FLAGS = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
|
14
|
+
NEW_SESSION = False
|
|
15
|
+
|
|
16
|
+
def is_running(pid: int) -> bool:
|
|
17
|
+
kernel32 = ctypes.windll.kernel32
|
|
18
|
+
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
19
|
+
if not handle:
|
|
20
|
+
return False
|
|
21
|
+
try:
|
|
22
|
+
code = ctypes.c_ulong()
|
|
23
|
+
alive = kernel32.GetExitCodeProcess(handle, ctypes.byref(code))
|
|
24
|
+
return bool(alive) and code.value == STILL_ACTIVE
|
|
25
|
+
finally:
|
|
26
|
+
kernel32.CloseHandle(handle)
|
|
27
|
+
|
|
28
|
+
def terminate_tree(pid: int) -> None:
|
|
29
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], capture_output=True, check=False)
|
|
30
|
+
|
|
31
|
+
else:
|
|
32
|
+
import signal
|
|
33
|
+
|
|
34
|
+
VENV_PYTHON = Path("bin", "python")
|
|
35
|
+
CREATION_FLAGS = 0
|
|
36
|
+
NEW_SESSION = True
|
|
37
|
+
|
|
38
|
+
def is_running(pid: int) -> bool:
|
|
39
|
+
try:
|
|
40
|
+
os.kill(pid, 0)
|
|
41
|
+
except OSError:
|
|
42
|
+
return False
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
def terminate_tree(pid: int) -> None:
|
|
46
|
+
with suppress(ProcessLookupError):
|
|
47
|
+
os.killpg(pid, signal.SIGTERM)
|
nblean/py.typed
ADDED
|
File without changes
|
nblean/render.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import mimetypes
|
|
3
|
+
import re
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from nblean.notebook import Cell, joined, source_of
|
|
11
|
+
|
|
12
|
+
type Output = dict[str, Any]
|
|
13
|
+
type ImageSink = Callable[[int, str, bytes], Path]
|
|
14
|
+
|
|
15
|
+
ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
|
16
|
+
HEAD_WIDTH = 72
|
|
17
|
+
LIBRARY_FRAME = re.compile(r"site-packages|\.pyx\b|\.pxi\b|[\\/]lib[\\/]python\d|[\\/]Lib[\\/]")
|
|
18
|
+
TRACEBACK_HEADER = "Traceback (most recent call last)"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class Budget:
|
|
23
|
+
lines: int | None = 20
|
|
24
|
+
width: int | None = 160
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
FULL = Budget(lines=None, width=None)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def shorten(line: str, width: int | None) -> str:
|
|
31
|
+
return line if width is None or len(line) <= width else f"{line[: width - 1]}…"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def clean(text: str) -> str:
|
|
35
|
+
text = ANSI_ESCAPE.sub("", text)
|
|
36
|
+
return "\n".join(line.rstrip("\r").rsplit("\r", 1)[-1] for line in text.split("\n"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def clip(text: str, budget: Budget, *, tail_only: bool = False) -> str:
|
|
40
|
+
lines = [shorten(line, budget.width) for line in clean(text).rstrip("\n").split("\n")]
|
|
41
|
+
if budget.lines is None or len(lines) <= budget.lines:
|
|
42
|
+
return "\n".join(lines)
|
|
43
|
+
head = 0 if tail_only else budget.lines // 2
|
|
44
|
+
tail = budget.lines - head
|
|
45
|
+
marker = f"… {len(lines) - budget.lines} lines omitted …"
|
|
46
|
+
return "\n".join([*lines[:head], marker, *lines[-tail:]])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def compact_traceback(traceback: list[str]) -> str:
|
|
50
|
+
entries = [clean(entry) for entry in traceback]
|
|
51
|
+
start = max((i for i, entry in enumerate(entries) if TRACEBACK_HEADER in entry), default=0)
|
|
52
|
+
*frames, exception = entries[start:]
|
|
53
|
+
kept = [frame for frame in frames if not LIBRARY_FRAME.search(frame.split("\n", 1)[0])]
|
|
54
|
+
hidden = len(entries) - 1 - len(kept)
|
|
55
|
+
marker = [f"… {hidden} library or chained frames omitted …"] if hidden else []
|
|
56
|
+
return "\n".join([*kept, *marker, exception])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def render_error(output: Output, budget: Budget) -> str:
|
|
60
|
+
traceback: list[str] = output.get("traceback", [])
|
|
61
|
+
if not traceback:
|
|
62
|
+
return f"{output.get('ename')}: {output.get('evalue')}"
|
|
63
|
+
text = "\n".join(traceback) if budget.lines is None else compact_traceback(traceback)
|
|
64
|
+
return clip(text, budget, tail_only=True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def line_count(text: str) -> int:
|
|
68
|
+
return clean(text).rstrip("\n").count("\n") + 1
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def image_mime(data: dict[str, Any]) -> str | None:
|
|
72
|
+
return next((mime for mime in data if mime.startswith("image/")), None)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def image_bytes(mime: str, payload: str | list[str]) -> bytes:
|
|
76
|
+
text = joined(payload)
|
|
77
|
+
return text.encode() if mime == "image/svg+xml" else base64.b64decode(text)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def image_suffix(mime: str) -> str:
|
|
81
|
+
return mimetypes.guess_extension(mime) or ".bin"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def render_output(position: int, output: Output, budget: Budget, sink: ImageSink) -> str:
|
|
85
|
+
match output:
|
|
86
|
+
case {"output_type": "stream", "text": text}:
|
|
87
|
+
return clip(joined(text), budget)
|
|
88
|
+
case {"output_type": "error"}:
|
|
89
|
+
return render_error(output, budget)
|
|
90
|
+
case {"data": data} if mime := image_mime(data):
|
|
91
|
+
return f"[{mime} → {sink(position, mime, image_bytes(mime, data[mime]))}]"
|
|
92
|
+
case {"data": {"text/plain": text}}:
|
|
93
|
+
return clip(joined(text), budget)
|
|
94
|
+
case {"data": data}:
|
|
95
|
+
return f"[{', '.join(data)}]"
|
|
96
|
+
case _:
|
|
97
|
+
return f"[{output.get('output_type', 'unknown output')}]"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def render_outputs(outputs: list[Output], budget: Budget, sink: ImageSink) -> str:
|
|
101
|
+
rendered = (render_output(n, output, budget, sink) for n, output in enumerate(outputs))
|
|
102
|
+
return "\n".join(rendered) or "(no output)"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def summarize(outputs: list[Output]) -> str:
|
|
106
|
+
text_lines = 0
|
|
107
|
+
marks: Counter[str] = Counter()
|
|
108
|
+
for output in outputs:
|
|
109
|
+
match output:
|
|
110
|
+
case {"output_type": "error", "ename": ename}:
|
|
111
|
+
marks[f"ERR {ename}"] += 1
|
|
112
|
+
case {"output_type": "stream", "text": text}:
|
|
113
|
+
text_lines += line_count(joined(text))
|
|
114
|
+
case {"data": data} if image_mime(data):
|
|
115
|
+
marks["img"] += 1
|
|
116
|
+
case {"data": {"text/plain": text}}:
|
|
117
|
+
text_lines += line_count(joined(text))
|
|
118
|
+
case _:
|
|
119
|
+
marks["other"] += 1
|
|
120
|
+
parts = [f"{text_lines}L"] if text_lines else []
|
|
121
|
+
parts += [mark if n == 1 else f"{mark}×{n}" for mark, n in marks.items()]
|
|
122
|
+
return " ".join(parts)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def outline_row(index: int, cell: Cell) -> str:
|
|
126
|
+
lines = [line.strip() for line in source_of(cell).splitlines() if line.strip()]
|
|
127
|
+
head = shorten(lines[0], HEAD_WIDTH) if lines else ""
|
|
128
|
+
extra = f" +{len(lines) - 1}L" if len(lines) > 1 else ""
|
|
129
|
+
suffix = ""
|
|
130
|
+
match cell["cell_type"]:
|
|
131
|
+
case "code":
|
|
132
|
+
tag = "code"
|
|
133
|
+
if cell.get("execution_count") is None:
|
|
134
|
+
suffix = " → not run"
|
|
135
|
+
elif summary := summarize(cell.get("outputs", [])):
|
|
136
|
+
suffix = f" → {summary}"
|
|
137
|
+
case "markdown":
|
|
138
|
+
tag = "md"
|
|
139
|
+
case kind:
|
|
140
|
+
tag = kind
|
|
141
|
+
return f"{index:>3} {tag:<5} {head}{extra}{suffix}"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def cell_header(index: int, cell: Cell) -> str:
|
|
145
|
+
fields = [str(index), cell["cell_type"]]
|
|
146
|
+
if cell_id := cell.get("id"):
|
|
147
|
+
fields.append(f"id={cell_id}")
|
|
148
|
+
if (count := cell.get("execution_count")) is not None:
|
|
149
|
+
fields.append(f"exec={count}")
|
|
150
|
+
return f"── {' '.join(fields)} ──"
|
nblean/skill.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
from importlib.resources import files
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
SKILL_DIRS = {
|
|
6
|
+
"claude": Path(".claude", "skills"),
|
|
7
|
+
"codex": Path(".agents", "skills"),
|
|
8
|
+
"opencode": Path(".agents", "skills"),
|
|
9
|
+
"gemini": Path(".gemini", "skills"),
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def install(agents: Iterable[str], *, project: bool) -> list[Path]:
|
|
14
|
+
names = SKILL_DIRS if "all" in agents else agents
|
|
15
|
+
base = Path.cwd() if project else Path.home()
|
|
16
|
+
targets = dict.fromkeys(base / SKILL_DIRS[name] / "nblean" / "SKILL.md" for name in names)
|
|
17
|
+
text = files("nblean").joinpath("SKILL.md").read_text(encoding="utf-8")
|
|
18
|
+
for target in targets:
|
|
19
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
target.write_text(text, encoding="utf-8", newline="\n")
|
|
21
|
+
return list(targets)
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nblean
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Token-lean Jupyter notebook access for coding agents: outlines, clipped outputs, lazy images, a persistent kernel.
|
|
5
|
+
Keywords: jupyter,notebook,ipynb,llm,coding-agent,claude-code,tokens,cli
|
|
6
|
+
Author: Recep Kaan Karaman
|
|
7
|
+
Author-email: Recep Kaan Karaman <kaanrkaraman@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Framework :: Jupyter
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering
|
|
21
|
+
Classifier: Topic :: Software Development
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Dist: jupyter-client>=8.10.0
|
|
24
|
+
Requires-Python: >=3.13
|
|
25
|
+
Project-URL: Homepage, https://github.com/kaanrkaraman/nblean
|
|
26
|
+
Project-URL: Repository, https://github.com/kaanrkaraman/nblean
|
|
27
|
+
Project-URL: Issues, https://github.com/kaanrkaraman/nblean/issues
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
<p align="center">
|
|
31
|
+
<strong>nblean</strong><br>
|
|
32
|
+
<strong>Lets coding agents work with Jupyter notebooks without reading every output</strong>
|
|
33
|
+
</p>
|
|
34
|
+
|
|
35
|
+
<p align="center">
|
|
36
|
+
<a href="https://github.com/kaanrkaraman/nblean/actions/workflows/ci.yml"><img src="https://github.com/kaanrkaraman/nblean/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
37
|
+
<a href="https://pypi.org/project/nblean/"><img src="https://img.shields.io/pypi/v/nblean" alt="PyPI"></a>
|
|
38
|
+
<a href="https://pypi.org/project/nblean/"><img src="https://img.shields.io/pypi/pyversions/nblean" alt="Python"></a>
|
|
39
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
|
|
40
|
+
</p>
|
|
41
|
+
|
|
42
|
+
<p align="center">
|
|
43
|
+
<a href="#installation">Install</a> •
|
|
44
|
+
<a href="#quick-start">Quick Start</a> •
|
|
45
|
+
<a href="#commands">Commands</a> •
|
|
46
|
+
<a href="#how-savings-work">Benchmarks</a> •
|
|
47
|
+
<a href="#supported-agents">Agents</a>
|
|
48
|
+
</p>
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
An `.ipynb` file holds code plus everything the code printed: DataFrame dumps,
|
|
53
|
+
progress bars, tracebacks, and plots stored as base64 images. An agent that opens one
|
|
54
|
+
reads all of it. To fix one cell, it then runs the whole notebook again and reads the
|
|
55
|
+
result again.
|
|
56
|
+
|
|
57
|
+
nblean is a small CLI and agent skill that lets the agent work one cell at a time. It
|
|
58
|
+
lists the notebook as an outline, opens only the cells asked for, clips their outputs,
|
|
59
|
+
and runs cells in a background kernel that stays alive between commands.
|
|
60
|
+
|
|
61
|
+
## What nblean Does
|
|
62
|
+
|
|
63
|
+
| Agent needs to | Without nblean | With nblean |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| See what a notebook contains | Reads the whole JSON, outputs included | `outline`: one line per cell, with an output summary like `→ 14L img ERR KeyError` |
|
|
66
|
+
| Look at a few cells | Writes a one-off `json`/`jq` script | `show 12 15-18`: source, plus outputs clipped to the first and last lines |
|
|
67
|
+
| Find code | Greps JSON-escaped strings | `grep`: matches by cell and line number |
|
|
68
|
+
| Debug | Scrolls through ANSI-colored tracebacks full of library frames | `errors`: only the failing cells, showing your frame and the exception |
|
|
69
|
+
| Edit a cell | Rewrites the JSON, or reads the notebook first | `set` / `add` / `rm`, with the new source passed on stdin |
|
|
70
|
+
| Re-run after a fix | Runs the whole notebook again and reads all the output | `run 12`: runs that cell in a kernel that is still alive, prints only its output, and saves it into the notebook |
|
|
71
|
+
| Check a value | Adds a throwaway `print` cell | `eval 'df.dtypes'`: runs the code in the kernel without changing the notebook |
|
|
72
|
+
| Look at plots | Every plot costs image tokens | Plots are saved as files and only their paths are printed; the agent opens one when it needs to |
|
|
73
|
+
|
|
74
|
+
## How Savings Work
|
|
75
|
+
|
|
76
|
+
nblean cuts **notebook tokens**, which are only part of an agent's bill. Every session
|
|
77
|
+
also pays for the system prompt, tool definitions, and conversation history. These
|
|
78
|
+
fixed costs come to about 28k tokens per session on Claude Code. Savings grow with the
|
|
79
|
+
size of the notebook and the volume of its outputs.
|
|
80
|
+
|
|
81
|
+
### A/B test in Claude Code
|
|
82
|
+
|
|
83
|
+
Claude Sonnet ran headless (`claude -p`), two times with the nblean skill and two
|
|
84
|
+
times without it. Token counts
|
|
85
|
+
come from the `usage` that Claude Code reports; costs are what the same usage would
|
|
86
|
+
cost at API prices.
|
|
87
|
+
|
|
88
|
+
| Task | Without nblean | With nblean | Change |
|
|
89
|
+
|---|---:|---:|---:|
|
|
90
|
+
| Answer two questions from the saved outputs of a 7 MB EDA notebook (97 cells, 31 plots) | 236k tokens · $0.207 | 172k tokens · $0.160 | **-27% tokens, -23% cost** |
|
|
91
|
+
| Find and fix a bug in a 124 KB notebook, then re-run it until clean | 183k tokens · $0.153 | 183k tokens · $0.150 | no measurable change |
|
|
92
|
+
|
|
93
|
+
The first version of nblean showed execution counts in the outline, and one run
|
|
94
|
+
reported them as cell indices. The numbers above come from runs after that fix, and
|
|
95
|
+
all of them answered correctly. Every fix run ended with a clean notebook. On the
|
|
96
|
+
small notebook the baseline agent already worked efficiently: it ran `nbconvert` and
|
|
97
|
+
grepped for errors, so nblean had little to remove.
|
|
98
|
+
|
|
99
|
+
### First look
|
|
100
|
+
|
|
101
|
+
This compares the `outline` output with reading the whole notebook,
|
|
102
|
+
counting text as bytes/4 and images by pixel area. It is an upper bound on what the
|
|
103
|
+
first step saves.
|
|
104
|
+
|
|
105
|
+
| Notebook | Whole notebook | `nblean outline` |
|
|
106
|
+
|---|---:|---:|
|
|
107
|
+
| Flight-data EDA, 97 cells, 31 plots | ~81k | ~1.5k |
|
|
108
|
+
| Data preprocessing | ~58k | ~260 |
|
|
109
|
+
| Kaggle training run | ~29k | ~90 |
|
|
110
|
+
|
|
111
|
+
## Installation
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
uv tool install nblean # or: pipx install nblean
|
|
115
|
+
nblean --help
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
You can also run it without installing: `uvx nblean outline nb.ipynb`.
|
|
119
|
+
|
|
120
|
+
The kernel runs in the notebook's own environment, not in nblean's. nblean picks the
|
|
121
|
+
nearest `.venv` above the notebook, which needs `ipykernel` installed
|
|
122
|
+
(`uv add --dev ipykernel`). Use `--python PATH` to pick a different interpreter.
|
|
123
|
+
|
|
124
|
+
## Quick Start
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# 1. Install the skill for your agent
|
|
128
|
+
nblean init # Claude Code (default)
|
|
129
|
+
nblean init --agent codex # Codex
|
|
130
|
+
nblean init --agent opencode # OpenCode
|
|
131
|
+
nblean init --agent gemini # Gemini CLI
|
|
132
|
+
nblean init --agent all # all of the above
|
|
133
|
+
nblean init --project # into ./ instead of ~/
|
|
134
|
+
|
|
135
|
+
# 2. Ask your agent anything about a notebook
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The skill tells the agent to start with `outline` and never to read the `.ipynb`
|
|
139
|
+
directly.
|
|
140
|
+
|
|
141
|
+
## How It Works
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
Without nblean: With nblean:
|
|
145
|
+
|
|
146
|
+
agent --read--> nb.ipynb agent --outline/show--> nblean --> nb.ipynb
|
|
147
|
+
^ | ^ | clip, summarize,
|
|
148
|
+
| every source, | | only requested | images to files
|
|
149
|
+
| output, image | | cells, clipped |
|
|
150
|
+
+-----------------+ +-----------------------+
|
|
151
|
+
agent --run 12--> nblean --> live kernel
|
|
152
|
+
| only this cell's output,
|
|
153
|
+
| saved back into nb.ipynb
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The kernel is a detached `ipykernel` process. Each notebook gets its own kernel, and
|
|
157
|
+
each nblean call connects to it, runs code, and disconnects. Variables stay in memory
|
|
158
|
+
between calls until `kernel stop` or `kernel restart`.
|
|
159
|
+
|
|
160
|
+
## Commands
|
|
161
|
+
|
|
162
|
+
CELL is an index (`12`), a range (`3-7`, `5-`), `all`, or the start of a cell id.
|
|
163
|
+
|
|
164
|
+
### Read
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
nblean outline nb.ipynb # one line per cell
|
|
168
|
+
nblean show nb.ipynb 12 15-18 # source + clipped outputs
|
|
169
|
+
nblean show nb.ipynb 12 --no-output # source only
|
|
170
|
+
nblean show nb.ipynb 12 --lines 60 # bigger clip; --full disables it
|
|
171
|
+
nblean grep nb.ipynb 'read_csv' # search sources; -o includes outputs
|
|
172
|
+
nblean errors nb.ipynb # failing cells only
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Edit
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
nblean set nb.ipynb 12 <<'PY' # replace source, clears stale outputs
|
|
179
|
+
df = pd.read_parquet(path)
|
|
180
|
+
PY
|
|
181
|
+
nblean add nb.ipynb --at 13 <<'PY' # insert; --md for markdown
|
|
182
|
+
df.describe()
|
|
183
|
+
PY
|
|
184
|
+
nblean rm nb.ipynb 14 20-22
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Execute
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
nblean run nb.ipynb 0-12 # run cells, save outputs, stop at first error
|
|
191
|
+
nblean eval nb.ipynb 'df.shape' # scratch code, nothing saved
|
|
192
|
+
nblean kernel nb.ipynb status|stop|restart
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Examples
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
$ nblean outline eda.ipynb
|
|
199
|
+
eda.ipynb: 97 cells (36 markdown, 61 code), kernel off
|
|
200
|
+
0 md 1. Files and Schema +14L
|
|
201
|
+
1 code import itertools +27L
|
|
202
|
+
3 code files = pl.DataFrame( +5L → 13L
|
|
203
|
+
13 code p0 = pl.read_parquet(PARQ / "part.0.parquet") +7L → 1L img
|
|
204
|
+
15 code fl_min = feat["flight_length"].to_numpy() / 60 +19L → img
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
A 40-line pandas traceback, cut down to the line that failed:
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
$ nblean errors buggy.ipynb
|
|
211
|
+
── 8 code id=cell08 exec=3 ──
|
|
212
|
+
KeyError Traceback (most recent call last)
|
|
213
|
+
Cell In[3], line 1
|
|
214
|
+
----> 1 df["pressure_kpa"] = df["presure"] * 6.89476
|
|
215
|
+
2 df.head()
|
|
216
|
+
|
|
217
|
+
… 11 library or chained frames omitted …
|
|
218
|
+
KeyError: 'presure'
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
A 300-line training log, with a plot that is saved to a file instead of loaded:
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
$ nblean run buggy.ipynb 3 4 --lines 6
|
|
225
|
+
── 3 code id=cell03 exec=4 ──
|
|
226
|
+
epoch 000 smoothing pass loss=1.000000 temp_mean=141.845
|
|
227
|
+
epoch 001 smoothing pass loss=0.500000 temp_mean=141.724
|
|
228
|
+
epoch 002 smoothing pass loss=0.333333 temp_mean=141.651
|
|
229
|
+
… 294 lines omitted …
|
|
230
|
+
epoch 297 smoothing pass loss=0.003356 temp_mean=139.027
|
|
231
|
+
epoch 298 smoothing pass loss=0.003344 temp_mean=139.038
|
|
232
|
+
epoch 299 smoothing pass loss=0.003333 temp_mean=139.048
|
|
233
|
+
── 4 code id=cell04 exec=5 ──
|
|
234
|
+
[image/png → /tmp/nblean/buggy-ec4a859a6390/img/4_0.png]
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Supported Agents
|
|
238
|
+
|
|
239
|
+
The skill uses the [Agent Skills](https://agentskills.io) `SKILL.md` format, so the
|
|
240
|
+
same file works in every agent below. The CLI works with any agent that can run shell
|
|
241
|
+
commands.
|
|
242
|
+
|
|
243
|
+
| Agent | `nblean init` | Skill location |
|
|
244
|
+
|---|---|---|
|
|
245
|
+
| Claude Code | `--agent claude`, or the plugin below | `~/.claude/skills/nblean` |
|
|
246
|
+
| Codex | `--agent codex` | `~/.agents/skills/nblean` |
|
|
247
|
+
| OpenCode | `--agent opencode` | `~/.agents/skills/nblean` |
|
|
248
|
+
| Gemini CLI | `--agent gemini` | `~/.gemini/skills/nblean` |
|
|
249
|
+
| Others | `npx skills add kaanrkaraman/nblean` | per agent |
|
|
250
|
+
|
|
251
|
+
In Claude Code the skill is also available as a plugin:
|
|
252
|
+
|
|
253
|
+
```
|
|
254
|
+
/plugin marketplace add kaanrkaraman/nblean
|
|
255
|
+
/plugin install nblean@nblean
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
nblean ships as a CLI and a skill instead of an MCP server. An MCP server's tool
|
|
259
|
+
definitions are loaded into every session. A skill is loaded only when a notebook
|
|
260
|
+
comes up.
|
|
261
|
+
|
|
262
|
+
## Platform Support
|
|
263
|
+
|
|
264
|
+
macOS, Linux, and Windows, on Python 3.13+. The kernel's Python can be any version
|
|
265
|
+
that `ipykernel` supports.
|
|
266
|
+
|
|
267
|
+
## Limitations
|
|
268
|
+
|
|
269
|
+
- A cell's outputs are written when the cell finishes. If a long cell is killed, its output is lost.
|
|
270
|
+
- Writes are atomic but not merged. If the notebook is open in Jupyter or VS Code, reload it there after nblean writes.
|
|
271
|
+
- Widgets, `update_display_data`, and `input()` are not supported.
|
|
272
|
+
|
|
273
|
+
## Contributing
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
uv sync
|
|
277
|
+
uv run pytest
|
|
278
|
+
uv run ruff check . && uv run mypy
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## License
|
|
282
|
+
|
|
283
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
nblean/SKILL.md,sha256=hfMltvod-KKHt5rsPBxlUYivo-IvpIdKsQ5xnHz5ibU,2772
|
|
2
|
+
nblean/__init__.py,sha256=XIUjpD7vCIRfnx18NyLhlcsMeBK0tONgaWWugsAml1A,48
|
|
3
|
+
nblean/__main__.py,sha256=fFi5nqpqk7_6wdAtSkmauDKeqHFBRov8ruLR-AfbszQ,58
|
|
4
|
+
nblean/cli.py,sha256=FlmyEVhEYvS7WgF1eYKrYvF5W4cUgf6Wc0LNFdDa7ts,10230
|
|
5
|
+
nblean/kernel.py,sha256=nd_9SWvYjDZT2gX7NzCKXmyMnOsJlNbmqL54_F003dM,5490
|
|
6
|
+
nblean/notebook.py,sha256=QMEgR_EaMz_3QaRtzIx5ij4woyQq-WGqR4_elr6VgIk,3285
|
|
7
|
+
nblean/process.py,sha256=0ZSxGr-GqhNNwMPruC7MNz76eEen-eH16PzFsJ9pL5A,1366
|
|
8
|
+
nblean/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
nblean/render.py,sha256=0xjXdnhwDuA2oIZKLMgAM783PN3GFlz-j0SJwaz2zjk,5477
|
|
10
|
+
nblean/skill.py,sha256=tHa3CWGEAIz0wxjBXsAJPWEgntLotLAYNm6ca_-ctsc,801
|
|
11
|
+
nblean-0.1.0.dist-info/licenses/LICENSE,sha256=hZ-d4XRb4sZgApI3eeJeMmaEpakU07fxMbQR8qf-1A8,1075
|
|
12
|
+
nblean-0.1.0.dist-info/WHEEL,sha256=e4_1dyBeezi8ZjfxrZ3bnVOxFDa3ksqVqH0jTHkUZ3k,81
|
|
13
|
+
nblean-0.1.0.dist-info/entry_points.txt,sha256=3no9FnFDoUuW3R4Of2rjsILULkMgV-Wn5W8wY8YXtV0,40
|
|
14
|
+
nblean-0.1.0.dist-info/METADATA,sha256=JtPqh4-mPfDjCfxuSTb8OGcSDkAWpLJa5bvEV1tHAWY,11444
|
|
15
|
+
nblean-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Recep Kaan Karaman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|