Writing and changing

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.


source

write_literal_replacements

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.

write_literal_replacements("nbs/example.ipynb", "old_name", "new_name", dry_run=True, show_cells=True)

source

write_nb

def write_nb(
    path:str, cells:str='', before_id:str | None=None, after_id:str | None=None, chapter:str | None=None,
    replace:bool=False, cell_type:str='code', # cell type code|markdown
    run_test:bool=False, # execute the tests
    run_style:bool=False, # return style problems
    style_strict:bool=False, validate_code:bool=True
):

Write cells to a notebook.

Updating and splitting

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.


source

update_cell

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.


source

split_cell

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.


source

explode_cell

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.


source

join_source_lines

def join_source_lines(
    lines, field:str='source_lines'
):

Return newline text from a structured list of source lines.


source

valid_split_line_numbers

def valid_split_line_numbers(
    source_lines, split_lines:NoneType=None
):

Return requested 1-based split lines that point at empty source lines.


source

split_source_lines

def split_source_lines(
    source_lines, split_lines:NoneType=None
):

Split source lines at requested empty 1-based line numbers.


source

source_lines_cell

def source_lines_cell(
    source_lines, cell_type:str='code'
):

Build one clean notebook cell from structured source lines.


source

source_lines_cells

def source_lines_cells(
    cell, default_cell_type:str='code'
):

Build one or more cells from a structured cell object.


source

should_run_cell_feedback

def should_run_cell_feedback(
    cell
):

Return whether an edited cell should be executed for immediate feedback.


source

notebook_edit_feedback

def notebook_edit_feedback(
    path, cell_ids, auto_feedback:bool=True, timeout:int=10, safe:bool=True
):

Execute through the last feedback-worthy edited cell and return visible output.


source

append_notebook_edit_feedback

def append_notebook_edit_feedback(
    message, path, cell_ids, auto_feedback:bool=True, feedback_timeout:int=10, feedback_safe:bool=True
):

Append automatic notebook execution feedback when an edited cell calls for it.


source

save_notebook_edit

def save_notebook_edit(
    nb, path
):

Stamp, write, export, and report whether nbdev produced Python.


source

replace_notebook_cell

def replace_notebook_cell(
    path, cell_id, source_lines, cell_type:str='code', split_lines:NoneType=None, validate_code:bool=True,
    auto_feedback:bool=True, feedback_timeout:int=10, feedback_safe:bool=True
):

Replace one notebook cell from structured source lines, optionally splitting on empty lines.


source

replace_notebook_range

def replace_notebook_range(
    path, cell_id, start_line, end_line, replacement_lines, validate_code:bool=True, auto_feedback:bool=True,
    feedback_timeout:int=10, feedback_safe:bool=True
):

Replace one inclusive 1-based line range from structured replacement lines.


source

insert_notebook_cells

def insert_notebook_cells(
    path, anchor_id, where:str='after', cells:NoneType=None, validate_code:bool=True, default_cell_type:str='code',
    auto_feedback:bool=True, feedback_timeout:int=10, feedback_safe:bool=True
):

Insert structured cells before or after an anchor cell id.


source

delete_notebook_cell

def delete_notebook_cell(
    path, cell_id
):

Delete one notebook cell by stable id.


source

apply_notebook_edit

def apply_notebook_edit(
    edit, default_path:NoneType=None, validate_code:bool=True, default_cell_type:str='code', auto_feedback:bool=True,
    feedback_timeout:int=10, feedback_safe:bool=True, dry_run:bool=False
):

Apply one structured notebook edit operation through edit_notebook.

