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 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
@@ -0,0 +1,3 @@
1
+ from nblean.cli import main
2
+
3
+ __all__ = ["main"]
nblean/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from nblean.cli import main
4
+
5
+ sys.exit(main())
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> &bull;
44
+ <a href="#quick-start">Quick Start</a> &bull;
45
+ <a href="#commands">Commands</a> &bull;
46
+ <a href="#how-savings-work">Benchmarks</a> &bull;
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.19
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ nblean = nblean:main
3
+
@@ -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.