Notebook editing

edit_notebook is the production notebook mutation adapter. It keeps text transforms deterministic, applies notebook changes all-or-nothing, and returns structured diffs without raw .ipynb JSON.

The shape is intentionally small: pure text helpers first, then one notebook/cell adapter. Read tools still provide context; this module only mutates notebooks.

Craft warnings make risky notebook edits visible without changing edit semantics. They now flag calls whose definitions remain below the edited cell, so a notebook stays executable from top to bottom.

Text and line transforms

The first layer edits plain text. Keeping it independent from notebook I/O makes the same operations useful in direct Python calls and structured notebook edits.

Pure text helpers

The text helpers do not know about notebooks. They take strings, return strings, and raise clear ValueErrors for invalid line bounds or malformed replacements. That makes cell-level and notebook-level editing share the same behavior.


source

insert_lines

def insert_lines(
    text, insert_line, new_lines
):

Insert new_lines before a 1-based line boundary in text; use n+1 to append.


source

replace_lines

def replace_lines(
    text, start_line, end_line:NoneType=None, replacement_lines:NoneType=None
):

Replace a 1-based inclusive line range in text.


source

delete_lines

def delete_lines(
    text, start_line:NoneType=None, end_line:NoneType=None, re_filter:NoneType=None, invert_filter:bool=False
):

Delete a 1-based inclusive line range, or lines matching re_filter.


source

replace_text

def replace_text(
    text, old, new, start_line:NoneType=None, end_line:NoneType=None
):

Replace literal old with new, optionally inside a line range.


source

replace_texts

def replace_texts(
    text, replacements:NoneType=None, olds:NoneType=None, news:NoneType=None, start_line:NoneType=None,
    end_line:NoneType=None
):

Apply several literal text replacements in order.

The public helper functions insert_lines, replace_lines, delete_lines, replace_text, and replace_texts use one-based line bounds for insert_line, start_line, and end_line, plus the same text semantics that edit_notebook applies to notebook cells.

sample = "alpha\nbeta\ngamma"

print(insert_lines(sample, 2, ["inserted"]))
print(replace_lines(sample, 2, 2, ["BETA"]))
print(delete_lines(sample, re_filter="beta"))
print(replace_text(sample, "alpha", "ALPHA"))
print(replace_texts(sample, [dict(old="alpha", new="A"), dict(old="gamma", new="G")]))

Notebook-aware edits

These operations add the notebook concerns: stable cell ids, metadata, output clearing, validation, and one atomic commit.

Notebook adapter

The adapter selects cells, applies one deterministic operation per selected cell, validates the edited notebook before writing, clears stale outputs for changed code cells, stamps metadata, exports with nbdev, and returns structured results. The whole call succeeds or fails as one unit.

fastcore 2.0 now provides the raw notebook insertion and movement mechanics through fastcore.nbio.Notebook. nbskill still builds cells, applies directives, validates code, records hashes, and formats structured diffs here; only the low-level list mutation is delegated.

Import grouping after an edit

An edited cell may contain global imports together with exported code, tests, or examples. Before committing, the editor moves those imports into the notebook’s single matching import cell: exported code uses exported_import_cell; all runtime-only cells use internal_import_cell. Function-local imports stay in place.


source

edit_notebook

def edit_notebook(
    path, edits, validate_code:bool=True, default_cell_type:str='code', directive:NoneType=None,
    auto_feedback:bool=True, feedback_timeout:int=10, feedback_safe:bool=True, detail:str='summary',
    dry_run:bool=False
):

Apply structured notebook edits atomically and return structured details.

Editor facade

The facade keeps repeated edits scoped to one notebook while the lower-level functions remain useful for one-off transformations.

A notebook-scoped editor

NotebookEditor is a small facade over edit_notebook for one notebook, and Notebook.edit hands one back. Edits accept a directive (a Directive, its name, or a comma string such as export,no_test) so callers can say export and do not test instead of typing #| lines. The directive is stamped onto code cells, and context strips it back out because the semantic type already conveys it.


source

NotebookEditor

def NotebookEditor(
    path:object, validate_code:bool=True, auto_feedback:bool=False
)->None:

Notebook-scoped facade over edit_notebook.


source

NotebookEditor.replace_cell

def replace_cell(
    cell_id, source, directive:NoneType=None, cell_type:NoneType=None, **kw
):

Replace one cell source, optionally stamping directives onto code.


source

NotebookEditor.insert

def insert(
    anchor_id, source, where:str='after', directive:NoneType=None, cell_type:str='code', **kw
):

Insert one cell relative to anchor_id; pass None to append.


source

NotebookEditor.explode

def explode(
    cell_id:NoneType=None, **kw
):

Split top-level function definitions in one cell, or matching cells, into separate cells.


source

NotebookEditor.delete

def delete(
    cell_id, **kw
):

Delete one cell by id.


source

NotebookEditor.replace_text

def replace_text(
    old, new:str='', cell_id:NoneType=None, **kw
):

Replace literal text in one cell, or across the notebook when cell_id is omitted.


source

Notebook.edit

def edit():

Return a NotebookEditor bound to this notebook path.

with write_demo_notebook('editor_directive_demo.ipynb', cells=[mk_cell('def helper():\n    return 1')]) as demo:
    editor = Notebook.from_path(demo).edit()
    print(type(editor).__name__)
    cell_id = read_nb(demo).cells[0].id
    editor.replace_cell(cell_id, 'def helper():\n    return 2', directive='export,no_test')
    print(read_nb(demo).cells[0].source)

A notebook-wide rename is just a replace_text operation with target="all". That path is the default for deterministic refactors such as renaming a public symbol across Markdown, examples, and tests.

with write_demo_notebook("edit_notebook_example.ipynb", cells=[mk_cell("old_name = 1")]) as example_path:
    result = edit_notebook(
        example_path,
        [dict(op="replace_text", target="all", old="old_name", new="new_name")],
        auto_feedback=False,
    )
    source = read_nb(example_path).cells[0].source
source

Hash-guarded dry runs

An expected_hash protects the exact cell source selected by context(..., mode="edit"). Use a dry run to inspect the one-cell diff and replacement count before writing. Pass auto_feedback=False to suppress automatic feedback; smoke snippets remain an advanced, explicit option.

with write_demo_notebook("02_edit_hash_example.ipynb", cells=[mk_cell("value = 1")]) as path:
    cell = read_nb(path).cells[0]
    result = edit_notebook(path, [dict(op="replace_text", cell_id=cell.id, old="1", new="2", expected_hash=source_hash(cell_source(cell)))], auto_feedback=False, dry_run=True)
    print(result["affected_cell_ids"], result["replacement_summary"])