lines = ["def first():", "    return 1", "", "def second():", "    return 2"]
assert join_source_lines(["a", "b"]) == "a\nb"
assert valid_split_line_numbers(lines, [2, 3]) == [3]
assert split_source_lines(lines, [2, 3]) == [lines[:2], lines[3:]]
assert source_lines_cell(["scratch = True"]).source == "scratch = True"
assert len(source_lines_cells({"source_lines": lines, "split_lines": [3]})) == 2
print("structured line helpers:", len(split_source_lines(lines, [3])))
structured line helpers: 2
with write_demo_notebook("02_write_structured_edit.ipynb") as path:
    write_nb(str(path), "%%code\nvalue = 1", replace=True)
    cell_id = read_nb(path).cells[0].id
    source = ["def first():", "    return 1", "", "def second():", "    return 2"]
    msg = replace_notebook_cell(str(path), cell_id, source, split_lines=[2, 3])
    nb = read_nb(path)
    assert len(nb.cells) == 2
    range_msg = replace_notebook_range(str(path), nb.cells[0].id, 1, 1, ["def uno():"])
    insert_msg = insert_notebook_cells(str(path), nb.cells[1].id, cells=[{"source_lines": ["tail = True"]}])
    assert read_nb(path).cells[2].source == "tail = True"
    print(msg)
    print(range_msg)
    print(insert_msg)
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)
    assert len(read_nb(path).cells) == 0
    assert save_notebook_edit(read_nb(path), path) is False
    print(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
with write_demo_notebook("02_write_feedback.ipynb") as path:
    write_nb(str(path), "%%code\nseed = 1", replace=True)
    cell_id = read_nb(path).cells[0].id
    msg = replace_notebook_cell(str(path), cell_id, ["print('feedback', 3)"])
    assert "Auto feedback" in msg
    assert "feedback 3" in msg
    assert read_nb(path).cells[0].outputs == []
    feedback_cell = read_nb(path).cells[0]
    assert should_run_cell_feedback(feedback_cell)
    assert "feedback 3" in notebook_edit_feedback(str(path), [cell_id])
    assert "feedback 3" in append_notebook_edit_feedback("prefix", str(path), [cell_id])
    quiet = replace_notebook_cell(str(path), cell_id, ["seed = 4"])
    assert "Auto feedback" not in quiet
    print("automatic edit feedback returned output")
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.


source

batch_edit_nb

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.


source

split_nb_chapter

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.


source

move_cells

def move_cells(
    source_path, destination_path:NoneType=None, cell_ids:NoneType=None, query:NoneType=None, chapter:NoneType=None,
    name:NoneType=None, mode:str='move', destination_anchor:NoneType=None, destination_where:str='after',
    default_exp:NoneType=None, promote_private:bool=True, dry_run:bool=False
):

Move or copy selected cells, preserving full cell payloads and inferred imports.


source

create_notebook

def create_notebook(
    path, name:NoneType=None, template:str='nbdev', default_exp:NoneType=None, dry_run:bool=False
):

Create a minimal flat numbered nbdev notebook without overwriting one.

plan_path = demo_path("batch_edit_plan.json")
try:
    with write_demo_notebook("batch_edit_target.ipynb") as nb_path:
        write_nb(str(nb_path), "%%code\nvalue = 1", replace=True)
        nb = read_nb(nb_path)
        cell = nb.cells[0]
        plan = {
            "operations": [
                {"op": "set_cell_source", "path": str(nb_path), "cell_id": cell.id, "source": "value = 2"},
                {"op": "insert_after_id", "path": str(nb_path), "cell_id": cell.id, "cells": "%%code\nassert value == 2"},
            ]
        }
        plan_path.write_text(json.dumps(plan), encoding="utf-8")
        batch_edit_nb(plan_file=str(plan_path), dry_run=True)
        out = StringIO()
        with redirect_stdout(out):
            batch_edit_nb(plan_file=str(plan_path), dry_run=False)
        applied = out.getvalue()
        print(applied, end="")
        nb = read_nb(nb_path)
        assert cell_source(nb.cells[0]) == "value = 2"
        assert "assert value == 2" in cell_source(nb.cells[1])
        assert "[verified]" in applied
        assert "->" in applied and "inserted id=" in applied
        print(f"verified {applied.count('[verified]')} batch edit records")
finally:
    remove_demo_path(plan_path)
Wrote 1 cells to nbs/data/batch_edit_target.ipynb using replace
Dry run: would apply 2 batch operations across 1 notebook(s)
- #1 set_cell_source: nbs/data/batch_edit_target.ipynb id=13f4ef6d [planned] e143e5f6659b->9cfe9326a093
    --- before
    +++ after
    @@ -1 +1 @@
    -value = 1
    +value = 2
- #2 insert_after_id: nbs/data/batch_edit_target.ipynb id=13f4ef6d [planned]
    inserted id=f631de99 hash=7e8872481e92
    inserted 1 cell(s) after id=13f4ef6d
Applied 2 batch operations across 1 notebook(s)
- #1 set_cell_source: nbs/data/batch_edit_target.ipynb id=13f4ef6d [verified] e143e5f6659b->9cfe9326a093
    --- before
    +++ after
    @@ -1 +1 @@
    -value = 1
    +value = 2
- #2 insert_after_id: nbs/data/batch_edit_target.ipynb id=13f4ef6d [verified]
    inserted id=74af8946 hash=7e8872481e92
    inserted 1 cell(s) after id=13f4ef6d
verified 2 batch edit records

The write APIs accept ordinary Python values and keep editing operations explicit for agent workflows.