nbio

Reading and writing Jupyter notebooks

Reading a notebook

A notebook is just a json file.

Exported source
def _read_json(self, encoding=None, errors=None):
    return loads(Path(self).read_text(encoding=encoding, errors=errors))
minimal_fn = Path('../tests/minimal.ipynb')
minimal_txt = AttrDict(_read_json(minimal_fn))

It contains two sections, the metadata…:

minimal_txt.metadata
{'solveit_dialog_mode': 'learning', 'solveit_ver': 2}

…and, more importantly, the cells:

minimal_txt.cells
[{'cell_type': 'markdown',
  'id': '801558df',
  'metadata': {},
  'source': ['## A minimal notebook']},
 {'cell_type': 'code',
  'execution_count': None,
  'id': 'e2147a69',
  'metadata': {'time_run': '2026-01-04T20:52:49.901559+00:00'},
  'outputs': [{'data': {'text/plain': ['2']},
    'execution_count': 0,
    'metadata': {},
    'output_type': 'execute_result'}],
  'source': ['# Do some arithmetic\n', '1+1']}]

The second cell here is a code cell, however it contains no outputs, because it hasn’t been executed yet. To execute a notebook, we first need to convert it into a format suitable for nbclient (which expects some dict keys to be available as attrs, and some available as regular dict keys). Normally, nbformat is used for this step, but it’s rather slow and inflexible, so we’ll write our own function based on fastcore’s handy dict2obj, which makes all keys available as both attrs and keys.


source

NbCell


def NbCell(
    idx, cell
):

dict subclass that also provides access to keys as attrs, and has a pretty markdown repr

We use an AttrDict subclass which has some basic functionality for accessing notebook cells.


source

dict2nb


def dict2nb(
    js:NoneType=None, kwargs:VAR_KEYWORD
):

Convert dict js to an AttrDict,

We can now convert our JSON into this nbclient-compatible format, which pretty prints the source code of cells in notebooks.

minimal = dict2nb(minimal_txt)
cell = minimal.cells[1]
cell
{ 'cell_type': 'code',
  'execution_count': None,
  'id': 'e2147a69',
  'idx_': 1,
  'metadata': {'time_run': '2026-01-04T20:52:49.901559+00:00'},
  'outputs': [ { 'data': {'text/plain': ['2']},
                 'execution_count': 0,
                 'metadata': {},
                 'output_type': 'execute_result'}],
  'source': '# Do some arithmetic\n1+1'}

The abstract syntax tree of source code cells is available in the parsed_ property:

cell.parsed_(), cell.parsed_()[0].value.op
([<ast.Expr>], <ast.Add>)

source

read_nb


def read_nb(
    path
):

Return notebook at path

This reads the JSON for the file at path and converts it with dict2nb. For instance:

minimal = read_nb(minimal_fn)
str(minimal.cells[0])
"{'cell_type': 'markdown', 'id': '801558df', 'metadata': {}, 'source': '## A minimal notebook', 'idx_': 0}"

The file name read is stored in path_:

minimal.path_
'../tests/minimal.ipynb'

Creating a notebook


source

mk_cell


def mk_cell(
    text, # `source` attr in cell
    cell_type:str='code', # `cell_type` attr in cell
    kwargs:VAR_KEYWORD
):

Create an NbCell containing text

mk_cell('print(1)', execution_count=0)
{ 'cell_type': 'code',
  'directives_': {},
  'execution_count': 0,
  'id': '784d2277',
  'idx_': 0,
  'metadata': {},
  'outputs': [],
  'source': 'print(1)'}

source

new_nb


def new_nb(
    cells:NoneType=None, meta:NoneType=None, nbformat:int=4, nbformat_minor:int=5
):

Returns an empty new notebook

Use this function when creating a new notebook. Useful for when you don’t want to create a notebook on disk first and then read it.

Writing a notebook


source

nb2dict


def nb2dict(
    d, k:NoneType=None
):

Convert parsed notebook to dict

This returns the exact same dict as is read from the notebook JSON.

minimal_fn = Path('../tests/minimal.ipynb')
minimal = read_nb(minimal_fn)
minimal_dict = _read_json(minimal_fn)
assert minimal_dict==nb2dict(minimal)

source

nb2str


def nb2str(
    nb
):

Convert nb to a str

To save a notebook we first need to convert it to a str:

