# Round-trip smoke test against a throwaway notebook.
import tempfile, os
tmp = tempfile.mktemp(suffix='.ipynb')
_nbf.write(_nbf.v4.new_notebook(cells=[_nbf.v4.new_code_cell('x = 1', id='cell0')]), tmp)
print(insert_cells(tmp, 'cell0', ['y = 2', 'z = 3']))
print(read_nb(tmp))
print(grep_nb(tmp, 'y ='))
print(edit_nb(tmp, 'cell0', 'x = 1', 'x = 100'))
print(patch_nb_cell(tmp, 'cell0', 'x = 999'))
# batch docs: insert two notes after cell0 (order preserved) + rewrite cell0, atomically
print(write_nb_docs(tmp, notes=[['cell0', '# Title'], ['cell0', 'intro paragraph']],
patches=[['cell0', 'x = 4242']]))
print(read_nb(tmp, full=True))
# project-level batch docs: same idea, spanning multiple notebooks + an extra plain-text file
tmp2 = tempfile.mktemp(suffix='.ipynb')
_nbf.write(_nbf.v4.new_notebook(cells=[_nbf.v4.new_code_cell('w = 1', id='cell0')]), tmp2)
llms_tmp = tempfile.mktemp(suffix='.txt')
print(write_project_docs({
tmp: {'patches': [['cell0', 'x = 1']]},
tmp2: {'notes': [['cell0', '# Other notebook']]},
}, llms_txt={llms_tmp: '# demo\n> summary\n'}))
os.remove(tmp2); os.remove(llms_tmp)
os.remove(tmp)nbtools
slmn CLI, or expose them over MCP (see 03_mcp.ipynb).
Path safety
Every path-taking tool below first calls _resolve_safe. It’s a no-op by default, but if SLMN_SANDBOX_ROOTS is set (colon-separated directories) it refuses any path that resolves outside them – a guardrail for when these tools are driven by an agent.
Reading & searching
read_nb and grep_nb are the read side. read_nb with no arguments lists every cell’s id and a one-line preview – the usual way to get your bearings before a targeted read or edit, since every other tool here addresses cells by id.
read_nb
def read_nb(
path:str, # path to the .ipynb file
cell_ids:list=None, # if given, only these cells' full source (ids may be partial/substring matches)
full:bool=False, # if True (and no cell_ids given), print full source of every cell
)->str:Read cell source(s) from a Jupyter notebook. With no cell_ids and full=False, lists every cell’s id + a source preview (one line each) – good for getting your bearings before a targeted read.
grep_nb
def grep_nb(
path:str, # path to the .ipynb file
pattern:str, # regex pattern to search for, per line, within each cell's source
context:int=0, # lines of context before/after each match to include
line_numbers:bool=False, # prefix each shown line with its (0-based) line number within the cell
)->str:Grep cell sources in a Jupyter notebook. Returns the matching cells (id header + matching/context lines); empty string if nothing matched.
Editing cells
Two ways to change an existing cell, both addressed by id: edit_nb does a find/replace within a cell’s source (like a code editor’s replace), while patch_nb_cell overwrites a cell’s source wholesale. All writes use json.dump(..., indent=1) to match Jupyter’s own formatting, so a one-cell change stays a one-cell git diff.
edit_nb
def edit_nb(
path:str, # path to the .ipynb file
cell_id:str, # cell id, or a prefix of one
old_str:str, # text to find in the cell's source (first occurrence)
new_str:str, # replacement text
)->str:Replace the first occurrence of old_str with new_str in one cell’s source, addressed by id-prefix. Falls back to a trailing-whitespace-insensitive match if the exact text isn’t found. Raises ValueError if the cell or the text can’t be found.
patch_nb_cell
def patch_nb_cell(
path:str, # path to the .ipynb file
cell_id:str, # exact cell id
new_source:str, # the cell's complete new source, replacing the old source entirely
)->str:Replace a cell’s entire source (exact id match, not a prefix – use this when you want to overwrite a cell wholesale rather than patch part of it; see edit_nb for a targeted string replacement). Raises ValueError if the cell isn’t found.
Adding cells
add_nb_cell is the single-cell primitive; insert_cells is a thin loop over it for bulk inserts. add_nb_cell – not the caller – owns the #| export / #| eval: false directives for code cells: pass plain source plus the export/eval flags and it regenerates them idempotently, while leaving other directives (#| default_exp, #| hide) alone. (This very notebook edit was made with these tools.)
insert_cells
def insert_cells(
path:str, # path to the .ipynb file
anchor_id:str, # exact cell id to insert after
sources:list, # one or more new cells' source, inserted in order right after the anchor -- plain source, no '#| ...' directives (see add_nb_cell)
export:bool=True, # forwarded to add_nb_cell for every inserted cell -- see there
eval:bool=True, # forwarded to add_nb_cell for every inserted cell -- see there
)->str:Insert one or more new code cells immediately after the cell with id anchor_id. A thin bulk convenience wrapper over add_nb_cell (the single-cell primitive), so the actual nbformat plumbing – including the ‘#| export’/‘#| eval: false’ directive handling – lives in one place. Raises ValueError if the anchor isn’t found.
add_nb_cell
def add_nb_cell(
path:str, # path to the .ipynb file
source:str, # the new cell's source text. Don't include '#| export'/'#| eval: false' -- they're regenerated from the flags below (any you do include are stripped first, so it stays idempotent). OTHER directives like '#| default_exp' or '#| hide' ARE preserved, so pass those literally.
cell_type:str='code', # nbformat cell type: 'code', 'markdown', or 'raw'
after_id:str=None, # id of the cell to insert immediately after; None appends at the end (in cell order, NOT a raw file append)
cell_id:str=None, # if given, force the new cell's id (e.g. to match an intended nbdev '# %%' marker); default lets nbformat assign one
export:bool=True, # code cells only: prepend '#| export' so nbdev exports this cell into the module. Ignored for markdown/raw cells.
eval:bool=True, # code cells only: if False, prepend '#| eval: false' so nbdev skips executing this cell during tests/docs (e.g. a live-server/network demo). Ignored for markdown/raw cells.
)->str:Add a new cell to a Jupyter notebook and return its id. Appends to the end by default, or inserts right after after_id. cell_type is an nbformat type (‘code’/‘markdown’/‘raw’) – note that boopiter-style note/prompt/assistant cells are all ‘markdown’ at the nbformat level. This tool – not the caller – owns the ‘#| export’/‘#| eval: false’ directives for code cells: pass plain source and use export/eval (any of those two you include literally get stripped and regenerated, so it’s idempotent; other directives like ‘#| default_exp’/‘#| hide’ are left as-is). Reads/writes the file as plain JSON (indent=1) like the other nbtools here, so a one-cell add stays a one-cell git diff rather than reserializing the whole notebook. Raises ValueError on an unknown cell_type, or an after_id that isn’t present.
Writing docs in bulk
write_nb_docs is the batch form of the two tools above, for documenting a notebook in one shot: give it notes (markdown section headings to insert after named cells) and/or patches (cells to rewrite), and it validates every id before writing anything – so a single wrong id fails cleanly instead of leaving a half-documented notebook. It’s the reusable version of this very notebook’s doc pass; point it at another project to do the same there.
write_nb_docs
def write_nb_docs(
path:str, # path to the .ipynb file
notes:list=None, # markdown Note cells to INSERT: a list of [after_id, source] pairs. Each cell is inserted right after the cell whose id equals after_id; order within the list is preserved even when several notes share one anchor.
patches:list=None, # existing cells to REWRITE: a list of [cell_id, new_source] pairs. Each named cell's entire source is replaced wholesale (like patch_nb_cell).
cell_type:str='markdown', # nbformat cell type for the inserted `notes` ('markdown' for docs; 'code'/'raw' also accepted)
)->str:Apply a batch of documentation edits to a notebook in ONE atomic pass: insert markdown section-Note cells after named anchor cells (notes) and/or rewrite existing cells wholesale (patches). Every referenced id is validated against the notebook FIRST, so a single wrong id raises before anything is written – no half-applied state (the failure mode of firing off many add_nb_cell/patch_nb_cell calls in a row, where one bad id leaves the earlier ones already committed). The whole notebook is written once with json.dump(indent=1), so it stays a minimal git diff. This is the reusable form of the ‘add section headings throughout a notebook, then tidy the index’ workflow – point it at any nbdev notebook (slmn’s own, or another project like boopiter) to document it. notes source is inserted verbatim (markdown needs no directives); for code cells that need ‘#| export’/‘#| eval’ use add_nb_cell instead.
Documenting a whole project
write_project_docs is write_nb_docs extended across every notebook in a project: give it {notebook_path: {'notes': ..., 'patches': ...}} and it validates every cell id in every notebook before writing any of them, then applies each notebook’s batch. It also folds in the recipe for wiring up an llms.txt file on an nbdev project’s docs site – see its docstring, which is intentionally long, since it’s the guidance a future documentation pass (on slmn itself, or another project like boopiter) should read before touching llms.txt.
write_project_docs
def write_project_docs(
specs:dict, # {notebook_path: {'notes': [[after_id, source], ...], 'patches': [[cell_id, new_source], ...]}} -- one entry per notebook to touch; either key may be omitted or None
llms_txt:dict=None, # optional {path: source} of extra plain-text files to write verbatim, e.g. {'nbs/llms.txt': '# proj\n> ...'}
)->str:Document a whole nbdev project in one call: apply a write_nb_docs batch to each notebook named in specs, plus optionally write extra plain-text files (llms_txt). Validates every cell id across every notebook FIRST, so a bad id in notebook #5 doesn’t leave #1-4 already rewritten – the project-wide version of write_nb_docs’s own atomicity.
This is the level to reach for when repeating slmn’s own documentation pass (section-Note headings per notebook + a tidied index/README) on another nbdev project, e.g. boopiter. For the full recipe – including when to use notes (additive, default) vs patches (only for disposable nbdev/quarto boilerplate, never over real human-written content) and how to wire up an llms.txt file for the project’s docs site – see the write-project-docs skill: slmn/skills/write-project-docs/SKILL.md.
Smoke test
A quick round-trip against a throwaway notebook – insert, read, grep, edit, patch – so a nbdev-test run exercises the whole toolkit end to end. Not exported.