serialize

The .ipynb file boundary: serialize the in-memory nb (see notebook.py) to a real Jupyter notebook and load one back. A Prompt cell and its Assistant reply are stored solveit-style as a single markdown cell – the prompt, a ##### 🤖Reply🤖<!-- SOLVEIT_SEPARATOR_... --> line, then the reply – so their structure lives in the cell source (which nbdev-clean leaves untouched) rather than in metadata (which it strips). Kept apart from the data model (notebook.py) and the rendering/routes (cells.py) so file-format concerns live in one place.

The #| export pragma

boopiter keeps nbdev’s #| export pragma out of a cell’s editable source entirely, tracking it as a flag instead. _has_export/_strip_export detect and remove that leading line when reading a cell from disk.

Saving

save_notebook writes nb out as a real Jupyter .ipynb, merging each Prompt + following Assistant into one markdown cell (solveit separator + the assistant’s <details> block + the reply). _blocks_to_nb_outputs converts boopiter’s output blocks into valid nbformat outputs so saved files still render in Jupyter. Cell ids and the separator’s reply-id are reused across saves to keep git diffs clean.


source

save_notebook

def save_notebook(
    path:str | pathlib.Path | None=None
)->Path:

Serialize nb.cells to a real Jupyter notebook file ({nb.name}.ipynb in the cwd by default). A Prompt cell and the Assistant reply that follows it are stored solveit-style as a SINGLE markdown cell – prompt, a ##### 🤖Reply🤖<!-- SOLVEIT_SEPARATOR_... --> line, the assistant’s <details> block, then the reply – so the prompt/reply structure lives in the cell source (which nbdev-clean leaves alone) rather than in strippable metadata. note/code/raw stay plain native cells; code cells keep their #| export pragma and outputs. Cell ids (and the separator’s reply-id hex) are preserved across saves so unchanged cells don’t churn git diffs. A solveit_ai:true flag is also stamped on prompt/reply cells for solveit’s benefit – boopiter itself relies only on the separator.

Loading

load_notebook is the inverse: a markdown cell carrying the SOLVEIT_SEPARATOR splits back into a Prompt + Assistant pair (re-extracting the <details> block); plain markdown is a note; code cells get their #| export flag detected. It falls back to legacy metadata.boopiter for notebooks saved by older boopiter, and to nbformat cell types for non-boopiter notebooks.


source

load_notebook

def load_notebook(
    path:str | pathlib.Path
)->Notebook:

Load a Jupyter notebook file into nb, replacing its current contents – the inverse of save_notebook(). A markdown cell containing the SOLVEIT_SEPARATOR splits back into a Prompt cell plus its Assistant reply (with the <details> block re-extracted into details); other markdown is a note; code cells get their leading #| export detected and stripped. For notebooks saved by older boopiter it falls back to reading legacy metadata.boopiter (ctype/visible/details); for plain non-boopiter notebooks it falls back to nbformat cell types.

Round-trip test

Proves the point of this whole scheme: save a notebook, wipe every cell’s metadata (what nbdev-clean does), reload, and confirm the prompt/reply structure – plus code/note/raw, #| export, and the assistant details – all survive because they live in the cell source, not metadata. Runs under nbdev-test.

# Round-trip test: the prompt/reply structure (and code/note/raw, export, details) must survive
# nbdev-clean, which strips cell metadata. We prove it by saving, deleting ALL metadata (exactly
# what nbdev-clean does to the parts we no longer rely on), then reloading and checking.
import tempfile as _tf, json as _json, os as _os
nb.cells.clear(); nb._nid = 0; nb.selected = None
nb.add('note', '# hi there')
nb.add('prompt', 'What is 2+2?')
nb.add('assistant', 'It is 4.', details='<details><summary>Reply details</summary><ul><li>Model: test</li></ul></details>')
nb.add('code', 'x = 4', export=True)
nb.add('raw', 'literal text')
_p = _tf.mktemp(suffix='.ipynb')
save_notebook(_p)
_doc = _json.load(open(_p))                       # simulate nbdev-clean: wipe all metadata
_doc['metadata'] = {}
for _c in _doc['cells']: _c['metadata'] = {}
_json.dump(_doc, open(_p, 'w'))
load_notebook(_p)
assert [c.ctype for c in nb.cells] == ['note','prompt','assistant','code','raw'], [c.ctype for c in nb.cells]
_a = nb.cells[2]
assert _a.ctype == 'assistant' and _a.details and 'Model: test' in _a.details
assert nb.cells[3].export and nb.cells[3].source == 'x = 4'
assert nb.cells[1].source == 'What is 2+2?' and nb.cells[1].ctype == 'prompt'
_os.remove(_p)
print('round-trip survived a full metadata strip:', [c.ctype for c in nb.cells])
round-trip survived a full metadata strip: ['note', 'prompt', 'assistant', 'code', 'raw']