Reviewing notebooks

Diff and style feedback that focuses on code cells.

Review should focus on the behavior encoded in code cells, not on notebook metadata churn. This notebook keeps that review loop small: run fast.ai style hints when desired, and print nbdev-style code diffs when comparing notebooks.

Review tools draw a line between source changes and notebook noise. style_check is useful before publishing exported code because it combines fast.ai style hints with notebook hygiene reports, while diff_nb is useful during agent edits because it ignores outputs and metadata unless metadata is the only thing that changed.

Production contract

Review tools are production core. Validation must fail on missing or stale nbskill metadata, style_check must combine capped style output with notebook hygiene diagnostics, and diff_nb must show code-cell behavior changes without raw notebook metadata noise.

import os
from fastcore.nbio import new_nb
from fastcore.test import test_eq
from nbskill.foundation import demo_path, remove_demo_path, write_demo_notebook, write_tool_notebook


def _write_review_notebook(path, cells, stamp=True):
    nb = new_nb(cells)
    if stamp: stamp_notebook_metadata(nb)
    _write_nb(nb, path)
    return nb

Style feedback as a tool

style_check wraps the fast.ai style checker as a direct Python API. Combine it with doctor when a notebook change needs broader diagnostics.

Public notebook APIs stay maintainable when their docs, implementation, examples, and tests travel together. public_function_literacy_problems checks exported public functions for that compact contract so maintainers and agents can quickly understand what each function is for, how to call it, and what behavior is protected.


source

public_function_literacy_problems

def public_function_literacy_problems(
    path:str='.'
):

Return docs/example/test contract warnings for exported public notebook functions.

Notebook craft is a small subset of style diagnostics that should steer agents toward literate, reviewable notebook changes.

_literacy_preview = public_function_literacy_problems("nbs/04_review.ipynb")[:2]
_literacy_preview
[{'code': 'public-function-markdown',
  'path': '04_review.ipynb',
  'severity': 'warning',
  'source': 'nbskill',
  'detail': "public function 'notebook_size_problems' needs Markdown docs cell mentioning the function",
  'cell_id': '93269841',
  'symbol': 'notebook_size_problems',
  'line': 1,
  'missing': 'Markdown docs cell mentioning the function'},
 {'code': 'public-function-example',
  'path': '04_review.ipynb',
  'severity': 'warning',
  'source': 'nbskill',
  'detail': "public function 'notebook_size_problems' needs example cell that calls the function",
  'cell_id': '93269841',
  'symbol': 'notebook_size_problems',
  'line': 1,
  'missing': 'example cell that calls the function'}]
assert {
    "public-function-docstring", "public-function-markdown",
    "public-function-example", "public-function-test",
} <= {problem["code"] for problem in public_function_literacy_problems("nbs/04_review.ipynb")}

source

notebook_validation_problems

def notebook_validation_problems(
    path:str='.'
):

Return notebook metadata and Python syntax validation problems.


source

notebook_size_problems

def notebook_size_problems(
    path:str='.'
):

Return size warnings for notebook cells and generated Python files.


source

reset_global_usage_summary

def reset_global_usage_summary():

source

notebook_autofix

def notebook_autofix(
    path:str='.', dry_run:bool=False
):

source

style_report

def style_report(
    path:str='.', # File or folder to check
    chkstyle:dict | None=None, # Captured chkstyle result to include
    max_output_chars:int=12000, # Maximum raw chkstyle text to keep in report
    max_diagnostics:int=200, # Maximum parsed chkstyle diagnostics
    changed_only:bool=False, # Report only diagnostics for changed code cells
    ref_a:str | None='HEAD', # First git ref for changed_only filtering
    ref_b:str | None=None, # Second git ref; defaults to working tree
    skip_folder_re:str | None=None, # Regex for notebook paths to skip
    skip_path:str | None=None, # Folder name/path to skip
):

Return structured style diagnostics, problem chart, and global nbskill usage data.

with write_tool_notebook("04_review_style_report.ipynb") as path:
    report = style_report(path)
    test_eq({"summary", "notebook_problems"} <= set(report), True)
    test_eq(isinstance(report["notebook_problems"], list), True)
    test_eq("Global nbskill usage:" in report["text"], True)