print(nb2str(minimal)[:45])
{
 "cells": [
  {
   "cell_type": "markdown",

source

write_nb


def write_nb(
    nb, path
):

Write nb to path

This returns the exact same string as saved by Jupyter.

tmp = Path('tmp.ipynb')
try:
    minimal_txt = minimal_fn.read_text()
    write_nb(minimal, tmp)
    test_eq(minimal_txt, tmp.read_text())
finally: tmp.unlink()

Here’s how to put all the pieces of fastcore.nbio together:

nb = new_nb([mk_cell('print(1)')])
path = Path('test.ipynb')
write_nb(nb, path)
nb2 = read_nb(path)
print(nb2.cells)
path.unlink()
[{'cell_type': 'code', 'execution_count': 0, 'id': '2d215f3a', 'metadata': {}, 'outputs': [], 'source': 'print(1)', 'idx_': 0}]

Notebook class


source

cells2xml


def cells2xml(
    cells, wrap:function=_f, ids:bool=True, incl_out:bool=True, kw:VAR_KEYWORD
):

Convert notebook cells to XML format


source

cell2xml


def cell2xml(
    cell, ids:bool=True, incl_out:bool=True
):

Convert NbCell to concise XML format

We can view any notebook as concise XML. For instance, our minimal notebook:

print(cells2xml(nb.cells))
<nb><code id="2d215f3a">print(1)</code></nb>
repr(cell2xml(nb.cells[0]))
'<code id="2d215f3a">print(1)</code>'

source

Notebook


def Notebook(
    nb, path:NoneType=None
):

Read, query, and edit Jupyter notebooks

We can now open a notebook and access its metadata and cells:

nbo = Notebook.open(minimal_fn)
list(nbo.meta), len(nbo.cells), len(nbo)
(['solveit_dialog_mode', 'solveit_ver'], 2, 2)
nbo.path.name
'minimal.ipynb'
[o.id for o in nbo]
['801558df', 'e2147a69']
'e2147a69' in nbo, 'nonexistent' in nbo
(True, False)

Notebooks’ repr is their xml:

nbo
<nb path="/Users/jhoward/aai-ws/fastcore/tests/minimal.ipynb"><markdown id="801558df">## A minimal notebook</markdown><code id="e2147a69"><source># Do some arithmetic
1+1<out>[{'data': {'text/plain': ['2']}, 'execution_count': 0, 'metadata': {}, 'output_type': 'execute_result'}]</out></code></nb>

You can also get a more concise version that doesn’t include outputs or the full path:

print(nbo.concise)
<nb path="minimal.ipynb"><markdown id="801558df">## A minimal notebook</markdown><code id="e2147a69"># Do some arithmetic
1+1</code></nb>

Cells can be accessed by integer index or by their string id:

nbo[0].source
'## A minimal notebook'
nbo['e2147a69'].source
'# Do some arithmetic\n1+1'

You can directly set a cell’s source by id or index:

nbo['e2147a69'] = '2+2'
nbo['e2147a69'].source
'2+2'

You can also update outputs and metadata directly on a cell:

nbo['e2147a69'].outputs = [{'output_type': 'execute_result', 'data': {'text/plain': ['4']}}]
nbo['e2147a69'].outputs
[{'output_type': 'execute_result', 'data': {'text/plain': ['4']}}]
nbo['e2147a69'].metadata['custom'] = True
nbo['e2147a69'].metadata
{'custom': True, 'time_run': '2026-01-04T20:52:49.901559+00:00'}

The add method inserts a new cell at a given position (defaulting to the end):


source

Notebook.add


def add(
    source, cell_type:str='code', idx:NoneType=None, after:NoneType=None, before:NoneType=None, kwargs:VAR_KEYWORD
):

Add a new cell with source at idx (default: end), or after/before a cell id

nbo.add('print("hello")')
nbo.add('# A heading', cell_type='markdown', idx=0)
len(nbo), nbo[0].source
(4, '## A minimal notebook')

Cells can also be inserted relative to an existing cell by id:

cid = nbo[0].id
nbo.add('# After first', cell_type='markdown', after=cid)
nbo.add('# Before first', cell_type='markdown', before=cid)
[c.source for c in nbo[:3]]
['# Before first', '## A minimal notebook', '# After first']

source

Notebook.md


def md(
    source, idx:NoneType=None, after:NoneType=None, before:NoneType=None, kwargs:VAR_KEYWORD
):

Add a new cell with source at idx (default: end), or after/before a cell id

md is a shortcut to add(..., cell_type='markdown')

nbo.md('A note')
len(nbo), nbo[-1].cell_type
(7, 'markdown')

You can delete by id or index:

prev_len = len(nbo)
del nbo[0]
len(nbo) == prev_len - 1
True

The find method searches cell sources by regex, returning matching cells:


source

Notebook.find


def find(
    pat, cell_type:NoneType=None
):

Find cells with source matching regex pat

nbo.find(r'\d\+\d', cell_type='code')
[{'cell_type': 'code',
  'execution_count': None,
  'id': 'e2147a69',
  'metadata': {'time_run': '2026-01-04T20:52:49.901559+00:00', 'custom': True},
  'outputs': [{'output_type': 'execute_result',
    'data': {'text/plain': ['4']}}],
  'source': '2+2',
  'idx_': 1}]

source

Notebook.move


def move(
    src_ids, after:NoneType=None, before:NoneType=None
):

Move cells with src_ids after/before a cell id, or to end

Cells can be moved by id, either relative to another cell or to the end:

nbo = Notebook.open(minimal_fn)
c0,c1 = nbo[0].id,nbo[1].id
nbo.move(c1, before=c0)
[c.id for c in nbo] == [c1, c0]
True

Use save to write to disk:

nbo.save('path.ipynb')

If no path is passed, the path used in open() will be re-used.


source

Notebook.view


def view(
    id, nums:bool=True
):

Show cell source with optional line numbers

The view method displays a cell’s source with optional line numbers:

print(nbo.view('e2147a69'))
     1 │ # Do some arithmetic
     2 │ 1+1