from contextlib import redirect_stdout
from io import StringIO
import os
from fastcore.test import test_eq
from tempfile import TemporaryDirectoryFoundation
Small shared mechanics: Python API behavior, failure telemetry, cell parsing, validation, chapter navigation, and dynamic cell classification for notebook context.
This notebook is the shared basement of the project. It keeps the small private helpers that every public tool relies on: Python API behavior, safe error reporting, cell selection, cell parsing, semantic cell classification, and chapter addressing.
Most functions here are intentionally private. The rest of the project can stay user-facing because this notebook absorbs the messy details of notebooks as data structures.
Read it as a tour of the shapes nbskill needs to recognize. A notebook is not just text: it has cells, headings, generated Python files, visible outputs, and tiny bits of metadata. The foundation layer gives all later notebooks one vocabulary for those shapes, so the public tools can talk about notebooks without making users think in raw JSON.
Production contract
This notebook owns the shared invariants for every other tool. Production behavior is: path helpers resolve notebooks from project-root and notebook-dir execution, cell-block parsing creates valid notebook cells, metadata stamping records semantic classes and export hashes, and validation reports missing or stale metadata before edits proceed.
The helpers here deliberately stay small because every higher-level tool depends on them. For example, parse_cells turns friendly cell-block text into notebook cells, the semantic classifiers let readers say “show me tests” or “show me exported code” without parsing raw notebook JSON.
numbered_notebook_path
def numbered_notebook_path(
nbs_path, name, index:NoneType=None
):Return a flat numbered notebook path for a simple module name.
Demo scratch files
Examples and tests in later notebooks need small notebooks they can safely mutate. Scratch helpers create disposable artifacts in the system temporary directory and remove them after use, so notebook test runners never create repository data folders. tool_notebook builds the shared reader, executor, reviewer, and example shape in code.
demo_path names a scratch artifact in the system temporary directory by default and optionally removes any previous copy. It only creates the parent folder; helpers that write content decide what belongs at the path. An explicit base remains available when an example needs a chosen location.
Use demo_path_context when a test needs an arbitrary scratch file or directory and should clean it up after the block. That keeps cleanup policy in one helper instead of repeating try/finally in every test.
demo_path_context
def demo_path_context(
name, base:NoneType=None, reset:bool=True
):Yield a demo path and remove it when the block exits.
demo_path
def demo_path(
name, base:NoneType=None, reset:bool=True
):Return a scratch path in the system temporary directory or base, optionally resetting it.
path_candidates
def path_candidates(
path
):Return cwd-relative path candidates for root and notebook-dir execution.
_path_candidate_rows = path_candidates("nbs/00_foundation.ipynb")
assert _path_candidate_rows[0] == Path("nbs/00_foundation.ipynb")
if Path.cwd().name == "nbs":
assert _path_candidate_rows[-1] == Path("00_foundation.ipynb")
else:
assert _path_candidate_rows == [Path("nbs/00_foundation.ipynb")]
assert path_candidates(Path("/tmp/example.ipynb")) == [Path("/tmp/example.ipynb")]
print("path_candidates handles root and notebook-dir paths")path_candidates handles root and notebook-dir paths
Notebook helpers should fail gently when a path is missing, unreadable, or shaped like ordinary JSON instead of a Jupyter notebook. is_valid_ipynb keeps that contract small and dependency-free.
write_demo_notebook owns the whole lifetime of a scratch notebook: create it, yield its path to the example, then remove it even if the example fails.
write_demo_notebook
def write_demo_notebook(
name, cells:NoneType=None, base:NoneType=None, reset:bool=True
):tool_notebook keeps the shared reader, executor, reviewer, and test shape in code. Each test writes a fresh notebook and removes it afterwards.
tool_notebook
def tool_notebook():Return the canonical notebook used by tool tests and examples.
write_tool_notebook gives each example and test an isolated copy of the canonical tool notebook.
write_tool_notebook
def write_tool_notebook(
name:str='tool.ipynb', base:NoneType=None
):Write the canonical tool notebook for one example or test.
write_demo_file keeps temporary conversion inputs out of the repository.
write_demo_file
def write_demo_file(
name, source, base:NoneType=None
):Write one disposable text file for an example or test.
Here is the tiny notebook fixture in the wild. It gives examples a real .ipynb to read, then vanishes after the context exits, which keeps the repo tidy while still letting the rendered notebook show concrete output.
from fastcore.nbio import read_nb
with write_demo_notebook("00_foundation_tour.ipynb") as path:
nb = read_nb(path)
print(path.name)
print([cell.cell_type for cell in nb.cells])
print(str(nb.cells[0].source).splitlines()[0])
print(path.exists())00_foundation_tour.ipynb
['markdown', 'code', 'code']
## Demo notebook
False
with write_demo_notebook("00_foundation_bad.ipynb") as bad_path:
bad_path.write_text("{}", encoding="utf-8")
assert not is_valid_ipynb(bad_path)
assert not is_valid_ipynb(bad_path.with_suffix(".missing.ipynb"))Python API contracts and diagnostics
The public functions in later notebooks are direct Python APIs. These helpers keep return values and validation errors predictable while recording tool starts or failures in a small local failure map for debugging repeated friction.
git_root
def git_root(
path:str='.'
):Return the containing git repository root, if one exists.
git_run
def git_run(
root, *args
):Run a git command from root and return stdout, or empty text on failure.
git_status_paths
def git_status_paths(
root
):Return paths with working-tree changes relative to root.
git_diff_stats
def git_diff_stats(
root, ref:str='HEAD'
):Return added/deleted line stats for working-tree changes from ref.
load_nbskill_config
def load_nbskill_config(
path:str='.'
):Load .nbskill.py config from project root, merged over defaults.
notebook_paths
def notebook_paths(
path:str='nbs'
):Return visible notebook paths for a file, directory, glob, or nbdev project path.
notebook_paths keeps an explicitly selected notebook reachable inside a hidden workspace folder such as .codex. It checks the filename in this case; directory discovery still filters hidden paths. A temporary hidden folder demonstrates the explicit-file contract.
visible_notebooks = notebook_paths("nbs/00_foundation.ipynb")
visible_notebookssource_without_directives
def source_without_directives(
source
):Return source with nbdev cell directives removed.
source_hash
def source_hash(
source, length:int=12
):Return a SHA-256 source hash, shortened unless length is None.
cap_text
def cap_text(
text, limit:int=2000, max_output_chars:NoneType=None
):Cap text, returning a string or review-style metadata when max_output_chars is used.
file_line_count
def file_line_count(
path
):Return the number of text lines in path, or zero when it cannot be read.
notebook_result
def notebook_result(
data, max_display_chars:int=6000
):Keep complete structured data while bounding the interpreter display.
NotebookResult
def NotebookResult(
*args, **kwargs
):Dictionary result with one compact interpreter display.
Generated source ownership
nbdev can place a module docstring before its autogenerated-file notice. Source ownership therefore finds the notice in the exported text instead of assuming that it is in the first three physical lines. This keeps generated modules read-only for agents.
generated_owner
def generated_owner(
path, notebooks:NoneType=None
):Return the source notebook for an nbdev-generated Python file, when known.
install_nbdev_pre_commit_hooks
def install_nbdev_pre_commit_hooks(
path:str='.', run_nbdev_install_hooks:bool=True
):Install nbdev-clean and nbdev-test pre-commit hooks in a git-backed nbdev project.
A project can need a small idempotent bootstrap before using nbskill. Once a workspace root is known, bootstrap_nbskill_project can copy the shared agent instructions, make notebook CI serial, and preserve nbskill metadata through nbdev cleanups without clobbering local AGENTS.md edits.
bootstrap_nbskill_project
def bootstrap_nbskill_project(
path:str='.', overwrite_agents:bool=False, pause:str='1'
):Install nbskill project guardrails once a workspace root is known.
Parsing user-facing selectors
Selectors are the boundary between human shorthand and list operations. A caller might ask for 3, 2:5, -1, or None; these helpers normalize that text before any read or write tool touches notebook cells. Keeping the grammar here makes higher-level tools predictable and keeps cell-index mistakes from spreading.
Turning text into notebook cells
Write tools accept plain text because that is what agents and humans can produce quickly. This section turns that text into real cells: split on block separators, honor %%markdown/%%code markers, and split exported code into symbol-sized chunks when needed. It is the small parser that makes notebook edits feel like editing a document instead of assembling JSON.
symbol_short_name
def symbol_short_name(
symbol
):Return the final dotted component of a symbol name.
call_name
def call_name(
node
):Return dotted name text for an AST name, attribute, or call expression.
short_call_name
def short_call_name(
node, default:NoneType=None
):Return the final dotted component of a call/name node, or default.
parse_code_cell
def parse_code_cell(
cell
):Parse a code cell after removing nbdev directives, or return None.
Cell metadata and semantic classes
Once cells can be parsed, the tools need names for what they are doing. A code cell with output is an example; a code cell with an assert is a test; a markdown cell with prose is documentation. Those labels power filters such as type=tests and help review tools talk about notebooks in human terms.
Domain entities
These tiny models give the project names for the shapes it already manipulates: cells, chapters, notebooks, and symbols. The classes stay thin; most behavior is patched onto them so each concept can grow near the helper that understands it.
Typed cell vocabularies
CellType names the three notebook cell kinds, SemanticType names the reader-facing role a cell plays, and Directive names the nbdev directives an editor can stamp onto a cell. Enums keep the rest of the project from passing bare strings around, and let context drop directive lines because the type already carries that meaning.
apply_directives
def apply_directives(
source, directives
):Prepend any missing #| ... directive lines to source.
directive_lines
def directive_lines(
directives
):Return #| ... lines for Directives given as members, names, or a comma string.
Directive
def Directive(
*args, **kwds
):nbdev cell directives an editor can stamp onto a cell.
SemanticType
def SemanticType(
*args, **kwds
):The reader-facing role of a cell, used by context and review tools.
NotebookSymbol
def NotebookSymbol(
name:str, path:object=None, cell_id:str='', cell_idx:object=None, module:str='', kind:str='',
data:object=<factory>
)->None:A named notebook symbol plus optional placement and graph data.
Notebook
def Notebook(
nb:object, path:object=None
)->None:A notebook plus the path it was read from.
Chapter
def Chapter(
title:str, start:int, end:int, cells:object=<factory>
)->None:A contiguous heading-owned span inside a notebook.
Cell
def Cell(
cell:object, idx:object=None, path:object=None
)->None:A focused view of one notebook cell plus its optional location.
xml_escape and xml_attrs keep model-to-XML snippets boring and predictable. They are intentionally small because the XML is only for readable LLM context, not a general document writer.
{
"escaped": xml_escape("<cell>"),
"attrs": xml_attrs(kind="cell", title="<demo>"),
}{'escaped': '<cell>', 'attrs': 'kind="cell" title="<demo>"'}
Cell.from_source
def from_source(
source, default_type:str='code', idx:NoneType=None, path:NoneType=None
):Build a cell model from editable source text.
Cell.source
def source():Return the cell source as one string.
Cell.metadata
def metadata():Return mutable cell metadata.
Cell.code
def code():Return source without nbdev directives.
Cell.directive
def directive(
name:NoneType=None
):Return the first directive, or the value for one directive name.
Cell.directives
def directives():Return nbdev directive lines without the leading marker.
Cell.heading_title
def heading_title(
levels:tuple=(2,)
):Return the first markdown heading title whose level is allowed.
Cell.class_names
def class_names():Return semantic class names for the cell.
Cell.prefix
def prefix(
show_ids:bool=False
):Return the compact rendered cell prefix used in context output.
Cell.to_xml
def to_xml(
include_output:bool=False
):Return an XML-shaped cell view for LLM context; directives become the semantic type.
Chapter.all
def all(
cells, levels:tuple=(2,), fallback:NoneType=None
):Return all chapter models opened by markdown headings.
Chapter.from_span
def from_span(
span, cells:NoneType=None
):Build a chapter model from a span dictionary.
Chapter.body
def body():Return cells inside this chapter span.
Chapter.to_span
def to_span():Return the dictionary span shape used by existing APIs.
Chapter.to_record
def to_record():Return structured chapter metadata.
Chapter.to_xml
def to_xml(
include_cells:bool=True
):Return an XML-shaped chapter view for LLM context.
Notebook.cells
def cells():Return notebook cells as cell models.
Notebook.from_path
def from_path(
path
):Read a notebook path into a notebook document model.
Notebook.cell
def cell(
cell_id
):Return one cell model by stable id.
notebook_hash
def notebook_hash(
nb
):Return a stable source hash for a notebook’s ordered cell contents.
chapter_spans
def chapter_spans(
cells, levels:tuple=(2,), fallback:NoneType=None
):Return contiguous notebook cell spans opened by markdown headings.
Notebook.chapter
def chapter(
title, create:bool=False
):Return one chapter model by title or regex.
Notebook.notebook_source_hash
def notebook_source_hash():Return the notebook’s ordered source hash.
ensure_cell_ids
def ensure_cell_ids(
nb
):Add nbdev-style ids to notebook cells missing id, mutating and returning nb.
exported_py_path
def exported_py_path(
nb_path, nb:NoneType=None
):Return the generated Python file path for an nbdev notebook, if it has one.
commit_notebook
def commit_notebook(
path, trial, before:NoneType=None, affected_cell_ids:NoneType=None, validate_code:bool=True, dry_run:bool=False,
export:bool=True, writer:function=write_nb
):Validate, stamp, write, export, and rollback one notebook as one commit.
Notebook.commit
def commit(
**kwargs
):Commit this notebook model through the shared atomic writer.
Notebook document helpers
NotebookDocument.cell returns one cell model by id. chapter resolves a heading-owned span, notebook_source_hash gives the model’s stable source hash, and commit sends the current model through commit_notebook.
document = NotebookDocument(new_nb([mk_cell("## Alpha", cell_type="markdown"), mk_cell("answer = 42")]), path="demo.ipynb")
(document.cell(document.nb.cells[1].id).source, document.chapter("Alpha").title, document.notebook_source_hash())The notebook domain model gives callers small, navigable objects: Notebook.cell finds a stable cell id, Notebook.chapter returns a dynamic Chapter, Notebook.notebook_source_hash summarizes ordered source, and Notebook.commit writes through the same atomic path as the editing tools.
Notebook.to_xml
def to_xml(
levels:tuple=(2,), include_cells:bool=True
):Return an XML-shaped notebook view for LLM context.
Notebook.chapters
def chapters(
levels:tuple=(2,), fallback:NoneType=None
):Return notebook chapters as chapter models.
NotebookSymbol.from_record
def from_record(
record, data:NoneType=None
):Build a symbol model from a graph or definition record.
nbskill_cell_metadata
def nbskill_cell_metadata(
cell, create:bool=True
):Return the shared nbskill metadata block for a cell.
assert source_without_directives("#| export\nx = 1\n#| default_exp demo") == "x = 1"
with write_demo_notebook("foundation_notebook_paths.ipynb") as nb_path:
checkpoint = nb_path.parent / ".ipynb_checkpoints" / "skip.ipynb"
checkpoint.parent.mkdir(parents=True, exist_ok=True)
write_nb(new_nb([mk_cell("assert True")]), checkpoint)
try:
paths = notebook_paths(nb_path.parent)
assert nb_path in paths
assert checkpoint not in paths
finally:
remove_demo_path(checkpoint.parent)
generated = demo_path("generated_owner_sample.py")
try:
generated.write_text(
'\n'.join([
'\"\"\"Generated module documentation.',
'It can span several lines',
'before the nbdev header.',
'\"\"\"',
'',
'# AUTOGENERATED! DO NOT EDIT! File to edit: ../00_foundation.ipynb.',
]) + '\n',
encoding="utf-8",
)
assert generated_owner(generated).name == "00_foundation.ipynb"
assert file_line_count(generated) == 6
finally:
remove_demo_path(generated)
assert cap_text("abcdef", limit=3).startswith("abc")
assert cap_text("abcdef", max_output_chars=3)["truncated"] is TrueProject-scoped nbdev export
run_nbdev_export_from_project and export_notebook deliberately run nbdev export in an isolated process under a project-scoped lock. This looks heavier than calling nbdev_export directly, but it protects concurrent Python callers working in different repositories.
The two failures this avoids are subtle. First, nbdev export depends on project context; changing the process cwd with os.chdir is global state, so concurrent exports from different projects can race. Second, a caller timeout does not stop a Python worker thread, so a timed-out edit can keep exporting in the background and consume memory. A child process is a boundary the caller can terminate, while the lock only serializes exports for the same project. Different projects get different lock files and can still make progress independently.
run_nbdev_export_from_project
def run_nbdev_export_from_project(
nb_path
):Run nbdev export in a project-scoped process without changing server cwd.
export_notebook
def export_notebook(
nb, nb_path, writer:function=write_nb
):Export an nbdev notebook and stamp export metadata when possible.
# Export a real nbdev notebook without changing the current process cwd.
run_nbdev_export_from_project("nbs/00_foundation.ipynb")Stable ids and notebook commits
ensure_cell_ids fills in missing notebook cell ids. notebook_hash changes when a cell’s id or source changes. commit_notebook validates and records a proposed notebook change, or writes and exports it when dry_run is false.
trial = new_nb([mk_cell("value = 1")])
ensure_cell_ids(trial)
commit_notebook("nbs/data/commit_notebook_example.ipynb", trial, dry_run=True, export=False)["planned_hash"] == notebook_hash(trial)notebook_hash and commit_notebook form the shared commit primitive. Callers build a trial notebook, pass the original as before, and get read-back hashes after the write succeeds. The commit boundary rejects malformed notebook structure and duplicate cell ids before it writes. Changed code-cell outputs are cleared before the notebook is committed.
load_cells_text
def load_cells_text(
cells:str='', cells_file:NoneType=None
):Return direct Python text or the contents of an explicit text file.
Naming cells by behavior
The reading layer presents cells by meaning, not only by code or markdown. This section detects imports, private helpers, exported code, tests, examples, docs, section headers, and mixed cells so callers can filter notebooks at a useful level. A test that produces a useful output is intentionally both a test and an example; that pairing is not an unclean mixed cell.
output_value_text
def output_value_text(
value
):Return a notebook output value as plain text.
cell_output_text
def cell_output_text(
cell
):Return all visible outputs for cell as compact plain text.
cell_semantic_warnings
def cell_semantic_warnings(
cell
):Return semantic warning codes for one cell.
from fastcore.nbio import read_nb
with write_demo_notebook("commit_notebook_example.ipynb", cells=[mk_cell(chr(10).join(["value = 1", "print(value)"]))]) as commit_path:
before_nb = read_nb(commit_path)
trial_nb = read_nb(commit_path)
print(notebook_hash(before_nb))
trial_nb.cells[0].source = chr(10).join(["value = 2", "print(value)"])
trial_nb.cells[0].outputs = [dict(output_type="stream", name="stdout", text="stale" + chr(10))]
result = commit_notebook(commit_path, trial_nb, before=before_nb, affected_cell_ids=[trial_nb.cells[0].id], export=False)
print(result["changed"], result["affected_cell_ids"])
print(read_nb(commit_path).cells[0].outputs)32c31893da74
True ['18ce0375']
[]
with write_demo_notebook("00_foundation_context.ipynb") as path:
assert path.exists()
assert is_valid_ipynb(path)
assert not path.exists()parse_cells is the little translator between a friendly edit format and real notebook cells. The dashed line means “start a new cell”, while %%markdown and %%code make the intended cell type explicit.
_demo_text = "\n".join(["%%markdown", "## Demo", "---", "%%code", "value = 42"])
_demo_cells = parse_cells(_demo_text)
for idx, cell in enumerate(_demo_cells):
print(cell_prefix(idx, cell))
print(" type:", cell.cell_type)
print(" first line:", first_line(cell_source(cell)))Cell id=25790bac: markdown
type: markdown
first line: ## Demo
Cell id=38f08303: code
type: code
first line: value = 42
Cell.output_text
def output_text():Return all visible outputs for this cell as compact plain text.
Cell.semantic_type
def semantic_type():Return the primary reader-facing semantic type for this cell.
Cell.context_record
def context_record():Return the context record shape used by read helpers, with directives stripped.
Visible cell output
output_value_text, output_text, and cell_output_text turn Jupyter output payloads into plain text. Cell.output_text, Cell.semantic_type, and Cell.context_record apply the same rules to a cell model used by readers.
output_cell = mk_cell("print('ok')")
output_cell.outputs = [dict(output_type="stream", name="stdout", text="ok\n")]
output_model = Cell(output_cell, idx=4)
(output_value_text(["o", "k"]), output_text(output_cell.outputs[0]), cell_output_text(output_cell), output_model.output_text(), output_model.semantic_type(), output_model.context_record()["cell_idx"])Chapters as editing scopes
Chapters are the notebook-native version of a module section. A heading cell opens a span, and everything until the next heading belongs to that span. Write, execute, and read tools use these spans so users can say “the query language chapter” instead of counting cell indexes by hand.
heading_title
def heading_title(
cell, levels:tuple=(2,)
):Return the first markdown heading title whose level is allowed.
with write_demo_notebook("domain_model_example.ipynb", cells=[mk_cell("## Alpha", cell_type="markdown"), mk_cell("answer = 42")]) as model_path:
model_doc = Notebook.from_path(model_path)
model_cell = model_doc.cell(model_doc.nb.cells[1].id)
model_chapter = model_doc.chapter("Alpha")
print(model_cell.source)
print(model_chapter.to_record())
print(model_doc.notebook_source_hash())
print(model_doc.commit(before=model_doc.nb, export=False)["changed"])answer = 42
{'title': 'Alpha', 'start': 0, 'end': 2, 'cell_count': 2}
a584b5a3979c
False
On a real notebook, chapter spans become a tiny table of contents with cell ranges. Here index.ipynb is being parsed by the foundation helpers that the reading tools themselves rely on.
_index_cells = read_nb("index.ipynb").cells
for span in chapter_spans(_index_cells)[:5]: print(f"{span['title']}: cells {span['start']}..{span['end'] - 1}")
print("reading indexes:", sorted(chapter_index_set(_index_cells, "Reading"))[:5])The problem this project solves: cells 2..2
How the notebooks fit together: cells 3..3
Production readiness: cells 4..5
A tiny notebook to work on: cells 6..7
Reading: choose the smallest useful context: cells 8..9
reading indexes: [8, 9]
custom_map = demo_path("00_foundation_errors.json")
_old_failure_map = os.environ.get("NBSKILL_FAILURE_MAP")
try:
os.environ["NBSKILL_FAILURE_MAP"] = str(custom_map)
assert failure_map_path() == custom_map
finally:
if _old_failure_map is None: os.environ.pop("NBSKILL_FAILURE_MAP", None)
else: os.environ["NBSKILL_FAILURE_MAP"] = _old_failure_mapcells = parse_cells("%%markdown\n## Demo\n---\n%%code\nvalue = 42")
assert len(cells) == 2
assert cells[0].cell_type == "markdown"
assert cells[1].source == "value = 42"
one = parse_one_cell("%%code\nvalue = 99")
assert one.cell_type == "code"
assert one.source == "value = 99"
encoded = "def f():" + chr(92) + "n return 1"
assert load_cells_text("%%code\\nvalue = 1") == "%%code\\nvalue = 1"
assert load_cells_text(encoded) == encodedHere are the labels on toy cells. This is deliberately small, but it is the same classification that lets the reading and review tools distinguish examples from tests without asking a model to guess.
public_cell = mk_cell("#| export\ndef public():\n return 1")
private_cell = mk_cell("#| export\ndef _private():\n return 1")
example_cell = mk_cell("value = 1")
example_cell.outputs = [dict(output_type="stream", name="stdout", text="1\n")]
for label, cell in {"public": public_cell, "private": private_cell, "example": example_cell}.items():
print(f"{label:8} -> {', '.join(cell_class_names(cell))}")The tests below define the complete semantic contract: public and private exported symbols, multicells, output-bearing tests, examples, documentation, titles, metadata refresh, and the only allowed mixed role (test_code plus example_code).
A semantic cell gets explicit source-and-output roles. Imports distinguish exported_import_cell from internal_import_cell; a visible test is the only allowed test_code + example_code combination. multicell is reserved for a cell with multiple differently named top-level functions, public or private. Every other mixed role is a writing warning.
Metadata stamping is separate from classification: it records the generated Python artifact and caches the semantic labels that downstream tools can reuse.
Python API helpers return values directly and raise ValueError for invalid inputs; notebook execution stays independent of process arguments.