The structured report is meant for tools as much as people. summary gives a compact count of problems, problem_chart groups diagnostics by source and code, and text is the human-readable report.

sample_chkstyle = {"status": 1, "output": "# demo.ipynb:cell[abc123]:2: Missing whitespace (hint: add a blank line)"}
with write_tool_notebook("04_review_sample_report.ipynb") as path:
    sample_report = style_report(path, chkstyle=sample_chkstyle, max_output_chars=200, max_diagnostics=5)
    print(sample_report["summary"])
    print(sample_report["problem_chart"]["by_source"])
    print(sample_report["diagnostics"][0])

source

run_style_check

def run_style_check(
    path:str='.', skip_folder_re:NoneType=None, skip_path:NoneType=None, strict:bool=False,
    max_output_chars:NoneType=None
):

Run chkstyle with nbskill’s default skip paths and capped output.

argv = _style_check_argv(".")
assert "--skip-path" in argv
assert "_proc" in argv
assert "--skip-folder-re" not in argv
assert "--skip-path-re" in _style_check_argv(".", skip_folder_re=r"^_proc/")
proc_report = run_style_check("_proc/01_read.ipynb")
assert proc_report["status"] == 0
assert proc_report["output"] == ""
assert notebook_validation_problems("_proc/01_read.ipynb") == []
assert not any(_style_root_is_skipped(problem["path"], skip_style_paths) for problem in _notebook_style_problems("."))

source

style_check

def style_check(
    path:str='.', skip_folder_re:str | None=None, # Regex for folders to skip
    skip_path:str | None=None, # Folder name/path to skip
    strict:bool=False, # Raise when style hints or notebook hygiene problems are found
    delete_after_output:bool=False, # Reset ~/.nbskill-errors.json after printing the global summary
    max_output_chars:int=12000, # Cap printed chkstyle output
    max_diagnostics:int=200, # Cap returned diagnostics
    fix:bool=False, # Show conservative fix suggestions
    dry_run:bool=True, # Keep fix mode non-mutating by default
    changed_only:bool=False, # Report only diagnostics for changed code cells
    ref_a:str | None='HEAD', # First git ref for changed_only filtering
    ref_b:str | None=None, # Second git ref; defaults to working tree
):

Print capped fast.ai style hints, notebook hygiene warnings, and global tool usage.


source

validate_nbs

def validate_nbs(
    path:str='nbs', strict:bool=True, # Raise when invalid metadata is found
):

Validate nbskill metadata needed for safe notebook tools.


source

code_source

def code_source(
    cell
):

Return source for code cells; ignore markdown and raw cells.

style_check is the direct Python wrapper around style_report. It captures fast.ai style output, appends notebook hygiene findings, prints the combined text report, and raises in strict mode when diagnostics are present.

with write_tool_notebook("04_review_style_check.ipynb") as path:
    style_output = StringIO()
    with redirect_stdout(style_output): style_check(path, strict=False, max_output_chars=500, max_diagnostics=5)
    print("\n".join(style_output.getvalue().splitlines()[:6]))
with write_demo_notebook("04_review_validate_valid.ipynb") as valid_path:
    _write_review_notebook(valid_path, [mk_cell("assert True", cell_type="code")])
    assert notebook_validation_problems(valid_path) == []
    validate_nbs(str(valid_path), strict=False)
with write_demo_notebook("04_review_validate_invalid.ipynb") as invalid_path:
    invalid_nb = _write_review_notebook(invalid_path, [
        mk_cell("x = 1", cell_type="code"),
        mk_cell("Some docs", cell_type="markdown"),
        mk_cell("assert True", cell_type="code"),
    ])
    invalid_nb.cells[1].metadata["nbskill"]["semantic_types"] = []
    invalid_nb.cells[2].metadata["nbskill"]["cell_type"] = "markdown"
    _write_nb(invalid_nb, invalid_path)
    codes = {problem["code"] for problem in notebook_validation_problems(invalid_path)}
    assert {"missing-cell-semantic-types", "cell-type-mismatch"} <= codes
