Notebook state

Explain which notebook work is current, changed, or likely affected by an upstream change.
from tempfile import TemporaryDirectory

from fastcore.nbio import mk_cell, new_nb, write_nb
from fastcore.test import test_eq

source

notebook_state

def notebook_state(
    path
):

Return evidence-backed execution state and likely downstream impact for one notebook.

Evidence-backed notebook state

notebook_state compares every code cell with the exact source hash recorded by exec_nb. A matching hash is clean, a changed hash is dirty, and missing execution evidence is unknown. Static name dependencies propagate a changed upstream cell as stale without claiming that Python is fully reactive.

Each dependency is marked likely. Calls such as globals() and assignment through attributes or subscripts appear under uncertainty, because AST and symbol-table analysis cannot prove the complete effect of dynamic Python.

A fresh notebook has three cells with exact execution fingerprints and one untracked dynamic cell. The report distinguishes clean from unknown without guessing.

state_notebook = Path("15_state.ipynb")
if not state_notebook.exists(): state_notebook = Path("nbs") / state_notebook
notebook_state(state_notebook)["counts"]
state_folder = TemporaryDirectory()
state_path = Path(state_folder.name) / "state.ipynb"
state_nb = new_nb([mk_cell("raw = 1"), mk_cell("clean = raw + 1"),
    mk_cell("result = clean * 2"), mk_cell('globals()["candidate"] = result')])
for state_cell in state_nb.cells[:3]: nbskill_cell_metadata(state_cell)["executed_hash"] = source_hash(cell_source(state_cell), length=None)
write_nb(state_nb, state_path)
initial_state = notebook_state(state_path)
test_eq(notebook_state(state_path)["counts"]["clean"], 3)
test_eq([cell["state"] for cell in initial_state["cells"]], ["clean", "clean", "clean", "unknown"])
test_eq(initial_state["cells"][3]["uncertainty"], ["dynamic-namespace", "in-place-mutation"])
initial_state

Changing only the first cell makes it dirty. Static name dependencies mark the other three cells stale, while stale_from keeps the changed root visible.

changed_nb = read_nb(state_path)
changed_nb.cells[0].source = "raw = 2"
write_nb(changed_nb, state_path)
changed_state = notebook_state(state_path)
test_eq([cell["state"] for cell in changed_state["cells"]], ["dirty", "stale", "stale", "stale"])
test_eq(changed_state["cells"][1]["stale_from"], [changed_nb.cells[0].id])
state_folder.cleanup()
changed_state