Cell creation, targeted updates, documentation insertion, and examples.
Writing notebooks safely is harder than appending text to a file. A notebook edit needs to preserve cell ids, clear stale outputs, validate Python when possible, export through nbdev automatically, and avoid overwriting the wrong cell.
write_nb inserts cells; replace_str replaces literal text across notebook cell sources.
There are two writing modes because notebook edits have two different shapes. Use cell-block writes when adding or replacing structured cells, and use literal replacement only for exact renames across existing cells. Both paths stamp nbskill metadata and export affected notebooks automatically when they have an nbdev export target. write_literal_replacements is the public literal-replacement API.
Production contract
The writing tools are production core. They must preserve stable cell ids where possible, clear stale outputs for changed cells, validate new Python, export nbdev modules after source changes, support dry-run review for broad replacements, and verify structured batch edits by reading the notebook back.
Writing cells
The write layer turns structured cell plans into valid notebook cells and keeps the on-disk notebook in sync with its exported module.
Adding cells
write_nb is the broad insertion tool. It can append, insert before or after a stable cell id, replace a whole notebook, or write into a named chapter while preserving notebook structure.
def write_literal_replacements( path:str, # path of the notebook old_str:str, # old string to replace new_str:str='', # new string to insert run_test:bool=False, # run the tests for this code validate_code:bool=True, # run the code to make sure it works dry_run:bool=False, # don't make actual changes show_cells:bool=False, # show the cells that were edited):
Replace literal text across notebook cell sources.
Use one-cell updates for surgical changes; use explosion or chapter splitting when the notebook structure itself is the thing being changed.
Updating one cell
update_cell is the surgical tool. It keeps the original cell id, can replace a whole cell or only a line range, and clears stale outputs.
For whole-cell replacement, pass exactly one notebook cell block: optional %%code, %%markdown, or %%raw marker followed by the cell source. Do not include standalone --- separators; those mean multiple cells and belong with write_nb or batch_edit_nb. Use line_range or old_str when replacing only part of a cell.
update_cell accepts exact Python text and structured source lines, so callers need no newline decoding or temporary patch files.
def update_cell( path:str, # Notebook path new:str='', new_file:str|None=None, # Read replacement text from a UTF-8 file cell_id:str|None=None, # Stable notebook cell id to update old_str:str|None=None, # Literal text to replace in the target cell, or used to locate the cell when cell_id is absent line_range:str|None=None, # 1-based inclusive lines to replace, e.g. 3 or 3:5 split:bool=False, # Split a multi-cell replacement into separate cells split_before:str|None=None, # Split existing cell before the first matching line cell_type:str='code', # Default type for whole-cell replacements without %% marker run_test:bool=False, # Execute the notebook with execnb after writing validate_code:bool=True, # Validate changed Python code before writing dry_run:bool=False, # Show the update plan without writing):
Update one notebook cell: replace whole cell, a text substring, or a line range.
def split_cell( path:str, # Notebook path cell_id:str, # Stable cell id to split new:str='', new_file:str|None=None, # Read replacement text from a UTF-8 file split_before:str|None=None, # Split existing cell before the first line containing or matching this text cell_type:str='code', # Default type for replacement cells without %% marker run_test:bool=False, # Execute the notebook with execnb after writing validate_code:bool=True, # Validate changed Python code before writing dry_run:bool=False, # Show the split plan without writing):
Split one notebook cell into multiple cells, either at a matching line or by supplying replacement content.
Exploding function cells
explode_cell splits a code cell containing multiple top-level functions into one code cell per function. The generated cells keep the original leading directives, and the normal notebook commit path stamps ids and nbskill metadata.
def explode_cell( path:str, # Notebook path cell_id:str|None=None, # Stable cell id to explode target_all:bool=False, # Explode every splittable code cell in the notebook run_test:bool=False, # Execute the notebook with execnb after writing validate_code:bool=True, # Validate changed Python code before writing dry_run:bool=False, # Show the explode plan without writing):
Split top-level function definitions in one or all code cells into separate cells.
Previewing an explosion
A dry run shows the planned split without changing the scratch notebook.
with write_demo_notebook("02_write_explode_preview.ipynb", cells=[mk_cell("#| export\ndef first():\n return 1\n\ndef second():\n return 2")]) as path: cell_id = read_nb(path).cells[0].id explode_cell(str(path), cell_id=cell_id, dry_run=True)
with write_demo_notebook("02_write_update.ipynb") as path: _exercise_direct_update_cell(path)print("direct update_cell preserved escaped newlines")
Structured cell edits
Python callers can pass lists of source lines instead of shell-escaped text. These helpers preserve that representation in the write layer, including optional split_lines markers that split only on empty lines.
Wrote 1 cells to nbs/data/02_write_structured_edit.ipynb using replace
Updated cell into 2 cells id=aa02cf6a
Updated lines 1:1 id=aa02cf6a
Inserted 1 cell after id=e05b6833
with write_demo_notebook("02_write_structured_apply.ipynb") as path: write_nb(str(path), "%%code\ntail = True", replace=True) tail_id = read_nb(path).cells[0].id op =dict(op="replace_cell", cell_id=tail_id, source_lines=["tail = 'applied'"]) apply_msg = apply_notebook_edit(op, str(path))assert read_nb(path).cells[0].source =="tail = 'applied'" delete_msg = delete_notebook_cell(str(path), tail_id)assertlen(read_nb(path).cells) ==0assert save_notebook_edit(read_nb(path), path) isFalseprint(apply_msg)print(delete_msg)print("structured helpers left cells:", len(read_nb(path).cells))
Wrote 1 cells to nbs/data/02_write_structured_apply.ipynb using replace
Updated cell id=892ad2a6
Deleted cell id=892ad2a6
structured helpers left cells: 0
Wrote 1 cells to nbs/data/02_write_feedback.ipynb using replace
automatic edit feedback returned output
Batch operations
Batch plans are useful when several related edits should be validated and committed together.
Batch editing
Agents often need to change several notebook cells together. batch_edit_nb accepts a JSON edit plan, runs the same notebook-aware validation and locking as the single-cell tools, and prints a compact diff before writing. Use it when repeated shell calls would make multiline code or dry-run diffs fragile.
Verified batch edits
Batch plans are useful when an agent already knows the exact notebook edits it wants to make. They are also risky if the tool only says “applied” without checking disk. batch_edit_nb now writes the notebooks, reads them back, and reports a verification status plus before/after hashes for each operation.
def batch_edit_nb( plan:str='', plan_file:str|None=None, # Read the JSON plan from a UTF-8 file path:str|None=None, # Default notebook path for operations that omit path dry_run:bool=True, # Show the plan and diffs without writing validate_code:bool=True, # Validate changed Python code before writing default_cell_type:str='code', # Default cell type for inserted cells without %% markers):
Apply a JSON batch edit plan as thin edit_notebook operations.
Splitting one chapter
split_nb_chapter moves one ## chapter into a new nbdev notebook. It copies imports used by the moved code, imports source-notebook definitions that the moved chapter still references, and promotes referenced private source helpers by dropping the leading underscore. Splitting modules is a broad, deliberate refactor, so it remains an explicit Python API.
def split_nb_chapter( path:str, # Source notebook path chapter:str, # Chapter title string or regex to split out dest:str, # Destination notebook path default_exp:str|None=None, # Destination nbdev default_exp; defaults from dest path dry_run:bool=True, # Show the split plan without writing notebooks force:bool=False, # Overwrite dest if it already exists promote_private:bool=True, # Promote referenced private source helpers by dropping the leading underscore):
Split one ## chapter into a new nbdev notebook.
Moving cells between notebooks
move_cells is the transfer boundary: it keeps full cells intact, derives imports from their AST names, and rolls both files back when a normal commit fails. A name turns the destination into a new chapter; without one, moves stay inside one notebook.