with write_demo_notebook("04_review_export_hash.ipynb", base="nbs") as export_nb_path:
    export_nb = _write_review_notebook(export_nb_path, [mk_cell("#| default_exp sample_tool")])
    generated_py_path = exported_py_path(export_nb_path, export_nb)
    try:
        generated_py_path.parent.mkdir(parents=True, exist_ok=True)
        generated_py_path.write_text("actual = 1\n", encoding="utf-8")
        export_nb.metadata["nbskill"] = {"exported_py_hash": "bad"}
        _write_nb(export_nb, export_nb_path)
        codes = {problem["code"] for problem in notebook_validation_problems(export_nb_path)}
        assert "exported-py-hash-mismatch" in codes
    finally:
        if generated_py_path is not None: remove_demo_path(generated_py_path)

notebook_validation_problems is narrower than style_report: it checks nbskill metadata plus Python syntax in code cells, the prerequisites for reliable notebook tooling. validate_nbs exposes the same failures to Python callers.

with write_demo_notebook("04_review_validation_example.ipynb") as validation_path:
    _write_review_notebook(validation_path, [
        mk_cell("x = 1", cell_type="code"),
        mk_cell("Some docs", cell_type="markdown"),
    ], stamp=False)
    problems = notebook_validation_problems(validation_path)
    print([problem["code"] for problem in problems[:4]])

Code-cell diffs

Notebook diffs are noisy when metadata and outputs are included. diff_nb asks nbdev for code-cell source on each side of a comparison and prints only the added, changed, or deleted code blocks the caller requested.

Public UI text surface

FastHTML and HTML-producing notebook cells often hide customer-visible copy inside Python component calls. A normal text search finds too much: headings, button labels, docstrings, route names, tests, exceptions, and implementation notes all look like strings. The public UI text surface gives agents a smaller first pass over likely visible copy before editing.

Use visible_text_inventory before an edit when the task is phrased in product or customer language. It answers: which notebook cell and symbol owns the visible words I may need to change? That reduces the manual rg loop where an agent has to compare notebook source, exported modules, and internal strings by hand.

Use diff_nb(surface="public-ui") after an edit when the review question is user-facing copy rather than Python structure. It keeps headings, labels, link text, image alt text, and similar UI literals separate from code churn, metadata churn, tests, and docstring edits.

This is deliberately heuristic. It is meant to support a faster notebook iteration loop, not to prove accessibility or render every possible runtime branch: inventory the likely visible text, make a focused edit, run the route/example smoke snippet, then review the public UI diff.


source

visible_text_inventory

def visible_text_inventory(
    path:str='.', include_re:NoneType=None, max_records:int=200
):

Print likely user-visible text from FastHTML/HTML-producing notebook code cells.


source

diff_nb

def diff_nb(
    path:str, # Notebook path
    ref_a:str | None='HEAD', # First git ref; use None for working tree
    ref_b:str | None=None, # Second git ref; defaults to working tree
    adds:bool=True, # Include code cells added in ref_b
    changes:bool=True, # Include changed code cells
    dels:bool=False, # Include deleted code cells
    cell_id:str | None=None, # Limit output to comma/space-separated cell ids
    after_id:str | None=None, # Limit output to cells after this id in ref_b/the working tree
    surface:str='code', # Review surface: "code" or "public-ui"
):

Print nbdev-style diffs for code cells, or a public UI text surface.

from tempfile import gettempdir

with write_demo_notebook("04_review_no_git.ipynb", base=gettempdir()) as path:
    _write_review_notebook(path, [mk_cell("x = 1", cell_type="code")])
    try:
        diff_nb(str(path))
    except (SystemExit, ValueError) as exc:
        if isinstance(exc, SystemExit): assert exc.code == 1
        else:
            msg = str(exc)
            assert "disposable notebook" in msg and "ref_a=None" in msg and "ref_b=None" in msg
    out = StringIO()
    with redirect_stdout(out):
        diff_nb(str(path), ref_a=None, ref_b=None)
    report = out.getvalue()
    assert "--- code cell" in report and "+x = 1" in report
def _committed_review_notebook(root, cells=None):
    root.mkdir()
    path = root / "demo.ipynb"
    _write_review_notebook(path, cells or [mk_cell("x = 1", cell_type="code")])
    subprocess.run(["git", "init"], cwd=root, check=True, capture_output=True)
    subprocess.run(["git", "add", "demo.ipynb"], cwd=root, check=True, capture_output=True)
    subprocess.run([
        "git", "-c", "user.name=Nbskill", "-c", "user.email=nbskill@example.com",
        "commit", "-m", "base",
    ], cwd=root, check=True, capture_output=True)
    return path
root = demo_path("04_review_git_metadata")
try:
    path = _committed_review_notebook(root)
    nb = read_nb(path)
    nb.cells[0].metadata["nbskill"] = {"cell_type": "code", "semantic_types": []}
    _write_nb(nb, path)
    out = StringIO()
    with redirect_stdout(out):
        diff_nb(str(path))
    text = out.getvalue()
    assert "No code cell changes" in text
    assert "Ignored nbskill metadata changes in 1 cell" in text
finally:
    remove_demo_path(root)
root = demo_path("04_review_git_filters")
try:
    path = _committed_review_notebook(root)
    nb = read_nb(path)
    first_id = nb.cells[0].id
    nb.cells[0].source = "x = 2"
    nb.cells.append(mk_cell("y = 3", cell_type="code"))
    _write_nb(nb, path)
    out = StringIO()
    with redirect_stdout(out): diff_nb(str(path), cell_id=first_id)
    text = out.getvalue()
    assert "x = 2" in text and "y = 3" not in text
    out = StringIO()
    with redirect_stdout(out): diff_nb(str(path), after_id=first_id)
    text = out.getvalue()
    assert "y = 3" in text and "x = 2" not in text
    delta = style_report(str(path), changed_only=True)
    assert delta["summary"]["changed_only"] is True
    assert delta["summary"]["changed_cell_count"] == 2
finally:
    remove_demo_path(root)
def _write_style_problem_notebook(path):
    long_source = "\n".join([f"x{i} = {i}" for i in range(31)])
    long_markdown = "\n".join([f"paragraph {i}" for i in range(21)])
    mixed_import = mk_cell("import json", cell_type="code")
    mixed_import.outputs = [dict(output_type="stream", name="stdout", text="json\n")]
    _write_review_notebook(path, [
        mk_cell(
            "#| export\n"
            "def a():\n"
            "    pass\n"
            "def b():\n"
            "    \"\"\"Describe b.\"\"\"\n"
            "    pass\n"
            "def c():\n"
            "    \"\"\"Line one.\n"
            "    Line two.\"\"\"\n"
            "    pass\n"
            "def _private():\n"
            "    pass",
            cell_type="code",
        ),
        mk_cell(long_source, cell_type="code"),
        mk_cell(long_markdown, cell_type="markdown"),
        mk_cell("assert 1 == 1\nassert 2 == 2\nassert 3 == 3\nassert 4 == 4", cell_type="code"),
        mk_cell("#| export\nimport os", cell_type="code"),
        mk_cell("#| export\nimport os", cell_type="code"),
        mixed_import,
        mk_cell("import sys", cell_type="code"),
        mk_cell("import sys", cell_type="code"),
        mk_cell("result = later_helper()", cell_type="code"),
        mk_cell("def later_helper():\n    return 1", cell_type="code"),
        mk_cell("def loader():\n    return MissingPath('x')", cell_type="code"),
    ])

_write_literacy_problem_notebook builds a compact demo notebook for the literate review contract. The fixture keeps compliant, missing-docs, missing-example, missing-test, and multiline-docstring cases in one place so each warning code has a precise example.

def _write_literacy_problem_notebook(path):
    _write_review_notebook(path, [
        mk_cell("`ready` has docs, a docstring, an example, and a test.", cell_type="markdown"),
        mk_cell("#| export\ndef ready(x):\n    \"Return x plus one.\"\n    return x + 1", cell_type="code"),
        mk_cell("`no_example` has docs and a test but no example call.", cell_type="markdown"),
        mk_cell("#| export\ndef no_example(x):\n    \"Return x plus one.\"\n    return x + 1", cell_type="code"),
        mk_cell("`no_test` has docs and an example but no test assertion.", cell_type="markdown"),
        mk_cell("#| export\ndef no_test(x):\n    \"Return x plus one.\"\n    return x + 1", cell_type="code"),
        mk_cell("`bad_doc` has a Markdown note but its docstring is too long.", cell_type="markdown"),
        mk_cell("#| export\ndef bad_doc(x):\n    \"\"\"Return x plus one.\n    Keep this second line out of file_context.\"\"\"\n    return x + 1", cell_type="code"),
        mk_cell("`eq_checked`, `ne_checked`, and `fail_checked` cover fastcore test helpers.", cell_type="markdown"),
        mk_cell("#| export\ndef eq_checked(x):\n    \"Return x plus one.\"\n    return x + 1", cell_type="code"),
        mk_cell("#| export\ndef ne_checked(x):\n    \"Return x plus one.\"\n    return x + 1", cell_type="code"),
        mk_cell("#| export\ndef fail_checked():\n    \"Raise a demo failure.\"\n    raise ValueError(\"boom\")", cell_type="code"),
        mk_cell("#| export\ndef no_docs(x):\n    \"Return x plus one.\"\n    return x + 1", cell_type="code"),
        mk_cell("#| export\ndef _hidden(x):\n    return x\n\ndef test_helper(x):\n    return x", cell_type="code"),
        mk_cell("ready(1)\nno_docs(1)\nno_test(1)\nbad_doc(1)\neq_checked(1)\nne_checked(1)\ntry:\n    fail_checked()\nexcept ValueError:\n    pass", cell_type="code"),
        mk_cell("assert ready(1) == 2\nassert no_docs(1) == 2\nassert no_example(1) == 2\nassert bad_doc(1) == 2\ntest_eq(eq_checked(1), 2)\ntest_ne(ne_checked(1), 3)\ntest_fail(lambda: fail_checked())\nassert _hidden(1) == 1\nassert test_helper(1) == 1", cell_type="code"),
    ])
with write_demo_notebook("04_review_literacy.ipynb") as path:
    _write_literacy_problem_notebook(path)
    problems = public_function_literacy_problems(path)
    codes = {(problem.get("symbol"), problem["code"]) for problem in problems}
    missing_codes = {("no_docs", "public-function-markdown"), ("no_example", "public-function-example"), ("no_test", "public-function-test")}
    ready_codes = {code for symbol, code in codes if symbol == "ready"}
    assert missing_codes <= codes
    assert not ready_codes
with write_demo_notebook("04_review_literacy_docstrings.ipynb") as path:
    _write_literacy_problem_notebook(path)
    problems = public_function_literacy_problems(path)
    codes = {(problem.get("symbol"), problem["code"]) for problem in problems}
    tested_codes = {
        ("eq_checked", "public-function-test"),
        ("ne_checked", "public-function-test"),
        ("fail_checked", "public-function-test"),
    }
    assert ("bad_doc", "public-function-docstring") in codes
    assert not tested_codes & codes
    assert not any(problem.get("symbol") in {"_hidden", "test_helper"} for problem in problems)
print(report)
Notebook style report:
- missing-cell-nbskill-metadata: nbs/data/04_review_style.ipynb id=18cf6a7f
- missing-cell-nbskill-metadata: nbs/data/04_review_style.ipynb id=3e6417a6
- missing-cell-nbskill-metadata: nbs/data/04_review_style.ipynb id=9d964403
- missing-cell-nbskill-metadata: nbs/data/04_review_style.ipynb id=56e0e6c4
- missing-cell-nbskill-metadata: nbs/data/04_review_style.ipynb id=4debe573
- missing-cell-nbskill-metadata: nbs/data/04_review_style.ipynb id=742a5cf5
- missing-cell-nbskill-metadata: nbs/data/04_review_style.ipynb id=f41bdaef
- missing-cell-nbskill-metadata: nbs/data/04_review_style.ipynb id=3b1110e1
- missing-cell-nbskill-metadata: nbs/data/04_review_style.ipynb id=23f2d90a
- missing-cell-nbskill-metadata: nbs/data/04_review_style.ipynb id=4268cb78
- missing-cell-nbskill-metadata: nbs/data/04_review_style.ipynb id=762d5962
- missing-cell-nbskill-metadata: nbs/data/04_review_style.ipynb id=711b360d
- large-cell: nbs/data/04_review_style.ipynb id=18cf6a7f function_count=4 4 top-level functions; split into one-idea cells
- large-cell: nbs/data/04_review_style.ipynb id=3e6417a6 line_count=31 31 code content lines; keep one idea per cell
- large-cell: nbs/data/04_review_style.ipynb id=9d964403 line_count=21 21 markdown content lines; keep one idea per cell
- public-function-docstring: nbs/data/04_review_style.ipynb id=18cf6a7f line=1 symbol='a' missing='one-line docstring' docstring_lines=0 public function 'a' needs a one-line docstring for context summaries
- public-function-markdown: nbs/data/04_review_style.ipynb id=18cf6a7f line=1 symbol='a' missing='Markdown docs cell mentioning the function' public function 'a' needs Markdown docs cell mentioning the function
- public-function-example: nbs/data/04_review_style.ipynb id=18cf6a7f line=1 symbol='a' missing='example cell that calls the function' public function 'a' needs example cell that calls the function
- public-function-test: nbs/data/04_review_style.ipynb id=18cf6a7f line=1 symbol='a' missing='test cell that asserts or checks the function' public function 'a' needs test cell that asserts or checks the function
- public-function-markdown: nbs/data/04_review_style.ipynb id=18cf6a7f line=3 symbol='b' missing='Markdown docs cell mentioning the function' public function 'b' needs Markdown docs cell mentioning the function
- public-function-example: nbs/data/04_review_style.ipynb id=18cf6a7f line=3 symbol='b' missing='example cell that calls the function' public function 'b' needs example cell that calls the function
- public-function-test: nbs/data/04_review_style.ipynb id=18cf6a7f line=3 symbol='b' missing='test cell that asserts or checks the function' public function 'b' needs test cell that asserts or checks the function
- public-function-docstring: nbs/data/04_review_style.ipynb id=18cf6a7f line=6 symbol='c' missing='one-line docstring' docstring_lines=2 public function 'c' has 2 docstring lines; keep it to one line for context summaries
- public-function-markdown: nbs/data/04_review_style.ipynb id=18cf6a7f line=6 symbol='c' missing='Markdown docs cell mentioning the function' public function 'c' needs Markdown docs cell mentioning the function
- public-function-example: nbs/data/04_review_style.ipynb id=18cf6a7f line=6 symbol='c' missing='example cell that calls the function' public function 'c' needs example cell that calls the function
- public-function-test: nbs/data/04_review_style.ipynb id=18cf6a7f line=6 symbol='c' missing='test cell that asserts or checks the function' public function 'c' needs test cell that asserts or checks the function
- multi-problem-test: nbs/data/04_review_style.ipynb id=56e0e6c4 4 asserts/test functions; split into one-problem-at-a-time cells
- unclean-cell: nbs/data/04_review_style.ipynb id=f41bdaef semantic_types=test_cell, private_code
- duplicate-import: nbs/data/04_review_style.ipynb scope=exported import_key='import os as os' cells=4debe573, 742a5cf5
- duplicate-import: nbs/data/04_review_style.ipynb scope=internal import_key='import sys as sys' cells=3b1110e1, 23f2d90a
- cell-order: nbs/data/04_review_style.ipynb id=4268cb78 line=1 symbol='later_helper' confidence=high called before definition/import in later cell id=762d5962
- missing-import: nbs/data/04_review_style.ipynb id=711b360d line=2 symbol='MissingPath' confidence=medium called without an earlier definition/import
with write_demo_notebook("04_review_generated.ipynb") as generated_path:
    generated_nb = _write_review_notebook(generated_path, [mk_cell("#| default_exp big_review_demo")])
    generated_py_path = exported_py_path(generated_path, generated_nb)
    try:
        generated_py_path.parent.mkdir(parents=True, exist_ok=True)
        generated_py_path.write_text("\n".join(f"x{i} = {i}" for i in range(1001)), encoding="utf-8")
        size_codes = {problem["code"] for problem in notebook_size_problems(generated_path)}
        assert "large-generated-py" in size_codes
    finally:
        if generated_py_path is not None: remove_demo_path(generated_py_path)
with write_demo_notebook("04_review_style.ipynb") as path:
    _write_style_problem_notebook(path)
    custom_map = demo_path("04_review_errors.json")
    old_map = os.environ.get("NBSKILL_FAILURE_MAP")
    try:
        os.environ["NBSKILL_FAILURE_MAP"] = str(custom_map)
        with redirect_stdout(StringIO()):
            style_check(str(path), delete_after_output=True)
        assert "usage:" in _format_global_usage_summary()
        assert not custom_map.exists()
    finally:
        if old_map is None: os.environ.pop("NBSKILL_FAILURE_MAP", None)
        else: os.environ["NBSKILL_FAILURE_MAP"] = old_map
with write_demo_notebook("04_review_style.ipynb") as path:
    _write_style_problem_notebook(path)
    try:
        with redirect_stdout(StringIO()): style_check(str(path), strict=True)
    except RuntimeError:
        pass
    else:
        raise AssertionError("strict style_check should raise for notebook hygiene problems")
assert code_source(mk_cell("plain docs", cell_type="markdown")) is None
assert code_source(mk_cell("answer = 42", cell_type="code")) == "answer = 42"

diff_nb deliberately compares code-cell source only. Markdown edits and notebook metadata churn stay out of the main diff so reviewers can see the executable behavior that changed.

diff_root = demo_path("04_review_diff_example")
remove_demo_path(diff_root)
try:
    diff_root.mkdir()
    diff_path = diff_root / "demo.ipynb"
    _write_nb(new_nb([
        mk_cell("value = 1\nvalue", cell_type="code"),
        mk_cell("Original note", cell_type="markdown"),
    ]), diff_path)
    subprocess.run(["git", "init"], cwd=diff_root, check=True, capture_output=True)
    subprocess.run(["git", "add", "demo.ipynb"], cwd=diff_root, check=True, capture_output=True)
    subprocess.run([
        "git", "-c", "user.name=Nbskill", "-c", "user.email=nbskill@example.com",
        "commit", "-m", "base",
    ], cwd=diff_root, check=True, capture_output=True)

    diff_nb_json = read_nb(diff_path)
    diff_nb_json.cells[0].source = "value = 2\nvalue"
    diff_nb_json.cells[1].source = "Updated note that will not appear in the code diff"
    _write_nb(diff_nb_json, diff_path)

    diff_output = StringIO()
    with redirect_stdout(diff_output):
        diff_nb(str(diff_path), ref_a="HEAD")
    print(diff_output.getvalue())
finally:
    remove_demo_path(diff_root)

Here is the intended workflow on a tiny FastHTML-shaped notebook: first inventory likely visible text, then review just the public UI surface. The output is compact enough for an agent to paste into a status update or use as a pre-edit checklist.

from tempfile import gettempdir

ui_source = """
def _hero():
    return Div(
        H1('Welcome'),
        P('Compare providers in minutes.'),
        Button('Start now'),
        Img(src='/logo.png', alt='Company logo'),
    )

raise ValueError('internal diagnostic, not customer copy')
"""

with write_demo_notebook("04_review_public_ui_demo.ipynb", base=gettempdir()) as ui_path:
    _write_review_notebook(ui_path, [mk_cell(ui_source, cell_type="code")])

    print("Inventory before editing:")
    visible_text_inventory(ui_path)

    print("\nPublic UI diff for the current notebook contents:")
    diff_nb(str(ui_path), ref_a=None, ref_b=None, surface="public-ui")
Inventory before editing:
cell_id | line | symbol | call | text
20a783b4 | 4 | _hero | H1 | Welcome
20a783b4 | 5 | _hero | P | Compare providers in minutes.
20a783b4 | 6 | _hero | Button | Start now
20a783b4 | 7 | _hero | Img.alt | Company logo

Public UI diff for the current notebook contents:
--- public-ui cell 20a783b4 ---
--- 
+++ 
@@ -0,0 +1,4 @@
+H1: Welcome
+P: Compare providers in minutes.
+Button: Start now
+Img.alt: Company logo

Review functions are direct Python APIs. Use doctor with diff_nb and style_check when a change needs broader diagnostics.